diff --git a/docs/source/_static/css/furo-theme-overrides.css b/docs/source/_static/css/furo-theme-overrides.css index 71875b4f2..cbe4e99ef 100644 --- a/docs/source/_static/css/furo-theme-overrides.css +++ b/docs/source/_static/css/furo-theme-overrides.css @@ -4,5 +4,5 @@ body { } .sidebar-container { - width: 16em; + width: 18em; } diff --git a/docs/source/adding-interactivity/components-with-state/index.rst b/docs/source/adding-interactivity/components-with-state/index.rst index 14d46302b..93928e05d 100644 --- a/docs/source/adding-interactivity/components-with-state/index.rst +++ b/docs/source/adding-interactivity/components-with-state/index.rst @@ -340,7 +340,7 @@ it. The parent component can’t change it. This lets you add state to any compo remove it without impacting the rest of the components. .. card:: - :link: /managing-state/shared-component-state/index + :link: /managing-state/sharing-component-state/index :link-type: doc :octicon:`book` Read More diff --git a/docs/source/escape-hatches/class-components.rst b/docs/source/escape-hatches/class-components.rst deleted file mode 100644 index 5b7ec19d8..000000000 --- a/docs/source/escape-hatches/class-components.rst +++ /dev/null @@ -1,13 +0,0 @@ -.. _Class Components: - -Class Components 🚧 -=================== - -.. warning:: - - This feature has not been implemented `yet - `__. - -.. note:: - - Under construction 🚧 diff --git a/docs/source/escape-hatches/index.rst b/docs/source/escape-hatches/index.rst index ddf0be60e..f17a3beb4 100644 --- a/docs/source/escape-hatches/index.rst +++ b/docs/source/escape-hatches/index.rst @@ -4,7 +4,6 @@ Escape Hatches .. toctree:: :hidden: - class-components javascript-components distributing-javascript writing-your-own-server diff --git a/docs/source/managing-state/combining-contexts-and-reducers/index.rst b/docs/source/managing-state/combining-contexts-and-reducers/index.rst new file mode 100644 index 000000000..b9f274f0a --- /dev/null +++ b/docs/source/managing-state/combining-contexts-and-reducers/index.rst @@ -0,0 +1,6 @@ +Combining Contexts and Reducers 🚧 +================================== + +.. note:: + + Under construction 🚧 diff --git a/docs/source/managing-state/deeply-sharing-state-with-contexts/index.rst b/docs/source/managing-state/deeply-sharing-state-with-contexts/index.rst new file mode 100644 index 000000000..4a00caa48 --- /dev/null +++ b/docs/source/managing-state/deeply-sharing-state-with-contexts/index.rst @@ -0,0 +1,6 @@ +Deeply Sharing State with Contexts 🚧 +===================================== + +.. note:: + + Under construction 🚧 diff --git a/docs/source/managing-state/structuring-your-state/index.rst b/docs/source/managing-state/how-to-structure-state/index.rst similarity index 77% rename from docs/source/managing-state/structuring-your-state/index.rst rename to docs/source/managing-state/how-to-structure-state/index.rst index 68209cccf..5092370a5 100644 --- a/docs/source/managing-state/structuring-your-state/index.rst +++ b/docs/source/managing-state/how-to-structure-state/index.rst @@ -1,6 +1,6 @@ .. _Structuring Your State: -Structuring Your State 🚧 +How to Structure State 🚧 ========================= .. note:: diff --git a/docs/source/managing-state/index.rst b/docs/source/managing-state/index.rst index 53dc5db26..971c563b4 100644 --- a/docs/source/managing-state/index.rst +++ b/docs/source/managing-state/index.rst @@ -4,9 +4,12 @@ Managing State .. toctree:: :hidden: - structuring-your-state/index - shared-component-state/index + how-to-structure-state/index + sharing-component-state/index when-and-how-to-reset-state/index + simplifying-updates-with-reducers/index + deeply-sharing-state-with-contexts/index + combining-contexts-and-reducers/index .. dropdown:: :octicon:`bookmark-fill;2em` What You'll Learn :color: info @@ -15,14 +18,15 @@ Managing State .. grid:: 1 2 2 2 - .. grid-item-card:: :octicon:`code-square` Structuring Your State - :link: structuring-your-state/index + .. grid-item-card:: :octicon:`organization` How to Structure State + :link: how-to-structure-state/index :link-type: doc - Make it easy to reason about your application by organizing its state. + Make it easy to reason about your application with strategies for organizing + state. - .. grid-item-card:: :octicon:`link` Shared Component State - :link: shared-component-state/index + .. grid-item-card:: :octicon:`link` Sharing Component State + :link: sharing-component-state/index :link-type: doc Allow components to vary vary together, by lifting state into common @@ -35,8 +39,37 @@ Managing State Control if and how state is preserved by understanding it's relationship to the "UI tree". + .. grid-item-card:: :octicon:`plug` Simplifying Updates with Reducers + :link: simplifying-updates-with-reducers/index + :link-type: doc + + Consolidate state update logic outside your component in a single function, + called a “reducer". + + .. grid-item-card:: :octicon:`broadcast` Deeply Sharing State with Contexts + :link: deeply-sharing-state-with-contexts/index + :link-type: doc + + Instead of passing shared state down deep component trees, bring state into + "contexts" instead. + + .. grid-item-card:: :octicon:`rocket` Combining Contexts and Reducers + :link: combining-contexts-and-reducers/index + :link-type: doc + + You can combine reducers and context together to manage state of a complex + screen. -Section 4: Shared Component State + +Section 1: How to Structure State +--------------------------------- + +.. note:: + + Under construction 🚧 + + +Section 2: Shared Component State --------------------------------- Sometimes, you want the state of two components to always change together. To do it, @@ -49,13 +82,46 @@ state, the state represents the food name. Note how the component ``Table`` gets at each change of state. The component is observing the state and reacting to state changes automatically, just like it would do in React. -.. idom:: shared-component-state/_examples/synced_inputs +.. idom:: sharing-component-state/_examples/synced_inputs .. card:: - :link: shared-component-state/index + :link: sharing-component-state/index :link-type: doc :octicon:`book` Read More ^^^^^^^^^^^^^^^^^^^^^^^^^ Allow components to vary vary together, by lifting state into common parents. + + +Section 3: When and How to Reset State +-------------------------------------- + +.. note:: + + Under construction 🚧 + + +Section 4: Simplifying Updates with Reducers +-------------------------------------------- + +.. note:: + + Under construction 🚧 + + +Section 5: Deeply Sharing State with Contexts +--------------------------------------------- + +.. note:: + + Under construction 🚧 + + + +Section 6: Combining Contexts and Reducers +------------------------------------------ + +.. note:: + + Under construction 🚧 diff --git a/docs/source/managing-state/shared-component-state/_examples/filterable_list/app.py b/docs/source/managing-state/sharing-component-state/_examples/filterable_list/app.py similarity index 100% rename from docs/source/managing-state/shared-component-state/_examples/filterable_list/app.py rename to docs/source/managing-state/sharing-component-state/_examples/filterable_list/app.py diff --git a/docs/source/managing-state/shared-component-state/_examples/filterable_list/data.json b/docs/source/managing-state/sharing-component-state/_examples/filterable_list/data.json similarity index 100% rename from docs/source/managing-state/shared-component-state/_examples/filterable_list/data.json rename to docs/source/managing-state/sharing-component-state/_examples/filterable_list/data.json diff --git a/docs/source/managing-state/shared-component-state/_examples/synced_inputs/app.py b/docs/source/managing-state/sharing-component-state/_examples/synced_inputs/app.py similarity index 100% rename from docs/source/managing-state/shared-component-state/_examples/synced_inputs/app.py rename to docs/source/managing-state/sharing-component-state/_examples/synced_inputs/app.py diff --git a/docs/source/managing-state/shared-component-state/index.rst b/docs/source/managing-state/sharing-component-state/index.rst similarity index 59% rename from docs/source/managing-state/shared-component-state/index.rst rename to docs/source/managing-state/sharing-component-state/index.rst index 14422f742..e7971054c 100644 --- a/docs/source/managing-state/shared-component-state/index.rst +++ b/docs/source/managing-state/sharing-component-state/index.rst @@ -1,13 +1,15 @@ -Shared Component State -====================== +Sharing Component State +======================= +.. note:: + + Parts of this document are still under construction 🚧 + +Sometimes, you want the state of two components to always change together. To do it, +remove state from both of them, move it to their closest common parent, and then pass it +down to them via props. This is known as “lifting state up”, and it’s one of the most +common things you will do writing code with IDOM. -Sometimes you want the state of two components to always change together. To do it, you -need to be able to share state between those two components, to share state between -components move state to the nearest parent. In React world this is known as "lifting -state up" and it is a very common thing to do. Let's look at 2 examples, also from -`React `__, -but translated to IDOM. Synced Inputs ------------- @@ -18,6 +20,7 @@ and ``set_value`` variables. .. idom:: _examples/synced_inputs + Filterable List ---------------- diff --git a/docs/source/managing-state/simplifying-updates-with-reducers/index.rst b/docs/source/managing-state/simplifying-updates-with-reducers/index.rst new file mode 100644 index 000000000..08fce5a69 --- /dev/null +++ b/docs/source/managing-state/simplifying-updates-with-reducers/index.rst @@ -0,0 +1,6 @@ +Simplifying Updates with Reducers 🚧 +==================================== + +.. note:: + + Under construction 🚧 diff --git a/docs/source/reference-material/hooks-api.rst b/docs/source/reference-material/hooks-api.rst index f2967376e..f41a532cf 100644 --- a/docs/source/reference-material/hooks-api.rst +++ b/docs/source/reference-material/hooks-api.rst @@ -186,9 +186,9 @@ There are **three important subtleties** to note about using asynchronous effect Manual Effect Conditions ........................ -In some cases, you may want to explicitely declare when an effect should be triggered. -You can do this by passing ``dependencies`` to ``use_effect``. Each of the following values -produce different effect behaviors: +In some cases, you may want to explicitly declare when an effect should be triggered. +You can do this by passing ``dependencies`` to ``use_effect``. Each of the following +values produce different effect behaviors: - ``use_effect(..., dependencies=None)`` - triggers and cleans up on every render. - ``use_effect(..., dependencies=[])`` - only triggers on the first and cleans up after @@ -197,6 +197,24 @@ produce different effect behaviors: ``x`` or ``y`` have changed. +Use Context +----------- + +.. code-block:: + + value = use_context(MyContext) + +Accepts a context object (the value returned from +:func:`idom.core.hooks.create_context`) and returns the current context value for that +context. The current context value is determined by the ``value`` argument passed to the +nearest ``MyContext`` in the tree. + +When the nearest above the component updates, this Hook will +trigger a rerender with the latest context value passed to that MyContext provider. Even +if an ancestor uses React.memo or shouldComponentUpdate, a rerender will still happen +starting at the component itself using useContext. + + Supplementary Hooks =================== diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 3d2324140..778368a6e 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -4,7 +4,9 @@ from .core.dispatcher import Stop from .core.events import EventHandler, event from .core.hooks import ( + create_context, use_callback, + use_context, use_effect, use_memo, use_reducer, @@ -28,6 +30,7 @@ "Component", "ComponentType", "config", + "create_context", "event", "EventHandler", "hooks", @@ -42,6 +45,7 @@ "run", "Stop", "use_callback", + "use_context", "use_effect", "use_memo", "use_reducer", diff --git a/src/idom/core/_thread_local.py b/src/idom/core/_thread_local.py new file mode 100644 index 000000000..f1168cc20 --- /dev/null +++ b/src/idom/core/_thread_local.py @@ -0,0 +1,25 @@ +from threading import Thread, current_thread +from typing import Callable, Generic, TypeVar +from weakref import WeakKeyDictionary + + +_StateType = TypeVar("_StateType") + + +class ThreadLocal(Generic[_StateType]): + """Utility for managing per-thread state information""" + + def __init__(self, default: Callable[[], _StateType]): + self._default = default + self._state: WeakKeyDictionary[Thread, _StateType] = WeakKeyDictionary() + + def get(self) -> _StateType: + thread = current_thread() + if thread not in self._state: + state = self._state[thread] = self._default() + else: + state = self._state[thread] + return state + + def set(self, state: _StateType) -> None: + self._state[current_thread()] = state diff --git a/src/idom/core/component.py b/src/idom/core/component.py index 04830780c..4b18d4712 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -9,7 +9,7 @@ def component( function: Callable[..., Union[ComponentType, VdomDict | None]] -) -> Callable[..., "Component"]: +) -> Callable[..., Component]: """A decorator for defining a new component. Parameters: @@ -54,6 +54,9 @@ def __init__( def render(self) -> VdomDict | ComponentType | None: return self.type(*self._args, **self._kwargs) + def should_render(self, new: Component) -> bool: + return True + def __repr__(self) -> str: try: args = self._sig.bind(*self._args, **self._kwargs).arguments diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 50bcd1da1..d0717162e 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -2,13 +2,13 @@ import asyncio from logging import getLogger -from threading import get_ident as get_thread_id from types import FunctionType from typing import ( TYPE_CHECKING, Any, Awaitable, Callable, + ClassVar, Dict, Generic, List, @@ -26,6 +26,10 @@ from idom.utils import Ref +from ._thread_local import ThreadLocal +from .proto import Key, VdomDict +from .vdom import vdom + if not TYPE_CHECKING: # make flake8 think that this variable exists @@ -187,11 +191,11 @@ def effect() -> None: clean = last_clean_callback.current = sync_function() if clean is not None: - hook.add_effect(WILL_UNMOUNT_EFFECT, clean) + hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean) return None - return memoize(lambda: hook.add_effect(DID_RENDER_EFFECT, effect)) + return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)) if function is not None: add_effect(function) @@ -200,6 +204,111 @@ def effect() -> None: return add_effect +def create_context( + default_value: _StateType, name: str | None = None +) -> type[_Context[_StateType]]: + """Return a new context type for use in :func:`use_context`""" + + class Context(_Context[_StateType]): + _default_value = default_value + + if name is not None: + Context.__name__ = name + + return Context + + +def use_context(context_type: type[_Context[_StateType]]) -> _StateType: + """Get the current value for the given context type. + + See the full :ref:`Use Context` docs for more information. + """ + # We have to use a Ref here since, if initially context_type._current is None, and + # then on a subsequent render it is present, we need to be able to dynamically adopt + # that newly present current context. When we update it though, we don't need to + # schedule a new render since we're already rending right now. Thus we can't do this + # with use_state() since we'd incur an extra render when calling set_state. + context_ref: Ref[_Context[_StateType] | None] = use_ref(None) + + if context_ref.current is None: + provided_context = context_type._current.get() + if provided_context is None: + # Cast required because of: https://github.com/python/mypy/issues/5144 + return cast(_StateType, context_type._default_value) + context_ref.current = provided_context + + # We need the hook now so that we can schedule an update when + hook = current_hook() + + context = context_ref.current + + @use_effect + def subscribe_to_context_change() -> Callable[[], None]: + def set_context(new: _Context[_StateType]) -> None: + # We don't need to check if `new is not context_ref.current` because we only + # trigger this callback when the value of a context, and thus the context + # itself changes. Therefore we can always schedule a render. + context_ref.current = new + hook.schedule_render() + + context.subscribers.add(set_context) + return lambda: context.subscribers.remove(set_context) + + return context.value + + +_UNDEFINED: Any = object() + + +class _Context(Generic[_StateType]): + + # This should be _StateType instead of Any, but it can't due to this limitation: + # https://github.com/python/mypy/issues/5144 + _default_value: ClassVar[Any] + + _current: ClassVar[ThreadLocal[_Context[Any] | None]] + + def __init_subclass__(cls) -> None: + # every context type tracks which of its instances are currently in use + cls._current = ThreadLocal(lambda: None) + + def __init__( + self, + *children: Any, + value: _StateType = _UNDEFINED, + key: Key | None = None, + ) -> None: + self.children = children + self.value: _StateType = self._default_value if value is _UNDEFINED else value + self.key = key + self.subscribers: set[Callable[[_Context[_StateType]], None]] = set() + self.type = self.__class__ + + def render(self) -> VdomDict: + current_ctx = self.__class__._current + + prior_ctx = current_ctx.get() + current_ctx.set(self) + + def reset_ctx() -> None: + current_ctx.set(prior_ctx) + + current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, reset_ctx) + + return vdom("", *self.children) + + def should_render(self, new: _Context[_StateType]) -> bool: + if self.value is not new.value: + new.subscribers.update(self.subscribers) + for set_context in self.subscribers: + set_context(new) + return True + return False + + def __repr__(self) -> str: + return f"{type(self).__name__}({id(self)})" + + _ActionType = TypeVar("_ActionType") @@ -413,25 +522,28 @@ def _try_to_infer_closure_values( return cast("Sequence[Any] | None", values) -_current_life_cycle_hook: Dict[int, "LifeCycleHook"] = {} - - -def current_hook() -> "LifeCycleHook": +def current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" - try: - return _current_life_cycle_hook[get_thread_id()] - except KeyError as error: + hook = _current_hook.get() + if hook is None: msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) from error + raise RuntimeError(msg) + return hook + + +_current_hook: ThreadLocal[LifeCycleHook | None] = ThreadLocal(lambda: None) 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""" +COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER") +"""An effect that will be triggered each time a component renders""" -WILL_UNMOUNT_EFFECT = EffectType("WILL_UNMOUNT") +LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER") +"""An effect that will be triggered each time a layout renders""" + +COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT") """An effect that will be triggered just before the component is unmounted""" @@ -461,7 +573,7 @@ class LifeCycleHook: # --- start render cycle --- - hook.component_will_render() + hook.affect_component_will_render() hook.set_current() @@ -478,10 +590,12 @@ class LifeCycleHook: finally: hook.unset_current() + hook.affect_component_did_render() + # 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() + # component_instance.render() have also been rendered because effects of + # this type must run after the full set of changes have been resolved. + hook.affect_layout_did_render() # Typically an event occurs and a new render is scheduled, thus begining # the render cycle anew. @@ -490,7 +604,7 @@ class LifeCycleHook: # --- end render cycle --- - hook.component_will_unmount() + hook.affect_component_will_unmount() del hook # --- end render cycle --- @@ -501,7 +615,7 @@ class LifeCycleHook: "_schedule_render_later", "_current_state_index", "_state", - "_rendered_at_least_once", + "_rendered_atleast_once", "_is_rendering", "_event_effects", "__weakref__", @@ -514,12 +628,13 @@ def __init__( self._schedule_render_callback = schedule_render self._schedule_render_later = False self._is_rendering = False - self._rendered_at_least_once = False + self._rendered_atleast_once = False self._current_state_index = 0 self._state: Tuple[Any, ...] = () self._event_effects: Dict[EffectType, List[Callable[[], None]]] = { - DID_RENDER_EFFECT: [], - WILL_UNMOUNT_EFFECT: [], + COMPONENT_DID_RENDER_EFFECT: [], + LAYOUT_DID_RENDER_EFFECT: [], + COMPONENT_WILL_UNMOUNT_EFFECT: [], } def schedule_render(self) -> None: @@ -530,44 +645,55 @@ def schedule_render(self) -> None: return None def use_state(self, function: Callable[[], _StateType]) -> _StateType: - if not self._rendered_at_least_once: + if not self._rendered_atleast_once: # since we're not intialized yet we're just appending state result = function() self._state += (result,) else: - # once finalized we iterate over each successively used piece of state + # once finalized we iterate over each succesively used piece of state result = self._state[self._current_state_index] self._current_state_index += 1 return result def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None: - """Trigger a function on the occurrence of the given effect type""" + """Trigger a function on the occurance of the given effect type""" self._event_effects[effect_type].append(function) - def component_will_render(self) -> None: + def affect_component_will_render(self) -> None: """The component is about to render""" self._is_rendering = True - self._event_effects[WILL_UNMOUNT_EFFECT].clear() + self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear() - def component_did_render(self) -> None: + def affect_component_did_render(self) -> None: """The component completed a render""" - did_render_effects = self._event_effects[DID_RENDER_EFFECT] - for effect in did_render_effects: + component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT] + for effect in component_did_render_effects: try: effect() except Exception: - logger.exception(f"Post-render effect {effect} failed") - did_render_effects.clear() + logger.exception(f"Component post-render effect {effect} failed") + component_did_render_effects.clear() self._is_rendering = False + self._rendered_atleast_once = True + self._current_state_index = 0 + + def affect_layout_did_render(self) -> None: + """The layout completed a render""" + layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT] + for effect in layout_did_render_effects: + try: + effect() + except Exception: + logger.exception(f"Layout post-render effect {effect} failed") + layout_did_render_effects.clear() + if self._schedule_render_later: self._schedule_render() - self._rendered_at_least_once = True - self._current_state_index = 0 - def component_will_unmount(self) -> None: + def affect_component_will_unmount(self) -> None: """The component is about to be removed from the layout""" - will_unmount_effects = self._event_effects[WILL_UNMOUNT_EFFECT] + will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT] for effect in will_unmount_effects: try: effect() @@ -581,13 +707,13 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - _current_life_cycle_hook[get_thread_id()] = self + _current_hook.set(self) def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" - # this assertion should never fail - primarily useful for debug - assert _current_life_cycle_hook[get_thread_id()] is self - del _current_life_cycle_hook[get_thread_id()] + # this assertion should never fail - primarilly useful for debug + assert _current_hook.get() is self + _current_hook.set(None) def _schedule_render(self) -> None: try: diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index e1e7ba027..87a632b79 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -136,7 +136,7 @@ async def render(self) -> LayoutUpdate: try: model_state = self._model_states_by_life_cycle_state_id[model_state_id] except KeyError: - logger.info( + logger.debug( "Did not render component with model state ID " "{model_state_id!r} - component already unmounted" ) @@ -168,7 +168,7 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: # hook effects must run after the update is complete for model_state in _iter_model_state_children(new_state): if model_state.is_component_state: - model_state.life_cycle_state.hook.component_did_render() + model_state.life_cycle_state.hook.affect_layout_did_render() old_model: Optional[VdomJson] try: @@ -191,38 +191,47 @@ def _render_component( 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() + if ( + old_state is not None + and old_state.is_component_state + and not _check_should_render( + old_state.life_cycle_state.component, component + ) + ): + new_state.model.current = old_state.model.current + else: + life_cycle_hook = life_cycle_state.hook + life_cycle_hook.affect_component_will_render() try: - raw_model = component.render() + life_cycle_hook.set_current() + try: + raw_model = component.render() + finally: + life_cycle_hook.unset_current() + # wrap the model in a fragment (i.e. tagName="") to ensure components have + # a separate node in the model state tree. This could be removed if this + # components are given a node in the tree some other way + wrapper_model: VdomDict = {"tagName": ""} + if raw_model is not None: + wrapper_model["children"] = [raw_model] + self._render_model(old_state, new_state, wrapper_model) + except Exception as error: + logger.exception(f"Failed to render {component}") + new_state.model.current = { + "tagName": "", + "error": ( + f"{type(error).__name__}: {error}" + if IDOM_DEBUG_MODE.current + else "" + ), + } finally: - life_cycle_hook.unset_current() - - # wrap the model in a fragment (i.e. tagName="") to ensure components have - # a separate node in the model state tree. This could be removed if this - # components are given a node in the tree some other way - wrapper_model: VdomDict = {"tagName": ""} - if raw_model is not None: - wrapper_model["children"] = [raw_model] - - self._render_model(old_state, new_state, wrapper_model) - except Exception as error: - logger.exception(f"Failed to render {component}") - new_state.model.current = { - "tagName": "", - "error": ( - f"{type(error).__name__}: {error}" - if IDOM_DEBUG_MODE.current - else "" - ), - } + life_cycle_hook.affect_component_did_render() + try: parent = new_state.parent except AttributeError: - pass + pass # only happens for root component else: key, index = new_state.key, new_state.index parent.children_by_key[key] = new_state @@ -230,6 +239,9 @@ def _render_component( parent.model.current["children"][index : index + 1] = [ new_state.model.current ] + finally: + # avoid double render + self._rendering_queue.remove_if_pending(life_cycle_state.id) def _render_model( self, @@ -447,7 +459,7 @@ def _unmount_model_states(self, old_states: List[_ModelState]) -> None: if model_state.is_component_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() + life_cycle_state.hook.affect_component_will_unmount() to_unmount.extend(model_state.children_by_key.values()) @@ -455,6 +467,14 @@ def __repr__(self) -> str: return f"{type(self).__name__}({self.root})" +def _check_should_render(old: ComponentType, new: ComponentType) -> bool: + try: + return old.should_render(new) + except Exception: + logger.exception(f"{old} component failed to check if {new} should be rendered") + return False + + def _iter_model_state_children(model_state: _ModelState) -> Iterator[_ModelState]: yield model_state for child in model_state.children_by_key.values(): @@ -696,8 +716,15 @@ def put(self, value: _Type) -> None: self._loop.call_soon_threadsafe(self._queue.put_nowait, value) return None + def remove_if_pending(self, value: _Type) -> None: + if value in self._pending: + self._pending.remove(value) + async def get(self) -> _Type: - value = await self._queue.get() + while True: + value = await self._queue.get() + if value in self._pending: + break self._pending.remove(value) return value diff --git a/src/idom/core/proto.py b/src/idom/core/proto.py index 4fd1c030d..915fa0a7f 100644 --- a/src/idom/core/proto.py +++ b/src/idom/core/proto.py @@ -25,6 +25,9 @@ Key = Union[str, int] +_OwnType = TypeVar("_OwnType") + + @runtime_checkable class ComponentType(Protocol): """The expected interface for all component-like objects""" @@ -41,6 +44,9 @@ class ComponentType(Protocol): def render(self) -> VdomDict | ComponentType | None: """Render the component's view model.""" + def should_render(self: _OwnType, new: _OwnType) -> bool: + """Whether the new component instance should be rendered.""" + _Self = TypeVar("_Self") _Render = TypeVar("_Render", covariant=True) diff --git a/src/idom/testing.py b/src/idom/testing.py index 7c4f1c2fb..f9581b45f 100644 --- a/src/idom/testing.py +++ b/src/idom/testing.py @@ -326,17 +326,17 @@ class HookCatcher: Example: .. code-block:: - hooks = HookCatcher(index_by_kwarg="key") + hooks = HookCatcher(index_by_kwarg="thing") @idom.component @hooks.capture - def MyComponent(key): + def MyComponent(thing): ... - ... # render the component + ... # render the component - # grab the last render of where MyComponent(key='some_key') - hooks.index["some_key"] + # grab the last render of where MyComponent(thing='something') + hooks.index["something"] # or grab the hook from the component's last render hooks.latest diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 93a220a4c..e15f95701 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -4,8 +4,9 @@ import pytest import idom +from idom import html from idom.core.dispatcher import render_json_patch -from idom.core.hooks import LifeCycleHook +from idom.core.hooks import COMPONENT_DID_RENDER_EFFECT, LifeCycleHook, current_hook from idom.testing import HookCatcher, assert_idom_logged from tests.assert_utils import assert_same_items @@ -543,11 +544,9 @@ def bad_effect(): return idom.html.div() - with idom.Layout(ComponentWithEffect()) as layout: - await layout.render() # no error - - first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match("Post-render effect .*? failed", first_log_line) + with assert_idom_logged(match_message=r"Layout post-render effect .* failed"): + with idom.Layout(ComponentWithEffect()) as layout: + await layout.render() # no error async def test_error_in_effect_cleanup_is_gracefully_handled(caplog): @@ -566,13 +565,11 @@ def bad_cleanup(): return idom.html.div() - with idom.Layout(ComponentWithEffect()) as layout: - await layout.render() - component_hook.latest.schedule_render() - await layout.render() # no error - - first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match("Post-render effect .*?", first_log_line) + with assert_idom_logged(match_error=r"Layout post-render effect .* failed"): + with idom.Layout(ComponentWithEffect()) as layout: + await layout.render() + component_hook.latest.schedule_render() + await layout.render() # no error async def test_error_in_effect_pre_unmount_cleanup_is_gracefully_handled(): @@ -899,3 +896,246 @@ def some_memo_func_that_uses_count(): await layout.render() await did_memo.wait() did_memo.clear() + + +async def test_use_context_default_value(): + Context = idom.create_context("something") + value = idom.Ref() + + @idom.component + def ComponentProvidesContext(): + return Context(ComponentUsesContext()) + + @idom.component + def ComponentUsesContext(): + value.current = idom.use_context(Context) + return html.div() + + with idom.Layout(ComponentProvidesContext()) as layout: + await layout.render() + assert value.current == "something" + + @idom.component + def ComponentUsesContext(): + value.current = idom.use_context(Context) + return html.div() + + with idom.Layout(ComponentUsesContext()) as layout: + await layout.render() + assert value.current == "something" + + +def test_context_repr(): + Context = idom.create_context(None) + assert re.match(r"Context\(.*\)", repr(Context())) + + MyContext = idom.create_context(None, name="MyContext") + assert re.match(r"MyContext\(.*\)", repr(MyContext())) + + +async def test_use_context_only_renders_for_value_change(): + Context = idom.create_context(None) + + provider_hook = HookCatcher() + render_count = idom.Ref(0) + set_state = idom.Ref() + + @idom.component + @provider_hook.capture + def ComponentProvidesContext(): + state, set_state.current = idom.use_state(0) + return Context(ComponentInContext(), value=state) + + @idom.component + def ComponentInContext(): + render_count.current += 1 + return html.div() + + with idom.Layout(ComponentProvidesContext()) as layout: + await layout.render() + assert render_count.current == 1 + + set_state.current(1) + + await layout.render() + assert render_count.current == 2 + + provider_hook.latest.schedule_render() + + await layout.render() + assert render_count.current == 2 + + +async def test_use_context_updates_components_even_if_memoized(): + Context = idom.create_context(None) + + value = idom.Ref(None) + render_count = idom.Ref(0) + set_state = idom.Ref() + + @idom.component + def ComponentProvidesContext(): + state, set_state.current = idom.use_state(0) + return Context(ComponentInContext(), value=state) + + @idom.component + def ComponentInContext(): + return idom.use_memo(MemoizedComponentUsesContext) + + @idom.component + def MemoizedComponentUsesContext(): + value.current = idom.use_context(Context) + render_count.current += 1 + return html.div() + + with idom.Layout(ComponentProvidesContext()) as layout: + await layout.render() + assert render_count.current == 1 + assert value.current == 0 + + set_state.current(1) + + await layout.render() + assert render_count.current == 2 + assert value.current == 1 + + set_state.current(2) + + await layout.render() + assert render_count.current == 3 + assert value.current == 2 + + +async def test_nested_contexts_do_not_conflict(): + Context = idom.create_context(None) + + outer_value = idom.Ref(None) + inner_value = idom.Ref(None) + outer_render_count = idom.Ref(0) + inner_render_count = idom.Ref(0) + set_outer_value = idom.Ref() + set_root_value = idom.Ref() + + @idom.component + def Root(): + outer_value, set_root_value.current = idom.use_state(-1) + return Context(Outer(), value=outer_value) + + @idom.component + def Outer(): + inner_value, set_outer_value.current = idom.use_state(1) + outer_value.current = idom.use_context(Context) + outer_render_count.current += 1 + return Context(Inner(), value=inner_value) + + @idom.component + def Inner(): + inner_value.current = idom.use_context(Context) + inner_render_count.current += 1 + return html.div() + + with idom.Layout(Root()) as layout: + await layout.render() + assert outer_render_count.current == 1 + assert inner_render_count.current == 1 + assert outer_value.current == -1 + assert inner_value.current == 1 + + set_root_value.current(-2) + + await layout.render() + assert outer_render_count.current == 2 + assert inner_render_count.current == 1 + assert outer_value.current == -2 + assert inner_value.current == 1 + + set_outer_value.current(2) + + await layout.render() + assert outer_render_count.current == 3 + assert inner_render_count.current == 2 + assert outer_value.current == -2 + assert inner_value.current == 2 + + +async def test_neighboring_contexts_do_not_conflict(): + LeftContext = idom.create_context(None, name="Left") + RightContext = idom.create_context(None, name="Right") + + set_left = idom.Ref() + set_right = idom.Ref() + left_used_value = idom.Ref() + right_used_value = idom.Ref() + left_render_count = idom.Ref(0) + right_render_count = idom.Ref(0) + + @idom.component + def Root(): + left_value, set_left.current = idom.use_state(1) + right_value, set_right.current = idom.use_state(1) + return idom.html.div( + LeftContext(Left(), value=left_value), + RightContext(Right(), value=right_value), + ) + + @idom.component + def Left(): + left_render_count.current += 1 + left_used_value.current = idom.use_context(LeftContext) + return idom.html.div() + + @idom.component + def Right(): + right_render_count.current += 1 + right_used_value.current = idom.use_context(RightContext) + return idom.html.div() + + with idom.Layout(Root()) as layout: + await layout.render() + assert left_render_count.current == 1 + assert right_render_count.current == 1 + assert left_used_value.current == 1 + assert right_used_value.current == 1 + + for i in range(2, 5): + set_left.current(i) + + await layout.render() + assert left_render_count.current == i + assert right_render_count.current == 1 + assert left_used_value.current == i + assert right_used_value.current == 1 + + for j in range(2, 5): + set_right.current(j) + + await layout.render() + assert left_render_count.current == i + assert right_render_count.current == j + assert left_used_value.current == i + assert right_used_value.current == j + + +async def test_error_in_effect_cleanup_is_gracefully_handled(): + component_hook = HookCatcher() + + @idom.component + @component_hook.capture + def ComponentWithEffect(): + hook = current_hook() + + def bad_effect(): + raise ValueError("The error message") + + hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) + return idom.html.div() + + with assert_idom_logged( + match_message="Component post-render effect .*? failed", + error_type=ValueError, + match_error="The error message", + ): + with idom.Layout(ComponentWithEffect()) as layout: + await layout.render() + component_hook.latest.schedule_render() + await layout.render() # no error diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 0ac7fe63e..f0dbbb4d5 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -1161,3 +1161,81 @@ def Child(): await layout.render() assert did_unmount.current + + +class ComponentShouldRender: + def __init__(self, child, should_render): + self.child = child or html.div() + self.should_render = should_render + self.key = None + self.type = self.__class__ + + def render(self): + return html.div(self.child) + + +async def test_component_should_render_always_true(): + render_count = idom.Ref(0) + root_hook = HookCatcher() + + @idom.component + @root_hook.capture + def Root(): + return ComponentShouldRender(SomeComponent(), should_render=lambda new: True) + + @idom.component + def SomeComponent(): + render_count.current += 1 + return html.div() + + with idom.Layout(Root()) as layout: + for _ in range(4): + await layout.render() + root_hook.latest.schedule_render() + + assert render_count.current == 4 + + +async def test_component_should_render_always_false(): + render_count = idom.Ref(0) + root_hook = HookCatcher() + + @idom.component + @root_hook.capture + def Root(): + return ComponentShouldRender(SomeComponent(), should_render=lambda new: False) + + @idom.component + def SomeComponent(): + render_count.current += 1 + return html.div() + + with idom.Layout(Root()) as layout: + for _ in range(4): + await layout.render() + root_hook.latest.schedule_render() + + assert render_count.current == 1 + + +async def test_component_error_in_should_render_is_handled_gracefully(): + root_hook = HookCatcher() + + @idom.component + @root_hook.capture + def Root(): + def bad_should_render(new): + raise ValueError("The error message") + + return ComponentShouldRender(html.div(), should_render=bad_should_render) + + with assert_idom_logged( + match_message=r".* component failed to check if .* should be rendered", + error_type=ValueError, + match_error="The error message", + clear_matched_records=True, + ): + with idom.Layout(Root()) as layout: + await layout.render() + root_hook.latest.schedule_render() + await layout.render()