Skip to content

Commit 8a15408

Browse files
Cache Fragment._component_parent() lookups
The parent-chain walk ran on every reactive attribute update (via _set_attr/_rem_attr calling component.updated()) and on every ref (un)registration, making update cost scale with fragment depth. The result is now cached per fragment. The cache is invalidated in the parent property setter, on unmount(destroy=True), and - via a new recursive helper - after DynamicFragment._create_fragment_for_tag() transfers live children to a new active fragment on a <component :is> tag switch, which reassigns _parent directly and changes the owning component of every transferred descendant. Two regression tests cover the tag-switch reparenting: updated() attribution and string-ref registration must follow the new owner. Both fail when the invalidation hook is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 36125ef commit 8a15408

2 files changed

Lines changed: 196 additions & 3 deletions

File tree

collagraph/fragment.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from .renderers import Renderer
1212
from .weak import weak
1313

14+
# Sentinel for an unpopulated Fragment._component_parent_cache
15+
_UNSET = object()
16+
1417

1518
class Fragment:
1619
"""
@@ -45,6 +48,10 @@ def __init__(
4548

4649
# Weak ref to parent fragment
4750
self._parent: ref[Fragment] | None = ref(parent) if parent else None
51+
# Cache for _component_parent(), invalidated whenever this fragment
52+
# is reparented to a different owner (see the `parent` setter and
53+
# DynamicFragment._create_fragment_for_tag)
54+
self._component_parent_cache: Component | None = _UNSET
4855
# Static attributes for the DOM element
4956
self._attributes: dict[str, str] = {}
5057
# Events for the DOM element
@@ -88,22 +95,31 @@ def _component_parent(self) -> Component | None:
8895
Returns the component of the first parent ComponentFragment that
8996
has a component property. Or None, if not there.
9097
98+
Cached, since parenthood is stable once a fragment is mounted. The
99+
cache is invalidated whenever a fragment is reparented to a
100+
different owner (see the `parent` setter and
101+
DynamicFragment._create_fragment_for_tag, the only place a live,
102+
already-mounted fragment's parent is reassigned directly).
91103
"""
92-
# TODO: would be nice if we could cache the _component_parent in a clever way
104+
if self._component_parent_cache is not _UNSET:
105+
return self._component_parent_cache
106+
93107
parent = self.parent
94108
while parent and (
95109
not isinstance(parent, ComponentFragment) or not parent.component
96110
):
97111
parent = parent.parent
98112

99-
if parent:
100-
return parent.component
113+
result = parent.component if parent else None
114+
self._component_parent_cache = result
115+
return result
101116

102117
@parent.setter
103118
def parent(self, parent: Fragment | None):
104119
# TODO: should this also check that this item is
105120
# now in the list of the parent's children?
106121
self._parent = ref(parent) if parent else None
122+
self._component_parent_cache = _UNSET
107123

108124
def register_child(self, child: Fragment) -> None:
109125
self.children.append(child)
@@ -448,6 +464,7 @@ def unmount(self, destroy=True):
448464
self.tag = None
449465
self._ref_name = None
450466
self._ref_is_dynamic = False
467+
self._component_parent_cache = _UNSET
451468
else:
452469
self.element = None
453470
# Disable the fn and callback of the watcher to disable
@@ -1038,6 +1055,13 @@ def _create_fragment_for_tag(self, tag):
10381055
# Create the fragment
10391056
self._active_fragment.create()
10401057

1058+
# `existing_children` (and their descendants) may have been mounted
1059+
# under a different owning component before this tag switch (e.g.
1060+
# transferred out of a previous ComponentFragment's slot_contents).
1061+
# Their cached _component_parent() result would now be stale, since
1062+
# their effective parent chain just changed.
1063+
_invalidate_component_parent_cache(self._active_fragment)
1064+
10411065
def mount(self, target: Any, anchor: Any | None = None):
10421066
if self._mounted:
10431067
return
@@ -1103,6 +1127,36 @@ def _remove(self):
11031127
self._active_fragment._remove()
11041128

11051129

1130+
def _invalidate_component_parent_cache(fragment: Fragment | None) -> None:
1131+
"""
1132+
Recursively reset the cached _component_parent() result for `fragment`
1133+
and its descendants.
1134+
1135+
Needed after a live, already-mounted fragment is reparented to a
1136+
different owner without going through the `parent` property setter
1137+
(currently only DynamicFragment._create_fragment_for_tag does this,
1138+
when transferring existing children to a newly created active
1139+
fragment on a `<component :is=...>` tag switch).
1140+
"""
1141+
if fragment is None:
1142+
return
1143+
1144+
fragment._component_parent_cache = _UNSET
1145+
1146+
for child in fragment.children:
1147+
_invalidate_component_parent_cache(child)
1148+
1149+
if isinstance(fragment, ComponentFragment):
1150+
# Note: fragment.fragment (the component's rendered template root)
1151+
# is already reachable via fragment.children, since create() always
1152+
# appends it there.
1153+
for slot_content in fragment.slot_contents:
1154+
_invalidate_component_parent_cache(slot_content)
1155+
1156+
if isinstance(fragment, DynamicFragment):
1157+
_invalidate_component_parent_cache(fragment._active_fragment)
1158+
1159+
11061160
def move_fragment_dom(
11071161
fragment: Fragment, renderer: Renderer, target: Any, anchor: Any | None
11081162
) -> None:

