Skip to content

Signal group of dynamic child events bubble up even if connect_child_events is False #423

Description

@fwdekker-pc

Events triggered on a child dataclass that is dynamically assigned to a parent dataclass are bubbled up to the parent even if connect_child_events=False is explicitly set on the parent's signal group.

This is true only if parent.events.connect is called before the child is dynamically assigned.

Details

If one evented dataclass contains another evented dataclass, then events bubble up by default. Setting connect_child_events=False on the parent's signal group disables event bubbling.

However, event bubbling is not correctly stopped if ALL of the following apply:

  1. The parent and child both have their own SignalGroupDescriptor(),
  2. The child reference in the parent (parent.child) is mutated, AND
  3. The listener (parent.events.connect) is registered before the child reference is changed.

In other words, this bug does not occur if ANY of the following applies:

  1. The child does not have its own SignalGroupDescriptor(),
  2. The child reference in the parent (parent.child) is immutable, OR
  3. The listener (parent.events.connect) is registered AFTER the child reference is changed.

Reproducible code

from dataclasses import dataclass

from psygnal import SignalGroupDescriptor


@dataclass
class Child:
    foo: int = 0

    events = SignalGroupDescriptor()


@dataclass
class Parent:
    child: Child | None = None

    events = SignalGroupDescriptor(connect_child_events=False)


if __name__ == "__main__":
    parent = Parent()
    parent.events.connect(lambda event: print(f"parent event: {event}"))

    child = Child()
    parent.child = child

    child.foo = 1

Expected output

One line is printed to the console, namely when parent.child is assigned.

Actual output

Two lines are printed: one when parent.child is assigned, and one when child.foo is assigned:

parent event: EmissionInfo(signal=<_DataclassFieldSignalInstance 'child' on <SignalGroup 'ParentSignalGroup' on <Parent instance at 0x23c858774d0> with 1 signals>>, args=(Child(foo=0), None), path=(.child,))
parent event: EmissionInfo(signal=<_DataclassFieldSignalInstance 'foo' on <SignalGroup 'ChildSignalGroup' on <Child instance at 0x23c85877620> with 1 signals>>, args=(1, 0), path=(.child, .foo))

Details (cont.)

Returning to those conditions I mentioned earlier, you can verify them. If you do ANY of the following, you'll find that there is no EmissionInfo(..., path=(.child, .foo), which is the expected behaviour:

  1. Remove the child's events = SignalGroupDescriptor(),
  2. Initialize the parent as parent = Parent(Child()), remove child = Child() and parent.child = child, and change child.foo = 1 to parent.child.foo = 1, OR
  3. Move parent.events.connect(...) to be right before child.foo = 1.

Temporary workaround

In some cases, it may be sufficient to edit your listener(s) to explicitly check the path of the event.

Instead of

parent.events.connect(lambda event: print(f"parent event: {event}"))

do

def parent_listener(event):
    if len(event.path) != 1:
        return

    print(f"parent event: {event}")

parent.events.connect(parent_listener)

You'll have to do this for each listener separately, at all levels of your class hierarchy, which is of course undesirable.

Environment details

Python: v3.14.6
psygnal: v0.15.1
OS: Windows 11 25H2

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions