Skip to content

Commit 3f12eae

Browse files
Prevent unnecessary remounts of fragments in 'if' directives (#152)
1 parent 870189e commit 3f12eae

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

collagraph/fragment.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,8 @@ def mount(self, target: Any, anchor: Any | None = None):
443443

444444
@weak(self)
445445
def update_fragment(self, new: Fragment | None, old: Fragment | None):
446+
if new is old:
447+
return # No need to remount, since it is the same fragment
446448
if old:
447449
old.unmount(destroy=False)
448450
if new:
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""
2+
Test that v-if does not unnecessarily remount when condition result stays the same.
3+
"""
4+
5+
from observ import reactive
6+
7+
import collagraph as cg
8+
9+
10+
def test_v_if_no_unnecessary_remount_when_condition_stays_true(
11+
parse_source, process_events
12+
):
13+
"""
14+
When a v-if condition depends on reactive state that changes, but the
15+
truthiness of the condition remains the same, the component should NOT
16+
be remounted.
17+
18+
For example, if the condition is `count > 0`:
19+
- count=1 -> True, component mounts, init called
20+
- count=2 -> still True, component should NOT remount (init should NOT
21+
be called again)
22+
"""
23+
# First, define a Child component with lifecycle tracking
24+
Child, namespace = parse_source(
25+
"""
26+
<child :value="value" />
27+
28+
<script>
29+
import collagraph as cg
30+
31+
class Child(cg.Component):
32+
init_count = 0
33+
mounted_count = 0
34+
updated_count = 0
35+
before_unmount_count = 0
36+
37+
def init(self):
38+
Child.init_count += 1
39+
40+
def mounted(self):
41+
Child.mounted_count += 1
42+
43+
def updated(self):
44+
Child.updated_count += 1
45+
46+
def before_unmount(self):
47+
Child.before_unmount_count += 1
48+
</script>
49+
"""
50+
)
51+
52+
# Now define a Parent component that renders Child with v-if
53+
Parent, _ = parse_source(
54+
"""
55+
<parent>
56+
<Child v-if="count > 0" :value="count" />
57+
</parent>
58+
59+
<script>
60+
import collagraph as cg
61+
62+
try:
63+
import Child
64+
except ImportError:
65+
pass
66+
67+
class Parent(cg.Component):
68+
pass
69+
</script>
70+
""",
71+
namespace=namespace,
72+
)
73+
74+
# Reset class-level counters
75+
Child.init_count = 0
76+
Child.mounted_count = 0
77+
Child.updated_count = 0
78+
Child.before_unmount_count = 0
79+
80+
gui = cg.Collagraph(cg.DictRenderer(), event_loop_type=cg.EventLoopType.DEFAULT)
81+
container = {"type": "root"}
82+
state = reactive({"count": 1})
83+
gui.render(Parent, container, state=state)
84+
process_events()
85+
86+
# Component should be mounted since count > 0
87+
parent = container["children"][0]
88+
assert parent["type"] == "parent"
89+
assert len(parent["children"]) == 1
90+
assert parent["children"][0]["type"] == "child"
91+
assert parent["children"][0]["attrs"]["value"] == 1
92+
93+
# init and mounted should have been called exactly once
94+
assert Child.init_count == 1, "init should be called once on initial mount"
95+
assert Child.mounted_count == 1, "mounted should be called once on initial mount"
96+
assert Child.updated_count == 0, "updated should not be called on initial mount"
97+
assert Child.before_unmount_count == 0, "before_unmount should not be called yet"
98+
99+
# Change count from 1 to 2 - condition is still True (2 > 0)
100+
state["count"] = 2
101+
process_events()
102+
103+
# Component should still be there with updated value
104+
assert len(parent["children"]) == 1
105+
assert parent["children"][0]["attrs"]["value"] == 2
106+
107+
# This is the key assertion: init should NOT be called again!
108+
# The condition `count > 0` is still True, so no remount should happen.
109+
assert Child.init_count == 1, (
110+
"init should still be 1 - component should not be remounted when "
111+
"condition stays truthy"
112+
)
113+
assert Child.mounted_count == 1, (
114+
"mounted should still be 1 - component should not be remounted"
115+
)
116+
# updated should be called because the :value prop changed
117+
assert Child.updated_count == 1, "updated should be called when props change"
118+
assert Child.before_unmount_count == 0, (
119+
"before_unmount should not be called - component was not unmounted"
120+
)
121+
122+
# Now actually toggle the condition: count=0 means condition is False
123+
state["count"] = 0
124+
process_events()
125+
126+
# Component should be unmounted
127+
assert "children" not in parent or len(parent["children"]) == 0
128+
129+
assert Child.before_unmount_count == 1, (
130+
"before_unmount should be called when condition becomes false"
131+
)
132+
133+
# Mount again
134+
state["count"] = 5
135+
process_events()
136+
137+
assert len(parent["children"]) == 1
138+
assert parent["children"][0]["attrs"]["value"] == 5
139+
140+
# Now init and mounted should have been called a second time (actual remount)
141+
assert Child.init_count == 2, "init should be called again after real remount"
142+
assert Child.mounted_count == 2, "mounted should be called again after real remount"
143+
144+
145+
def test_v_if_no_unnecessary_remount_with_unchanged_truthy_value(
146+
parse_source, process_events
147+
):
148+
"""
149+
Test that changing a condition value to another truthy value that
150+
evaluates the same way does not cause a remount.
151+
"""
152+
Child, namespace = parse_source(
153+
"""
154+
<child />
155+
156+
<script>
157+
import collagraph as cg
158+
159+
class Child(cg.Component):
160+
init_count = 0
161+
mounted_count = 0
162+
163+
def init(self):
164+
Child.init_count += 1
165+
166+
def mounted(self):
167+
Child.mounted_count += 1
168+
</script>
169+
"""
170+
)
171+
172+
Parent, _ = parse_source(
173+
"""
174+
<parent>
175+
<Child v-if="show" />
176+
</parent>
177+
178+
<script>
179+
import collagraph as cg
180+
181+
try:
182+
import Child
183+
except ImportError:
184+
pass
185+
186+
class Parent(cg.Component):
187+
pass
188+
</script>
189+
""",
190+
namespace=namespace,
191+
)
192+
193+
Child.init_count = 0
194+
Child.mounted_count = 0
195+
196+
gui = cg.Collagraph(cg.DictRenderer(), event_loop_type=cg.EventLoopType.DEFAULT)
197+
container = {"type": "root"}
198+
state = reactive({"show": True})
199+
gui.render(Parent, container, state=state)
200+
process_events()
201+
202+
parent = container["children"][0]
203+
assert len(parent["children"]) == 1
204+
assert Child.init_count == 1
205+
assert Child.mounted_count == 1
206+
207+
# Change show to 1 (different truthy value, but still truthy)
208+
state["show"] = 1
209+
process_events()
210+
211+
# The condition result is the same (truthy), so no remount should happen
212+
assert Child.init_count == 1, (
213+
"init should not be called when condition stays truthy"
214+
)
215+
assert Child.mounted_count == 1, (
216+
"mounted should not be called when condition stays truthy"
217+
)
218+
219+
# Change to another truthy value
220+
state["show"] = "yes"
221+
process_events()
222+
223+
assert Child.init_count == 1
224+
assert Child.mounted_count == 1

0 commit comments

Comments
 (0)