From 3d88b921153da0ec39f88b9e0f19086792430fdd Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 18 Aug 2026 01:18:42 +0300 Subject: [PATCH] fix(components): use identity equality for ComponentOwned lifecycle objects ComponentOwned and its subclasses (Hook, ObservableSubscription) are lifecycle objects tracked in lists and matched with `in`/`list.remove`. They are dataclasses whose only declared fields are InitVars, so the generated __eq__ compares no fields and every instance compares equal. In Component._detach_observable_subscription, `subscription in self._state.observable_subscriptions` then matches a different subscription and `.remove()` deletes the wrong one, leaking the subscription that should have been detached. Set eq=False on ComponentOwned, Hook and ObservableSubscription so they fall back to identity equality/hash. Add regression tests. Closes #6776. --- .../src/flet/components/component_owned.py | 6 +- .../flet/src/flet/components/hooks/hook.py | 4 +- .../flet/src/flet/components/observable.py | 5 +- .../tests/test_component_owned_identity.py | 61 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 sdk/python/packages/flet/tests/test_component_owned_identity.py diff --git a/sdk/python/packages/flet/src/flet/components/component_owned.py b/sdk/python/packages/flet/src/flet/components/component_owned.py index c00b06de78..5bf927684a 100644 --- a/sdk/python/packages/flet/src/flet/components/component_owned.py +++ b/sdk/python/packages/flet/src/flet/components/component_owned.py @@ -6,7 +6,11 @@ from flet.components.component import Component -@dataclass() +# Identity equality (eq=False) is required: these are lifecycle objects tracked +# in lists and matched with `in`/`list.remove`. The dataclass fields are all +# InitVars, so a generated __eq__ would compare no fields and make every +# instance equal, matching/removing the wrong subscription (see #6776). +@dataclass(eq=False) class ComponentOwned: """ Base mixin for objects owned by a component via weak reference. diff --git a/sdk/python/packages/flet/src/flet/components/hooks/hook.py b/sdk/python/packages/flet/src/flet/components/hooks/hook.py index 48793c237f..dcfdefb897 100644 --- a/sdk/python/packages/flet/src/flet/components/hooks/hook.py +++ b/sdk/python/packages/flet/src/flet/components/hooks/hook.py @@ -7,7 +7,9 @@ pass -@dataclass() +# eq=False: hooks are stored per render slot and compared by identity; a +# field-based __eq__ would make distinct hooks compare equal (see #6776). +@dataclass(eq=False) class Hook(ComponentOwned): """ Base class for component hook state objects. diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index d7cca21194..c93610848d 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -49,7 +49,10 @@ class MyDataClass: return Mixed -@dataclass +# eq=False: subscriptions are matched by identity in _detach_observable_subscription +# (`subscription in ...`, `list.remove`); a field-based __eq__ collides across +# distinct subscriptions and detaches the wrong one, leaking the other (see #6776). +@dataclass(eq=False) class ObservableSubscription(ComponentOwned): """ Lifecycle helper that binds an observable object to a component update cycle. diff --git a/sdk/python/packages/flet/tests/test_component_owned_identity.py b/sdk/python/packages/flet/tests/test_component_owned_identity.py new file mode 100644 index 0000000000..1f71c7ce7e --- /dev/null +++ b/sdk/python/packages/flet/tests/test_component_owned_identity.py @@ -0,0 +1,61 @@ +"""Regression tests for #6776. + +`ComponentOwned` and its subclasses (`Hook`, `ObservableSubscription`) are +lifecycle objects tracked in lists and matched with ``in`` / ``list.remove``. +They are dataclasses whose only declared fields are ``InitVar``s, so a generated +``__eq__`` compares no fields and makes every instance compare equal — which made +``_detach_observable_subscription`` detach the wrong subscription (a leak). They +must use identity equality (``eq=False``). +""" + +import flet as ft +from flet.components.component_owned import ComponentOwned +from flet.components.hooks.hook import Hook +from flet.components.observable import ObservableSubscription + + +class _Owner: + """Minimal stand-in for a Component owner (only needs to be weak-referenceable).""" + + +class _Obs(ft.Observable): + value: int = 0 + + +def test_component_owned_uses_identity_equality(): + owner = _Owner() + a = ComponentOwned(owner) + b = ComponentOwned(owner) + assert a != b + assert a == a + assert b not in [a] + assert hash(a) != hash(b) + + +def test_hook_uses_identity_equality(): + owner = _Owner() + h1 = Hook(owner) + h2 = Hook(owner) + assert h1 != h2 + # `in` and `remove` must operate on the exact instance. + hooks = [h1, h2] + assert h1 in hooks + hooks.remove(h2) + assert hooks == [h1] + assert h2 not in hooks + + +def test_observable_subscription_detaches_the_correct_instance(): + owner = _Owner() + s1 = ObservableSubscription(owner, _Obs()) + s2 = ObservableSubscription(owner, _Obs()) + + assert s1 != s2 + assert s2 not in [s1] + + subs = [s1, s2] + # Removing s2 must leave s1 attached (the bug removed s1 instead). + subs.remove(s2) + assert s1 in subs + assert s2 not in subs + assert len(subs) == 1