Skip to content

Commit 64b9a4c

Browse files
committed
add tests for keyed hook preservation
this needs to be cleaned up now that we know what the bugs were
1 parent abfd2e7 commit 64b9a4c

File tree

4 files changed

+115
-67
lines changed

4 files changed

+115
-67
lines changed

src/idom/core/component.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ def component(function: ComponentRenderFunction) -> Callable[..., "Component"]:
2121
"""
2222

2323
@wraps(function)
24-
def constructor(*args: Any, **kwargs: Any) -> Component:
25-
return Component(function, args, kwargs)
24+
def constructor(*args: Any, key: str = "", **kwargs: Any) -> Component:
25+
return Component(function, key, args, kwargs)
2626

2727
return constructor
2828

@@ -48,13 +48,16 @@ class Component(AbstractComponent):
4848
def __init__(
4949
self,
5050
function: ComponentRenderFunction,
51+
key: str,
5152
args: Tuple[Any, ...],
5253
kwargs: Dict[str, Any],
5354
) -> None:
54-
self.key = kwargs.get("key", "")
55+
self.key = key
5556
self._function = function
5657
self._args = args
5758
self._kwargs = kwargs
59+
if key:
60+
kwargs["key"] = key
5861

5962
def render(self) -> VdomDict:
6063
model = self._function(*self._args, **self._kwargs)

src/idom/core/hooks.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import weakref
45
from logging import getLogger
56
from threading import get_ident as get_thread_id
67
from typing import (
@@ -374,7 +375,7 @@ class LifeCycleHook:
374375

375376
__slots__ = (
376377
"component",
377-
"_schedule_render_callback",
378+
"_layout",
378379
"_schedule_render_later",
379380
"_current_state_index",
380381
"_state",
@@ -390,7 +391,7 @@ def __init__(
390391
layout: idom.core.layout.Layout,
391392
) -> None:
392393
self.component = component
393-
self._schedule_render_callback = layout.update
394+
self._layout = weakref.ref(layout)
394395
self._schedule_render_later = False
395396
self._is_rendering = False
396397
self._rendered_atleast_once = False
@@ -468,4 +469,6 @@ def unset_current(self) -> None:
468469
del _current_life_cycle_hook[get_thread_id()]
469470

470471
def _schedule_render(self) -> None:
471-
self._schedule_render_callback(self.component)
472+
layout = self._layout()
473+
assert layout is not None
474+
layout.update(self.component)

src/idom/core/layout.py

Lines changed: 78 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
Set,
1616
Tuple,
1717
)
18-
from weakref import ReferenceType, ref
18+
from weakref import ref
1919

2020
from jsonpatch import apply_patch, make_patch
2121
from typing_extensions import TypedDict
@@ -125,7 +125,7 @@ async def _model_state_by_component_id(
125125

126126
def _create_layout_update(self, component: AbstractComponent) -> LayoutUpdate:
127127
old_state = self._model_state_by_component_id[id(component)]
128-
new_state = old_state.new()
128+
new_state = old_state.new(None, component)
129129

130130
self._render_component(old_state, new_state, component)
131131
changes = make_patch(getattr(old_state, "model", {}), new_state.model).patch
@@ -269,26 +269,33 @@ def _render_model_children(
269269

270270
old_keys = set(old_state.children_by_key).difference(raw_typed_children_by_key)
271271
old_child_states = {key: old_state.children_by_key[key] for key in old_keys}
272-
if old_child_states:
272+
if old_keys:
273273
self._unmount_model_states(list(old_child_states.values()))
274274

275275
new_children = new_state.model["children"] = []
276276
for index, (key, (child_type, child)) in enumerate(
277277
raw_typed_children_by_key.items()
278278
):
279279
if child_type is DICT_TYPE:
280-
child_state = _ModelState(ref(new_state), index, key, None)
281-
self._render_model(old_child_states.get(key), child_state, child)
282-
new_children.append(child_state.model)
283-
new_state.children_by_key[key] = child_state
280+
old_child_state = old_state.children_by_key.get(key)
281+
if old_child_state is not None:
282+
new_child_state = old_child_state.new(new_state, None)
283+
else:
284+
new_child_state = _ModelState(new_state, index, key, None)
285+
self._render_model(old_child_state, new_child_state, child)
286+
new_children.append(new_child_state.model)
287+
new_state.children_by_key[key] = new_child_state
284288
elif child_type is COMPONENT_TYPE:
285-
key = getattr(child, "key", "") or hex(id(child))
286-
life_cycle_hook = LifeCycleHook(child, self)
287-
child_state = _ModelState(ref(new_state), index, key, life_cycle_hook)
288-
self._render_component(old_child_states.get(key), child_state, child)
289-
new_children.append(child_state.model)
290-
new_state.children_by_key[key] = child_state
291-
self._model_state_by_component_id[id(child)] = child_state
289+
old_child_state = old_state.children_by_key.get(key)
290+
if old_child_state is not None:
291+
new_child_state = old_child_state.new(new_state, child)
292+
else:
293+
hook = LifeCycleHook(child, self)
294+
new_child_state = _ModelState(new_state, index, key, hook)
295+
self._render_component(old_child_state, new_child_state, child)
296+
new_children.append(new_child_state.model)
297+
new_state.children_by_key[key] = new_child_state
298+
self._model_state_by_component_id[id(child)] = new_child_state
292299
else:
293300
new_children.append(child)
294301

@@ -299,14 +306,14 @@ def _render_model_children_without_old_state(
299306
for index, child in enumerate(raw_children):
300307
if isinstance(child, dict):
301308
key = child.get("key") or hex(id(child))
302-
child_state = _ModelState(ref(new_state), index, key, None)
309+
child_state = _ModelState(new_state, index, key, None)
303310
self._render_model(None, child_state, child)
304311
new_children.append(child_state.model)
305312
new_state.children_by_key[key] = child_state
306313
elif isinstance(child, AbstractComponent):
307314
key = getattr(child, "key", "") or hex(id(child))
308315
life_cycle_hook = LifeCycleHook(child, self)
309-
child_state = _ModelState(ref(new_state), index, key, life_cycle_hook)
316+
child_state = _ModelState(new_state, index, key, life_cycle_hook)
310317
self._render_component(None, child_state, child)
311318
new_children.append(child_state.model)
312319
new_state.children_by_key[key] = child_state
@@ -322,6 +329,10 @@ def _unmount_model_states(self, old_states: List[_ModelState]) -> None:
322329
hook = state.life_cycle_hook
323330
hook.component_will_unmount()
324331
del self._model_state_by_component_id[id(hook.component)]
332+
import gc
333+
334+
print(state)
335+
print(gc.get_referrers(hook))
325336
to_unmount.extend(state.children_by_key.values())
326337

327338
def __repr__(self) -> str:
@@ -333,7 +344,7 @@ class _ModelState:
333344
__slots__ = (
334345
"index",
335346
"key",
336-
"parent_ref",
347+
"_parent_ref",
337348
"life_cycle_hook",
338349
"patch_path",
339350
"key_path",
@@ -347,40 +358,49 @@ class _ModelState:
347358

348359
def __init__(
349360
self,
350-
parent_ref: Optional[ReferenceType[_ModelState]],
361+
parent: Optional[_ModelState],
351362
index: int,
352363
key: str,
353364
life_cycle_hook: Optional[LifeCycleHook],
354365
) -> None:
355366
self.index = index
356367
self.key = key
357368

358-
if parent_ref is not None:
359-
self.parent_ref = parent_ref
360-
# temporarilly hydrate for use below
361-
parent = parent_ref()
369+
if parent is not None:
370+
self._parent_ref = ref(parent)
371+
self.key_path = f"{parent.key_path}/{key}"
372+
self.patch_path = f"{parent.patch_path}/children/{index}"
362373
else:
363-
parent = None
374+
self.key_path = self.patch_path = ""
364375

365376
if life_cycle_hook is not None:
366377
self.life_cycle_hook = life_cycle_hook
367378

368-
if parent is None:
369-
self.key_path = self.patch_path = ""
370-
else:
371-
self.key_path = f"{parent.key_path}/{key}"
372-
self.patch_path = f"{parent.patch_path}/children/{index}"
373-
374379
self.event_targets: Set[str] = set()
375380
self.children_by_key: Dict[str, _ModelState] = {}
376381

377-
def new(self) -> _ModelState:
378-
return _ModelState(
379-
getattr(self, "parent_ref", None),
380-
self.index,
381-
self.key,
382-
getattr(self, "life_cycle_hook", None),
383-
)
382+
@property
383+
def parent(self) -> _ModelState:
384+
# An AttributeError here is ok. It's synonymous
385+
# with the existance of 'parent' attribute
386+
p = self._parent_ref()
387+
assert p is not None, "detached model state"
388+
return p
389+
390+
def new(
391+
self,
392+
new_parent: Optional[_ModelState],
393+
component: Optional[AbstractComponent],
394+
) -> _ModelState:
395+
if new_parent is None:
396+
new_parent = getattr(self, "parent", None)
397+
if hasattr(self, "life_cycle_hook"):
398+
assert component is not None
399+
life_cycle_hook = self.life_cycle_hook
400+
life_cycle_hook.component = component
401+
else:
402+
life_cycle_hook = None
403+
return _ModelState(new_parent, self.index, self.key, life_cycle_hook)
384404

385405
def iter_children(self, include_self: bool = True) -> Iterator[_ModelState]:
386406
to_yield = [self] if include_self else []
@@ -390,6 +410,28 @@ def iter_children(self, include_self: bool = True) -> Iterator[_ModelState]:
390410
to_yield.extend(node.children_by_key.values())
391411

392412

413+
class _ComponentQueue:
414+
415+
__slots__ = "_loop", "_queue", "_pending"
416+
417+
def __init__(self) -> None:
418+
self._loop = asyncio.get_event_loop()
419+
self._queue: "asyncio.Queue[AbstractComponent]" = asyncio.Queue()
420+
self._pending: Set[int] = set()
421+
422+
def put(self, component: AbstractComponent) -> None:
423+
component_id = id(component)
424+
if component_id not in self._pending:
425+
self._pending.add(component_id)
426+
self._loop.call_soon_threadsafe(self._queue.put_nowait, component)
427+
return None
428+
429+
async def get(self) -> AbstractComponent:
430+
component = await self._queue.get()
431+
self._pending.remove(id(component))
432+
return component
433+
434+
393435
class _ModelEventTarget(TypedDict):
394436
target: str
395437
preventDefault: bool # noqa
@@ -415,25 +457,3 @@ class _ModelVdomRequired(TypedDict, total=True):
415457

416458
class _ModelVdom(_ModelVdomRequired, _ModelVdomOptional):
417459
"""A VDOM dictionary model specifically for use with a :class:`Layout`"""
418-
419-
420-
class _ComponentQueue:
421-
422-
__slots__ = "_loop", "_queue", "_pending"
423-
424-
def __init__(self) -> None:
425-
self._loop = asyncio.get_event_loop()
426-
self._queue: "asyncio.Queue[AbstractComponent]" = asyncio.Queue()
427-
self._pending: Set[int] = set()
428-
429-
def put(self, component: AbstractComponent) -> None:
430-
component_id = id(component)
431-
if component_id not in self._pending:
432-
self._pending.add(component_id)
433-
self._loop.call_soon_threadsafe(self._queue.put_nowait, component)
434-
return None
435-
436-
async def get(self) -> AbstractComponent:
437-
component = await self._queue.get()
438-
self._pending.remove(id(component))
439-
return component

tests/test_core/test_layout.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ def Inner():
384384
)
385385

386386

387-
async def test_keyed_components_get_garbage_collected():
387+
async def test_hooks_for_keyed_components_get_garbage_collected():
388388
pop_item = idom.Ref(None)
389389
garbage_collect_items = []
390390

@@ -396,8 +396,7 @@ def Outer():
396396

397397
@idom.component
398398
def Inner(key):
399-
component = idom.hooks.current_hook().component
400-
finalize(component, lambda: garbage_collect_items.append(key))
399+
finalize(idom.hooks.current_hook(), lambda: garbage_collect_items.append(key))
401400
return idom.html.div(key)
402401

403402
async with idom.Layout(Outer()) as layout:
@@ -428,3 +427,26 @@ def ComponentReturnsDuplicateKeys():
428427

429428
with pytest.raises(ValueError, match="Duplicate keys in"):
430429
raise next(iter(caplog.records)).exc_info[1]
430+
431+
432+
async def test_keyed_components_preserve_hook_on_parent_update():
433+
outer_hook = HookCatcher()
434+
inner_hook = HookCatcher()
435+
436+
@idom.component
437+
@outer_hook.capture
438+
def Outer():
439+
return Inner(key=1)
440+
441+
@idom.component
442+
@inner_hook.capture
443+
def Inner(key):
444+
return idom.html.div(key)
445+
446+
async with idom.Layout(Outer()) as layout:
447+
await layout.render()
448+
old_inner_hook = inner_hook.current
449+
450+
outer_hook.current.schedule_render()
451+
await layout.render()
452+
assert old_inner_hook is inner_hook.current

0 commit comments

Comments
 (0)