diff --git a/docs/source/auto/api-reference.rst b/docs/source/auto/api-reference.rst index 9d3cab20a..d47db5797 100644 --- a/docs/source/auto/api-reference.rst +++ b/docs/source/auto/api-reference.rst @@ -19,6 +19,9 @@ API Reference .. automodule:: idom.core.layout :members: +.. automodule:: idom.core.proto + :members: + .. automodule:: idom.core.vdom :members: diff --git a/docs/source/core-abstractions.rst b/docs/source/core-abstractions.rst index c8983ba3c..faa21bce7 100644 --- a/docs/source/core-abstractions.rst +++ b/docs/source/core-abstractions.rst @@ -54,23 +54,30 @@ whose body contains a hook usage. We'll demonstrate that with a simple Component Layout ---------------- -Displaying components requires you to turn them into :ref:`VDOM ` - -this is done using a :class:`~idom.core.layout.Layout`. Layouts are responsible for -rendering components (turning them into VDOM) and scheduling their re-renders when they -:meth:`~idom.core.layout.Layout.update`. To create a layout, you'll need a -:class:`~idom.core.component.Component` instance, which will become its root, and won't -ever be removed from the model. Then you'll just need to call and await a -:meth:`~idom.core.layout.Layout.render` which will return a :ref:`JSON Patch`: +Displaying components requires you to turn them into :ref:`VDOM `. This +transformation, known as "rendering a component", is done by a +:class:`~idom.core.proto.LayoutType`. Layouts are responsible for rendering components +and scheduling their re-renders when they change. IDOM's concrete +:class:`~idom.core.layout.Layout` implementation renders +:class:`~idom.core.component.Component` instances into +:class:`~idom.core.layout.LayoutUpdate` and responds to +:class:`~idom.core.layout.LayoutEvent` objects respectively. + +To create a layout, you'll need a :class:`~idom.core.component.Component` instance, that +will become its root. This component won't ever be removed from the model. Then, you'll +just need to call and await a :meth:`~idom.core.layout.Layout.render` which will return +a :ref:`JSON Patch`: + .. testcode:: with idom.Layout(ClickCount()) as layout: - patch = await layout.render() + update = await layout.render() -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 +The layout also handles the deliver of events to their handlers. Normally these are sent +through a :ref:`Dispatcher ` first, but for now we'll do it manually. +To accomplish this we need to pass a fake event with its "target" (event handler +identifier), to the layout's :meth:`~idom.core.layout.Layout.deliver` method, after which we can re-render and see what changed: .. testcode:: @@ -92,17 +99,13 @@ which we can re-render and see what changed: with idom.Layout(ClickCount()) as layout: - patch_1 = await layout.render() + update_1 = await layout.render() fake_event = LayoutEvent(target=static_handler.target, data=[{}]) - await layout.dispatch(fake_event) - patch_2 = await layout.render() - - for change in patch_2.changes: - if change["path"] == "/children/0": - count_did_increment = change["value"] == "Click count: 1" + await layout.deliver(fake_event) - assert count_did_increment + update_2 = await layout.render() + assert update_2.new["children"][0] == "Click count: 1" .. note:: diff --git a/src/idom/core/component.py b/src/idom/core/component.py index ece4ea478..2789f7eb2 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -5,23 +5,18 @@ from __future__ import annotations -import abc import inspect import warnings from functools import wraps from typing import Any, Callable, Dict, Optional, Tuple, Union -from uuid import uuid4 - -from typing_extensions import Protocol, runtime_checkable +from .proto import ComponentType from .vdom import VdomDict -ComponentConstructor = Callable[..., "ComponentType"] -ComponentRenderFunction = Callable[..., Union["ComponentType", VdomDict]] - - -def component(function: ComponentRenderFunction) -> Callable[..., "Component"]: +def component( + function: Callable[..., Union[ComponentType, VdomDict]] +) -> Callable[..., "Component"]: """A decorator for defining an :class:`Component`. Parameters: @@ -48,26 +43,14 @@ def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Compone return constructor -@runtime_checkable -class ComponentType(Protocol): - """The expected interface for all component-like objects""" - - id: str - key: Optional[Any] - - @abc.abstractmethod - def render(self) -> VdomDict: - """Render the component's :class:`VdomDict`.""" - - class Component: """An object for rending component models.""" - __slots__ = "__weakref__", "_func", "_args", "_kwargs", "id", "key" + __slots__ = "__weakref__", "_func", "_args", "_kwargs", "key" def __init__( self, - function: ComponentRenderFunction, + function: Callable[..., Union[ComponentType, VdomDict]], key: Optional[Any], args: Tuple[Any, ...], kwargs: Dict[str, Any], @@ -75,7 +58,6 @@ def __init__( self._args = args self._func = function self._kwargs = kwargs - self.id = uuid4().hex self.key = key def render(self) -> VdomDict: @@ -93,6 +75,6 @@ def __repr__(self) -> str: else: items = ", ".join(f"{k}={v!r}" for k, v in args.items()) if items: - return f"{self._func.__name__}({self.id}, {items})" + return f"{self._func.__name__}({id(self)}, {items})" else: - return f"{self._func.__name__}({self.id})" + return f"{self._func.__name__}({id(self)})" diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py index bf8932722..2fb9fa724 100644 --- a/src/idom/core/dispatcher.py +++ b/src/idom/core/dispatcher.py @@ -5,34 +5,43 @@ from __future__ import annotations -import sys from asyncio import Future, Queue from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait +from contextlib import asynccontextmanager from logging import getLogger -from typing import Any, AsyncIterator, Awaitable, Callable, List, Sequence, Tuple +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + List, + NamedTuple, + Sequence, + Tuple, + cast, +) from weakref import WeakSet from anyio import create_task_group +from jsonpatch import apply_patch, make_patch +from idom.core.vdom import VdomJson from idom.utils import Ref -from .layout import Layout, LayoutEvent, LayoutUpdate - - -if sys.version_info >= (3, 7): # pragma: no cover - from contextlib import asynccontextmanager # noqa -else: # pragma: no cover - from async_generator import asynccontextmanager +from .layout import LayoutEvent, LayoutUpdate +from .proto import LayoutType logger = getLogger(__name__) -SendCoroutine = Callable[[Any], Awaitable[None]] + +SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]] RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] async def dispatch_single_view( - layout: Layout, + layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine, recv: RecvCoroutine, ) -> None: @@ -49,14 +58,14 @@ async def dispatch_single_view( @asynccontextmanager async def create_shared_view_dispatcher( - layout: Layout, run_forever: bool = False + layout: LayoutType[LayoutUpdate, LayoutEvent], ) -> AsyncIterator[_SharedViewDispatcherFuture]: """Enter a dispatch context where all subsequent view instances share the same state""" with layout: ( dispatch_shared_view, model_state, - all_update_queues, + all_patch_queues, ) = await _make_shared_view_dispatcher(layout) dispatch_tasks: List[Future[None]] = [] @@ -85,16 +94,16 @@ def dispatch_shared_view_soon( update_future.cancel() break else: - update: LayoutUpdate = update_future.result() + patch = VdomJsonPatch.create_from(update_future.result()) - model_state.current = update.apply_to(model_state.current) + model_state.current = patch.apply_to(model_state.current) # push updates to all dispatcher callbacks - for queue in all_update_queues: - queue.put_nowait(update) + for queue in all_patch_queues: + queue.put_nowait(patch) def ensure_shared_view_dispatcher_future( - layout: Layout, + layout: LayoutType[LayoutUpdate, LayoutEvent], ) -> Tuple[Future[None], SharedViewDispatcher]: """Ensure the future of a dispatcher created by :func:`create_shared_view_dispatcher`""" dispatcher_future: Future[SharedViewDispatcher] = Future() @@ -104,17 +113,17 @@ async def dispatch_shared_view_forever() -> None: ( dispatch_shared_view, model_state, - all_update_queues, + all_patch_queues, ) = await _make_shared_view_dispatcher(layout) dispatcher_future.set_result(dispatch_shared_view) while True: - update = await layout.render() - model_state.current = update.apply_to(model_state.current) + patch = await render_json_patch(layout) + model_state.current = patch.apply_to(model_state.current) # push updates to all dispatcher callbacks - for queue in all_update_queues: - queue.put_nowait(update) + for queue in all_patch_queues: + queue.put_nowait(patch) async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: await (await dispatcher_future)(send, recv) @@ -122,41 +131,75 @@ async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: return ensure_future(dispatch_shared_view_forever()), dispatch +async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch: + """Render a class:`VdomJsonPatch` from a layout""" + return VdomJsonPatch.create_from(await layout.render()) + + +class VdomJsonPatch(NamedTuple): + """An object describing an update to a :class:`Layout` in the form of a JSON patch""" + + path: str + """The path where changes should be applied""" + + changes: List[Dict[str, Any]] + """A list of JSON patches to apply at the given path""" + + def apply_to(self, model: VdomJson) -> VdomJson: + """Return the model resulting from the changes in this update""" + return cast( + VdomJson, + apply_patch( + model, [{**c, "path": self.path + c["path"]} for c in self.changes] + ), + ) + + @classmethod + def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch: + """Return a patch given an layout update""" + return cls(update.path, make_patch(update.old or {}, update.new).patch) + + async def _make_shared_view_dispatcher( - layout: Layout, -) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[LayoutUpdate]]]: - initial_update = await layout.render() - model_state = Ref(initial_update.apply_to({})) + layout: LayoutType[LayoutUpdate, LayoutEvent], +) -> Tuple[SharedViewDispatcher, Ref[Any], WeakSet[Queue[VdomJsonPatch]]]: + update = await layout.render() + model_state = Ref(update.new) # We push updates to queues instead of pushing directly to send() callbacks in # order to isolate the render loop from any errors dispatch callbacks might # raise. - all_update_queues: WeakSet[Queue[LayoutUpdate]] = WeakSet() + all_patch_queues: WeakSet[Queue[VdomJsonPatch]] = WeakSet() async def dispatch_shared_view(send: SendCoroutine, recv: RecvCoroutine) -> None: - update_queue: Queue[LayoutUpdate] = Queue() + patch_queue: Queue[VdomJsonPatch] = Queue() async with create_task_group() as inner_task_group: - all_update_queues.add(update_queue) - await send(LayoutUpdate.create_from({}, model_state.current)) + all_patch_queues.add(patch_queue) + effective_update = LayoutUpdate("", None, model_state.current) + await send(VdomJsonPatch.create_from(effective_update)) inner_task_group.start_soon(_single_incoming_loop, layout, recv) - inner_task_group.start_soon(_shared_outgoing_loop, send, update_queue) + inner_task_group.start_soon(_shared_outgoing_loop, send, patch_queue) return None - return dispatch_shared_view, model_state, all_update_queues + return dispatch_shared_view, model_state, all_patch_queues -async def _single_outgoing_loop(layout: Layout, send: SendCoroutine) -> None: +async def _single_outgoing_loop( + layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine +) -> None: while True: - await send(await layout.render()) + await send(await render_json_patch(layout)) -async def _single_incoming_loop(layout: Layout, recv: RecvCoroutine) -> None: +async def _single_incoming_loop( + layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine +) -> None: while True: - await layout.dispatch(await recv()) + await layout.deliver(await recv()) async def _shared_outgoing_loop( - send: SendCoroutine, queue: Queue[LayoutUpdate] + send: SendCoroutine, queue: Queue[VdomJsonPatch] ) -> None: while True: await send(await queue.get()) diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index ac9a84458..5d5ecb8a9 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -6,7 +6,6 @@ from __future__ import annotations import asyncio -import weakref from logging import getLogger from threading import get_ident as get_thread_id from typing import ( @@ -16,7 +15,7 @@ Dict, Generic, List, - NamedTuple, + NewType, Optional, Sequence, Tuple, @@ -28,11 +27,8 @@ from typing_extensions import Protocol -import idom from idom.utils import Ref -from .component import ComponentType - __all__ = [ "use_state", @@ -162,11 +158,11 @@ def effect() -> None: clean = last_clean_callback.current = sync_function() if clean is not None: - hook.add_effect("will_unmount", clean) + hook.add_effect(WILL_UNMOUNT_EFFECT, clean) return None - return memoize(lambda: hook.add_effect("did_render", effect)) + return memoize(lambda: hook.add_effect(DID_RENDER_EFFECT, effect)) if function is not None: add_effect(function) @@ -366,21 +362,79 @@ def current_hook() -> "LifeCycleHook": raise RuntimeError(msg) from error -class _EventEffects(NamedTuple): - did_render: List[Callable[[], Any]] - will_unmount: List[Callable[[], Any]] +EffectType = NewType("EffectType", str) +"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved""" + +DID_RENDER_EFFECT = EffectType("DID_RENDER") +"""An effect that will be triggered after each render""" + +WILL_UNMOUNT_EFFECT = EffectType("WILL_UNMOUNT") +"""An effect that will be triggered just before the component is unmounted""" class LifeCycleHook: """Defines the life cycle of a layout component. - Components can request access to their own life cycle events and state, while layouts - drive the life cycle forward by triggering events. + Components can request access to their own life cycle events and state through hooks + while :class:`~idom.core.proto.LayoutType` objects drive drive the life cycle + forward by triggering events and rendering view changes. + + Example: + + If removed from the complexities of a layout, a very simplified full life cycle + for a single component with no child components would look a bit like this: + + .. testcode:: + + from idom.core.hooks import LifeCycleHook, DID_RENDER_EFFECT + + + # this function will come from a layout implementation + schedule_render = lambda: ... + + # --- start life cycle --- + + hook = hooks.LifeCycle(schedule_render) + + # --- start render cycle --- + + hook.component_will_render() + + hook.set_current() + + try: + # render the component + ... + + # the component may access the current hook + assert hooks.current_hook() is hook + + # and save state or add effects + current_hook().use_state(lambda: ...) + current_hook().use_effect(DID_RENDER_EFFECT, lambda: ...) + finally: + hook.unset_current() + + # This should only be called after any child components yielded by + # component_instance.render() have also been rendered because effects + # must run after the full set of changes have been resolved. + hook.component_did_render() + + # Typically an event occurs and a new render is scheduled, thus begining + # the render cycle anew. + hook.schedule_render() + + + # --- end render cycle --- + + hook.component_will_unmount() + del hook + + # --- end render cycle --- """ __slots__ = ( - "component", - "_layout", + "_schedule_render_callback", "_schedule_render_later", "_current_state_index", "_state", @@ -392,17 +446,18 @@ class LifeCycleHook: def __init__( self, - layout: idom.core.layout.Layout, - component: ComponentType, + schedule_render: Callable[[], None], ) -> None: - self.component = component - self._layout = weakref.ref(layout) + self._schedule_render_callback = schedule_render self._schedule_render_later = False self._is_rendering = False self._rendered_atleast_once = False self._current_state_index = 0 self._state: Tuple[Any, ...] = () - self._event_effects = _EventEffects([], []) + self._event_effects: Dict[EffectType, List[Callable[[], None]]] = { + DID_RENDER_EFFECT: [], + WILL_UNMOUNT_EFFECT: [], + } def schedule_render(self) -> None: if self._is_rendering: @@ -422,25 +477,24 @@ def use_state(self, function: Callable[[], _StateType]) -> _StateType: self._current_state_index += 1 return result - def add_effect(self, events: str, function: Callable[[], None]) -> None: - for e in events.split(): - getattr(self._event_effects, e).append(function) + def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None: + """Trigger a function on the occurance of the given effect type""" + self._event_effects[effect_type].append(function) def component_will_render(self) -> None: """The component is about to render""" self._is_rendering = True - self._event_effects.will_unmount.clear() + self._event_effects[WILL_UNMOUNT_EFFECT].clear() def component_did_render(self) -> None: """The component completed a render""" - for effect in self._event_effects.did_render: + did_render_effects = self._event_effects[DID_RENDER_EFFECT] + for effect in did_render_effects: try: effect() except Exception: - msg = f"Post-render effect {effect} failed for {self.component}" - logger.exception(msg) - - self._event_effects.did_render.clear() + logger.exception(f"Post-render effect {effect} failed") + did_render_effects.clear() self._is_rendering = False if self._schedule_render_later: @@ -450,14 +504,13 @@ def component_did_render(self) -> None: def component_will_unmount(self) -> None: """The component is about to be removed from the layout""" - for effect in self._event_effects.will_unmount: + will_unmount_effects = self._event_effects[WILL_UNMOUNT_EFFECT] + for effect in will_unmount_effects: try: effect() except Exception: - msg = f"Pre-unmount effect {effect} failed for {self.component}" - logger.exception(msg) - - self._event_effects.will_unmount.clear() + logger.exception(f"Pre-unmount effect {effect} failed") + will_unmount_effects.clear() def set_current(self) -> None: """Set this hook as the active hook in this thread @@ -474,6 +527,9 @@ def unset_current(self) -> None: del _current_life_cycle_hook[get_thread_id()] def _schedule_render(self) -> None: - layout = self._layout() - assert layout is not None - layout.update(self.component) + try: + self._schedule_render_callback() + except Exception: + logger.exception( + f"Failed to schedule render via {self._schedule_render_callback}" + ) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index c2d24f19d..3cb303f9d 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -9,41 +9,51 @@ from collections import Counter from functools import wraps from logging import getLogger -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple, TypeVar -from weakref import ref - -from jsonpatch import apply_patch, make_patch -from typing_extensions import TypedDict +from typing import ( + Any, + Callable, + Dict, + Generic, + Iterator, + List, + NamedTuple, + NewType, + Optional, + Set, + Tuple, + TypeVar, +) +from uuid import uuid4 +from weakref import ref as weakref from idom.config import IDOM_DEBUG_MODE, IDOM_FEATURE_INDEX_AS_DEFAULT_KEY +from idom.utils import Ref -from .component import ComponentType from .events import EventHandler from .hooks import LifeCycleHook -from .vdom import validate_vdom +from .proto import ComponentType +from .vdom import VdomJson, validate_vdom logger = getLogger(__name__) class LayoutUpdate(NamedTuple): - """An object describing an update to a :class:`Layout`""" + """A change to a view as a result of a :meth:`Layout.render`""" path: str - changes: List[Dict[str, Any]] + """A "/" delimited path to the element from the root of the layout""" - def apply_to(self, model: Any) -> Any: - """Return the model resulting from the changes in this update""" - return apply_patch( - model, [{**c, "path": self.path + c["path"]} for c in self.changes] - ) + old: Optional[VdomJson] + """The old state of the layout""" - @classmethod - def create_from(cls, source: Any, target: Any) -> "LayoutUpdate": - return cls("", make_patch(source, target).patch) + new: VdomJson + """The new state of the layout""" class LayoutEvent(NamedTuple): + """An event that should be relayed to its handler by :meth:`Layout.deliver`""" + target: str """The ID of the event handler.""" data: List[Any] @@ -60,7 +70,8 @@ class Layout: "root", "_event_handlers", "_rendering_queue", - "_model_state_by_component_id", + "_root_life_cycle_state_id", + "_model_states_by_life_cycle_state_id", ] if not hasattr(abc.ABC, "__weakref__"): # pragma: no cover @@ -75,30 +86,31 @@ def __init__(self, root: "ComponentType") -> None: def __enter__(self: _Self) -> _Self: # create attributes here to avoid access before entering context manager self._event_handlers: Dict[str, EventHandler] = {} - self._rendering_queue = _ComponentQueue() - self._model_state_by_component_id: Dict[str, _ModelState] = { - self.root.id: _ModelState(None, -1, "", LifeCycleHook(self, self.root)) - } - self._rendering_queue.put(self.root) + + self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() + root_model_state = _new_root_model_state(self.root, self._rendering_queue.put) + + self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id + self._rendering_queue.put(root_id) + + self._model_states_by_life_cycle_state_id = {root_id: root_model_state} + return self def __exit__(self, *exc: Any) -> None: - root_state = self._model_state_by_component_id[self.root.id] - self._unmount_model_states([root_state]) + root_csid = self._root_life_cycle_state_id + root_model_state = self._model_states_by_life_cycle_state_id[root_csid] + self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager del self._event_handlers del self._rendering_queue - del self._model_state_by_component_id + del self._root_life_cycle_state_id + del self._model_states_by_life_cycle_state_id return None - def update(self, component: "ComponentType") -> None: - """Schedule a re-render of a component in the layout""" - self._rendering_queue.put(component) - return None - - async def dispatch(self, event: LayoutEvent) -> None: + async def deliver(self, event: LayoutEvent) -> None: """Dispatch an event to the targeted handler""" # It is possible for an element in the frontend to produce an event # associated with a backend model that has been deleted. We only handle @@ -119,13 +131,16 @@ async def dispatch(self, event: LayoutEvent) -> None: async def render(self) -> LayoutUpdate: """Await the next available render. This will block until a component is updated""" while True: - component = await self._rendering_queue.get() - if component.id in self._model_state_by_component_id: - return self._create_layout_update(component) - else: + model_state_id = await self._rendering_queue.get() + try: + model_state = self._model_states_by_life_cycle_state_id[model_state_id] + except KeyError: logger.info( - f"Did not render component - {component} already unmounted or does not belong to this layout" + f"Did not render component with model state ID {model_state_id!r} " + "- component already unmounted or does not belong to this layout" ) + else: + return self._create_layout_update(model_state) if IDOM_DEBUG_MODE.current: # If in debug mode inject a function that ensures all returned updates @@ -136,24 +151,35 @@ async def render(self) -> LayoutUpdate: @wraps(_debug_render) async def render(self) -> LayoutUpdate: - # Ensure that the model is valid VDOM on each render result = await self._debug_render() - validate_vdom(self._model_state_by_component_id[self.root.id].model) + # Ensure that the model is valid VDOM on each render + root_id = self._root_life_cycle_state_id + root_model = self._model_states_by_life_cycle_state_id[root_id] + validate_vdom(root_model.model.current) return result - def _create_layout_update(self, component: ComponentType) -> LayoutUpdate: - old_state = self._model_state_by_component_id[component.id] - new_state = old_state.new(None, component) + def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: + new_state = _copy_component_model_state(old_state) + component = new_state.life_cycle_state.component self._render_component(old_state, new_state, component) - changes = make_patch(getattr(old_state, "model", {}), new_state.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 model_state in _iter_model_state_children(new_state): + if hasattr(model_state, "life_cycle_state"): + model_state.life_cycle_state.hook.component_did_render() + + old_model: Optional[VdomJson] + try: + old_model = old_state.model.current + except AttributeError: + old_model = None - return LayoutUpdate(path=new_state.patch_path, changes=changes) + return LayoutUpdate( + path=new_state.patch_path, + old=old_model, + new=new_state.model.current, + ) def _render_component( self, @@ -161,8 +187,12 @@ def _render_component( new_state: _ModelState, component: ComponentType, ) -> None: - life_cycle_hook = new_state.life_cycle_hook + life_cycle_state = new_state.life_cycle_state + self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state + + life_cycle_hook = life_cycle_state.hook life_cycle_hook.component_will_render() + try: life_cycle_hook.set_current() try: @@ -172,11 +202,7 @@ def _render_component( self._render_model(old_state, new_state, raw_model) except Exception as error: logger.exception(f"Failed to render {component}") - new_state.model = {"tagName": "__error__", "children": [str(error)]} - - if old_state is not None and old_state.component is not component: - del self._model_state_by_component_id[old_state.component.id] - self._model_state_by_component_id[component.id] = new_state + new_state.model.current = {"tagName": "__error__", "children": [str(error)]} try: parent = new_state.parent @@ -192,7 +218,9 @@ def _render_component( ) 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] + parent.model.current["children"][index : index + 1] = [ + new_state.model.current + ] def _render_model( self, @@ -200,15 +228,15 @@ def _render_model( new_state: _ModelState, raw_model: Any, ) -> None: - new_state.model = {"tagName": raw_model["tagName"]} + new_state.model.current = {"tagName": raw_model["tagName"]} 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"] + new_state.model.current["key"] = raw_model["key"] if "importSource" in raw_model: - new_state.model["importSource"] = raw_model["importSource"] + new_state.model.current["importSource"] = raw_model["importSource"] def _render_model_attributes( self, @@ -223,7 +251,8 @@ def _render_model_attributes( handlers_by_event.update(raw_model["eventHandlers"]) if "attributes" in raw_model: - attrs = new_state.model["attributes"] = raw_model["attributes"].copy() + attrs = raw_model["attributes"].copy() + new_state.model.current["attributes"] = attrs for k, v in list(attrs.items()): if callable(v): if not isinstance(v, EventHandler): @@ -246,7 +275,7 @@ def _render_model_attributes( if not handlers_by_event: return None - model_event_handlers = new_state.model["eventHandlers"] = {} + model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): target = old_state.targets_by_event.get(event, handler.target) new_state.targets_by_event[event] = target @@ -267,7 +296,7 @@ def _render_model_event_handlers_without_old_state( if not handlers_by_event: return None - model_event_handlers = new_state.model["eventHandlers"] = {} + model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): target = handler.target new_state.targets_by_event[event] = target @@ -313,24 +342,42 @@ def _render_model_children( [old_state.children_by_key[key] for key in old_keys] ) - new_children = new_state.model["children"] = [] + new_children = new_state.model.current["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) + if old_child_state is None: + new_child_state = _make_element_model_state( + new_state, + index, + key, + ) else: - new_child_state = _ModelState(new_state, index, key, None) + new_child_state = _update_element_model_state( + old_child_state, + new_state, + index, + ) self._render_model(old_child_state, new_child_state, child) - new_children.append(new_child_state.model) + new_children.append(new_child_state.model.current) 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) + if old_child_state is None: + new_child_state = _make_component_model_state( + new_state, + index, + key, + child, + self._rendering_queue.put, + ) else: - hook = LifeCycleHook(self, child) - new_child_state = _ModelState(new_state, index, key, hook) + new_child_state = _update_component_model_state( + old_child_state, + new_state, + index, + child, + ) self._render_component(old_child_state, new_child_state, child) else: new_children.append(child) @@ -338,138 +385,292 @@ def _render_model_children( def _render_model_children_without_old_state( self, new_state: _ModelState, raw_children: List[Any] ) -> None: - new_children = new_state.model["children"] = [] + new_children = new_state.model.current["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) + child_state = _make_element_model_state(new_state, index, key) self._render_model(None, child_state, child) - new_children.append(child_state.model) + new_children.append(child_state.model.current) new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: - life_cycle_hook = LifeCycleHook(self, child) - child_state = _ModelState(new_state, index, key, life_cycle_hook) + child_state = _make_component_model_state( + new_state, index, key, child, self._rendering_queue.put + ) 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] + to_unmount = old_states[::-1] # unmount in reversed order of rendering 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[hook.component.id] - to_unmount.extend(state.children_by_key.values()) + model_state = to_unmount.pop() + + if hasattr(model_state, "life_cycle_state"): + life_cycle_state = model_state.life_cycle_state + del self._model_states_by_life_cycle_state_id[life_cycle_state.id] + life_cycle_state.hook.component_will_unmount() + + to_unmount.extend(model_state.children_by_key.values()) def __repr__(self) -> str: return f"{type(self).__name__}({self.root})" +def _iter_model_state_children(model_state: _ModelState) -> Iterator[_ModelState]: + yield model_state + for child in model_state.children_by_key.values(): + yield from _iter_model_state_children(child) + + +def _new_root_model_state( + component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None] +) -> _ModelState: + return _ModelState( + parent=None, + index=-1, + key=None, + model=Ref(), + patch_path="", + children_by_key={}, + targets_by_event={}, + life_cycle_state=_make_life_cycle_state(component, schedule_render), + ) + + +def _make_component_model_state( + parent: _ModelState, + index: int, + key: Any, + component: ComponentType, + schedule_render: Callable[[_LifeCycleStateId], None], +) -> _ModelState: + return _ModelState( + parent=parent, + index=index, + key=key, + model=Ref(), + patch_path=f"{parent.patch_path}/children/{index}", + children_by_key={}, + targets_by_event={}, + life_cycle_state=_make_life_cycle_state(component, schedule_render), + ) + + +def _copy_component_model_state(old_model_state: _ModelState) -> _ModelState: + + # use try/except here because not having a parent is rare (only the root state) + try: + parent: Optional[_ModelState] = old_model_state.parent + except AttributeError: + parent = None + + return _ModelState( + parent=parent, + index=old_model_state.index, + key=old_model_state.key, + model=Ref(), # does not copy the model + patch_path=old_model_state.patch_path, + children_by_key={}, + targets_by_event={}, + life_cycle_state=old_model_state.life_cycle_state, + ) + + +def _update_component_model_state( + old_model_state: _ModelState, + new_parent: _ModelState, + new_index: int, + new_component: ComponentType, +) -> _ModelState: + try: + old_life_cycle_state = old_model_state.life_cycle_state + except AttributeError: + raise ValueError( + f"Failed to render layout at {old_model_state.patch_path!r} with key " + f"{old_model_state.key!r} - prior element with this key wasn't a component" + ) + + return _ModelState( + parent=new_parent, + index=new_index, + key=old_model_state.key, + model=Ref(), # does not copy the model + patch_path=old_model_state.patch_path, + children_by_key={}, + targets_by_event={}, + life_cycle_state=_update_life_cycle_state(old_life_cycle_state, new_component), + ) + + +def _make_element_model_state( + parent: _ModelState, + index: int, + key: Any, +) -> _ModelState: + return _ModelState( + parent=parent, + index=index, + key=key, + model=Ref(), + patch_path=f"{parent.patch_path}/children/{index}", + children_by_key={}, + targets_by_event={}, + ) + + +def _update_element_model_state( + old_model_state: _ModelState, + new_parent: _ModelState, + new_index: int, +) -> _ModelState: + if hasattr(old_model_state, "life_cycle_state"): + raise ValueError( + f"Failed to render layout at {old_model_state.patch_path!r} with key " + f"{old_model_state.key!r} - prior element with this key was a component" + ) + + return _ModelState( + parent=new_parent, + index=new_index, + key=old_model_state.key, + model=Ref(), # does not copy the model + patch_path=old_model_state.patch_path, + children_by_key=old_model_state.children_by_key.copy(), + targets_by_event={}, + ) + + class _ModelState: + """State that is bound to a particular element within the layout""" __slots__ = ( + "__weakref__", + "_parent_ref", + "children_by_key", "index", "key", - "_parent_ref", - "life_cycle_hook", - "component", - "patch_path", + "life_cycle_state", "model", + "patch_path", "targets_by_event", - "children_by_key", - "__weakref__", ) - model: _ModelVdom - life_cycle_hook: LifeCycleHook - patch_path: str - component: ComponentType - def __init__( self, parent: Optional[_ModelState], index: int, key: Any, - life_cycle_hook: Optional[LifeCycleHook], - ) -> None: + model: Ref[VdomJson], + patch_path: str, + children_by_key: Dict[str, _ModelState], + targets_by_event: Dict[str, str], + life_cycle_state: Optional[_LifeCycleState] = None, + ): self.index = index + """The index of the element amongst its siblings""" + self.key = key + """A key that uniquely identifies the element amongst its siblings""" - if parent is not None: - self._parent_ref = ref(parent) - self.patch_path = f"{parent.patch_path}/children/{index}" - else: - self.patch_path = "" + self.model = model + """The actual model of the element""" - if life_cycle_hook is not None: - self.life_cycle_hook = life_cycle_hook - self.component = life_cycle_hook.component + self.patch_path = patch_path + """A "/" delimitted path to the element within the greater layout""" - self.targets_by_event: Dict[str, str] = {} - self.children_by_key: Dict[str, _ModelState] = {} + self.children_by_key = children_by_key + """Child model states indexed by their unique keys""" + + self.targets_by_event = targets_by_event + """The element's event handler target strings indexed by their event name""" + + # === Conditionally Evailable Attributes === + # It's easier to conditionally assign than to force a null check on every usage + + if parent is not None: + self._parent_ref = weakref(parent) + """The parent model state""" + + if life_cycle_state is not None: + self.life_cycle_state = life_cycle_state + """The state for the element's component (if it has one)""" @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 + parent = self._parent_ref() + assert parent is not None, "detached model state" + return parent + + +def _make_life_cycle_state( + component: ComponentType, + schedule_render: Callable[[_LifeCycleStateId], None], +) -> _LifeCycleState: + life_cycle_state_id = _LifeCycleStateId(uuid4().hex) + return _LifeCycleState( + life_cycle_state_id, + LifeCycleHook(lambda: schedule_render(life_cycle_state_id)), + component, + ) - def new( - self, - new_parent: Optional[_ModelState], - component: Optional[ComponentType], - ) -> _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 _update_life_cycle_state( + old_life_cycle_state: _LifeCycleState, + new_component: ComponentType, +) -> _LifeCycleState: + return _LifeCycleState( + old_life_cycle_state.id, + # the hook is preserved across renders because it holds the state + old_life_cycle_state.hook, + new_component, + ) + + +_LifeCycleStateId = NewType("_LifeCycleStateId", str) + - 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 _LifeCycleState(NamedTuple): + """Component state for :class:`_ModelState`""" + id: _LifeCycleStateId + """A unique identifier used in the :class:`~idom.core.hooks.LifeCycleHook` callback""" -class _ComponentQueue: + hook: LifeCycleHook + """The life cycle hook""" + + component: ComponentType + """The current component instance""" + + +_Type = TypeVar("_Type") + + +class _ThreadSafeQueue(Generic[_Type]): __slots__ = "_loop", "_queue", "_pending" def __init__(self) -> None: self._loop = asyncio.get_event_loop() - self._queue: "asyncio.Queue[ComponentType]" = asyncio.Queue() - self._pending: Set[str] = set() - - def put(self, component: ComponentType) -> None: - component_id = component.id - if component_id not in self._pending: - self._pending.add(component_id) - self._loop.call_soon_threadsafe(self._queue.put_nowait, component) + self._queue: asyncio.Queue[_Type] = asyncio.Queue() + self._pending: Set[_Type] = set() + + def put(self, value: _Type) -> None: + if value not in self._pending: + self._pending.add(value) + self._loop.call_soon_threadsafe(self._queue.put_nowait, value) return None - async def get(self) -> ComponentType: - component = await self._queue.get() - self._pending.remove(component.id) - return component + async def get(self) -> _Type: + value = await self._queue.get() + self._pending.remove(value) + return value def _process_child_type_and_key( children: List[Any], -) -> Iterator[Tuple[Any, int, Any]]: +) -> Iterator[Tuple[Any, _ElementType, Any]]: for index, child in enumerate(children): if isinstance(child, dict): child_type = _DICT_TYPE @@ -488,6 +689,13 @@ def _process_child_type_and_key( yield (child, child_type, key) +# used in _process_child_type_and_key +_ElementType = NewType("_ElementType", int) +_DICT_TYPE = _ElementType(1) +_COMPONENT_TYPE = _ElementType(2) +_STRING_TYPE = _ElementType(3) + + if IDOM_FEATURE_INDEX_AS_DEFAULT_KEY.current: def _default_key(index: int) -> Any: # pragma: no cover @@ -498,36 +706,3 @@ def _default_key(index: int) -> Any: # pragma: no cover 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/proto.py b/src/idom/core/proto.py new file mode 100644 index 000000000..24689ecc9 --- /dev/null +++ b/src/idom/core/proto.py @@ -0,0 +1,51 @@ +""" +Core Interfaces +=============== +""" + +from __future__ import annotations + +from types import TracebackType +from typing import Any, Callable, Optional, Type, TypeVar + +from typing_extensions import Protocol, runtime_checkable + +from .vdom import VdomDict + + +ComponentConstructor = Callable[..., "ComponentType"] + + +@runtime_checkable +class ComponentType(Protocol): + """The expected interface for all component-like objects""" + + key: Optional[Any] + """An identifier which is unique amongst a component's immediate siblings""" + + def render(self) -> VdomDict: + """Render the component's :class:`VdomDict`.""" + + +_Self = TypeVar("_Self") +_Render = TypeVar("_Render", covariant=True) +_Event = TypeVar("_Event", contravariant=True) + + +@runtime_checkable +class LayoutType(Protocol[_Render, _Event]): + """Renders and delivers, updates to views and events to handlers, respectively""" + + async def render(self) -> _Render: + """Render an update to a view""" + + async def deliver(self, event: _Event) -> None: + """Relay an event to its respective handler""" + + def __enter__(self: _Self) -> _Self: + """Prepare the layout for its first render""" + + def __exit__( + self, exc_type: Type[Exception], exc_value: Exception, traceback: TracebackType + ) -> Optional[bool]: + """Clean up the view after its final render""" diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 8f25aec96..a84cf34ee 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -7,7 +7,18 @@ import inspect import logging -from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, + cast, +) from fastjsonschema import compile as compile_json_schema from mypy_extensions import TypedDict @@ -83,36 +94,15 @@ _COMPILED_VDOM_VALIDATOR = compile_json_schema(VDOM_JSON_SCHEMA) -def validate_vdom(value: Any) -> None: +def validate_vdom(value: Any) -> VdomJson: """Validate serialized VDOM - see :attr:`VDOM_JSON_SCHEMA` for more info""" _COMPILED_VDOM_VALIDATOR(value) - - -class ImportSourceDict(TypedDict): - source: str - fallback: Any - sourceType: str # noqa - - -class _VdomDictOptional(TypedDict, total=False): - key: str # noqa - children: Sequence[Any] # noqa - attributes: Mapping[str, Any] # noqa - eventHandlers: Mapping[str, EventHandler] # noqa - importSource: ImportSourceDict # noqa - - -class _VdomDictRequired(TypedDict, total=True): - tagName: str # noqa - - -class VdomDict(_VdomDictRequired, _VdomDictOptional): - """A VDOM dictionary - see :ref:`VDOM Mimetype` for more info""" + return cast(VdomJson, value) _AttributesAndChildrenArg = Union[Mapping[str, Any], str, Iterable[Any], Any] _EventHandlersArg = Optional[Mapping[str, EventHandler]] -_ImportSourceArg = Optional[ImportSourceDict] +_ImportSourceArg = Optional["ImportSourceDict"] def vdom( @@ -262,7 +252,7 @@ def _is_single_child(value: Any) -> bool: if _debug_is_single_child(value): return True - from .component import ComponentType + from .proto import ComponentType if hasattr(value, "__iter__") and not hasattr(value, "__len__"): logger.error( @@ -277,3 +267,55 @@ def _is_single_child(value: Any) -> bool: logger.error(f"Key not specified for dynamic child {child}") return False + + +class _VdomDictOptional(TypedDict, total=False): + key: str # noqa + children: Sequence[Any] # noqa + attributes: Mapping[str, Any] # noqa + eventHandlers: Mapping[str, EventHandler] # noqa + importSource: ImportSourceDict # noqa + + +class _VdomDictRequired(TypedDict, total=True): + tagName: str # noqa + + +class VdomDict(_VdomDictRequired, _VdomDictOptional): + """A VDOM dictionary - see :ref:`VDOM Mimetype` for more info""" + + +class ImportSourceDict(TypedDict): + source: str + fallback: Any + sourceType: str # noqa + + +class _OptionalVdomJson(TypedDict, total=False): + key: str # noqa + children: List[Any] # noqa + attributes: Dict[str, Any] # noqa + eventHandlers: Dict[str, _JsonEventTarget] # noqa + importSource: _JsonImportSource # noqa + + +class _RequiredVdomJson(TypedDict, total=True): + tagName: str # noqa + + +class VdomJson(_RequiredVdomJson, _OptionalVdomJson): + """A JSON serializable form of :class:`VdomDict` compliant with :data:`VDOM_JSON_SCHEMA` + + For more information on this form see :ref:`VDOM Mimetype`. + """ + + +class _JsonEventTarget(TypedDict): + target: str + preventDefault: bool # noqa + stopPropagation: bool # noqa + + +class _JsonImportSource(TypedDict): + source: str + fallback: Any diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index fcd27172f..17c3762b0 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -24,15 +24,16 @@ from uvicorn.supervisors.statreload import StatReload as ChangeReload from idom.config import IDOM_WED_MODULES_DIR -from idom.core.component import ComponentConstructor from idom.core.dispatcher import ( RecvCoroutine, SendCoroutine, SharedViewDispatcher, + VdomJsonPatch, dispatch_single_view, ensure_shared_view_dispatcher_future, ) -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.layout import Layout, LayoutEvent +from idom.core.proto import ComponentConstructor from .utils import CLIENT_BUILD_DIR, poll, threaded @@ -275,7 +276,7 @@ async def model_stream(socket: WebSocket) -> None: def _make_send_recv_callbacks( socket: WebSocket, ) -> Tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: LayoutUpdate) -> None: + async def sock_send(value: VdomJsonPatch) -> None: await socket.send_text(json.dumps(value)) async def sock_recv() -> LayoutEvent: diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 05c1dc3c5..708dbe590 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -24,9 +24,9 @@ import idom from idom.config import IDOM_DEBUG_MODE, IDOM_WED_MODULES_DIR -from idom.core.component import ComponentConstructor, ComponentType from idom.core.dispatcher import dispatch_single_view from idom.core.layout import LayoutEvent, LayoutUpdate +from idom.core.proto import ComponentConstructor, ComponentType from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event diff --git a/src/idom/server/prefab.py b/src/idom/server/prefab.py index 33fb3a53a..5ac2fdc01 100644 --- a/src/idom/server/prefab.py +++ b/src/idom/server/prefab.py @@ -6,7 +6,7 @@ import logging from typing import Any, Dict, Optional, Tuple, TypeVar -from idom.core.component import ComponentConstructor +from idom.core.proto import ComponentConstructor from idom.widgets import MountFunc, MultiViewMount, hotswap, multiview from .proto import Server, ServerFactory diff --git a/src/idom/server/proto.py b/src/idom/server/proto.py index 8f309e5c0..962de4cc0 100644 --- a/src/idom/server/proto.py +++ b/src/idom/server/proto.py @@ -4,7 +4,7 @@ from typing_extensions import Protocol -from idom.core.component import ComponentConstructor +from idom.core.proto import ComponentConstructor _App = TypeVar("_App") diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 3a379c33a..91ad5d7b9 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -18,15 +18,16 @@ from websockets import WebSocketCommonProtocol from idom.config import IDOM_WED_MODULES_DIR -from idom.core.component import ComponentConstructor from idom.core.dispatcher import ( RecvCoroutine, SendCoroutine, SharedViewDispatcher, + VdomJsonPatch, dispatch_single_view, ensure_shared_view_dispatcher_future, ) -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.layout import Layout, LayoutEvent +from idom.core.proto import ComponentConstructor from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event @@ -240,7 +241,7 @@ async def model_stream( def _make_send_recv_callbacks( socket: WebSocketCommonProtocol, ) -> Tuple[SendCoroutine, RecvCoroutine]: - async def sock_send(value: LayoutUpdate) -> None: + async def sock_send(value: VdomJsonPatch) -> None: await socket.send(json.dumps(value)) async def sock_recv() -> LayoutEvent: diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index 6eb419ad1..02f611a79 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -19,9 +19,9 @@ from typing_extensions import TypedDict from idom.config import IDOM_WED_MODULES_DIR -from idom.core.component import ComponentConstructor -from idom.core.dispatcher import dispatch_single_view -from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.dispatcher import VdomJsonPatch, dispatch_single_view +from idom.core.layout import Layout, LayoutEvent +from idom.core.proto import ComponentConstructor from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event @@ -176,7 +176,7 @@ async def open(self, *args: str, **kwargs: str) -> None: message_queue: "AsyncQueue[str]" = AsyncQueue() query_params = {k: v[0].decode() for k, v in self.request.arguments.items()} - async def send(value: LayoutUpdate) -> None: + async def send(value: VdomJsonPatch) -> None: await self.write_message(json.dumps(value)) async def recv() -> LayoutEvent: diff --git a/src/idom/utils.py b/src/idom/utils.py index 93d1c7525..d2dac350e 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -8,6 +8,7 @@ _RefValue = TypeVar("_RefValue") +_UNDEFINED: Any = object() class Ref(Generic[_RefValue]): @@ -23,9 +24,10 @@ class Ref(Generic[_RefValue]): __slots__ = "current" - def __init__(self, initial_value: _RefValue) -> None: - self.current = initial_value - """The present value""" + def __init__(self, initial_value: _RefValue = _UNDEFINED) -> None: + if initial_value is not _UNDEFINED: + self.current = initial_value + """The present value""" def set_current(self, new: _RefValue) -> _RefValue: """Set the current value and return what is now the old value @@ -37,10 +39,19 @@ def set_current(self, new: _RefValue) -> _RefValue: return old def __eq__(self, other: Any) -> bool: - return isinstance(other, Ref) and (other.current == self.current) + try: + return isinstance(other, Ref) and (other.current == self.current) + except AttributeError: + # attribute error occurs for uninitialized refs + return False def __repr__(self) -> str: - return f"{type(self).__name__}({self.current})" + try: + current = repr(self.current) + except AttributeError: + # attribute error occurs for uninitialized refs + current = "" + return f"{type(self).__name__}({current})" _ModelTransform = Callable[[Dict[str, Any]], Any] diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 574aef314..a8d16c209 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -11,7 +11,8 @@ from . import html from .core import hooks -from .core.component import ComponentConstructor, component +from .core.component import component +from .core.proto import ComponentConstructor from .core.vdom import VdomDict from .utils import Ref diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 1eee7759a..2fad5e61a 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -8,7 +8,7 @@ def MyComponent(a, *b, **c): mc1 = MyComponent(1, 2, 3, x=4, y=5) - expected = f"MyComponent({mc1.id}, a=1, b=(2, 3), c={{'x': 4, 'y': 5}})" + expected = f"MyComponent({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 c76cd3120..9b743777b 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -5,6 +5,7 @@ import idom from idom.core.dispatcher import ( + VdomJsonPatch, create_shared_view_dispatcher, dispatch_single_view, ensure_shared_view_dispatcher_future, @@ -17,6 +18,13 @@ EVENT_HANDLER = StaticEventHandler() +def test_vdom_json_patch_create_from_apply_to(): + update = LayoutUpdate("", {"a": 1, "b": [1]}, {"a": 2, "b": [1, 2]}) + patch = VdomJsonPatch.create_from(update) + result = patch.apply_to({"a": 1, "b": [1]}) + assert result == {"a": 2, "b": [1, 2]} + + def make_send_recv_callbacks(events_to_inject): changes = [] diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 2bbd4aacf..d5693f1df 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -4,6 +4,8 @@ import pytest import idom +from idom.core.dispatcher import render_json_patch +from idom.core.hooks import LifeCycleHook from idom.testing import HookCatcher from tests.general_utils import assert_same_items @@ -31,7 +33,7 @@ def SimpleStatefulComponent(): sse = SimpleStatefulComponent() with idom.Layout(sse) as layout: - patch_1 = await layout.render() + patch_1 = await render_json_patch(layout) assert patch_1.path == "" assert_same_items( patch_1.changes, @@ -41,13 +43,13 @@ def SimpleStatefulComponent(): ], ) - patch_2 = await layout.render() + patch_2 = await render_json_patch(layout) assert patch_2.path == "" assert patch_2.changes == [ {"op": "replace", "path": "/children/0", "value": "1"} ] - patch_3 = await layout.render() + patch_3 = await render_json_patch(layout) assert patch_3.path == "" assert patch_3.changes == [ {"op": "replace", "path": "/children/0", "value": "2"} @@ -563,7 +565,7 @@ def bad_effect(): await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match("Post-render effect .*? failed for .*?", first_log_line) + assert re.match("Post-render effect .*? failed", first_log_line) async def test_error_in_effect_cleanup_is_gracefully_handled(caplog): @@ -588,7 +590,7 @@ def bad_cleanup(): await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match("Post-render effect .*? failed for .*?", first_log_line) + assert re.match("Post-render effect .*?", first_log_line) async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled(caplog): @@ -616,7 +618,7 @@ def bad_cleanup(): await layout.render() # no error first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match("Pre-unmount effect .*? failed for .*?", first_log_line) + assert re.match("Pre-unmount effect .*? failed", first_log_line) async def test_use_reducer(): @@ -846,3 +848,15 @@ def ComponentWithRef(): assert used_refs[0] is used_refs[1] assert len(used_refs) == 2 + + +def test_bad_schedule_render_callback(caplog): + def bad_callback(): + raise ValueError("something went wrong") + + hook = LifeCycleHook(bad_callback) + + hook.schedule_render() + + first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] + assert re.match(f"Failed to schedule render via {bad_callback}", first_log_line) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 6f8ed1742..64b4c11da 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -1,20 +1,17 @@ import asyncio +import gc import re from weakref import finalize import pytest import idom -from idom.core.layout import LayoutEvent, LayoutUpdate +from idom.core.dispatcher import render_json_patch +from idom.core.layout import LayoutEvent from idom.testing import HookCatcher, StaticEventHandler from tests.general_utils import assert_same_items -def test_layout_update_create_from_apply_to(): - update = LayoutUpdate.create_from({"a": 1, "b": [1]}, {"a": 2, "b": [1, 2]}) - assert update.apply_to({"a": 1, "b": [1]}) == {"a": 2, "b": [1, 2]} - - def test_layout_repr(): @idom.component def MyComponent(): @@ -22,7 +19,7 @@ def MyComponent(): my_component = MyComponent() layout = idom.Layout(my_component) - assert str(layout) == f"Layout(MyComponent({my_component.id}))" + assert str(layout) == f"Layout(MyComponent({id(my_component)}))" def test_layout_expects_abstract_component(): @@ -41,13 +38,13 @@ def Component(): layout = idom.Layout(component) with pytest.raises(Exception): - await layout.dispatch(LayoutEvent("something", [])) + await layout.deliver(LayoutEvent("something", [])) with pytest.raises(Exception): layout.update(component) with pytest.raises(Exception): - await layout.render() + await render_json_patch(layout) async def test_simple_layout(): @@ -59,13 +56,13 @@ def SimpleComponent(): return idom.vdom(tag) with idom.Layout(SimpleComponent()) as layout: - path, changes = await layout.render() + path, changes = await render_json_patch(layout) assert path == "" assert changes == [{"op": "add", "path": "/tagName", "value": "div"}] set_state_hook.current("table") - path, changes = await layout.render() + path, changes = await render_json_patch(layout) assert path == "" assert changes == [{"op": "replace", "path": "/tagName", "value": "table"}] @@ -76,7 +73,7 @@ async def test_nested_component_layout(): child_set_state = idom.Ref(None) @idom.component - def Parent(): + def Parent(key): state, parent_set_state.current = idom.hooks.use_state(0) return idom.html.div(state, Child(key="c")) @@ -85,9 +82,8 @@ def Child(key): state, child_set_state.current = idom.hooks.use_state(0) return idom.html.div(state) - with idom.Layout(Parent()) as layout: - - path, changes = await layout.render() + with idom.Layout(Parent(key="p")) as layout: + path, changes = await render_json_patch(layout) assert path == "" assert_same_items( @@ -103,13 +99,13 @@ def Child(key): ) parent_set_state.current(1) - path, changes = await layout.render() + path, changes = await render_json_patch(layout) assert path == "" assert changes == [{"op": "replace", "path": "/children/0", "value": "1"}] child_set_state.current(1) - path, changes = await layout.render() + path, changes = await render_json_patch(layout) assert path == "/children/1" assert changes == [{"op": "replace", "path": "/children/0", "value": "1"}] @@ -129,7 +125,7 @@ def BadChild(): raise ValueError("Something went wrong :(") with idom.Layout(Main()) as layout: - patch = await layout.render() + patch = await render_json_patch(layout) assert_same_items( patch.changes, [ @@ -160,7 +156,7 @@ def Child(): return {"tagName": "div", "children": {"tagName": "h1"}} with idom.Layout(Main()) as layout: - patch = await layout.render() + patch = await render_json_patch(layout) assert_same_items( patch.changes, [ @@ -178,23 +174,29 @@ async def test_components_are_garbage_collected(): live_components = set() outer_component_hook = HookCatcher() + def add_to_live_components(constructor): + def wrapper(*args, **kwargs): + component = constructor(*args, **kwargs) + component_id = id(component) + live_components.add(component_id) + finalize(component, live_components.discard, component_id) + return component + + return wrapper + + @add_to_live_components @idom.component @outer_component_hook.capture def Outer(): - component = idom.hooks.current_hook().component - live_components.add(component.id) - finalize(component, live_components.discard, component.id) return Inner() + @add_to_live_components @idom.component def Inner(): - component = idom.hooks.current_hook().component - live_components.add(component.id) - finalize(component, live_components.discard, component.id) return idom.html.div() with idom.Layout(Outer()) as layout: - await layout.render() + await render_json_patch(layout) assert len(live_components) == 2 @@ -204,12 +206,12 @@ def Inner(): # changed component in the set of `live_components` the old `Inner` deleted and new # `Inner` added. outer_component_hook.latest.schedule_render() - await layout.render() + await render_json_patch(layout) assert len(live_components - last_live_components) == 1 # The layout still holds a reference to the root so that's - # only deleted once we release a reference to it. + # only deleted once we release our reference to the layout. del layout # the hook also contains a reference to the root component del outer_component_hook @@ -217,6 +219,96 @@ def Inner(): assert not live_components +async def test_root_component_life_cycle_hook_is_garbage_collected(): + live_hooks = set() + + def add_to_live_hooks(constructor): + def wrapper(*args, **kwargs): + result = constructor(*args, **kwargs) + hook = idom.hooks.current_hook() + hook_id = id(hook) + live_hooks.add(hook_id) + finalize(hook, live_hooks.discard, hook_id) + return result + + return wrapper + + @idom.component + @add_to_live_hooks + def Root(): + return idom.html.div() + + with idom.Layout(Root()) as layout: + await render_json_patch(layout) + + assert len(live_hooks) == 1 + + # The layout still holds a reference to the root so that's only deleted once we + # release our reference to the layout. + del layout + + assert not live_hooks + + +async def test_life_cycle_hooks_are_garbage_collected(): + live_hooks = set() + set_inner_component = None + + def add_to_live_hooks(constructor): + def wrapper(*args, **kwargs): + result = constructor(*args, **kwargs) + hook = idom.hooks.current_hook() + hook_id = id(hook) + live_hooks.add(hook_id) + finalize(hook, live_hooks.discard, hook_id) + return result + + return wrapper + + @idom.component + @add_to_live_hooks + def Outer(): + nonlocal set_inner_component + inner_component, set_inner_component = idom.hooks.use_state(InnerOne()) + return inner_component + + @idom.component + @add_to_live_hooks + def InnerOne(): + return idom.html.div() + + @idom.component + @add_to_live_hooks + def InnerTwo(): + return idom.html.div() + + with idom.Layout(Outer()) as layout: + await render_json_patch(layout) + + assert len(live_hooks) == 2 + last_live_hooks = live_hooks.copy() + + # We expect the hook for `InnerOne` to be garbage collected since it the + # component will get replaced. + set_inner_component(InnerTwo()) + await render_json_patch(layout) + assert len(live_hooks - last_live_hooks) == 1 + + # The layout still holds a reference to the root so that's only deleted once we + # release our reference to the layout. + del layout + del set_inner_component + + # For some reason, holding `set_inner_component` outside the render context causes + # the associated hook to not be automatically garbage collected. After some + # imperical investigation, it seems that if we do not hold `set_inner_component` in + # this way, the call to `gc.collect()` isn't required. This is demonstrated in + # `test_root_component_life_cycle_hook_is_garbage_collected` + gc.collect() + + assert not live_hooks + + async def test_double_updated_component_is_not_double_rendered(): hook = HookCatcher() run_count = idom.Ref(0) @@ -228,14 +320,14 @@ def AnyComponent(): return idom.html.div() with idom.Layout(AnyComponent()) as layout: - await layout.render() + await render_json_patch(layout) assert run_count.current == 1 hook.latest.schedule_render() hook.latest.schedule_render() - await layout.render() + await render_json_patch(layout) try: await asyncio.wait_for( layout.render(), @@ -260,11 +352,11 @@ def Child(): return idom.html.div() with idom.Layout(Parent()) as layout: - await layout.render() + await render_json_patch(layout) hook.latest.schedule_render() - update = await layout.render() + update = await render_json_patch(layout) assert update.path == "/children/0/children/0" @@ -274,7 +366,7 @@ def SomeComponent(): return idom.html.div() with idom.Layout(SomeComponent()) as layout: - await layout.dispatch(LayoutEvent(target="missing", data=[])) + await layout.deliver(LayoutEvent(target="missing", data=[])) assert re.match( "Ignored event - handler 'missing' does not exist or its component unmounted", @@ -318,16 +410,16 @@ def bad_trigger(): return idom.html.div(children) with idom.Layout(MyComponent()) as layout: - await layout.render() + await render_json_patch(layout) for i in range(3): event = LayoutEvent(good_handler.target, []) - await layout.dispatch(event) + await layout.deliver(event) assert called_good_trigger.current # reset after checking called_good_trigger.current = False - await layout.render() + await render_json_patch(layout) assert not caplog.records @@ -366,16 +458,16 @@ def callback(): return idom.html.button({"onClick": callback, "id": "good"}, "good") with idom.Layout(RootComponent()) as layout: - await layout.render() + await render_json_patch(layout) for _ in range(3): event = LayoutEvent(good_handler.target, []) - await layout.dispatch(event) + await layout.deliver(event) assert called_good_trigger.current # reset after checking called_good_trigger.current = False - await layout.render() + await render_json_patch(layout) async def test_component_can_return_another_component_directly(): @@ -388,7 +480,7 @@ def Inner(): return idom.html.div("hello") with idom.Layout(Outer()) as layout: - update = await layout.render() + update = await render_json_patch(layout) assert_same_items( update.changes, [ @@ -422,18 +514,18 @@ def Inner(key): return idom.html.div(key) with idom.Layout(Outer()) as layout: - await layout.render() + await render_json_patch(layout) pop_item.current() - await layout.render() + await render_json_patch(layout) assert garbage_collect_items == [3] pop_item.current() - await layout.render() + await render_json_patch(layout) assert garbage_collect_items == [3, 2] pop_item.current() - await layout.render() + await render_json_patch(layout) assert garbage_collect_items == [3, 2, 1] @@ -445,7 +537,7 @@ def ComponentReturnsDuplicateKeys(): ) with idom.Layout(ComponentReturnsDuplicateKeys()) as layout: - await layout.render() + await render_json_patch(layout) with pytest.raises(ValueError, match=r"Duplicate keys \['duplicate'\] at '/'"): raise next(iter(caplog.records)).exc_info[1] @@ -466,38 +558,14 @@ def Inner(key): return idom.html.div(key) with idom.Layout(Outer()) as layout: - await layout.render() + await render_json_patch(layout) old_inner_hook = inner_hook.latest outer_hook.latest.schedule_render() - await layout.render() + await render_json_patch(layout) assert old_inner_hook is inner_hook.latest -async def test_log_warning_on_attempt_to_render_component_not_in_layout(caplog): - @idom.component - def SomeComponent(): - return idom.html.div() - - component_in_layout = SomeComponent() - component_not_in_layout = SomeComponent() - - with idom.Layout(component_in_layout) as layout: - await layout.render() - - # try to update a component instance not in layout - layout.update(component_not_in_layout) - # update this too so the next render doesn't hang forever - layout.update(component_in_layout) - - await layout.render() - - assert ( - next(iter(caplog.records)).message - == f"Did not render component - {component_not_in_layout} already unmounted or does not belong to this layout" - ) - - async def test_log_error_on_bad_event_handler(caplog): bad_handler = StaticEventHandler() @@ -510,10 +578,109 @@ def raise_error(): return idom.html.button({"onClick": raise_error}) with idom.Layout(ComponentWithBadEventHandler()) as layout: - await layout.render() + await render_json_patch(layout) event = LayoutEvent(bad_handler.target, []) - await layout.dispatch(event) + await layout.deliver(event) assert next(iter(caplog.records)).message.startswith( "Failed to execute event handler" ) + + +async def test_schedule_render_from_unmounted_hook(caplog): + parent_set_state = idom.Ref() + + @idom.component + def Parent(): + state, parent_set_state.current = idom.hooks.use_state(1) + return Child(key=state) + + child_hook = HookCatcher() + + @idom.component + @child_hook.capture + def Child(key): + idom.hooks.use_effect(lambda: lambda: print("unmount", key)) + return idom.html.div(key) + + with idom.Layout(Parent()) as layout: + await layout.render() + + old_hook = child_hook.latest + + # cause initial child to be unmounted + parent_set_state.current(2) + await layout.render() + + # trigger render for hook that's been unmounted + old_hook.schedule_render() + + # schedule one more render just to make it so `layout.render()` doesn't hang + # when the scheduled render above gets skipped + parent_set_state.current(3) + + await layout.render() + + assert re.match( + ( + "Did not render component with model state ID .*? - component already " + "unmounted or does not belong to this layout" + ), + caplog.records[0].message, + ) + + +async def test_layout_element_cannot_become_a_component(caplog): + set_child_type = idom.Ref() + + @idom.component + def Root(): + child_type, set_child_type.current = idom.hooks.use_state("element") + return idom.html.div(child_nodes[child_type]) + + @idom.component + def Child(key): + return idom.html.div() + + child_nodes = { + "element": idom.html.div(key="the-same-key"), + "component": Child(key="the-same-key"), + } + + with idom.Layout(Root()) as layout: + await layout.render() + + set_child_type.current("component") + + await layout.render() + + error = caplog.records[0].exc_info[1] + assert "prior element with this key wasn't a component" in str(error) + + +async def test_layout_component_cannot_become_an_element(caplog): + set_child_type = idom.Ref() + + @idom.component + def Root(): + child_type, set_child_type.current = idom.hooks.use_state("component") + return idom.html.div(child_nodes[child_type]) + + @idom.component + def Child(key): + return idom.html.div() + + child_nodes = { + "element": idom.html.div(key="the-same-key"), + "component": Child(key="the-same-key"), + } + + with idom.Layout(Root()) as layout: + await layout.render() + + set_child_type.current("element") + + await layout.render() + + error = caplog.records[0].exc_info[1] + assert "prior element with this key was a component" in str(error) diff --git a/tests/test_server/test_common/test_shared_state_client.py b/tests/test_server/test_common/test_shared_state_client.py index a883d1802..f29cbb785 100644 --- a/tests/test_server/test_common/test_shared_state_client.py +++ b/tests/test_server/test_common/test_shared_state_client.py @@ -37,12 +37,13 @@ def incr_on_click(event): {"onClick": incr_on_click, "id": "incr-button"}, "click to increment" ) - return idom.html.div(button, Counter(count)) + counter = Counter(count) + finalize(counter, was_garbage_collected.set) + + return idom.html.div(button, counter) @idom.component def Counter(count): - component = idom.hooks.current_hook().component - finalize(component, was_garbage_collected.set) return idom.html.div({"id": f"count-is-{count}"}, count) server_mount_point.mount(IncrCounter) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8dcd1a6f8..cca97a0ac 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -14,15 +14,25 @@ def test_basic_ref_behavior(): assert r.set_current(3) == 2 assert r.current == 3 + r = idom.Ref() + with pytest.raises(AttributeError): + r.current + + r.current = 4 + assert r.current == 4 + def test_ref_equivalence(): assert idom.Ref([1, 2, 3]) == idom.Ref([1, 2, 3]) assert idom.Ref([1, 2, 3]) != idom.Ref([1, 2]) assert idom.Ref([1, 2, 3]) != [1, 2, 3] + assert idom.Ref() != idom.Ref() + assert idom.Ref() != idom.Ref(1) def test_ref_repr(): assert repr(idom.Ref([1, 2, 3])) == "Ref([1, 2, 3])" + assert repr(idom.Ref()) == "Ref()" @pytest.mark.parametrize(