Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion sdk/python/packages/flet/src/flet/components/hooks/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion sdk/python/packages/flet/src/flet/components/observable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions sdk/python/packages/flet/tests/test_component_owned_identity.py
Original file line number Diff line number Diff line change
@@ -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