tests/test_tag_dynamic.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,145 @@ class App(Component):
984984
assert root["children"][0]["type"] == "content"
985985

986986

987+
def test_dynamic_component_tag_switch_updates_correct_owner(parse_source):
988+
"""
989+
Regression test for Fragment._component_parent() caching: when a
990+
<component :is="tag"> switches between a plain element and a
991+
Component class, the transferred slot content's `updated()`
992+
notifications must go to the *new* owning component, not a stale
993+
cached owner from before the switch.
994+
"""
995+
Wrapper, _ = parse_source(
996+
"""
997+
<wrapper>
998+
<slot />
999+
</wrapper>
1000+
<script>
1001+
from collagraph import Component
1002+
class Wrapper(Component):
1003+
updates = 0
1004+
1005+
def updated(self):
1006+
Wrapper.updates += 1
1007+
</script>
1008+
"""
1009+
)
1010+
1011+
App, _ = parse_source(
1012+
"""
1013+
<component :is="tag">
1014+
<content :text="text" />
1015+
</component>
1016+
<script>
1017+
from collagraph import Component
1018+
class App(Component):
1019+
updates = 0
1020+
1021+
def updated(self):
1022+
App.updates += 1
1023+
</script>
1024+
""",
1025+
namespace={"Wrapper": Wrapper},
1026+
)
1027+
1028+
state = reactive({"tag": "div", "text": "Hello"})
1029+
container = {"type": "root"}
1030+
gui = Collagraph(
1031+
renderer=DictRenderer(),
1032+
event_loop_type=EventLoopType.SYNC,
1033+
)
1034+
gui.render(App, container, state=state)
1035+
1036+
# Plain element: updates to "content" should be attributed to App
1037+
App.updates = 0
1038+
Wrapper.updates = 0
1039+
state["text"] = "World"
1040+
assert App.updates == 1
1041+
assert Wrapper.updates == 0
1042+
1043+
# Switch to a Component tag: updates to "content" should now be
1044+
# attributed to Wrapper, not the stale cached App
1045+
state["tag"] = Wrapper
1046+
App.updates = 0
1047+
Wrapper.updates = 0
1048+
state["text"] = "Wrapped"
1049+
assert Wrapper.updates == 1
1050+
assert App.updates == 0
1051+
1052+
# Switch back to a plain element: updates should flow to App again
1053+
state["tag"] = "section"
1054+
App.updates = 0
1055+
Wrapper.updates = 0
1056+
state["text"] = "Plain again"
1057+
assert App.updates == 1
1058+
assert Wrapper.updates == 0
1059+
1060+
1061+
def test_dynamic_component_tag_switch_moves_string_ref(parse_source):
1062+
"""
1063+
Regression test for Fragment._component_parent() caching: a string
1064+
ref on slot content of <component :is="tag"> must register with the
1065+
component that structurally owns the content after each tag switch,
1066+
not with a stale cached owner from before the switch.
1067+
"""
1068+
Wrapper, _ = parse_source(
1069+
"""
1070+
<wrapper>
1071+
<slot />
1072+
</wrapper>
1073+
<script>
1074+
from collagraph import Component
1075+
class Wrapper(Component):
1076+
instance = None
1077+
1078+
def mounted(self):
1079+
Wrapper.instance = self
1080+
</script>
1081+
"""
1082+
)
1083+
1084+
App, _ = parse_source(
1085+
"""
1086+
<component :is="tag">
1087+
<content ref="content_el" />
1088+
</component>
1089+
<script>
1090+
from collagraph import Component
1091+
class App(Component):
1092+
instance = None
1093+
1094+
def mounted(self):
1095+
App.instance = self
1096+
</script>
1097+
""",
1098+
namespace={"Wrapper": Wrapper},
1099+
)
1100+
1101+
state = reactive({"tag": "div"})
1102+
container = {"type": "root"}
1103+
gui = Collagraph(
1104+
renderer=DictRenderer(),
1105+
event_loop_type=EventLoopType.SYNC,
1106+
)
1107+
gui.render(App, container, state=state)
1108+
1109+
# Plain element tag: the content is owned by App
1110+
assert "content_el" in App.instance.refs
1111+
assert App.instance.refs["content_el"]["type"] == "content"
1112+
1113+
# Switch to a Component tag: the content is transferred into
1114+
# Wrapper's slot and remounted, so the ref must register with the
1115+
# new owner
1116+
state["tag"] = Wrapper
1117+
assert "content_el" in Wrapper.instance.refs
1118+
assert Wrapper.instance.refs["content_el"]["type"] == "content"
1119+
1120+
# Switch back to a plain element: the ref must follow back to App
1121+
state["tag"] = "section"
1122+
assert "content_el" in App.instance.refs
1123+
assert App.instance.refs["content_el"]["type"] == "content"
1124+
1125+
9871126
def test_dynamic_component_with_named_slots(parse_source):
9881127
"""Test dynamic component with Component class that has named slots"""
9891128
Layout, _ = parse_source(

0 commit comments

Comments
 (0)