diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93b003403..d1eec3883 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - coverage: + test-coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/docs/source/core-concepts.rst b/docs/source/core-concepts.rst index b3aae76c1..5eb10b5e1 100644 --- a/docs/source/core-concepts.rst +++ b/docs/source/core-concepts.rst @@ -66,38 +66,32 @@ ever be removed from the model. Then you'll just need to call and await a async with idom.Layout(ClickCount()) as layout: patch = await layout.render() -The layout also handles the triggering of event handlers. Normally this is done -automatically by a :ref:`Dispatcher `, but for now we'll do it manually. -We can use a trick to hard-code the ``event_handler_id`` so we can pass it, and a fake -event, to the layout's :meth:`~idom.core.layout.Layout.dispatch` method. Then we just -have to re-render the layout and see what changed: +The layout also handles the triggering of event handlers. Normally these are +automatically sent to a :ref:`Dispatcher `, but for now we'll do it +manually. To do this we need to pass a fake event with its "target" (event handler +identifier), to the layout's :meth:`~idom.core.layout.Layout.dispatch` method, after +which we can re-render and see what changed: .. testcode:: from idom.core.layout import LayoutEvent + from idom.testing import StaticEventHandler - - event_handler_id = "on-click" - + static_handler = StaticEventHandler() @idom.component def ClickCount(): count, set_count = idom.hooks.use_state(0) - @idom.event(target_id=event_handler_id) # <-- trick to hard code event handler ID - def on_click(event): - set_count(count + 1) - - return idom.html.button( - {"onClick": on_click}, - [f"Click count: {count}"], - ) + # we do this in order to capture the event handler's target ID + handler = static_handler.use(lambda event: set_count(count + 1)) + return idom.html.button({"onClick": handler}, [f"Click count: {count}"]) async with idom.Layout(ClickCount()) as layout: patch_1 = await layout.render() - fake_event = LayoutEvent(event_handler_id, [{}]) + fake_event = LayoutEvent(target=static_handler.target, data=[{}]) await layout.dispatch(fake_event) patch_2 = await layout.render() @@ -107,6 +101,12 @@ have to re-render the layout and see what changed: assert count_did_increment +.. note:: + + Don't worry about the format of the layout event's ``target``. Its an internal + detail of the layout's implementation that is neither neccessary to understanding + how things work, nor is it part of the interface clients should rely on. + Layout Dispatcher ----------------- @@ -138,7 +138,7 @@ callback that's called by the dispatcher to events it should execute. async def recv(): - event = LayoutEvent(event_handler_id, [{}]) + event = LayoutEvent(target=static_handler.target, data=[{}]) # We need this so we don't flood the render loop with events. # In practice this is never an issue since events won't arrive diff --git a/requirements/check-style.txt b/requirements/check-style.txt index 9bd0ead39..1a15e1860 100644 --- a/requirements/check-style.txt +++ b/requirements/check-style.txt @@ -1,5 +1,6 @@ black flake8 +flake8-print pep8-naming flake8-idom-hooks >=0.4.0 isort >=5.7.0 diff --git a/src/idom/config.py b/src/idom/config.py index c1ec85ebe..a7e952f4a 100644 --- a/src/idom/config.py +++ b/src/idom/config.py @@ -52,3 +52,29 @@ Absolute URL ``ABSOLUTE_PATH/my-module.js`` """ + +IDOM_FEATURE_INDEX_AS_DEFAULT_KEY = _option.Option( + "IDOM_FEATURE_INDEX_AS_DEFAULT_KEY", + default=False, + mutable=False, + validator=lambda x: bool(int(x)), +) +"""A feature flag for using the index of a sibling element as its default key + +In a future release this flag's default value will be set to true, and after that, this +flag willbe removed entirely and the indices will always be the default key. + +For more information on changes to this feature flag see: https://github.com/idom-team/idom/issues/351 +""" + +if not IDOM_FEATURE_INDEX_AS_DEFAULT_KEY.get(): # pragma: no cover + from warnings import warn + + warn( + ( + "In a future release 'IDOM_FEATURE_INDEX_AS_DEFAULT_KEY' will be turned on " + "by default. For more information on changes to this feature flag, see: " + "https://github.com/idom-team/idom/issues/351" + ), + UserWarning, + ) diff --git a/src/idom/core/component.py b/src/idom/core/component.py index 333e8fe86..08a1990ed 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -1,15 +1,16 @@ +from __future__ import annotations + import abc import inspect from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union - +from typing import Any, Callable, Dict, Optional, Tuple, Union -if TYPE_CHECKING: # pragma: no cover - from .vdom import VdomDict # noqa +from .utils import hex_id +from .vdom import VdomDict ComponentConstructor = Callable[..., "AbstractComponent"] -ComponentRenderFunction = Callable[..., Union["AbstractComponent", "VdomDict"]] +ComponentRenderFunction = Callable[..., Union["AbstractComponent", VdomDict]] def component(function: ComponentRenderFunction) -> Callable[..., "Component"]: @@ -21,52 +22,59 @@ def component(function: ComponentRenderFunction) -> Callable[..., "Component"]: """ @wraps(function) - def constructor(*args: Any, **kwargs: Any) -> Component: - return Component(function, args, kwargs) + def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Component: + return Component(function, key, args, kwargs) return constructor class AbstractComponent(abc.ABC): - __slots__ = [] if hasattr(abc.ABC, "__weakref__") else ["__weakref__"] + __slots__ = ["key"] + if not hasattr(abc.ABC, "__weakref__"): + __slots__.append("__weakref__") # pragma: no cover + + key: Optional[Any] @abc.abstractmethod - def render(self) -> "VdomDict": + def render(self) -> VdomDict: """Render the component's :ref:`VDOM ` model.""" class Component(AbstractComponent): """An object for rending component models.""" - __slots__ = ( - "_function", - "_args", - "_kwargs", - ) + __slots__ = "_func", "_args", "_kwargs" def __init__( self, function: ComponentRenderFunction, + key: Optional[Any], args: Tuple[Any, ...], kwargs: Dict[str, Any], ) -> None: - self._function = function + self.key = key + self._func = function self._args = args self._kwargs = kwargs + if key is not None: + kwargs["key"] = key - def render(self) -> Any: - return self._function(*self._args, **self._kwargs) + def render(self) -> VdomDict: + model = self._func(*self._args, **self._kwargs) + if isinstance(model, AbstractComponent): + model = {"tagName": "div", "children": [model]} + return model def __repr__(self) -> str: - sig = inspect.signature(self._function) + sig = inspect.signature(self._func) try: args = sig.bind(*self._args, **self._kwargs).arguments except TypeError: - return f"{self._function.__name__}(...)" + return f"{self._func.__name__}(...)" else: items = ", ".join(f"{k}={v!r}" for k, v in args.items()) if items: - return f"{self._function.__name__}({hex(id(self))}, {items})" + return f"{self._func.__name__}({hex_id(self)}, {items})" else: - return f"{self._function.__name__}({hex(id(self))})" + return f"{self._func.__name__}({hex_id(self)})" diff --git a/src/idom/core/events.py b/src/idom/core/events.py index 6b3f49950..c596bedbd 100644 --- a/src/idom/core/events.py +++ b/src/idom/core/events.py @@ -12,23 +12,15 @@ ) from anyio import create_task_group -from mypy_extensions import TypedDict EventsMapping = Union[Dict[str, Union["Callable[..., Any]", "EventHandler"]], "Events"] -class EventTarget(TypedDict): - target: str - preventDefault: bool # noqa - stopPropagation: bool # noqa - - def event( function: Optional[Callable[..., Any]] = None, stop_propagation: bool = False, prevent_default: bool = False, - target_id: Optional[str] = None, ) -> Union["EventHandler", Callable[[Callable[..., Any]], "EventHandler"]]: """Create an event handler function with extra functionality. @@ -49,12 +41,8 @@ def event( Block the event from propagating further up the DOM. prevent_default: Stops the default actional associate with the event from taking place. - target_id: - A unique ID used to locate this handler in the resulting VDOM. This is - automatically generated by default and is typically not set manually - except in testing. """ - handler = EventHandler(stop_propagation, prevent_default, target_id=target_id) + handler = EventHandler(stop_propagation, prevent_default) if function is not None: handler.add(function) return handler @@ -85,7 +73,7 @@ def on( stop_propagation: Block the event from propagating further up the DOM. prevent_default: - Stops the default actional associate with the event from taking place. + Stops the default action associate with the event from taking place. Returns: A decorator which accepts an event handler function as its first argument. @@ -142,53 +130,40 @@ def __repr__(self) -> str: # pragma: no cover class EventHandler: """An object which defines an event handler. - Get a serialized reference to the handler via :meth:`Handler.serialize`. - The event handler object acts like a coroutine when called. Parameters: - event_name: - The camel case name of the event. - target_id: - A unique identifier for the event handler. This is generally used if - an element has more than on event handler for the same event type. If - no ID is provided one will be generated automatically. + stop_propagation: + Block the event from propagating further up the DOM. + prevent_default: + Stops the default action associate with the event from taking place. """ __slots__ = ( "__weakref__", "_coro_handlers", "_func_handlers", - "_target_id", - "_prevent_default", - "_stop_propogation", + "prevent_default", + "stop_propagation", ) def __init__( self, stop_propagation: bool = False, prevent_default: bool = False, - target_id: Optional[str] = None, ) -> None: + self.stop_propagation = stop_propagation + self.prevent_default = prevent_default self._coro_handlers: List[Callable[..., Coroutine[Any, Any, Any]]] = [] self._func_handlers: List[Callable[..., Any]] = [] - self._target_id = target_id or str(id(self)) - self._stop_propogation = stop_propagation - self._prevent_default = prevent_default - - @property - def id(self) -> str: - """ID of the event handler.""" - return self._target_id def add(self, function: Callable[..., Any]) -> "EventHandler": - """Add a callback to the event handler. + """Add a callback function or coroutine to the event handler. Parameters: function: - The event handler function. Its parameters may indicate event attributes - which should be sent back from the fronend unless otherwise specified by - the ``properties`` parameter. + The event handler function accepting parameters sent by the client. + Typically this is a single ``event`` parameter that is a dictionary. """ if asyncio.iscoroutinefunction(function): self._coro_handlers.append(function) @@ -197,7 +172,7 @@ def add(self, function: Callable[..., Any]) -> "EventHandler": return self def remove(self, function: Callable[..., Any]) -> None: - """Remove the function from the event handler. + """Remove the given function or coroutine from this event handler. Raises: ValueError: if not found @@ -207,13 +182,10 @@ def remove(self, function: Callable[..., Any]) -> None: else: self._func_handlers.remove(function) - def serialize(self) -> EventTarget: - """Serialize the event handler.""" - return { - "target": self._target_id, - "preventDefault": self._prevent_default, - "stopPropagation": self._stop_propogation, - } + def clear(self) -> None: + """Remove all functions and coroutines from this event handler""" + self._coro_handlers.clear() + self._func_handlers.clear() async def __call__(self, data: List[Any]) -> Any: """Trigger all callbacks in the event handler.""" @@ -229,6 +201,3 @@ def __contains__(self, function: Any) -> bool: return function in self._coro_handlers else: return function in self._func_handlers - - def __repr__(self) -> str: # pragma: no cover - return f"{type(self).__name__}({self.serialize()})" diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index bfb062e4c..168f69ea4 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import asyncio +import weakref from logging import getLogger from threading import get_ident as get_thread_id from typing import ( @@ -20,6 +23,7 @@ from typing_extensions import Protocol +import idom from idom.utils import Ref from .component import AbstractComponent @@ -371,7 +375,7 @@ class LifeCycleHook: __slots__ = ( "component", - "_schedule_render_callback", + "_layout", "_schedule_render_later", "_current_state_index", "_state", @@ -384,10 +388,10 @@ class LifeCycleHook: def __init__( self, component: AbstractComponent, - schedule_render: Callable[[AbstractComponent], None], + layout: idom.core.layout.Layout, ) -> None: self.component = component - self._schedule_render_callback = schedule_render + self._layout = weakref.ref(layout) self._schedule_render_later = False self._is_rendering = False self._rendered_atleast_once = False @@ -465,4 +469,6 @@ def unset_current(self) -> None: del _current_life_cycle_hook[get_thread_id()] def _schedule_render(self) -> None: - self._schedule_render_callback(self.component) + layout = self._layout() + assert layout is not None + layout.update(self.component) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index 403b12ef0..4873d6796 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import abc import asyncio +from collections import Counter from functools import wraps from logging import getLogger from typing import ( @@ -8,23 +11,23 @@ Dict, Iterator, List, - Mapping, NamedTuple, Optional, Set, Tuple, - Union, ) +from weakref import ref from jsonpatch import apply_patch, make_patch +from typing_extensions import TypedDict -from idom.config import IDOM_DEBUG_MODE +from idom.config import IDOM_DEBUG_MODE, IDOM_FEATURE_INDEX_AS_DEFAULT_KEY from .component import AbstractComponent -from .events import EventHandler, EventTarget +from .events import EventHandler from .hooks import LifeCycleHook -from .utils import CannotAccessResource, HasAsyncResources, async_resource -from .vdom import validate_serialized_vdom +from .utils import CannotAccessResource, HasAsyncResources, async_resource, hex_id +from .vdom import validate_vdom logger = getLogger(__name__) @@ -54,16 +57,6 @@ class LayoutEvent(NamedTuple): """A list of event data passed to the event handler.""" -class ComponentState(NamedTuple): - model: Dict[str, Any] - path: str - component_id: int - component_obj: AbstractComponent - event_handler_ids: Set[str] - child_component_ids: List[int] - life_cycle_hook: LifeCycleHook - - class Layout(HasAsyncResources): __slots__ = ["root", "_event_handlers"] @@ -100,214 +93,339 @@ async def dispatch(self, event: LayoutEvent) -> None: async def render(self) -> LayoutUpdate: while True: component = await self._rendering_queue.get() - if self._has_component_state(component): + if id(component) in self._model_state_by_component_id: return self._create_layout_update(component) if IDOM_DEBUG_MODE.get(): + # If in debug mode inject a function that ensures all returned updates + # contain valid VDOM models. We only do this in debug mode in order to + # avoid unnecessarily impacting performance. + _debug_render = render @wraps(_debug_render) async def render(self) -> LayoutUpdate: # Ensure that the model is valid VDOM on each render result = await self._debug_render() - validate_serialized_vdom(self._component_states[id(self.root)].model) + validate_vdom(self._model_state_by_component_id[id(self.root)].model) return result @async_resource - async def _rendering_queue(self) -> AsyncIterator["_ComponentQueue"]: + async def _rendering_queue(self) -> AsyncIterator[_ComponentQueue]: queue = _ComponentQueue() queue.put(self.root) yield queue @async_resource - async def _component_states(self) -> AsyncIterator[Dict[int, ComponentState]]: - root_component_state = self._create_component_state(self.root, "", save=False) - try: - yield {root_component_state.component_id: root_component_state} - finally: - self._delete_component_state(root_component_state) + async def _model_state_by_component_id( + self, + ) -> AsyncIterator[Dict[int, _ModelState]]: + root_state = _ModelState(None, -1, "", LifeCycleHook(self.root, self)) + yield {id(self.root): root_state} + self._unmount_model_states([root_state]) def _create_layout_update(self, component: AbstractComponent) -> LayoutUpdate: - component_state = self._get_component_state(component) - - component_state.life_cycle_hook.component_will_render() + old_state = self._model_state_by_component_id[id(component)] + new_state = old_state.new(None, component) - for state in self._iter_component_states_from_root( - component_state, - include_root=False, - ): - state.life_cycle_hook.component_will_unmount() - - self._clear_component_state_event_handlers(component_state) - self._delete_component_state_children(component_state) + self._render_component(old_state, new_state, component) + changes = make_patch(getattr(old_state, "model", {}), new_state.model).patch - old_model = component_state.model.copy() # we copy because it will be mutated - new_model = self._render_component(component_state) - changes = make_patch(old_model, new_model).patch + # hook effects must run after the update is complete + for state in new_state.iter_children(): + if hasattr(state, "life_cycle_hook"): + state.life_cycle_hook.component_did_render() - for state in self._iter_component_states_from_root( - component_state, - include_root=True, - ): - state.life_cycle_hook.component_did_render() + return LayoutUpdate(path=new_state.patch_path, changes=changes) - return LayoutUpdate(path=component_state.path, changes=changes) - - def _render_component(self, component_state: ComponentState) -> Dict[str, Any]: + def _render_component( + self, + old_state: Optional[_ModelState], + new_state: _ModelState, + component: AbstractComponent, + ) -> None: + life_cycle_hook = new_state.life_cycle_hook + life_cycle_hook.component_will_render() try: - component_state.life_cycle_hook.set_current() + life_cycle_hook.set_current() try: - raw_model = component_state.component_obj.render() + raw_model = component.render() finally: - component_state.life_cycle_hook.unset_current() - - if isinstance(raw_model, AbstractComponent): - raw_model = {"tagName": "div", "children": [raw_model]} - - resolved_model = self._render_model(component_state, raw_model) - component_state.model.clear() - component_state.model.update(resolved_model) + life_cycle_hook.unset_current() + self._render_model(old_state, new_state, raw_model) except Exception as error: - logger.exception(f"Failed to render {component_state.component_obj}") - component_state.model.update({"tagName": "div", "__error__": str(error)}) + logger.exception(f"Failed to render {component}") + new_state.model = {"tagName": "__error__", "children": [str(error)]} - # We need to return the model from the `component_state` so that the model - # between all `ComponentState` objects within a `Layout` are shared. - return component_state.model + if old_state is not None and old_state.component is not component: + del self._model_state_by_component_id[id(old_state.component)] + self._model_state_by_component_id[id(component)] = new_state + + try: + parent = new_state.parent + except AttributeError: + pass + else: + key, index = new_state.key, new_state.index + if old_state is not None: + assert (key, index) == (old_state.key, old_state.index,), ( + "state mismatch during component update - " + f"key {key!r}!={old_state.key} " + f"or index {index}!={old_state.index}" + ) + parent.children_by_key[key] = new_state + # need to do insertion in case where old_state is None and we're appending + parent.model["children"][index : index + 1] = [new_state.model] def _render_model( self, - component_state: ComponentState, - model: Mapping[str, Any], - path: Optional[str] = None, - ) -> Dict[str, Any]: - if path is None: - path = component_state.path - - serialized_model: Dict[str, Any] = {} - event_handlers = self._render_model_event_targets(component_state, model) - if event_handlers: - serialized_model["eventHandlers"] = event_handlers - if "children" in model: - serialized_model["children"] = self._render_model_children( - component_state, model["children"], path - ) - return {**model, **serialized_model} + old_state: Optional[_ModelState], + new_state: _ModelState, + raw_model: Any, + ) -> None: + new_state.model = {"tagName": raw_model["tagName"]} - def _render_model_children( + self._render_model_attributes(old_state, new_state, raw_model) + self._render_model_children(old_state, new_state, raw_model.get("children", [])) + + if "key" in raw_model: + new_state.model["key"] = raw_model["key"] + if "importSource" in raw_model: + new_state.model["importSource"] = raw_model["importSource"] + + def _render_model_attributes( self, - component_state: ComponentState, - children: Union[List[Any], Tuple[Any, ...]], - path: str, - ) -> List[Any]: - resolved_children: List[Any] = [] - for index, child in enumerate( - children if isinstance(children, (list, tuple)) else [children] - ): - if isinstance(child, dict): - child_path = f"{path}/children/{index}" - resolved_children.append( - self._render_model(component_state, child, child_path) - ) - elif isinstance(child, AbstractComponent): - child_path = f"{path}/children/{index}" - child_state = self._create_component_state(child, child_path, save=True) - resolved_children.append(self._render_component(child_state)) - component_state.child_component_ids.append(id(child)) - else: - resolved_children.append(str(child)) - return resolved_children - - def _render_model_event_targets( - self, component_state: ComponentState, model: Mapping[str, Any] - ) -> Dict[str, EventTarget]: - handlers: Dict[str, EventHandler] = {} - if "eventHandlers" in model: - handlers.update(model["eventHandlers"]) - if "attributes" in model: - attrs = model["attributes"] + old_state: Optional[_ModelState], + new_state: _ModelState, + raw_model: Dict[str, Any], + ) -> None: + # extract event handlers from 'eventHandlers' and 'attributes' + handlers_by_event: Dict[str, EventHandler] = {} + + if "eventHandlers" in raw_model: + handlers_by_event.update(raw_model["eventHandlers"]) + + if "attributes" in raw_model: + attrs = new_state.model["attributes"] = raw_model["attributes"].copy() for k, v in list(attrs.items()): if callable(v): if not isinstance(v, EventHandler): - h = handlers[k] = EventHandler() + h = handlers_by_event[k] = EventHandler() h.add(attrs.pop(k)) else: h = attrs.pop(k) - handlers[k] = h + handlers_by_event[k] = h - event_handlers_by_id = {h.id: h for h in handlers.values()} - component_state.event_handler_ids.clear() - component_state.event_handler_ids.update(event_handlers_by_id) - self._event_handlers.update(event_handlers_by_id) + if old_state is None: + self._render_model_event_handlers_without_old_state( + new_state, handlers_by_event + ) + return None + + for old_event in set(old_state.targets_by_event).difference(handlers_by_event): + old_target = old_state.targets_by_event[old_event] + del self._event_handlers[old_target] + + if not handlers_by_event: + return None + + model_event_handlers = new_state.model["eventHandlers"] = {} + for event, handler in handlers_by_event.items(): + target = old_state.targets_by_event.get(event, hex_id(handler)) + new_state.targets_by_event[event] = target + self._event_handlers[target] = handler + model_event_handlers[event] = { + "target": target, + "preventDefault": handler.prevent_default, + "stopPropagation": handler.stop_propagation, + } - return {e: h.serialize() for e, h in handlers.items()} + return None - def _get_component_state(self, component: AbstractComponent) -> ComponentState: - return self._component_states[id(component)] + def _render_model_event_handlers_without_old_state( + self, + new_state: _ModelState, + handlers_by_event: Dict[str, EventHandler], + ) -> None: + if not handlers_by_event: + return None + + model_event_handlers = new_state.model["eventHandlers"] = {} + for event, handler in handlers_by_event.items(): + target = hex_id(handler) + new_state.targets_by_event[event] = target + self._event_handlers[target] = handler + model_event_handlers[event] = { + "target": target, + "preventDefault": handler.prevent_default, + "stopPropagation": handler.stop_propagation, + } - def _has_component_state(self, component: AbstractComponent) -> bool: - return id(component) in self._component_states + return None - def _create_component_state( + def _render_model_children( self, - component: AbstractComponent, - path: str, - save: bool, - ) -> ComponentState: - component_id = id(component) - state = ComponentState( - model={}, - path=path, - component_id=component_id, - component_obj=component, - event_handler_ids=set(), - child_component_ids=[], - life_cycle_hook=LifeCycleHook(component, self.update), - ) - if save: - self._component_states[component_id] = state - return state + old_state: Optional[_ModelState], + new_state: _ModelState, + raw_children: Any, + ) -> None: + if not isinstance(raw_children, (list, tuple)): + raw_children = [raw_children] + + if old_state is None: + if raw_children: + self._render_model_children_without_old_state(new_state, raw_children) + return None + elif not raw_children: + self._unmount_model_states(list(old_state.children_by_key.values())) + return None + + child_type_key_tuples = list(_process_child_type_and_key(raw_children)) + + new_keys = {item[2] for item in child_type_key_tuples} + if len(new_keys) != len(raw_children): + key_counter = Counter(item[2] for item in child_type_key_tuples) + duplicate_keys = [key for key, count in key_counter.items() if count > 1] + raise ValueError( + f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" + ) + + old_keys = set(old_state.children_by_key).difference(new_keys) + if old_keys: + self._unmount_model_states( + [old_state.children_by_key[key] for key in old_keys] + ) - def _delete_component_state(self, component_state: ComponentState) -> None: - self._clear_component_state_event_handlers(component_state) - self._delete_component_state_children(component_state) - del self._component_states[component_state.component_id] + new_children = new_state.model["children"] = [] + for index, (child, child_type, key) in enumerate(child_type_key_tuples): + if child_type is _DICT_TYPE: + old_child_state = old_state.children_by_key.get(key) + if old_child_state is not None: + new_child_state = old_child_state.new(new_state, None) + else: + new_child_state = _ModelState(new_state, index, key, None) + self._render_model(old_child_state, new_child_state, child) + new_children.append(new_child_state.model) + new_state.children_by_key[key] = new_child_state + elif child_type is _COMPONENT_TYPE: + old_child_state = old_state.children_by_key.get(key) + if old_child_state is not None: + new_child_state = old_child_state.new(new_state, child) + else: + hook = LifeCycleHook(child, self) + new_child_state = _ModelState(new_state, index, key, hook) + self._render_component(old_child_state, new_child_state, child) + else: + new_children.append(child) - def _clear_component_state_event_handlers( - self, component_state: ComponentState + def _render_model_children_without_old_state( + self, new_state: _ModelState, raw_children: List[Any] ) -> None: - for handler_id in component_state.event_handler_ids: - del self._event_handlers[handler_id] - component_state.event_handler_ids.clear() + new_children = new_state.model["children"] = [] + for index, (child, child_type, key) in enumerate( + _process_child_type_and_key(raw_children) + ): + if child_type is _DICT_TYPE: + child_state = _ModelState(new_state, index, key, None) + self._render_model(None, child_state, child) + new_children.append(child_state.model) + new_state.children_by_key[key] = child_state + elif child_type is _COMPONENT_TYPE: + life_cycle_hook = LifeCycleHook(child, self) + child_state = _ModelState(new_state, index, key, life_cycle_hook) + self._render_component(None, child_state, child) + else: + new_children.append(child) + + def _unmount_model_states(self, old_states: List[_ModelState]) -> None: + to_unmount = old_states[::-1] + while to_unmount: + state = to_unmount.pop() + if hasattr(state, "life_cycle_hook"): + hook = state.life_cycle_hook + hook.component_will_unmount() + del self._model_state_by_component_id[id(hook.component)] + to_unmount.extend(state.children_by_key.values()) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.root})" - def _delete_component_state_children(self, component_state: ComponentState) -> None: - for e_id in component_state.child_component_ids: - self._delete_component_state(self._component_states[e_id]) - component_state.child_component_ids.clear() - def _iter_component_states_from_root( +class _ModelState: + + __slots__ = ( + "index", + "key", + "_parent_ref", + "life_cycle_hook", + "component", + "patch_path", + "model", + "targets_by_event", + "children_by_key", + "__weakref__", + ) + + model: _ModelVdom + life_cycle_hook: LifeCycleHook + patch_path: str + component: AbstractComponent + + def __init__( self, - root_component_state: ComponentState, - include_root: bool, - ) -> Iterator[ComponentState]: - if include_root: - pending = [root_component_state] + parent: Optional[_ModelState], + index: int, + key: Any, + life_cycle_hook: Optional[LifeCycleHook], + ) -> None: + self.index = index + self.key = key + + if parent is not None: + self._parent_ref = ref(parent) + self.patch_path = f"{parent.patch_path}/children/{index}" else: - pending = [ - self._component_states[i] - for i in root_component_state.child_component_ids - ] - - while pending: - visited_component_state = pending.pop(0) - yield visited_component_state - pending.extend( - self._component_states[i] - for i in visited_component_state.child_component_ids - ) + self.patch_path = "" - def __repr__(self) -> str: - return f"{type(self).__name__}({self.root})" + if life_cycle_hook is not None: + self.life_cycle_hook = life_cycle_hook + self.component = life_cycle_hook.component + + self.targets_by_event: Dict[str, str] = {} + self.children_by_key: Dict[str, _ModelState] = {} + + @property + def parent(self) -> _ModelState: + # An AttributeError here is ok. It's synonymous + # with the existance of 'parent' attribute + p = self._parent_ref() + assert p is not None, "detached model state" + return p + + def new( + self, + new_parent: Optional[_ModelState], + component: Optional[AbstractComponent], + ) -> _ModelState: + if new_parent is None: + new_parent = getattr(self, "parent", None) + + life_cycle_hook: Optional[LifeCycleHook] + if hasattr(self, "life_cycle_hook"): + assert component is not None + life_cycle_hook = self.life_cycle_hook + life_cycle_hook.component = component + else: + life_cycle_hook = None + + return _ModelState(new_parent, self.index, self.key, life_cycle_hook) + + def iter_children(self, include_self: bool = True) -> Iterator[_ModelState]: + to_yield = [self] if include_self else [] + while to_yield: + node = to_yield.pop() + yield node + to_yield.extend(node.children_by_key.values()) class _ComponentQueue: @@ -330,3 +448,69 @@ async def get(self) -> AbstractComponent: component = await self._queue.get() self._pending.remove(id(component)) return component + + +def _process_child_type_and_key( + children: List[Any], +) -> Iterator[Tuple[Any, int, Any]]: + for index, child in enumerate(children): + if isinstance(child, dict): + child_type = _DICT_TYPE + key = child.get("key") + elif isinstance(child, AbstractComponent): + child_type = _COMPONENT_TYPE + key = getattr(child, "key", None) + else: + child = f"{child}" + child_type = _STRING_TYPE + key = None + + if key is None: + key = _default_key(index) + + yield (child, child_type, key) + + +if IDOM_FEATURE_INDEX_AS_DEFAULT_KEY.get(): + + def _default_key(index: int) -> Any: # pragma: no cover + return index + + +else: + + def _default_key(index: int) -> Any: + return object() + + +# used in _process_child_type_and_key +_DICT_TYPE = 1 +_COMPONENT_TYPE = 2 +_STRING_TYPE = 3 + + +class _ModelEventTarget(TypedDict): + target: str + preventDefault: bool # noqa + stopPropagation: bool # noqa + + +class _ModelImportSource(TypedDict): + source: str + fallback: Any + + +class _ModelVdomOptional(TypedDict, total=False): + key: str # noqa + children: List[Any] # noqa + attributes: Dict[str, Any] # noqa + eventHandlers: Dict[str, _ModelEventTarget] # noqa + importSource: _ModelImportSource # noqa + + +class _ModelVdomRequired(TypedDict, total=True): + tagName: str # noqa + + +class _ModelVdom(_ModelVdomRequired, _ModelVdomOptional): + """A VDOM dictionary model specifically for use with a :class:`Layout`""" diff --git a/src/idom/core/utils.py b/src/idom/core/utils.py index bd069a6fc..09e05c5c6 100644 --- a/src/idom/core/utils.py +++ b/src/idom/core/utils.py @@ -22,6 +22,10 @@ from async_generator import asynccontextmanager +def hex_id(obj: Any) -> str: + return format(id(obj), "x") + + _Rsrc = TypeVar("_Rsrc") _Self = TypeVar("_Self", bound="HasAsyncResources") diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 3c5725649..2d9269bfc 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,11 +1,12 @@ -from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Union +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union from fastjsonschema import compile as compile_json_schema from mypy_extensions import TypedDict from typing_extensions import Protocol -from .component import AbstractComponent -from .events import EventsMapping +from .events import EventHandler VDOM_JSON_SCHEMA = { @@ -16,6 +17,7 @@ "type": "object", "properties": { "tagName": {"type": "string"}, + "key": {"type": "string"}, "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, @@ -63,7 +65,7 @@ } -validate_serialized_vdom = compile_json_schema(VDOM_JSON_SCHEMA) +validate_vdom = compile_json_schema(VDOM_JSON_SCHEMA) class ImportSourceDict(TypedDict): @@ -72,9 +74,10 @@ class ImportSourceDict(TypedDict): class _VdomDictOptional(TypedDict, total=False): - children: Union[List[Any], Tuple[Any, ...]] # noqa - attributes: Dict[str, Any] # noqa - eventHandlers: EventsMapping # noqa + key: str # noqa + children: Sequence[Any] # noqa + attributes: Mapping[str, Any] # noqa + eventHandlers: Mapping[str, EventHandler] # noqa importSource: ImportSourceDict # noqa @@ -86,31 +89,15 @@ class VdomDict(_VdomDictRequired, _VdomDictOptional): """A VDOM dictionary""" -_TagArg = str -_ComponentFunc = Callable[..., Union[VdomDict, AbstractComponent]] _AttributesAndChildrenArg = Union[Mapping[str, Any], str, Iterable[Any], Any] -_EventHandlersArg = Optional[EventsMapping] +_EventHandlersArg = Optional[Mapping[str, EventHandler]] _ImportSourceArg = Optional[ImportSourceDict] -def component( - tag: Union[_TagArg, _ComponentFunc], - *attributes_and_children: _AttributesAndChildrenArg, -) -> Union[VdomDict, AbstractComponent]: - if isinstance(tag, str): - return vdom(tag, *attributes_and_children) - - attributes, children = _coalesce_attributes_and_children(attributes_and_children) - - if children: - return tag(children=children, **attributes) - else: - return tag(**attributes) - - def vdom( - tag: _TagArg, + tag: str, *attributes_and_children: _AttributesAndChildrenArg, + key: str = "", event_handlers: _EventHandlersArg = None, import_source: _ImportSourceArg = None, ) -> VdomDict: @@ -123,6 +110,11 @@ def vdom( The attributes **must** precede the children, though you may pass multiple sets of attributes, or children which will be merged into their respective parts of the model. + key: + A string idicating the identity of a particular element. This is significant + to preserve event handlers across updates - without a key, a re-render would + cause these handlers to be deleted, but with a key, they would be redirected + to any newly defined handlers. event_handlers: Maps event types to coroutines that are responsible for handling those events. import_source: @@ -131,7 +123,7 @@ def vdom( """ model: VdomDict = {"tagName": tag} - attributes, children = _coalesce_attributes_and_children(attributes_and_children) + attributes, children = coalesce_attributes_and_children(attributes_and_children) if attributes: model["attributes"] = attributes @@ -139,6 +131,9 @@ def vdom( if children: model["children"] = children + if key: + model["key"] = key + if event_handlers is not None: model["eventHandlers"] = event_handlers @@ -166,12 +161,14 @@ def make_vdom_constructor(tag: str, allow_children: bool = True) -> VdomDictCons def constructor( *attributes_and_children: _AttributesAndChildrenArg, + key: str = "", event_handlers: _EventHandlersArg = None, import_source: _ImportSourceArg = None, ) -> VdomDict: model = vdom( tag, *attributes_and_children, + key=key, event_handlers=event_handlers, import_source=import_source, ) @@ -189,7 +186,7 @@ def constructor( return constructor -def _coalesce_attributes_and_children( +def coalesce_attributes_and_children( attributes_and_children: _AttributesAndChildrenArg, ) -> Tuple[Dict[str, Any], List[Any]]: attributes: Dict[str, Any] = {} diff --git a/src/idom/testing.py b/src/idom/testing.py index c98111cc7..c800f560c 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -1,5 +1,6 @@ import logging import re +from functools import wraps from types import TracebackType from typing import ( Any, @@ -14,10 +15,14 @@ Union, ) from urllib.parse import urlencode, urlunparse +from weakref import ref from selenium.webdriver import Chrome from selenium.webdriver.remote.webdriver import WebDriver +from idom.core.events import EventHandler +from idom.core.hooks import LifeCycleHook, current_hook +from idom.core.utils import hex_id from idom.server.base import AbstractRenderServer from idom.server.prefab import hotswap_server from idom.server.utils import find_available_port, find_builtin_server_type @@ -167,3 +172,111 @@ def __init__(self) -> None: def handle(self, record: logging.LogRecord) -> None: self.records.append(record) + + +class HookCatcher: + """Utility for capturing a LifeCycleHook from a component + + Example: + .. code-block:: + + hooks = HookCatcher(index_by_kwarg="key") + + @idom.component + @hooks.capture + def MyComponent(key): + ... + + ... # render the component + + # grab the last render of where MyComponent(key='some_key') + hooks.index["some_key"] + # or grab the hook from the component's last render + hooks.latest + + After the first render of ``MyComponent`` the ``HookCatcher`` will have + captured the component's ``LifeCycleHook``. + """ + + latest: LifeCycleHook + + def __init__(self, index_by_kwarg: Optional[str] = None): + self.index_by_kwarg = index_by_kwarg + self.index: Dict[Any, LifeCycleHook] = {} + + def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" + + # The render function holds a reference to `self` and, via the `LifeCycleHook`, + # the component. Some tests check whether components are garbage collected, thus + # we must use a `ref` here to ensure these checks pass once the catcher itself + # has been collected. + self_ref = ref(self) + + @wraps(render_function) + def wrapper(*args: Any, **kwargs: Any) -> Any: + self = self_ref() + assert self is not None, "Hook catcher has been garbage collected" + + hook = current_hook() + if self.index_by_kwarg is not None: + self.index[kwargs[self.index_by_kwarg]] = hook + self.latest = hook + return render_function(*args, **kwargs) + + return wrapper + + +class StaticEventHandler: + """Utility for capturing the target of one event handler + + Example: + .. code-block:: + + static_handler = StaticEventHandler() + + @idom.component + def MyComponent(): + state, set_state = idom.hooks.use_state(0) + handler = static_handler.use(lambda event: set_state(state + 1)) + return idom.html.button({"onClick": handler}, "Click me!") + + # gives the target ID for onClick where from the last render of MyComponent + static_handlers.target + + If you need to capture event handlers from different instances of a component + the you should create multiple ``StaticEventHandler`` instances. + + .. code-block:: + + static_handlers_by_key = { + "first": StaticEventHandler(), + "second": StaticEventHandler(), + } + + @idom.component + def Parent(): + return idom.html.div(Child(key="first"), Child(key="second")) + + @idom.component + def Child(key): + state, set_state = idom.hooks.use_state(0) + handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) + return idom.html.button({"onClick": handler}, "Click me!") + + # grab the individual targets for each instance above + first_target = static_handlers_by_key["first"].target + second_target = static_handlers_by_key["second"].target + """ + + def __init__(self) -> None: + self._handler = EventHandler() + + @property + def target(self) -> str: + return hex_id(self._handler) + + def use(self, function: Callable[..., Any]) -> EventHandler: + self._handler.clear() + self._handler.add(function) + return self._handler diff --git a/src/idom/widgets/html.py b/src/idom/widgets/html.py index 1faf00f2e..1fc4b1287 100644 --- a/src/idom/widgets/html.py +++ b/src/idom/widgets/html.py @@ -1,12 +1,14 @@ from base64 import b64encode -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, Optional, Union, overload import idom +from idom.core.component import AbstractComponent, ComponentConstructor, component from idom.core.vdom import ( VdomDict, VdomDictConstructor, - component, + coalesce_attributes_and_children, make_vdom_constructor, + vdom, ) @@ -33,7 +35,7 @@ def image( return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} -@idom.core.component +@component def Input( callback: Callable[[str], None], type: str, @@ -69,8 +71,6 @@ class Html: All constructors return :class:`~idom.core.vdom.VdomDict`. """ - __call__ = staticmethod(component) - def __init__(self) -> None: # External sources self.link = make_vdom_constructor("link", allow_children=False) @@ -166,6 +166,33 @@ def __init__(self) -> None: self.menuitem = make_vdom_constructor("menuitem") self.summary = make_vdom_constructor("summary") + @overload + @staticmethod + def __call__( + tag: ComponentConstructor, *attributes_and_children: Any + ) -> AbstractComponent: + ... + + @overload + @staticmethod + def __call__(tag: str, *attributes_and_children: Any) -> VdomDict: + ... + + @staticmethod + def __call__( + tag: Union[str, ComponentConstructor], + *attributes_and_children: Any, + ) -> Union[VdomDict, AbstractComponent]: + if isinstance(tag, str): + return vdom(tag, *attributes_and_children) + + attributes, children = coalesce_attributes_and_children(attributes_and_children) + + if children: + return tag(children=children, **attributes) + else: + return tag(**attributes) + def __getattr__(self, tag: str) -> VdomDictConstructor: return make_vdom_constructor(tag) diff --git a/tests/general_utils.py b/tests/general_utils.py index f49a9b3ff..978dc6a2f 100644 --- a/tests/general_utils.py +++ b/tests/general_utils.py @@ -1,8 +1,6 @@ -from contextlib import contextmanager -from functools import wraps -from weakref import ref +# dialect=pytest -import idom +from contextlib import contextmanager @contextmanager @@ -17,53 +15,8 @@ def patch_slots_object(obj, attr, new_value): setattr(obj, attr, old_value) -class HookCatcher: - """Utility for capturing a LifeCycleHook from a component - - Example: - .. code-block:: - component_hook = HookCatcher() - - @idom.component - @component_hook.capture - def MyComponent(): - ... - - After the first render of ``MyComponent`` the ``HookCatcher`` will have - captured the component's ``LifeCycleHook``. - """ - - current: idom.hooks.LifeCycleHook - - def capture(self, render_function): - """Decorator for capturing a ``LifeCycleHook`` on the first render of a component""" - - # The render function holds a reference to `self` and, via the `LifeCycleHook`, - # the component. Some tests check whether components are garbage collected, thus we - # must use a `ref` here to ensure these checks pass. - self_ref = ref(self) - - @wraps(render_function) - def wrapper(*args, **kwargs): - self_ref().current = idom.hooks.current_hook() - return render_function(*args, **kwargs) - - return wrapper - - def schedule_render(self) -> None: - """Useful alias of ``HookCatcher.current.schedule_render``""" - self.current.schedule_render() - - -def assert_same_items(x, y): - """Check that two unordered sequences are equal""" - - list_x = list(x) - list_y = list(y) - - assert len(x) == len(y), f"len({x}) != len({y})" - assert all( - # this is not very efficient unfortunately so don't compare anything large - list_x.count(value) == list_y.count(value) - for value in list_x - ), f"{x} != {y}" +def assert_same_items(left, right): + """Check that two unordered sequences are equal (only works if reprs are equal)""" + sorted_left = list(sorted(left, key=repr)) + sorted_right = list(sorted(right, key=repr)) + assert sorted_left == sorted_right diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index a087f4e5f..0b4767dab 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -1,4 +1,5 @@ import idom +from idom.core.utils import hex_id def test_component_repr(): @@ -8,7 +9,7 @@ def MyComponent(a, *b, **c): mc1 = MyComponent(1, 2, 3, x=4, y=5) - expected = f"MyComponent({hex(id(mc1))}, a=1, b=(2, 3), c={{'x': 4, 'y': 5}})" + expected = f"MyComponent({hex_id(mc1)}, a=1, b=(2, 3), c={{'x': 4, 'y': 5}})" assert repr(mc1) == expected # not enough args supplied to function diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index 5c3e62509..3858336d4 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -10,6 +10,7 @@ SingleViewDispatcher, ) from idom.core.layout import Layout, LayoutEvent +from idom.testing import StaticEventHandler from tests.general_utils import assert_same_items @@ -17,14 +18,19 @@ async def test_shared_state_dispatcher(): done = asyncio.Event() changes_1 = [] changes_2 = [] - target_id = "an-event" - events_to_inject = [LayoutEvent(target=target_id, data=[])] * 4 + event_name = "onEvent" + event_handler = StaticEventHandler() + + events_to_inject = [LayoutEvent(event_handler.target, [])] * 4 async def send_1(patch): changes_1.append(patch.changes) async def recv_1(): + # Need this to yield control back to event loop otherwise we block indefinitely + # for some reason. Realistically this await would be on some client event, so + # this isn't too contrived. await asyncio.sleep(0) try: return events_to_inject.pop(0) @@ -42,12 +48,8 @@ async def recv_2(): @idom.component def Clickable(): count, set_count = idom.hooks.use_state(0) - - @idom.event(target_id=target_id) - async def an_event(): - set_count(count + 1) - - return idom.html.div({"anEvent": an_event, "count": count}) + handler = event_handler.use(lambda: set_count(count + 1)) + return idom.html.div({event_name: handler, "count": count}) async with SharedViewDispatcher(Layout(Clickable())) as dispatcher: await dispatcher.run(send_1, recv_1, "1") @@ -59,8 +61,8 @@ async def an_event(): "op": "add", "path": "/eventHandlers", "value": { - "anEvent": { - "target": "an-event", + event_name: { + "target": event_handler.target, "preventDefault": False, "stopPropagation": False, } diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 821638602..2cada485d 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -39,22 +39,18 @@ async def handler_2(): assert calls == [1, 2] -def test_event_handler_serialization(): - assert EventHandler(target_id="uuid").serialize() == { - "target": "uuid", - "stopPropagation": False, - "preventDefault": False, - } - assert EventHandler(target_id="uuid", prevent_default=True).serialize() == { - "target": "uuid", - "stopPropagation": False, - "preventDefault": True, - } - assert EventHandler(target_id="uuid", stop_propagation=True).serialize() == { - "target": "uuid", - "stopPropagation": True, - "preventDefault": False, - } +def test_event_handler_props(): + handler_0 = EventHandler() + assert handler_0.stop_propagation is False + assert handler_0.prevent_default is False + + handler_1 = EventHandler(prevent_default=True) + assert handler_1.stop_propagation is False + assert handler_1.prevent_default is True + + handler_2 = EventHandler(stop_propagation=True) + assert handler_2.stop_propagation is True + assert handler_2.prevent_default is False async def test_multiple_callbacks_per_event_handler(): diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index d1caaa3d7..bcce896ef 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -4,7 +4,8 @@ import pytest import idom -from tests.general_utils import HookCatcher, assert_same_items +from idom.testing import HookCatcher +from tests.general_utils import assert_same_items async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -345,7 +346,7 @@ def cleanup(): assert not cleanup_triggered.current - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert cleanup_triggered.current @@ -354,18 +355,22 @@ def cleanup(): async def test_use_effect_cleanup_occurs_on_will_unmount(): outer_component_hook = HookCatcher() + component_did_render = idom.Ref(False) cleanup_triggered = idom.Ref(False) cleanup_triggered_before_next_render = idom.Ref(False) @idom.component @outer_component_hook.capture def OuterComponent(): - if cleanup_triggered.current: - cleanup_triggered_before_next_render.current = True return ComponentWithEffect() @idom.component def ComponentWithEffect(): + if component_did_render.current and cleanup_triggered.current: + cleanup_triggered_before_next_render.current = True + + component_did_render.current = True + @idom.hooks.use_effect def effect(): def cleanup(): @@ -380,7 +385,7 @@ def cleanup(): assert not cleanup_triggered.current - outer_component_hook.schedule_render() + outer_component_hook.latest.schedule_render() await layout.render() assert cleanup_triggered.current @@ -411,7 +416,7 @@ def effect(): assert effect_run_count.current == 1 - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert effect_run_count.current == 1 @@ -421,7 +426,7 @@ def effect(): assert effect_run_count.current == 2 - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert effect_run_count.current == 2 @@ -454,7 +459,7 @@ def cleanup(): assert cleanup_trigger_count.current == 0 - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert cleanup_trigger_count.current == 0 @@ -500,7 +505,7 @@ async def effect(): await layout.render() await effect_ran.wait() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() @@ -532,7 +537,7 @@ async def effect(): await layout.render() await effect_ran.wait() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() @@ -579,7 +584,7 @@ def bad_cleanup(): async with idom.Layout(ComponentWithEffect()) as layout: await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] @@ -607,7 +612,7 @@ def bad_cleanup(): async with idom.Layout(OuterComponent()) as layout: await layout.render() - outer_component_hook.schedule_render() + outer_component_hook.latest.schedule_render() await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] @@ -685,7 +690,7 @@ def ComponentWithRef(): async with idom.Layout(ComponentWithRef()) as layout: await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert used_callbacks[0] is used_callbacks[1] @@ -713,7 +718,7 @@ def cb(): await layout.render() set_state_hook.current(1) await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert used_callbacks[0] is not used_callbacks[1] @@ -741,7 +746,7 @@ def ComponentWithMemo(): await layout.render() set_state_hook.current(1) await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert used_values[0] is not used_values[1] @@ -764,9 +769,9 @@ def ComponentWithMemo(): async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert used_values == [1, 2, 3] @@ -791,10 +796,10 @@ def ComponentWithMemo(): async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() args_used_in_memo.current = None await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() args_used_in_memo.current = () await layout.render() @@ -816,9 +821,9 @@ def ComponentWithMemo(): async with idom.Layout(ComponentWithMemo()) as layout: await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert used_values == [1, 1, 1] @@ -836,7 +841,7 @@ def ComponentWithRef(): async with idom.Layout(ComponentWithRef()) as layout: await layout.render() - component_hook.schedule_render() + component_hook.latest.schedule_render() await layout.render() assert used_refs[0] is used_refs[1] diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index cf5c42e3f..d61152756 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -1,5 +1,4 @@ import asyncio -import gc import re from weakref import finalize @@ -7,7 +6,9 @@ import idom from idom.core.layout import LayoutEvent, LayoutUpdate -from tests.general_utils import HookCatcher, assert_same_items +from idom.core.utils import hex_id +from idom.testing import HookCatcher, StaticEventHandler +from tests.general_utils import assert_same_items def test_layout_update_create_from_apply_to(): @@ -22,7 +23,7 @@ def MyComponent(): my_component = MyComponent() layout = idom.Layout(my_component) - assert str(layout) == f"Layout(MyComponent({hex(id(my_component))}))" + assert str(layout) == f"Layout(MyComponent({hex_id(my_component)}))" def test_layout_expects_abstract_component(): @@ -75,10 +76,10 @@ async def test_nested_component_layout(): @idom.component def Parent(): state, parent_set_state.current = idom.hooks.use_state(0) - return idom.html.div(state, Child()) + return idom.html.div(state, Child(key="c")) @idom.component - def Child(): + def Child(key): state, child_set_state.current = idom.hooks.use_state(0) return idom.html.div(state) @@ -135,7 +136,10 @@ def BadChild(): "path": "/children", "value": [ {"tagName": "div", "children": ["hello"]}, - {"tagName": "div", "__error__": "Something went wrong :("}, + { + "tagName": "__error__", + "children": ["Something went wrong :("], + }, {"tagName": "div", "children": ["hello"]}, ], }, @@ -177,21 +181,14 @@ async def test_components_are_garbage_collected(): def Outer(): component = idom.hooks.current_hook().component live_components.add(id(component)) - finalize(component, live_components.remove, id(component)) - - hook = idom.hooks.current_hook() - - @idom.event(target_id="force-update") - async def force_update(): - hook.schedule_render() - - return idom.html.div({"onEvent": force_update}, Inner()) + finalize(component, live_components.discard, id(component)) + return Inner() @idom.component def Inner(): component = idom.hooks.current_hook().component live_components.add(id(component)) - finalize(component, live_components.remove, id(component)) + finalize(component, live_components.discard, id(component)) return idom.html.div() async with idom.Layout(Outer()) as layout: @@ -204,7 +201,7 @@ def Inner(): # the the old `Inner` component should be deleted. Thus there should be one # changed component in the set of `live_components` the old `Inner` deleted and new # `Inner` added. - outer_component_hook.schedule_render() + outer_component_hook.latest.schedule_render() await layout.render() assert len(live_components - last_live_components) == 1 @@ -215,7 +212,6 @@ def Inner(): # the hook also contains a reference to the root component del outer_component_hook - gc.collect() assert not live_components @@ -234,8 +230,8 @@ def AnyComponent(): assert run_count.current == 1 - hook.schedule_render() - hook.schedule_render() + hook.latest.schedule_render() + hook.latest.schedule_render() await layout.render() try: @@ -264,7 +260,7 @@ def Child(): async with idom.Layout(Parent()) as layout: await layout.render() - hook.current.schedule_render() + hook.latest.schedule_render() update = await layout.render() assert update.path == "/children/0/children/0" @@ -282,3 +278,193 @@ def SomeComponent(): "Ignored event - handler 'missing' does not exist or its component unmounted", next(iter(caplog.records)).msg, ) + + +def use_toggle(init=False): + state, set_state = idom.hooks.use_state(init) + return state, lambda: set_state(lambda old: not old) + + +async def test_model_key_preserves_callback_identity_for_common_elements(): + called_good_trigger = idom.Ref(False) + good_handler = StaticEventHandler() + bad_handler = StaticEventHandler() + + @idom.component + def MyComponent(): + reverse_children, set_reverse_children = use_toggle() + + @good_handler.use + def good_trigger(): + called_good_trigger.current = True + set_reverse_children() + + @bad_handler.use + def bad_trigger(): + raise ValueError("Called bad trigger") + + children = [ + idom.html.button( + {"onClick": good_trigger, "id": "good"}, "good", key="good" + ), + idom.html.button({"onClick": bad_trigger, "id": "bad"}, "bad", key="bad"), + ] + + if reverse_children: + children.reverse() + + return idom.html.div(children) + + async with idom.Layout(MyComponent()) as layout: + await layout.render() + for i in range(3): + event = LayoutEvent(good_handler.target, []) + await layout.dispatch(event) + + assert called_good_trigger.current + # reset after checking + called_good_trigger.current = False + + await layout.render() + + +async def test_model_key_preserves_callback_identity_for_components(): + called_good_trigger = idom.Ref(False) + good_handler = StaticEventHandler() + bad_handler = StaticEventHandler() + + @idom.component + def RootComponent(): + reverse_children, set_reverse_children = use_toggle() + + children = [Trigger(set_reverse_children, key=name) for name in ["good", "bad"]] + + if reverse_children: + children.reverse() + + return idom.html.div(children) + + @idom.component + def Trigger(set_reverse_children, key): + if key == "good": + + @good_handler.use + def callback(): + called_good_trigger.current = True + set_reverse_children() + + else: + + @bad_handler.use + def callback(): + raise ValueError("Called bad trigger") + + return idom.html.button({"onClick": callback, "id": "good"}, "good") + + async with idom.Layout(RootComponent()) as layout: + await layout.render() + for _ in range(3): + event = LayoutEvent(good_handler.target, []) + await layout.dispatch(event) + + assert called_good_trigger.current + # reset after checking + called_good_trigger.current = False + + await layout.render() + + +async def test_component_can_return_another_component_directly(): + @idom.component + def Outer(): + return Inner() + + @idom.component + def Inner(): + return idom.html.div("hello") + + async with idom.Layout(Outer()) as layout: + update = await layout.render() + assert_same_items( + update.changes, + [ + { + "op": "add", + "path": "/children", + "value": [{"children": ["hello"], "tagName": "div"}], + }, + {"op": "add", "path": "/tagName", "value": "div"}, + ], + ) + + +async def test_hooks_for_keyed_components_get_garbage_collected(): + pop_item = idom.Ref(None) + garbage_collect_items = [] + registered_finalizers = set() + + @idom.component + def Outer(): + items, set_items = idom.hooks.use_state([1, 2, 3]) + pop_item.current = lambda: set_items(items[:-1]) + return idom.html.div(Inner(key=k) for k in items) + + @idom.component + def Inner(key): + if key not in registered_finalizers: + hook = idom.hooks.current_hook() + finalize(hook, lambda: garbage_collect_items.append(key)) + registered_finalizers.add(key) + return idom.html.div(key) + + async with idom.Layout(Outer()) as layout: + await layout.render() + + pop_item.current() + await layout.render() + assert garbage_collect_items == [3] + + pop_item.current() + await layout.render() + assert garbage_collect_items == [3, 2] + + pop_item.current() + await layout.render() + assert garbage_collect_items == [3, 2, 1] + + +async def test_duplicate_sibling_keys_causes_error(caplog): + @idom.component + def ComponentReturnsDuplicateKeys(): + return idom.html.div( + idom.html.div(key="duplicate"), idom.html.div(key="duplicate") + ) + + async with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: + await layout.render() + + with pytest.raises(ValueError, match=r"Duplicate keys \['duplicate'\] at '/'"): + raise next(iter(caplog.records)).exc_info[1] + + +async def test_keyed_components_preserve_hook_on_parent_update(): + outer_hook = HookCatcher() + inner_hook = HookCatcher() + + @idom.component + @outer_hook.capture + def Outer(): + return Inner(key=1) + + @idom.component + @inner_hook.capture + def Inner(key): + return idom.html.div(key) + + async with idom.Layout(Outer()) as layout: + await layout.render() + old_inner_hook = inner_hook.latest + + outer_hook.latest.schedule_render() + await layout.render() + assert old_inner_hook is inner_hook.latest diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 735b46146..b5738d0cb 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -2,7 +2,7 @@ from fastjsonschema import JsonSchemaException import idom -from idom.core.vdom import component, make_vdom_constructor, validate_serialized_vdom +from idom.core.vdom import make_vdom_constructor, validate_vdom fake_events = idom.Events() @@ -118,39 +118,6 @@ def test_make_vdom_constructor(): assert no_children() == {"tagName": "no-children"} -def test_vdom_component(): - def MyComponentWithAttributes(x, y): - return idom.html.div({"x": x * 2, "y": y * 2}) - - assert component(MyComponentWithAttributes, {"x": 1}, {"y": 2}) == { - "tagName": "div", - "attributes": {"x": 2, "y": 4}, - } - - with pytest.raises(TypeError, match="unexpected keyword argument 'children'"): - assert component(MyComponentWithAttributes, "a-child") - - def MyComponentWithChildren(children): - return idom.html.div(children + ["world"]) - - assert component(MyComponentWithChildren, "hello") == { - "tagName": "div", - "children": ["hello", "world"], - } - - with pytest.raises(TypeError, match="unexpected keyword argument"): - assert component(MyComponentWithAttributes, {"an-attribute": 1}) - - def MyComponentWithChildrenAndAttributes(children, x): - return idom.html.div({"x": x * 2}, children + ["world"]) - - assert component(MyComponentWithChildrenAndAttributes, {"x": 2}, "hello") == { - "tagName": "div", - "attributes": {"x": 4}, - "children": ["hello", "world"], - } - - @pytest.mark.parametrize( "value", [ @@ -213,7 +180,7 @@ def MyComponentWithChildrenAndAttributes(children, x): ], ) def test_valid_vdom(value): - validate_serialized_vdom(value) + validate_vdom(value) @pytest.mark.parametrize( @@ -312,4 +279,4 @@ def test_valid_vdom(value): ) def test_invalid_vdom(value, error_message_pattern): with pytest.raises(JsonSchemaException, match=error_message_pattern): - validate_serialized_vdom(value) + validate_vdom(value)