Skip to content

Commit 2f459c5

Browse files
committed
allow elements with the same key to change type
1 parent b7f5af4 commit 2f459c5

File tree

3 files changed

+65
-93
lines changed

3 files changed

+65
-93
lines changed

docs/source/reference-material/_examples/snake_game.py

+2-12
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ def GameView():
1818
game_state, set_game_state = idom.hooks.use_state(GameState.init)
1919

2020
if game_state == GameState.play:
21-
return GameLoop(
22-
grid_size=6,
23-
block_scale=50,
24-
set_game_state=set_game_state,
25-
key="game loop",
26-
)
21+
return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state)
2722

2823
start_button = idom.html.button(
2924
{"onClick": lambda event: set_game_state(GameState.play)},
@@ -45,12 +40,7 @@ def GameView():
4540
"""
4641
)
4742

48-
return idom.html.div(
49-
{"className": "snake-game-menu"},
50-
menu_style,
51-
menu,
52-
key="menu",
53-
)
43+
return idom.html.div({"className": "snake-game-menu"}, menu_style, menu)
5444

5545

5646
class Direction(enum.Enum):

src/idom/core/layout.py

+35-33
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def __enter__(self: _Self) -> _Self:
100100
def __exit__(self, *exc: Any) -> None:
101101
root_csid = self._root_life_cycle_state_id
102102
root_model_state = self._model_states_by_life_cycle_state_id[root_csid]
103-
self._unmount_model_states([root_model_state])
103+
self._deep_unmount_model_states([root_model_state])
104104

105105
# delete attributes here to avoid access after exiting context manager
106106
del self._event_handlers
@@ -166,7 +166,7 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate:
166166

167167
# hook effects must run after the update is complete
168168
for model_state in _iter_model_state_children(new_state):
169-
if hasattr(model_state, "life_cycle_state"):
169+
if model_state.is_component_state:
170170
model_state.life_cycle_state.hook.component_did_render()
171171

172172
old_model: Optional[VdomJson]
@@ -271,10 +271,10 @@ def _render_model_attributes(
271271

272272
model_event_handlers = new_state.model.current["eventHandlers"] = {}
273273
for event, handler in handlers_by_event.items():
274-
target = old_state.targets_by_event.get(
275-
event,
276-
uuid4().hex if handler.target is None else handler.target,
277-
)
274+
if event in old_state.targets_by_event:
275+
target = old_state.targets_by_event[event]
276+
else:
277+
target = uuid4().hex if handler.target is None else handler.target
278278
new_state.targets_by_event[event] = target
279279
self._event_handlers[target] = handler
280280
model_event_handlers[event] = {
@@ -320,7 +320,7 @@ def _render_model_children(
320320
self._render_model_children_without_old_state(new_state, raw_children)
321321
return None
322322
elif not raw_children:
323-
self._unmount_model_states(list(old_state.children_by_key.values()))
323+
self._deep_unmount_model_states(list(old_state.children_by_key.values()))
324324
return None
325325

326326
child_type_key_tuples = list(_process_child_type_and_key(raw_children))
@@ -335,12 +335,13 @@ def _render_model_children(
335335

336336
old_keys = set(old_state.children_by_key).difference(new_keys)
337337
if old_keys:
338-
self._unmount_model_states(
338+
self._deep_unmount_model_states(
339339
[old_state.children_by_key[key] for key in old_keys]
340340
)
341341

342342
new_children = new_state.model.current["children"] = []
343343
for index, (child, child_type, key) in enumerate(child_type_key_tuples):
344+
old_child_state = old_state.children_by_key.get(key)
344345
if child_type is _DICT_TYPE:
345346
old_child_state = old_state.children_by_key.get(key)
346347
if old_child_state is None:
@@ -350,6 +351,8 @@ def _render_model_children(
350351
key,
351352
)
352353
else:
354+
if old_child_state.is_component_state:
355+
self._shallow_unmount_model_state(old_child_state)
353356
new_child_state = _update_element_model_state(
354357
old_child_state,
355358
new_state,
@@ -374,9 +377,13 @@ def _render_model_children(
374377
new_state,
375378
index,
376379
child,
380+
self._rendering_queue.put,
377381
)
378382
self._render_component(old_child_state, new_child_state, child)
379383
else:
384+
old_child_state = old_state.children_by_key.get(key)
385+
if old_child_state is not None:
386+
self._deep_unmount_model_states([old_child_state])
380387
new_children.append(child)
381388

382389
def _render_model_children_without_old_state(
@@ -399,20 +406,21 @@ def _render_model_children_without_old_state(
399406
else:
400407
new_children.append(child)
401408

402-
def _unmount_model_states(self, old_states: List[_ModelState]) -> None:
409+
def _deep_unmount_model_states(self, old_states: List[_ModelState]) -> None:
403410
to_unmount = old_states[::-1] # unmount in reversed order of rendering
404411
while to_unmount:
405412
model_state = to_unmount.pop()
413+
self._shallow_unmount_model_state(model_state)
414+
to_unmount.extend(model_state.children_by_key.values())
406415

407-
for target in model_state.targets_by_event.values():
408-
del self._event_handlers[target]
409-
410-
if hasattr(model_state, "life_cycle_state"):
411-
life_cycle_state = model_state.life_cycle_state
412-
del self._model_states_by_life_cycle_state_id[life_cycle_state.id]
413-
life_cycle_state.hook.component_will_unmount()
416+
def _shallow_unmount_model_state(self, old_state: _ModelState) -> None:
417+
for target in old_state.targets_by_event.values():
418+
del self._event_handlers[target]
414419

415-
to_unmount.extend(model_state.children_by_key.values())
420+
if old_state.is_component_state:
421+
life_cycle_state = old_state.life_cycle_state
422+
del self._model_states_by_life_cycle_state_id[life_cycle_state.id]
423+
life_cycle_state.hook.component_will_unmount()
416424

417425
def __repr__(self) -> str:
418426
return f"{type(self).__name__}({self.root})"
@@ -459,7 +467,6 @@ def _make_component_model_state(
459467

460468

461469
def _copy_component_model_state(old_model_state: _ModelState) -> _ModelState:
462-
463470
# use try/except here because not having a parent is rare (only the root state)
464471
try:
465472
parent: Optional[_ModelState] = old_model_state.parent
@@ -483,15 +490,8 @@ def _update_component_model_state(
483490
new_parent: _ModelState,
484491
new_index: int,
485492
new_component: ComponentType,
493+
schedule_render: Callable[[_LifeCycleStateId], None],
486494
) -> _ModelState:
487-
try:
488-
old_life_cycle_state = old_model_state.life_cycle_state
489-
except AttributeError:
490-
raise ValueError(
491-
f"Failed to render layout at {old_model_state.patch_path!r} with key "
492-
f"{old_model_state.key!r} - prior element with this key wasn't a component"
493-
)
494-
495495
return _ModelState(
496496
parent=new_parent,
497497
index=new_index,
@@ -500,7 +500,11 @@ def _update_component_model_state(
500500
patch_path=old_model_state.patch_path,
501501
children_by_key={},
502502
targets_by_event={},
503-
life_cycle_state=_update_life_cycle_state(old_life_cycle_state, new_component),
503+
life_cycle_state=(
504+
_update_life_cycle_state(old_model_state.life_cycle_state, new_component)
505+
if old_model_state.is_component_state
506+
else _make_life_cycle_state(new_component, schedule_render)
507+
),
504508
)
505509

506510

@@ -525,12 +529,6 @@ def _update_element_model_state(
525529
new_parent: _ModelState,
526530
new_index: int,
527531
) -> _ModelState:
528-
if hasattr(old_model_state, "life_cycle_state"):
529-
raise ValueError(
530-
f"Failed to render layout at {old_model_state.patch_path!r} with key "
531-
f"{old_model_state.key!r} - prior element with this key was a component"
532-
)
533-
534532
return _ModelState(
535533
parent=new_parent,
536534
index=new_index,
@@ -597,6 +595,10 @@ def __init__(
597595
self.life_cycle_state = life_cycle_state
598596
"""The state for the element's component (if it has one)"""
599597

598+
@property
599+
def is_component_state(self) -> bool:
600+
return hasattr(self, "life_cycle_state")
601+
600602
@property
601603
def parent(self) -> _ModelState:
602604
parent = self._parent_ref()

tests/test_core/test_layout.py

+28-48
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import idom
1010
from idom.config import IDOM_DEBUG_MODE
1111
from idom.core.dispatcher import render_json_patch
12+
from idom.core.hooks import use_effect
1213
from idom.core.layout import LayoutEvent
1314
from idom.testing import (
1415
HookCatcher,
@@ -736,66 +737,45 @@ def Child(state):
736737
await layout.render()
737738

738739

739-
async def test_layout_element_cannot_become_a_component():
740-
set_child_type = idom.Ref()
740+
async def test_elements_and_components_with_the_same_key_can_be_interchanged():
741+
set_toggle = idom.Ref()
742+
effects = []
743+
744+
def use_toggle():
745+
state, set_state = idom.hooks.use_state(True)
746+
return state, lambda: set_state(not state)
741747

742748
@idom.component
743749
def Root():
744-
child_type, set_child_type.current = idom.hooks.use_state("element")
745-
return idom.html.div(child_nodes[child_type])
750+
toggle, set_toggle.current = use_toggle()
751+
if toggle:
752+
return idom.html.div(SomeComponent("x"))
753+
else:
754+
return idom.html.div(idom.html.div(SomeComponent("y")))
746755

747756
@idom.component
748-
def Child():
749-
return idom.html.div()
750-
751-
child_nodes = {
752-
"element": idom.html.div(key="the-same-key"),
753-
"component": Child(key="the-same-key"),
754-
}
755-
756-
with assert_idom_logged(
757-
error_type=ValueError,
758-
match_error="prior element with this key wasn't a component",
759-
clear_matched_records=True,
760-
):
761-
762-
with idom.Layout(Root()) as layout:
763-
await layout.render()
764-
765-
set_child_type.current("component")
766-
767-
await layout.render()
768-
757+
def SomeComponent(name):
758+
@use_effect
759+
def some_effect():
760+
effects.append("mount " + name)
761+
return lambda: effects.append("unmount " + name)
769762

770-
async def test_layout_component_cannot_become_an_element():
771-
set_child_type = idom.Ref()
763+
return idom.html.div(name)
772764

773-
@idom.component
774-
def Root():
775-
child_type, set_child_type.current = idom.hooks.use_state("component")
776-
return idom.html.div(child_nodes[child_type])
777-
778-
@idom.component
779-
def Child():
780-
return idom.html.div()
765+
with idom.Layout(Root()) as layout:
766+
await layout.render()
781767

782-
child_nodes = {
783-
"element": idom.html.div(key="the-same-key"),
784-
"component": Child(key="the-same-key"),
785-
}
768+
assert effects == ["mount x"]
786769

787-
with assert_idom_logged(
788-
error_type=ValueError,
789-
match_error="prior element with this key was a component",
790-
clear_matched_records=True,
791-
):
770+
set_toggle.current()
771+
await layout.render()
792772

793-
with idom.Layout(Root()) as layout:
794-
await layout.render()
773+
assert effects == ["mount x", "unmount x", "mount y"]
795774

796-
set_child_type.current("element")
775+
set_toggle.current()
776+
await layout.render()
797777

798-
await layout.render()
778+
assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"]
799779

800780

801781
async def test_layout_does_not_copy_element_children_by_key():

0 commit comments

Comments
 (0)