diff --git a/benchmarks/README.md b/benchmarks/README.md index 30dc814..16b773c 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -39,11 +39,22 @@ memoized-subtree "skip", and a full `force_update` all cost the same (~14ms on a renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both: - **Dirty-subtree skipping.** State setters mark `needs_render_descendant` up - the parent chain, so a render pass only descends into subtrees that can + the parent chain, and record the dirty child in every parent + (`dirty_children`), so a render pass only descends into subtrees that can contain work. A component subtree whose element is identical to the previous - render (`el is el_prev`), is fully reconciled, and has no dirty/excepted - contexts is skipped in *both* phases and keeps its previous widgets - (`clean_subtree`). + render (`el is el_prev`), or a new element with equal arguments, is fully + reconciled, and has no dirty/excepted contexts is skipped in *both* phases + and keeps its previous widgets (`clean_subtree`). Not while a widget above it + at the same key is replaced by another widget type: reconciliation removes + that whole subtree first. +- **Walking the context tree, not the element tree.** A component that does + not render again, but has a dirty descendant, has the element tree of its + last reconciliation. Its element tree is not walked; only its dirty child + contexts are (`partial`), in element order, in both phases. An update costs + work proportional to the depth of the dirty component, not to the number of + siblings. When the root widget of such a child changes (another widget type, + or a fragment with other children), the widgets holding it are updated + (`_rewire`). Shared elements and pending exceptions keep the full walk. - **Forced full walks** (`force_update()`, `update()`, the first render) set `rc._walk_all`, disabling skipping for that pass — faithful to the old behavior. @@ -52,19 +63,37 @@ renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both: element. - **Widget updates** are skipped when an identical element reconciles to identical child widget objects (`_values_identical`), avoiding pointless - traitlets assignments. + traitlets assignments. Container widgets keep the kwargs they were last + created or updated with (`resolved_kwargs`) for this compare. +- **Mount path.** The implicit container is only built when a component body + returns `None`; component context managers are entered without an + `ExitStack`; the child visitors dispatch on exact types and skip scalars. - **Side-effect ("orphan") widgets** (Layout/Style created during construction) are tracked via ipywidgets' `on_widget_constructed` hook instead of diffing the global widgets dict per creation — the old diff was O(live widgets) per widget, so it degraded as an app grew. +Shared by both renderers (cheaper, same behavior): widget creation +(`Element._create_widget`) installs the render batching of +`hold_trait_notifications` once per widget class instead of a wrapper per +widget, records side-effect widgets per thread (no global lock), and only asks +for the trait names when a kwarg starts with `on_`; `ComponentContext` is a +plain class that makes its rarely used containers on first use; +`utils.equals`, the setter, the hooks and `render()` avoid work that is only +needed with logging on. Only the last two rerender reasons are kept (they held +every old state value). + ## Renderer contract What both renderers must preserve (derived from `core.py` + the test suite). -The fast renderer overrides only `_render`, `_reconsolidate`, `_remove_element`, -`_visit_children`, `_visit_children_values`; everything else (Element widget +The fast renderer overrides the tree walking (`_render`, `_reconsolidate`, +`_remove_element`, the child visitors, `_mark_dirty`, `_discard_aborted_pass`) +and adds `_render_component`, `_render_dirty_children`, +`_reconsolidate_partial`, `_rewire`; everything else (Element widget create/update/close, hooks storage, `ComponentContext`, exception plumbing, the render loop) is shared with the default renderer. +`test_renderers_agree_on_random_updates` drives both renderers with the same +random state changes and compares the widgets and the effect order. **Phases** (inside one `rc.render()` call, under `thread_lock`, with `local.rc` set): @@ -93,7 +122,8 @@ context raises `KeyError`. `el._key_frozen` is set once an element is rendered. - `needs_render` = `context.needs_render` (set by setters/force) OR `el._arguments_changed(el_prev)` OR `context.exceptions_children`. If false, the body is *not* executed (component `render_count` stays put), the previous - `root_element` is reused, but it is still walked. + `root_element` is reused, but it is still walked (the fast renderer only walks + the dirty child contexts, see above). - Body execution resets `state_index`/`effect_index`/`memo_index`, `user_contexts={}`, `exception_handler=False`, `needs_render=False` before the call; wraps in `context_managers` (the solara `ContextManager` hook) and the @@ -159,6 +189,8 @@ contexts; `provide` notifies listeners only when the value changed. assigned (the old code closed them mid-walk, so a closed widget could briefly remain in a container's `children`). - Per-element debug logging in the hot paths was dropped. +- Elements of a component that is not walked do not get their (test only) + `_render_count` incremented. ## Known issues worth revisiting (found during the rewrite, not fixed here) @@ -172,6 +204,14 @@ contexts; `provide` notifies listeners only when the value changed. likely needs a pandas-3 fix; the test environment pins `pandas<3` for now. - `ComponentContext.owns` is dead — never written, only asserted empty in `_remove_element`. +- **Shared elements in a component that does not render again** (both + renderers): when such a component is walked because a child is dirty, its + shared element (the same object as before) is added to + `_shared_elements_next` in the render phase, but reconciliation returns early + for an already reconciled shared element, so the render fails with + `RuntimeError: Element not reconsolidated`. Example: `App` returns + `VBox(children=[HBox(children=[shared, shared]), Child()])` and only `Child` + changes state. ## Where initial-render time goes diff --git a/reacton/core.py b/reacton/core.py index 93441c8..2f6342b 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -7,27 +7,31 @@ """ +import collections import contextlib import copy import functools import inspect import logging +import operator import sys import threading import traceback +import typing import weakref from collections import defaultdict -from dataclasses import dataclass, field from inspect import isclass from types import TracebackType from typing import ( Any, Callable, ContextManager, + Deque, Dict, Generic, List, Optional, + Sequence, Set, Tuple, Type, @@ -85,30 +89,94 @@ def get(): # via the widget constructed hook: diffing the global widgets dict per widget # creation is O(total widgets), and solara replaces that dict with a context # aware mapping we should not depend on. -_construction_recording: Optional[List["widgets.Widget"]] = None +# The recording is per thread: renders in other threads (e.g. other kernels) construct +# widgets at the same time, and must not end up in our recording. +_construction_local = threading.local() _chained_construction_callback: Optional[Callable] = None def _record_constructed_widget(widget: "widgets.Widget"): - if _construction_recording is not None: - _construction_recording.append(widget) + recording = getattr(_construction_local, "recording", None) + if recording is not None: + recording.append(widget) if _chained_construction_callback is not None: _chained_construction_callback(widget) -def _start_recording_constructed(recording: List["widgets.Widget"]): - global _construction_recording, _chained_construction_callback +def _start_recording_constructed(recording: List["widgets.Widget"]) -> Optional[List["widgets.Widget"]]: + """Record the widgets this thread constructs into recording, returns the recording it replaces.""" + global _chained_construction_callback current = getattr(widgets.Widget, "_widget_construction_callback", None) if current is not _record_constructed_widget: # first time, or someone else registered a callback after us: chain it _chained_construction_callback = current widgets.Widget.on_widget_constructed(_record_constructed_widget) - _construction_recording = recording + previous = getattr(_construction_local, "recording", None) + _construction_local.recording = recording + return previous -def _stop_recording_constructed(): - global _construction_recording - _construction_recording = None +def _stop_recording_constructed(previous: Optional[List["widgets.Widget"]] = None): + _construction_local.recording = previous + + +class _BatchedHold: + """hold_trait_notifications of a widget made by reacton: also batch the renders. + + A frontend update of several traits (Widget.set_state) holds the trait notifications; + each notification can call an on_ listener that sets state. Entering the render + context as well makes that a single render at the end, instead of one per trait. + """ + + __slots__ = ("rc", "hold") + + def __init__(self, rc: "_RenderContext", hold): + self.rc = rc + self.hold = hold + + def __enter__(self): + self.rc.__enter__() + try: + return self.hold.__enter__() + except BaseException: + self.rc.__exit__(*sys.exc_info()) + raise + + def __exit__(self, exc_type, exc_value, tb): + try: + suppress = self.hold.__exit__(exc_type, exc_value, tb) + except BaseException: + self.rc.__exit__(*sys.exc_info()) + raise + if suppress: + self.rc.__exit__(None, None, None) + else: + self.rc.__exit__(exc_type, exc_value, tb) + return suppress + + +def _install_batched_hold(cls: type): + """Make hold_trait_notifications of cls batch renders for widgets made by reacton. + + Done once per class: a wrapper per widget instance (a closure and a contextmanager + per widget) was a large part of the cost of creating a widget. The wrapper only + changes widgets that have a render context in _reacton_rc (set by + Element._create_widget), all others get the original context manager. + """ + hold = cls.hold_trait_notifications # type: ignore + if getattr(hold, "_reacton_batched", False): + return + + @functools.wraps(hold) + def hold_trait_notifications(self, *args, **kwargs): + cm = hold(self, *args, **kwargs) + rc = self.__dict__.get("_reacton_rc") + if rc is None: + return cm + return _BatchedHold(rc, cm) + + hold_trait_notifications._reacton_batched = True # type: ignore + cls.hold_trait_notifications = hold_trait_notifications # type: ignore _last_rc = None # used for testing @@ -149,14 +217,23 @@ def are_events_supressed(): return getattr(local, "events_supressed", False) -@contextlib.contextmanager +class _SuppressEvents: + # a plain context manager: a @contextmanager generator per widget update costs ~1 us + __slots__ = () + + def __enter__(self): + local.events_supressed = True + + def __exit__(self, *args): + local.events_supressed = False + + +_suppress_events = _SuppressEvents() + + def suppress_events(): """Suppress events while updating a widget""" - local.events_supressed = True - try: - yield - finally: - local.events_supressed = False + return _suppress_events widgets.Widget.element = classmethod(element) @@ -249,7 +326,10 @@ def _values_identical(a, b): if type_a is not type(b): return False if type_a is list or type_a is tuple: - return len(a) == len(b) and all(x is y or _values_identical(x, y) for x, y in zip(a, b)) + if len(a) != len(b): + return False + # a list of child widgets: usually all the same objects + return all(map(operator.is_, a, b)) or all(x is y or _values_identical(x, y) for x, y in zip(a, b)) if type_a is dict: return len(a) == len(b) and all(k in b and (v is b[k] or _values_identical(v, b[k])) for k, v in a.items()) return False @@ -296,23 +376,38 @@ class Element(Generic[W]): # so that we can remove the listeners _callback_wrappers: Dict[Tuple[str, str, Callable], Callable] = {} create_lock: ContextManager = threading.Lock() - _shared = False + # a plain attribute (not a property): it is read for every element in every walk + is_shared: bool = False + # Defaults as class attributes: every component body makes elements, most of them + # never change these. (_meta is never changed in place, meta() makes a new dict.) + mime_bundle: Dict[str, Any] = mime_bundle_default + _key: Optional[str] = None + _meta: Dict[str, Any] = {} + # for debugging/testing only + _render_count = 0 + _key_frozen: bool = False + # facts about the kwargs of a widget element, learned when its widget is created or + # updated (None: not known), so a close of the whole tree can skip work + _on_kwargs: Optional[bool] = None # a kwarg starts with on_ (maybe an event listener) + _leaf: Optional[bool] = None # no elements in the kwargs + # handlers (of reacton.ipyvue.use_event) to register on the widget of this element when it + # is created or updated: objects with _reacton_attach(widget) + _event_handlers: Tuple[Any, ...] = () def __init__(self, component, args=None, kwargs=None): self.component = component - self.mime_bundle = mime_bundle_default - self._key: Optional[str] = None self.args = args or [] self.kwargs = kwargs or {} - self.handlers = [] - self._meta = {} - # for debugging/testing only - self._render_count = 0 - self._key_frozen: bool = False - - rc = _get_render_context(required=False) - if rc is not None and rc.container_adders: - rc.container_adders[-1].add(self) + + rc = getattr(local, "rc", None) + if rc is not None: + container_adders = rc.container_adders + if container_adders: + adder = container_adders[-1] + if type(adder) is ContainerAdder: + adder.created.append(self) + else: + adder.add(self) if DEBUG: # since we construct widgets or components from a different code path # we want to preserve the original call stack, by manually tracking frames @@ -329,18 +424,32 @@ def __init__(self, component, args=None, kwargs=None): self.traceback = TracebackType(tb_frame=frame_py, tb_lasti=self.traceback.tb_lasti, tb_lineno=frame_py.f_lineno, tb_next=None) def _arguments_changed(self, other: "Element"): - if len(self.args) != len(other.args): - return True - if len(self.kwargs) != len(other.kwargs): - return True - for k, v in self.kwargs.items(): - if k not in other.kwargs: - return True - if not utils.equals(v, other.kwargs[k]): + # called for every child of a component that renders again: the same objects + # (small ints, interned strings, the same callbacks) need no utils.equals call + args = self.args + other_args = other.args + kwargs = self.kwargs + other_kwargs = other.kwargs + if args: + if len(args) != len(other_args): return True - for a, b in zip(self.args, other.args): - if not utils.equals(a, b): + elif other_args: + return True + if kwargs: + if len(kwargs) != len(other_kwargs): return True + for k, v in kwargs.items(): + if k not in other_kwargs: + return True + other_v = other_kwargs[k] + if v is not other_v and not utils.equals(v, other_v): + return True + elif other_kwargs: + return True + if args: + for a, b in zip(args, other_args): + if a is not b and not utils.equals(a, b): + return True return False def key(self, value: str): @@ -361,12 +470,17 @@ def meta(self, **kwargs): self._meta = {**self._meta, **kwargs} return self + # the old name of is_shared @property - def is_shared(self): - return self._shared + def _shared(self) -> bool: + return self.is_shared + + @_shared.setter + def _shared(self, value: bool): + self.is_shared = value def shared(self): - self._shared = True + self.is_shared = True return self def __repr__(self): @@ -405,6 +519,13 @@ def format_kwarg(key, value): else: raise RuntimeError(f"No repr for {type(self)}") + @property + def handlers(self) -> List[Tuple[str, Callable]]: + handlers = self.__dict__.get("_handlers") + if handlers is None: + handlers = self.__dict__["_handlers"] = [] + return handlers + def on(self, name, callback): self.handlers.append((name, callback)) return self @@ -451,71 +572,90 @@ def _split_kwargs(self, kwargs): def _close_widget(self, widget: widgets.Widget): close_widget(widget) - try: - delattr(widget, "hold_trait_notifications") - except AttributeError: - raise + # a closed widget no longer batches renders into (and keeps alive) our render context + widget.__dict__.pop("_reacton_rc", None) def _create_widget(self, kwargs): # we can't use our own kwarg, since that contains elements, not widgets - kwargs, listeners = self._split_kwargs(kwargs) + listeners = None + for name in kwargs: + if name.startswith("on_"): + # only then we need the trait names, to tell listeners from on_* traits + kwargs, listeners = self._split_kwargs(kwargs) + self._on_kwargs = True + break + else: + self._on_kwargs = False assert isinstance(self.component, ComponentWidget) - # The recording is global state, so we need a lock. - with self.create_lock: - rc = get_render_context(required=True) - recorded: List[widgets.Widget] = [] - _start_recording_constructed(recorded) + rc = get_render_context(required=True) + recorded: List[widgets.Widget] = [] + # the recording is per thread, so no lock is needed + previous_recording = _start_recording_constructed(recorded) + try: try: - try: - widget = self.component.widget(**kwargs) - hold_trait_notifications = widget.hold_trait_notifications - - @contextlib.contextmanager - def hold_trait_notifications_extra(*args, **kwargs): - with rc, hold_trait_notifications(*args, **kwargs): - yield - - widget.hold_trait_notifications = hold_trait_notifications_extra - - if self._meta: - widget._react_meta = dict(self._meta) - except Exception as e: - raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e + widget = self.component.widget(**kwargs) + cls = type(widget) + if not getattr(cls.hold_trait_notifications, "_reacton_batched", False): + _install_batched_hold(cls) + widget._reacton_rc = rc + if self._meta: + widget._react_meta = dict(self._meta) + except Exception as e: + raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e + if listeners: for name, callback in listeners.items(): if callback is not None: self._add_widget_event_listener(widget, name, callback) - finally: - _stop_recording_constructed() - widgets_dict = _get_widgets_dict() - orphans = {w.model_id for w in recorded if w is not widget and w.comm is not None and w.model_id in widgets_dict} + for handler in self._event_handlers: + handler._reacton_attach(widget) + finally: + _stop_recording_constructed(previous_recording) + if recorded and (len(recorded) > 1 or recorded[0] is not widget): + widgets_dict = _get_widgets_dict() + orphans = {w.model_id for w in recorded if w is not widget and w.comm is not None and w.model_id in widgets_dict} + else: + orphans = set() return widget, orphans def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): assert isinstance(self.component, ComponentWidget) assert isinstance(el_prev.component, ComponentWidget) - assert same_component(self.component, el_prev.component) + assert self.component is el_prev.component or same_component(self.component, el_prev.component) # used_kwargs, _ = el_prev.split_kwargs(el_prev.kwargs) - args = self.component.widget.class_trait_names() + # the trait names are only needed for on_* names (listener or trait?) + args = None + on_kwargs = False with widget.hold_sync(), suppress_events(): # update values for name, value in kwargs.items(): - if name.startswith("on_") and name not in args: - self._update_widget_event_listener(widget, name, value, el_prev.kwargs.get(name)) - else: - self._update_widget_prop(widget, name, value) + if name.startswith("on_"): + on_kwargs = True + if args is None: + args = self.component.widget.class_trait_names() + if name not in args: + self._update_widget_event_listener(widget, name, value, el_prev.kwargs.get(name)) + continue + self._update_widget_prop(widget, name, value) # if we previously gave an argument, but now we don't # we have to restore the default values, and remove listeners - cls = widget.__class__ - traits = cls.class_traits() - - dropped_arguments = set(el_prev.kwargs) - set(self.kwargs) - for name in dropped_arguments: - if name.startswith("on_") and name not in args: - self._remove_widget_event_listener(widget, name, el_prev.kwargs[name]) - else: - value = traits[name].default() - self._update_widget_prop(widget, name, value) + new_kwargs = self.kwargs + dropped_arguments = [name for name in el_prev.kwargs if name not in new_kwargs] + if dropped_arguments: + cls = widget.__class__ + traits = cls.class_traits() + if args is None: + args = self.component.widget.class_trait_names() + for name in dropped_arguments: + if name.startswith("on_") and name not in args: + self._remove_widget_event_listener(widget, name, el_prev.kwargs[name]) + else: + value = traits[name].default() + self._update_widget_prop(widget, name, value) + # only when the update finished (else: not known) + self._on_kwargs = on_kwargs + for handler in self._event_handlers: + handler._reacton_attach(widget) def _update_widget_prop(self, widget, name, value): setattr(widget, name, value) @@ -552,10 +692,13 @@ def _remove_widget_event_listener(self, widget: widgets.Widget, name: str, callb logger.error("Could not remove event listener %r from %r", name, widget) def _cleanup_callbacks(self, widget: widgets.Widget): - args = self._get_widget_args() + args = None for name, value in self.kwargs.items(): - if name.startswith("on_") and name not in args and value is not None: - self._remove_widget_event_listener(widget, name, value) + if name.startswith("on_") and value is not None: + if args is None: + args = self._get_widget_args() + if name not in args: + self._remove_widget_event_listener(widget, name, value) class Value(Generic[V], Protocol): @@ -567,7 +710,7 @@ def set(self, value: V): ... class ValueElement(Generic[W, V], Element[W]): def __init__(self, value_property, component, args=None, kwargs=None): self.value_property = value_property - super().__init__(component, args, kwargs) + Element.__init__(self, component, args, kwargs) # type: ignore[arg-type] # (faster than super()) # TODO: we want to enable something like this, but requires a good hash function # for the key @@ -624,6 +767,10 @@ def find_elements(value: Union[Element, List, Tuple, Dict]) -> Set[Element]: return elements +# the list of a ContainerAdder that is not on the stack (never appended to) +_NO_ELEMENTS: List["Element"] = [] + + class ContainerAdder(Generic[W]): def __init__(self, el: Element[W], prop_name: str): self.el = el @@ -642,10 +789,25 @@ def collect(self): class ComponentWidget(Component): - def __init__(self, widget: Type[widgets.Widget], mime_bundle=mime_bundle_default): - self.mime_bundle = mime_bundle + mime_bundle: Dict[str, Any] = mime_bundle_default + widget: Type[widgets.Widget] + + def __new__(cls, widget: Type[widgets.Widget], mime_bundle=mime_bundle_default): + # The generated element factories make one per element. One instance per widget + # class is cheaper, and makes the component compares of the render walks identity + # checks (__eq__ compares the widget classes). + if cls is ComponentWidget and mime_bundle is mime_bundle_default: + self = _component_widgets.get(widget) + if self is not None: + return self + self = super().__new__(cls) + if mime_bundle is not mime_bundle_default: + self.mime_bundle = mime_bundle self.widget = widget self.name = widget.__name__ + if cls is ComponentWidget and mime_bundle is mime_bundle_default: + _component_widgets[widget] = self + return self def __eq__(self, rhs): if self is rhs: @@ -658,13 +820,20 @@ def __repr__(self): return f"Component[{self.widget!r}]" def __call__(self, *args, **kwargs): - el: Element = Element(self, args=args, kwargs=kwargs) + el: Element = Element(self, args, kwargs) # TODO: temporary, we cannot change the constructor # otherwise we need to generate the wrapper code again for all libraries - el.mime_bundle = self.mime_bundle + if self.mime_bundle is not mime_bundle_default: + el.mime_bundle = self.mime_bundle return el +# ComponentWidget per widget class (see ComponentWidget.__new__). Weak values: an entry goes +# away when no element uses it any more, so widget classes made at runtime (e.g. by a hot +# reload) can be freed. +_component_widgets: "weakref.WeakValueDictionary[type, ComponentWidget]" = weakref.WeakValueDictionary() + + class ComponentFunction(Component): def __init__(self, f: Callable[[], Element], mime_bundle=mime_bundle_default, value_name=None): self.f = f @@ -751,10 +920,11 @@ def __repr__(self): def __call__(self, *args, **kwargs): if self.value_name is not None: - el: Element = ValueElement(self.value_name, self, args=args, kwargs=kwargs) + el: Element = ValueElement(self.value_name, self, args, kwargs) else: - el = Element(self, args=args, kwargs=kwargs) - el.mime_bundle = self.mime_bundle + el = Element(self, args, kwargs) + if self.mime_bundle is not mime_bundle_default: + el.mime_bundle = self.mime_bundle return el @@ -828,8 +998,14 @@ def get_widget(el: Element): element will be returned. """ rc = get_render_context() + context = rc.context + if context is not None and not el.is_shared: + # the common case: an element of the component whose effect runs + element_to_widget = context.element_to_widget + if el in element_to_widget: + return element_to_widget[el] # breadth first search - contexts = [rc.context] + contexts = [context] while contexts: context = contexts.pop() if context is None: @@ -841,11 +1017,52 @@ def get_widget(el: Element): else: if el in context.element_to_widget: return context.element_to_widget[el] - if id(el) in rc._old_element_ids: + if id(el) in rc._old_element_ids or el._key_frozen: + # (the fast renderer does not record the ids: every rendered element is frozen) raise KeyError(f"Element {el} was found to be in a previous render, you may have used a stale element") raise KeyError(f"Element {el} not found in all known widgets") # for the component {context.widgets}") +def _add_event_handlers(el: Element, handlers: Tuple[Any, ...], context: "ComponentContext", rc: "_RenderContext") -> None: + # Add use_event handlers (reacton.ipyvue) to an element: the renderer registers them on its + # widget when it creates or updates that widget. A component element hands them on to the + # element its body returns, when it renders (the widget of a component is the widget of its + # root element). context: the component that renders el (or makes it, for use_event). + current = el._event_handlers + for handler in handlers: + if handler in current: + continue + current = el._event_handlers = (*current, handler) + if el._key_frozen: + # The element was rendered before, so its widget may exist already and not be created + # or updated again. E.g. a memoized element, or an element of a parent that a child + # hooks into (the fast mount makes the parent's widget before the child renders). + widget = rc._shared_widgets.get(el) if el.is_shared else _find_widget(context, el) + if widget is not None: + handler._reacton_attach(widget) + + +def _find_widget(context: Optional["ComponentContext"], el: Element) -> Optional[widgets.Widget]: + # the widget of el, or None: in the component, its parents, then in the whole tree (e.g. an + # element placed by a sibling) + top = context + while context is not None: + widget = context.element_to_widget.get(el) + if widget is not None: + return widget + top = context + context = context.parent + contexts = [top] + while contexts: + context = contexts.pop() + if context is not None: + widget = context.element_to_widget.get(el) + if widget is not None: + return widget + contexts.extend(context.children.values()) + return None + + def use_state(initial: T, key: str = None, eq: Callable[[Any, Any], bool] = None) -> Tuple[T, Callable[[Union[T, Callable[[T], T]]], None]]: """Returns a `(value, setter)` tuple that is used to manage state in a component. @@ -884,13 +1101,17 @@ def my_click_handler(): The last one avoid issues with stale data, which means you have a reference to the value of an old render pass (not present in this simple example). """ - rc = _get_render_context() + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") return rc.use_state(initial, key, eq) def use_effect(effect: EffectCallable, dependencies=None): - rc = _get_render_context() - return rc.use_effect(effect, dependencies=dependencies) + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + return rc.use_effect(effect, dependencies) def use_side_effect(effect: EffectCallable, dependencies=None): @@ -943,9 +1164,11 @@ def state_updater(state): def use_memo(f: Callable[[], T], dependencies=None, debug_name: str = None) -> T: + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") if debug_name is None: debug_name = f.__name__ - rc = _get_render_context() if dependencies is None: dependencies = inspect.getclosurevars(f).nonlocals dependencies = {k: v for k, v in dependencies.items() if not k.startswith("__")} @@ -990,11 +1213,10 @@ def __init__(self, initial_value: T): def use_ref(initial_value: T) -> Ref[T]: - def make_ref(): - return Ref(initial_value) - - ref = use_memo(make_ref, []) - return ref + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + return rc.use_ref(initial_value) class UserContext(Generic[T]): @@ -1117,9 +1339,15 @@ def App(): """ -@dataclass class ComponentContext: - parent: Optional["ComponentContext"] = field(default=None, repr=False) + """The state of one component instance: hooks, its element tree, child contexts, widgets. + + A plain class, not a dataclass: one is made for every component instance, and the + dataclass made 16 containers for each, most of them never used. The containers in + _COMPONENT_CONTEXT_LAZY are made on first use (see __getattr__). + """ + + parent: Optional["ComponentContext"] = None # this is the element in the parent context invoke_element: Optional[Element] = None @@ -1128,36 +1356,38 @@ class ComponentContext: root_element_next: Optional[Element] = None root_element: Optional[Element] = None # all elements, including the root element - elements_next: Dict[str, Element] = field(default_factory=dict) + elements_next: Dict[str, Element] # from previous reconciliation phase - elements: Dict[str, Element] = field(default_factory=dict) + elements: Dict[str, Element] # contexts for child elements which are a component # (every function component should be in children and elements, but not widget component) - children_next: Dict[str, "ComponentContext"] = field(default_factory=dict) + children_next: Dict[str, "ComponentContext"] # from previous reconciliation phase, so we can reuse hooks - children: Dict[str, "ComponentContext"] = field(default_factory=dict) + children: Dict[str, "ComponentContext"] # widgets correponding to the elements (non-shared widgets) - widgets: Dict[str, "widgets.Widget"] = field(default_factory=dict) + widgets: Dict[str, "widgets.Widget"] # used for get_widget to find the widget corresponding to an element - element_to_widget: Dict[Element, "ipywidgets.Widget"] = field(default_factory=dict) + element_to_widget: Dict[Element, "ipywidgets.Widget"] # hooks data - state: Dict = field(default_factory=dict) - state_metadata: Dict = field(default_factory=dict) + state: Dict + # one setter per state key, made once (a stable setter, like React's setState) + setters: Dict[str, Callable] + state_metadata: Dict # lazy state_index = 0 - effects: List["Effect"] = field(default_factory=list) + effects: List["Effect"] effect_index = 0 - memo: List[Any] = field(default_factory=list) + memo: List[Any] memo_index = 0 # for provide/use_context - user_contexts: Dict["UserContext", Any] = field(default_factory=dict) - user_contexts_prev: Dict["UserContext", Any] = field(default_factory=dict) - context_listeners: Dict["UserContext", Set[Callable]] = field(default_factory=lambda: defaultdict(set)) + user_contexts: Dict["UserContext", Any] # lazy + user_contexts_prev: Dict["UserContext", Any] # lazy + context_listeners: Dict["UserContext", Set[Callable]] # lazy # to track key collisions, and remove unused elements - used_keys: Set[str] = field(default_factory=set) + used_keys: Set[str] # if a child component's state if changed, it needs a rerender needs_render: bool = True # some context in this subtree may need a render (set by setters walking up, @@ -1166,21 +1396,125 @@ class ComponentContext: # the render phase skipped this whole subtree (nothing changed), so the # reconciliation phase can reuse the previous result without walking clean_subtree: bool = False + # the kwargs (elements resolved to widgets) that the widget of a container element + # was last created or updated with, so an unchanged element that resolves to the + # same widgets is not updated again (only used by the fast renderer) + resolved_kwargs: Dict[str, Dict[str, Any]] # lazy # elements created in this context go there - owns: Set[Element] = field(default_factory=set) + owns: Set[Element] # lazy # the exception that were raised in this component - exceptions_self: List[BaseException] = field(default_factory=list) + exceptions_self: List[BaseException] # all exceptions that occurred during render, reconcolliate or use effect # that bubbled up (children with exception_handler = False) - exceptions_children: List[BaseException] = field(default_factory=list) + exceptions_children: List[BaseException] # flag if this component will handle an exception of it's children # NOTE: we can never handle an exception in our own render function, # it will always bubble up to the parent component. exception_handler: bool = False - context_managers: List[ContextManager] = field(default_factory=list) + # (only a component context manager class makes a list, see _render_component) + context_managers: Sequence[ContextManager] = () + # the use_event handlers of this component (reacton.ipyvue), detached when it is removed + event_handlers: Tuple[Any, ...] = () + + # For the fast renderer, which walks the context tree (not the element tree) of a + # component that does not render again: + # the key of this context in parent.children + key_in_parent: Optional[str] = None + # the position of the component element in the element tree of the parent + order_in_parent: int = 0 + # counts the component elements while the render phase walks the element tree + child_order_counter: int = 0 + # the child contexts that need a render, or have a descendant that does (set by + # setters walking up, taken by the render phase) + dirty_children: Optional[Dict["ComponentContext", None]] = None + # the render phase only walked the dirty children of this component: its element tree + # is the one of the last reconciliation, so reconciliation only needs those children + partial: bool = False + # the element tree of this component holds shared elements (they need the full walk) + has_shared: bool = False + # the render phase of the fast renderer also created the widgets of this new component + # (the fused mount): reconciliation only has to finish it, see _finish_mount + mounted: bool = False + # the root widget of a mounted component + mount_widget: Optional[ipywidgets.Widget] = None + # on the first component of a mounted subtree: all mounted components in it, children first + mount_order: Optional[List["ComponentContext"]] = None + + def __init__( + self, + parent: Optional["ComponentContext"] = None, + invoke_element: Optional[Element] = None, + context_managers: Optional[List[ContextManager]] = None, + **fields, + ): + self.parent = parent + if invoke_element is not None: + self.invoke_element = invoke_element + if context_managers is not None: + self.context_managers = context_managers + # the containers every rendered component uses + self.elements_next = {} + self.elements = {} + self.children_next = {} + self.children = {} + self.widgets = {} + self.element_to_widget = {} + self.effects = [] + self.memo = [] + self.used_keys = set() + self.exceptions_self = [] + self.exceptions_children = [] + # most components use state (solara: every component), and have a container widget: + # making these on first use (see __getattr__) costs more than an empty dict + self.state = {} + self.setters = {} + self.resolved_kwargs = {} + # the dataclass constructor took every field as a keyword argument + for name, value in fields.items(): + if name not in _COMPONENT_CONTEXT_FIELDS: + raise TypeError(f"ComponentContext() got an unexpected keyword argument {name!r}") + setattr(self, name, value) + + if not typing.TYPE_CHECKING: # keep attribute checks for mypy + + def __getattr__(self, name): + # only called for an attribute that is not set yet: make a rarely used container + factory = _COMPONENT_CONTEXT_LAZY.get(name) + if factory is None: + raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") + value = factory() + setattr(self, name, value) + return value + + +_COMPONENT_CONTEXT_LAZY: Dict[str, Callable[[], Any]] = { + "state": dict, + "setters": dict, + "state_metadata": dict, + "user_contexts": dict, + "user_contexts_prev": dict, + "context_listeners": lambda: defaultdict(set), + "resolved_kwargs": dict, + "owns": set, + # made in __init__, and made again after _teardown_component_context + "elements_next": dict, + "elements": dict, + "children_next": dict, + "children": dict, + "widgets": dict, + "element_to_widget": dict, + "effects": list, + "memo": list, + "used_keys": set, + "exceptions_self": list, + "exceptions_children": list, +} +_COMPONENT_CONTEXT_FIELDS = set(ComponentContext.__annotations__) +# use_state keys of the first 64 hooks of a component (str(index)) +_STATE_KEYS = [str(i) for i in range(64)] TEffect = TypeVar("TEffect", bound="Effect") @@ -1201,48 +1535,50 @@ def _teardown_component_context(context: ComponentContext): """ # replace the containers instead of clearing them: state_get() hands out the live # state dicts (test_state_get closes and re-renders with them), and in general we - # only want to drop OUR references, not destroy objects someone else captured - context.parent = None - context.invoke_element = None - context.root_element = None - context.root_element_next = None - context.elements = {} - context.elements_next = {} - context.children = {} - context.children_next = {} - context.widgets = {} - context.element_to_widget = {} - context.state = {} - context.state_metadata = {} - context.effects = [] - context.memo = [] - context.user_contexts = {} - context.user_contexts_prev = {} - context.context_listeners = defaultdict(set) - context.used_keys = set() - context.owns = set() - context.exceptions_self = [] - context.exceptions_children = [] - context.context_managers = [] - - -@dataclass + # only want to drop OUR references, not destroy objects someone else captured. + # Dropping all instance attributes does that: what is left are the class defaults + # (parent, invoke_element, root_element... are None), and every container is made + # again, empty, when it is used (see ComponentContext.__getattr__). + context.__dict__.clear() + + class RerenderReason: - reason: str - prev_value: Any = None - next_value: Any = None - created_stack: List[str] = field(default_factory=list) - trigger_stack: List[str] = field(default_factory=list) + # one is made for every state change: a plain class is cheaper than the dataclass it was + __slots__ = ("reason", "prev_value", "next_value", "created_stack", "trigger_stack") + + def __init__( + self, + reason: str, + prev_value: Any = None, + next_value: Any = None, + created_stack: Optional[List[str]] = None, + trigger_stack: Optional[List[str]] = None, + ): + self.reason = reason + self.prev_value = prev_value + self.next_value = next_value + self.created_stack: List[str] = created_stack if created_stack is not None else [] + self.trigger_stack: List[str] = trigger_stack if trigger_stack is not None else [] + + def __repr__(self): + return ( + f"RerenderReason(reason={self.reason!r}, prev_value={self.prev_value!r}, next_value={self.next_value!r}, " + f"created_stack={self.created_stack!r}, trigger_stack={self.trigger_stack!r})" + ) class Effect: + # defaults as class attributes: one Effect is made per use_effect call per render + _cleanup: Optional[EffectCleanupCallable] = None + next: Optional["Effect"] = None + executed = False + _cleaned_up = False + def __init__(self, callable: EffectCallable, dependencies: Optional[List[Any]] = None, next: Optional["Effect"] = None) -> None: self.callable = callable self.dependencies = dependencies - self._cleanup: Optional[EffectCleanupCallable] = None - self.next = next - self.executed = False - self._cleaned_up = False + if next is not None: + self.next = next def cleanup(self): if self._cleaned_up: @@ -1274,7 +1610,10 @@ def __init__(self, element: Element, container: widgets.Widget = None, children_ self.last_root_widget: widgets.Widget = None self._is_rendering = False self._rerender_needed = False - self._rerender_needed_reasons: List[RerenderReason] = [] + # only the last two reasons are ever read (the "too many renders" error message), + # and a reason holds the previous and next state value: keeping all of them kept + # every old state value alive until close() + self._rerender_needed_reasons: Deque[RerenderReason] = collections.deque(maxlen=2) self.thread_lock = threading.Lock() self._closing = False self.tracebacks: List[TracebackType] = [] @@ -1324,22 +1663,33 @@ def find(self, cls: Type[W] = ipywidgets.Widget, **matches): _find = find # for backward compatibility + def _end_render_pass(self): + """Called after every render pass (a walk from the root), before the next pass or the reconciliation.""" + + def _before_close(self): + """Called by close() before the tree is removed.""" + + def _close_tree(self): + """Remove the whole element tree (close): effect cleanups, widgets, orphans.""" + self._remove_element(self.element, default_key="/", parent_key=ROOT_KEY) + def close(self): with self.thread_lock: self._closing = True + self._before_close() # snapshot the component contexts before _remove_element detaches them from # their parents: detached contexts would escape the teardown below while the # setter/handler closures in their state still reference them and us - all_contexts: List[ComponentContext] = [] - - def collect(context: ComponentContext): - all_contexts.append(context) - for child in list(context.children.values()) + list(context.children_next.values()): - collect(child) - - collect(self.context_root) + all_contexts: List[ComponentContext] = [self.context_root] + index = 0 + while index < len(all_contexts): + context = all_contexts[index] + index += 1 + all_contexts.extend(context.children.values()) + if context.children_next: + all_contexts.extend(context.children_next.values()) logger.info("Removing elements...") - self._remove_element(self.element, default_key="/", parent_key=ROOT_KEY) + self._close_tree() logger.info("Removing elements done.") assert self.context is self.context_root # everything below used to run outside the lock: a render() that was @@ -1397,42 +1747,81 @@ def use_memo(self, f, dependencies, debug_name: str = None, use_nonlocals=False) memo = (value, dependencies) self.context.memo.append(memo) self.context.memo_index += 1 - logger.debug("Initial memo = %r for index %r (debug-name: %r)", memo, self.context.memo_index - 1, name) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Initial memo = %r for index %r (debug-name: %r)", memo, self.context.memo_index - 1, name) return value else: memo = self.context.memo[self.context.memo_index] value, dependencies_previous = memo if utils.equals(dependencies_previous, dependencies): - logger.debug("Got memo hit = %r for index %r (debug-name: %r)", memo, self.context.memo_index, name) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Got memo hit = %r for index %r (debug-name: %r)", memo, self.context.memo_index, name) else: - logger.debug("Replace memo with = %r for index %r (debug-name: %r)", memo, self.context.memo_index, name) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Replace memo with = %r for index %r (debug-name: %r)", memo, self.context.memo_index, name) value = f() memo = (value, dependencies) self.context.memo[self.context.memo_index] = memo self.context.memo_index += 1 return value + def use_ref(self, initial_value): + # use_memo(lambda: Ref(initial_value), []), without the closure and the extra calls + context = self.context + assert context is not None + memo = context.memo + index = context.memo_index + if index < len(memo): + value, dependencies_previous = memo[index] + if type(dependencies_previous) is not list or dependencies_previous: + # not the memo of a use_ref (e.g. conditional hooks): what use_memo would do + if not utils.equals(dependencies_previous, []): + value = Ref(initial_value) + memo[index] = (value, []) + else: + value = Ref(initial_value) + memo.append((value, [])) + context.memo_index = index + 1 + return value + def use_state(self, initial, key: str = None, eq: Callable[[Any, Any], bool] = None) -> Tuple[T, Callable[[Union[T, Callable[[T], T]]], None]]: assert self.context is not None if key is None: - key = str(self.context.state_index) - self.context.state_index += 1 + index = self.context.state_index + key = _STATE_KEYS[index] if index < 64 else str(index) + self.context.state_index = index + 1 if key not in self.context.state: self.context.state[key] = initial if isinstance(initial, (list, dict, set)): self.context.state_metadata[key] = len(initial) elif utils.isinstance_lazy(initial, "pandas.DataFrame"): self.context.state_metadata[key] = utils.dataframe_fingerprint(initial) - logger.debug("Initial state = %r for key %r (%r)", initial, key, id(self.context)) - return initial, self.make_setter(key, self.context, eq) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Initial state = %r for key %r (%r)", initial, key, id(self.context)) + state = initial else: state = self.context.state[key] - logger.debug("Got state = %r for key %r (%r)", state, key, id(self.context)) - return state, self.make_setter(key, self.context, eq) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Got state = %r for key %r (%r)", state, key, id(self.context)) + # the setter is made once per state key and kept (like React's setState): it compares + # equal to itself, so a child that gets it as an argument, or an effect that has it as + # a dependency, sees no change (utils.equals already found the setters of two renders + # equal, by comparing their code and closures) + setters = self.context.setters + setter = setters.get(key) + if setter is None: + setter = setters[key] = self.make_setter(key, self.context, eq) + else: + eq_cell = setter._reacton_eq # type: ignore + if eq_cell[0] is not eq: + # the latest eq, as when a setter was made every render + eq_cell[0] = eq + return state, setter def make_setter(self, key, context: ComponentContext, eq: Callable[[Any, Any], bool] = None): if DEBUG: created_stack = traceback.format_stack() + eq_cell = [eq] # NOTE: set_ captures self and context strongly, and that is a requirement: # a setter may be the ONLY reference keeping its component context alive @@ -1441,6 +1830,7 @@ def make_setter(self, key, context: ComponentContext, eq: Callable[[Any, Any], b # the component contexts instead. def set_(value): + eq = eq_cell[0] if self._closing: # the render context is closed (or closing) and the tree is (being) # torn down: there is nothing to update. This check must come first: @@ -1448,7 +1838,8 @@ def set_(value): return if callable(value): value = value(context.state[key]) - logger.info("Set state = %r for key %r (previous value was %r) (%r)", value, key, context.state[key], id(self.context)) + if logger.isEnabledFor(logging.INFO): + logger.info("Set state = %r for key %r (previous value was %r) (%r)", value, key, context.state[key], id(self.context)) should_update = False new_metadata = None @@ -1483,12 +1874,12 @@ def set_(value): context.state[key] = value if context.state[key] is value and isinstance(value, (list, dict, set)) and new_metadata is None: new_metadata = len(value) - if context.state[key] is value and utils.isinstance_lazy(value, "pandas.DataFrame") and new_metadata is None: + if new_metadata is None and context.state[key] is value and utils.isinstance_lazy(value, "pandas.DataFrame"): new_metadata = utils.dataframe_fingerprint(value) context.state_metadata[key] = new_metadata # TODO: enable context.needs_render = True - _mark_needs_render_ancestors(context) + self._mark_dirty(context) if self._rerender_needed is False: if DEBUG: trigger_stack = traceback.format_stack() @@ -1507,8 +1898,14 @@ def set_(value): self._rerender_needed = True self._possible_rerender() + # (a cell, not an attribute read via set_ itself: that would be a reference cycle) + set_._reacton_eq = eq_cell # type: ignore return set_ + def _mark_dirty(self, context: ComponentContext): + """Let the render phase find its way down to a context that needs a render.""" + _mark_needs_render_ancestors(context) + def force_update(self): if self._closing: # e.g. an event handler on a closed tree routing an exception to us @@ -1519,23 +1916,31 @@ def force_update(self): self.render(self.element, self.container) def use_effect(self, effect: EffectCallable, dependencies=None): - assert self.context is not None - if len(self.context.effects) <= self.context.effect_index: - self.context.effect_index += 1 - self.context.effects.append(Effect(effect, dependencies)) - logger.debug("Initial effect = %r for index %r (%r)", effect, self.context.effect_index - 1, dependencies) + context = self.context + assert context is not None + effects = context.effects + index = context.effect_index + context.effect_index = index + 1 + if len(effects) <= index: + effects.append(Effect(effect, dependencies)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Initial effect = %r for index %r (%r)", effect, index, dependencies) else: - previous_effect = self.context.effects[self.context.effect_index] - # we always set it, even replacing it when we didn't execute it - # in the consolidation phase we decide what to do (e.g. skip it) - logger.debug("Setting next effect = %r for index %r (%r)", effect, self.context.effect_index, dependencies) + previous_effect = effects[index] + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Setting next effect = %r for index %r (%r)", effect, index, dependencies) if previous_effect.executed: - # line up... - previous_effect.next = Effect(effect, dependencies) + if dependencies is not None and utils.equals(previous_effect.dependencies, dependencies): + # The same dependencies: the effect does not run again. Reconciliation made + # this same compare on a new Effect (and dropped it); now no Effect is made + # (and one from an earlier render pass of this call is dropped). + previous_effect.next = None + else: + # line up, reconciliation cleans up the previous one and runs this one + previous_effect.next = Effect(effect, dependencies) else: # replace - self.context.effects[self.context.effect_index] = Effect(effect, dependencies) - self.context.effect_index += 1 + effects[index] = Effect(effect, dependencies) def update(self, element: Element): self._walk_all = True @@ -1549,7 +1954,7 @@ def update(self, element: Element): def _possible_rerender(self): if not self._is_rendering and self._batch_counter.current() == 0: self.render(self.element, self.container) - else: + elif logger.isEnabledFor(logging.INFO): logger.info("No render phase triggered, already rendering") def _discard_aborted_pass(self): @@ -1616,7 +2021,11 @@ def render(self, element: Element, container: widgets.Widget = None): main_render_phase = not self._is_rendering render_count = self.render_count # make a copy self._rerender_needed = False - logger.info("Render phase: %r %r of %r", self.render_count, "main" if main_render_phase else "(nested)", self.element) + # the logging calls below cost a noticeable part of a small update when logging is off + log_info = logger.isEnabledFor(logging.INFO) + log_debug = logger.isEnabledFor(logging.DEBUG) + if log_info: + logger.info("Render phase: %r %r of %r", self.render_count, "main" if main_render_phase else "(nested)", self.element) self.render_count += 1 self._is_rendering = True # if we got called recursively, self.context is not the root context @@ -1631,6 +2040,7 @@ def render(self, element: Element, container: widgets.Widget = None): try: self._shared_elements_next = set() self._render(self.element, "/", parent_key=ROOT_KEY) + self._end_render_pass() self.first_render = False self._walk_all = False except BaseException: @@ -1659,7 +2069,8 @@ def format(reason: RerenderReason): if len(self._rerender_needed_reasons) >= 2: msg += f"Previous reasons: {format(self._rerender_needed_reasons[-2])}\n" raise RuntimeError(msg) - logger.info("Entering nested render phase: %r", self._rerender_needed_reasons[-1]) + if log_info: + logger.info("Entering nested render phase: %r", self._rerender_needed_reasons[-1]) self._rerender_needed = False self._shared_elements_next = set() self.context.exception_handler = False @@ -1667,22 +2078,26 @@ def format(reason: RerenderReason): self.context.exceptions_self = [] self._render(self.element, "/", parent_key=ROOT_KEY) + self._end_render_pass() self._walk_all = False - logger.info("Render done: %r %r", self._rerender_needed, self._rerender_needed_reasons[-1]) + if log_info: + logger.info("Render done: %r %r", self._rerender_needed, self._rerender_needed_reasons[-1]) assert self.context is self.context_root render_counts += 1 - logger.debug("Render phase resulted in (next) elements:") - for el in self._shared_elements_next: - logger.debug("\t%r %x", el, id(el)) - - logger.debug("Current elements:") - for el in self._shared_elements: - logger.debug("\t %r %x", el, id(el)) + if log_debug: + logger.debug("Render phase resulted in (next) elements:") + for el in self._shared_elements_next: + logger.debug("\t%r %x", el, id(el)) + + logger.debug("Current elements:") + for el in self._shared_elements: + logger.debug("\t %r %x", el, id(el)) if self.context_root.exceptions_children: # an exception bubbled up render break - logger.info("Render reconsolidate...") + if log_info: + logger.info("Render reconsolidate...") self.reconsolidating = True in_render_phase = False try: @@ -1690,7 +2105,8 @@ def format(reason: RerenderReason): finally: self.reconsolidating = False in_render_phase = True - logger.info("Render reconsolidate done") + if log_info: + logger.info("Render reconsolidate done") self.context.root_element = self.context.root_element_next self.context.root_element_next = None @@ -1700,9 +2116,10 @@ def format(reason: RerenderReason): if self._shared_elements_next: raise RuntimeError(f"Element not reconsolidated: {self._shared_elements_next}") - logger.debug("Reconsolidate phase resulted in elements:") - for el in self._shared_elements: - logger.debug("\t%r %x", el, id(el)) + if log_debug: + logger.debug("Reconsolidate phase resulted in elements:") + for el in self._shared_elements: + logger.debug("\t%r %x", el, id(el)) # RESET assert self.context is self.context_root if self.element.is_shared: @@ -1730,14 +2147,16 @@ def format(reason: RerenderReason): break if self._rerender_needed: - logger.info("Need rerender after reconsolidation: %r", self._rerender_needed_reasons[-1]) + if log_info: + logger.info("Need rerender after reconsolidation: %r", self._rerender_needed_reasons[-1]) stable = False else: stable = True self._is_rendering = False self.context = context_prev - logger.info("Done with render phase: %r", render_count) + if log_info: + logger.info("Done with render phase: %r", render_count) except BaseException as e: # Exceptions raised by components are collected in exceptions_self, so an # exception here comes from the render machinery itself (duplicate key, @@ -1961,6 +2380,8 @@ def _render(self, element: Element, default_key: str, parent_key: str): raise RuntimeError("Recursive render detected, possible a bug in react") if root_element is not None: logger.debug("root element: %r %x", root_element, id(root_element)) + if el._event_handlers: + _add_event_handlers(root_element, el._event_handlers, context, self) new_parent_key = join_key(parent_key, key) self._render(root_element, "/", parent_key=new_parent_key) # depth first context.root_element_next = root_element @@ -2201,7 +2622,7 @@ def reconsolidate_children(): self._shared_widgets[el] = widget else: context.widgets[key] = widget - elif el_prev is not None and el_prev.component == el.component: + elif el_prev is not None and (el_prev.component is el.component or el_prev.component == el.component): logger.debug("Updating widget: %r → %r %r", el_prev, el, key) assert el_prev is not None # TODO: remove event listeners while doing so @@ -2347,6 +2768,14 @@ def _remove_element(self, el: Element, default_key: str, parent_key): child_context.exceptions_self.append(e) self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect")) self._rerender_needed = True + for handler in child_context.event_handlers: + try: + handler._reacton_detach() + except BaseException as e: + logger.exception("Removing event handler %r raised exception %r", handler, e) + child_context.exceptions_self.append(e) + self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect")) + self._rerender_needed = True assert self.context.root_element is not None new_parent_key = join_key(parent_key, key) self._remove_element(self.context.root_element, "/", parent_key=new_parent_key) @@ -2446,23 +2875,215 @@ class _RenderContextFast(_RenderContext): # bookkeeping to current. # # A component subtree in which no state changed, no exception is - # pending, and whose element is identical to the previous render is - # skipped in both phases: setters mark the path from their context up - # to the root (ComponentContext.needs_render_descendant), so the walk - # only descends where work can exist. _render marks skipped contexts - # (clean_subtree) so _reconsolidate can reuse the previous widget - # without walking either. + # pending, and whose element is identical to the previous render (or + # a new element with equal arguments) is skipped in both phases: + # setters mark the path from their context up to the root + # (ComponentContext.needs_render_descendant and dirty_children), so the + # walk only descends where work can exist. _render marks skipped + # contexts (clean_subtree) so _reconsolidate can reuse the previous + # widget without walking either. + # + # A component that does not render again, but has a dirty descendant, + # has the same element tree as at the last reconciliation. Its element + # tree is not walked: only the dirty child contexts are (partial), in + # both phases, so an update costs work proportional to the depth, not + # to the number of siblings. When the root widget of such a child + # changes, the widgets holding it are updated (_rewire). + # + # A new component (no previous context: a first render, a new list + # item, another component type at a key) is mounted in one walk + # (_mount_component, _mount_node): it runs the bodies as the render phase + # does, creates the widgets of the new subtree children first, and writes + # the bookkeeping in its reconciled form. Reconciliation then only runs + # the effects, in the same order as before (_finish_mount). A mount is + # undone into the render bookkeeping of the two phase walk (_unmount) + # when the pass needs a next pass (state set during render), when a body + # raised, for shared elements, and when a widget fails to be created. # ------------------------------------------------------------------ + # > 0 while the render phase walks the new children of a widget that replaces a + # widget of another type (see _render) + _replacing = 0 + # set to False to disable the fused mount (e.g. to compare) + _mount_enabled = True + + def __init__(self, *args, **kwargs): + # the mounted subtrees (their first component) that reconciliation still has to finish + self._mount_roots: Dict[ComponentContext, None] = {} + # the widgets created by mounts in this render pass: (context, key, element, widget) + self._mount_created: List[Tuple[ComponentContext, str, Element, widgets.Widget]] = [] + # the mounted components of the subtree that is being mounted, children first + self._mount_list: Optional[List[ComponentContext]] = None + # every component context made by mounts in this render pass + self._mount_contexts: List[ComponentContext] = [] + # the widgets constructed during the mount of a subtree (one recording per mount) + self._mount_recording: List[widgets.Widget] = [] + # something in this pass cannot be mounted: undo the mounts at the end of the pass + self._mount_failed = False + # records the elements a component body makes (see _call_component): one for all bodies, + # bodies of one render context do not nest + self._body_adder: ContainerAdder = ContainerAdder(None, "children") # type: ignore[arg-type] + super().__init__(*args, **kwargs) + def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) self._rerender_needed = True + def _mark_dirty(self, context: ComponentContext): + # like _mark_needs_render_ancestors, and also record which children are dirty, so the + # render phase can go to them without walking the element trees on the way + child = context + parent = context.parent + while parent is not None: + dirty = parent.dirty_children + if dirty is None: + parent.dirty_children = {child: None} + elif child in dirty: + # marked before (the render phase takes both at the same time), so is the path up + break + else: + dirty[child] = None + parent.needs_render_descendant = True + child = parent + parent = parent.parent + + def _end_render_pass(self): + if self._mount_contexts: + if self._rerender_needed or self._mount_failed or self.context_root.exceptions_children: + # another pass (or no reconciliation at all) follows: go back to the plain two + # phase state, as if the widgets were never created + self._unmount() + else: + self._mount_created = [] + self._mount_contexts = [] + self._mount_failed = False + + def _unmount(self): + widgets_dict = _get_widgets_dict() + for context, key, el, widget in self._mount_created: + for orphan in self._orphans.pop(widget.model_id, ()): + orphan_widget = widgets_dict.get(orphan) + if orphan_widget: + close_widget(orphan_widget) + el._cleanup_callbacks(widget) + el._close_widget(widget) + for context in self._mount_contexts: + # the render bookkeeping of the two phase walk + context.elements_next = context.elements + context.elements = {} + context.children_next = context.children + context.children = {} + context.root_element_next = context.root_element + context.root_element = None + context.widgets = {} + context.element_to_widget = {} + context.resolved_kwargs = {} + context.mounted = False + context.mount_widget = None + context.mount_order = None + self._mount_roots = {} + self._mount_created = [] + self._mount_contexts = [] + self._mount_list = None + self._mount_failed = False + + def _before_close(self): + if self._mount_contexts or self._mount_roots: + # a reconciliation that raised did not finish them + self._unmount() + + def _close_tree(self): + if self._shared_elements or self._shared_widgets or DEBUG: + super()._close_tree() + return + # The whole tree goes away: the same walk as _remove_element (the same order of + # effect cleanups and widget closes, the same exception handling), without the + # bookkeeping of a partial removal (the contexts are dropped after this). + self._close_element(self.element, "/") + + def _close_element(self, el: Element, default_key: str): + context = self.context + assert context is not None + key = el._key + if key is None: + key = default_key + if isinstance(el.component, ComponentFunction): + child_context = context.children.get(key) + if child_context is None: + return + # the element is going away, pending exceptions only matter if cleanup fails + if child_context.exceptions_self: + child_context.exceptions_self = [] + if child_context.exceptions_children: + child_context.exceptions_children = [] + self.context = child_context + try: + for effect in child_context.effects: + if not effect._cleaned_up: + # Effect.cleanup, inline + cleanup = effect._cleanup + try: + if cleanup is not None: + cleanup() + except BaseException as e: + logger.exception("Effect cleanup %r raised exception %r", effect.callable, e) + child_context.exceptions_self.append(e) + effect._cleaned_up = True + root_element = child_context.root_element + if root_element is not None: + self._close_element(root_element, "/") + finally: + self.context = context + if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler: + # child does not handle exceptions, so bubble up + context.exceptions_children.extend(child_context.exceptions_self) + context.exceptions_children.extend(child_context.exceptions_children) + else: + if el._leaf is not True: + self._walk_children_values(el.kwargs, key, "", self._close_child) + widget = context.widgets.get(key) + if widget is not None: + orphans = self._orphans.pop(widget.model_id, None) + if orphans: + widgets_dict = _get_widgets_dict() + for orphan in orphans: + orphan_widget = widgets_dict.get(orphan) + if orphan_widget: + close_widget(orphan_widget) + element_class = type(el) + if el._on_kwargs is not False or element_class._cleanup_callbacks is not _element_cleanup_callbacks: + el._cleanup_callbacks(widget) + if element_class._close_widget is _element_close_widget: + # Element._close_widget, inline + close = widget.close + if callable(close): + close() + else: + close_widget(widget) # logs the warning + widget.__dict__.pop("_reacton_rc", None) + else: + el._close_widget(widget) + + def _close_child(self, el: Element, key: str, parent_key: str): + self._close_element(el, key) + + def _discard_aborted_pass(self): + self._unmount() + super()._discard_aborted_pass() + contexts: List[ComponentContext] = [self.context_root] + while contexts: + context = contexts.pop() + context.partial = False + context.dirty_children = None + contexts.extend(context.children.values()) + contexts.extend(context.children_next.values()) + # every context is marked dirty now: walk the whole tree + self._walk_all = True + def _render(self, element: Element, default_key: str, parent_key: str): if not isinstance(element, Element): raise TypeError(f"Expected element, not {element}") - # for tracking stale elements when using get_widget - self._old_element_ids.add(id(element)) + # (no _old_element_ids: get_widget recognizes a stale element by _key_frozen) context = self.context assert context is not None @@ -2470,6 +3091,8 @@ def _render(self, element: Element, default_key: str, parent_key: str): # the root element of a component determines which keys are in use, # everything else is stale and gets removed during reconciliation context.used_keys.clear() + context.has_shared = False + context.child_order_counter = 0 el = element key = el._key @@ -2484,6 +3107,9 @@ def _render(self, element: Element, default_key: str, parent_key: str): context.used_keys.add(key) if el.is_shared: + # the walk over the dirty children only (see _render_component) does not + # handle shared elements + context.has_shared = True # a shared element renders a single widget, process it once per phase if el in self._shared_elements_next: return @@ -2502,42 +3128,85 @@ def _render(self, element: Element, default_key: str, parent_key: str): del context.children_next[key] # the element arguments are part of this component's element tree if el.kwargs: - self._visit_children(el, key, parent_key, self._render) + el_reconciled = context.elements.get(key) + if el_reconciled is not None and el_reconciled.component is not el.component and el_reconciled.component != el.component: + # reconciliation replaces the widget at this key, and first removes the + # old subtree, including the component contexts in it: the walk below + # must not keep one of those as it is (see the fast path further down) + self._replacing += 1 + try: + self._render_children(el.kwargs, key, parent_key) + finally: + self._replacing -= 1 + else: + self._render_children(el.kwargs, key, parent_key) return - assert isinstance(el.component, ComponentFunction) - if el.is_shared and (el.args or el.kwargs): - # arguments of a shared element belong to the context it is rendered in; - # for non-shared component elements the component function decides - # what ends up in the tree - self._visit_children(el, key, parent_key, self._render) - + order = context.child_order_counter + context.child_order_counter = order + 1 + if el.is_shared: + if el.args or el.kwargs: + # arguments of a shared element belong to the context it is rendered in; + # for non-shared component elements the component function decides + # what ends up in the tree + self._visit_children(el, key, parent_key, self._render) + elif not self._walk_all and not self._replacing: + child = context.children.get(key) + if ( + child is not None + and not child.needs_render + and not child.needs_render_descendant + and child.root_element_next is None + and child.root_element is not None + and not child.exceptions_self + and not child.exceptions_children + and ( + el is el_prev + or ( + # a new element for the same component with equal arguments (the parent + # re-rendered): the component would not re-render, so it would only walk + # an unchanged subtree + el_prev is not None + and el_prev is child.invoke_element + and not el._meta + and (el_prev.component is el.component or same_component(el_prev.component, el.component)) + and not el._arguments_changed(el_prev) + ) + ) + ): + child_next = context.children_next.get(key) + if child_next is None or child_next is child: + # fast path: no state changes or pending exceptions anywhere in this + # subtree, and fully reconciled: the previous result stands, skip the + # subtree in both phases (a skipped child stays in children only, the + # reconciliation finds it there) + child.clean_subtree = True + child.invoke_element = el + child.order_in_parent = order + return + self._render_component(el, key, parent_key, el_prev, order) + + def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Optional[Element], order: int): + # the render phase of a component element that is not skipped (see _render) + context = self.context + assert context is not None context_previous = context.children_next.get(key) if context_previous is None: context_previous = context.children.get(key) - if ( - not self._walk_all - and el is el_prev - and not el.is_shared - and context_previous is not None - and context.children.get(key) is context_previous - and not context_previous.needs_render - and not context_previous.needs_render_descendant - and not context_previous.exceptions_self - and not context_previous.exceptions_children - and context_previous.root_element is not None - and context_previous.root_element_next is None - ): - # fast path: same element, no state changes or pending exceptions - # anywhere in this subtree, and fully reconciled: the previous - # result stands, skip the subtree in both phases - context_previous.clean_subtree = True - context.children_next[key] = context_previous - return - parent_context = context del context + if self._mount_enabled and not el.is_shared and not DEBUG: + # a new component without a widget yet: mount it (render + create widgets) in one walk + if context_previous is None: + return self._mount_component(el, key, parent_context, parent_key, order) + if context_previous.root_element is None and context_previous.root_element_next is None: + # pre-created, carrying initial state (state_set), but never rendered + return self._mount_component(el, key, parent_context, parent_key, order, context_previous) + assert context_previous.invoke_element is not None + if not same_component(context_previous.invoke_element.component, el.component): + # a different component took this key (the old context is removed during reconciliation) + return self._mount_component(el, key, parent_context, parent_key, order) if context_previous is None: context = ComponentContext(parent=parent_context, context_managers=[cm(el) for cm in _component_context_manager_classes]) el_prev = None # do not compare against an element of a different component @@ -2570,6 +3239,23 @@ def _render(self, element: Element, default_key: str, parent_key: str): needs_render = True if not needs_render: assert el_prev is not None + # A component that does not render again and whose element tree is the one of the + # last reconciliation: only walk its dirty children, not its element tree (the + # reconciliation of this pass must then also only look at those, see + # _reconsolidate). With shared elements, or pending exceptions, the full walk. + partial = ( + not needs_render + and not self._walk_all + and not self._replacing + and not el.is_shared + and context is context_previous + and parent_context.children.get(key) is context + and context.root_element is not None + and (context.root_element_next is None or context.partial) + and not context.has_shared + and not context.exceptions_self + and not context.exceptions_children + ) self.container_adders = [] self.context = context @@ -2587,22 +3273,22 @@ def _render(self, element: Element, default_key: str, parent_key: str): # which might set it to true again context.needs_render = False try: - with contextlib.ExitStack() as stack: - for cm in context.context_managers: - stack.enter_context(cm) - if _default_container is not None: - with _default_container() as container: - el.component.render_count += 1 - root_element = el.component.f(*el.args, **el.kwargs) - if root_element is None: - if len(container.kwargs["children"]) == 1: - root_element = container.kwargs["children"][0] - else: - root_element = container - else: - el.component.render_count += 1 - root_element = el.component.f(*el.args, **el.kwargs) + # an ExitStack costs more than most component bodies; solara registers + # a single context manager, and plain reacton none + context_managers = context.context_managers + if not context_managers: + root_element = self._call_component(el) assert root_element is not None + elif len(context_managers) == 1: + with context_managers[0]: + root_element = self._call_component(el) + assert root_element is not None + else: + with contextlib.ExitStack() as stack: + for cm in context_managers: + stack.enter_context(cm) + root_element = self._call_component(el) + assert root_element is not None except BaseException as e: if DEBUG: # we might be interested in the traceback inside the call... @@ -2626,8 +3312,18 @@ def _render(self, element: Element, default_key: str, parent_key: str): # the subtree walk below will mark this again when state changes context.needs_render_descendant = False + dirty_children = context.dirty_children + context.dirty_children = None if root_element is not None: - self._render(root_element, "/", parent_key=join_key(parent_key, key)) # depth first + if el._event_handlers: + _add_event_handlers(root_element, el._event_handlers, context, self) + if partial: + context.partial = True + if dirty_children: + self._render_dirty_children(context, dirty_children, join_key(parent_key, key)) + else: + context.partial = False + self._render(root_element, "/", parent_key=join_key(parent_key, key)) # depth first context.root_element_next = root_element elif el.is_shared: self._shared_elements_next.remove(el) @@ -2654,6 +3350,8 @@ def _render(self, element: Element, default_key: str, parent_key: str): # only expose to the parent when we get this far parent_context.children_next[key] = context + context.key_in_parent = key + context.order_in_parent = order # drop children/elements from a previous render pass that are no longer used used_keys = context.used_keys for unused in [k for k in context.children_next if k not in used_keys]: @@ -2671,7 +3369,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): if context.exceptions_self or context.exceptions_children: # make sure the next render pass walks down to this context # (e.g. so a parent with use_exception gets a chance to handle it) - _mark_needs_render_ancestors(context) + self._mark_dirty(context) if parent_context.exceptions_self or parent_context.exceptions_children: if not self._rerender_needed: # this happens when an exception was added from an event handler: @@ -2680,6 +3378,324 @@ def _render(self, element: Element, default_key: str, parent_key: str): self._set_rerender_needed("Exception ocurred during render") parent_context.needs_render = True + def _mount_component( + self, el: Element, key: str, parent_context: ComponentContext, parent_key: str, order: int, context: Optional[ComponentContext] = None + ) -> Optional[widgets.Widget]: + # Mount a new component (no previous state; context is a context pre-created by + # state_set, or None): run the body with its hooks, mount the element tree it returns + # (_mount_node), and write the bookkeeping in its reconciled form. Returns the root + # widget, or None when this pass does not create widgets (any more). + mount_list = self._mount_list + first = mount_list is None + if first: + # one recording of the widgets constructed during the whole mount (to find the + # widgets made as a side effect of making a widget, like Layout and Style) + mount_list = self._mount_list = [] + recording: List[widgets.Widget] = [] + previous_recording = _start_recording_constructed(recording) + self._mount_recording = recording + try: + if context is None: + context = ComponentContext(parent=parent_context) + else: + context.parent = parent_context + if _component_context_manager_classes: + context.context_managers = [cm(el) for cm in _component_context_manager_classes] + context.invoke_element = el + context.key_in_parent = key + context.order_in_parent = order + self._mount_contexts.append(context) + if first: + # the parent renders in two phases + parent_context.children_next[key] = context + else: + parent_context.children[key] = context + + self.container_adders = [] + self.context = context + render_count_check = self.render_count + context.user_contexts = {} + context.needs_render = False + root_element: Optional[Element] = None + try: + context_managers = context.context_managers + if not context_managers: + root_element = self._call_component(el) + assert root_element is not None + elif len(context_managers) == 1: + with context_managers[0]: + root_element = self._call_component(el) + assert root_element is not None + else: + with contextlib.ExitStack() as stack: + for cm in context_managers: + stack.enter_context(cm) + root_element = self._call_component(el) + assert root_element is not None + except BaseException as e: + logger.exception("Component %r raised exception %r", el.component, e) + context.exceptions_self.append(e) + self._set_rerender_needed("Exception ocurred during render") + context.needs_render = True + if self.render_count != render_count_check: + raise RuntimeError("Recursive render detected, possible a bug in react") + context.needs_render_descendant = False + widget = None + if root_element is not None: + if el._event_handlers: + _add_event_handlers(root_element, el._event_handlers, context, self) + widget = self._mount_node(root_element, "/", join_key(parent_key, key)) + context.root_element = root_element + elif el.is_shared: + self._shared_elements_next.discard(el) + # (no hook count check: this render made the hooks) + if context.children_next: + # pre-created (state_set) children that were not used + context.children_next = {} + context.user_contexts_prev = context.user_contexts + self.context = parent_context + # exceptions: as in _render_component + if context.exceptions_self or context.exceptions_children and not context.exception_handler: + parent_context.exceptions_children.extend(context.exceptions_self) + parent_context.exceptions_children.extend(context.exceptions_children) + if context.exceptions_self or context.exceptions_children: + self._mark_dirty(context) + if parent_context.exceptions_self or parent_context.exceptions_children: + if not self._rerender_needed: + self._set_rerender_needed("Exception ocurred during render") + parent_context.needs_render = True + if widget is not None: + context.mount_widget = widget + context.mounted = True + assert mount_list is not None + mount_list.append(context) + return widget + finally: + if first: + _stop_recording_constructed(previous_recording) + self._mount_recording = [] + self._mount_list = None + if context is not None and context.mounted: + context.mount_order = mount_list + self._mount_roots[context] = None + elif mount_list: + # parts of the subtree were mounted, not all: undo at the end of the pass + self._mount_failed = True + + def _mount_node(self, element: Element, default_key: str, parent_key: str) -> Optional[widgets.Widget]: + # The mount walk of an element of the component self.context: returns its widget. + if not isinstance(element, Element): + raise TypeError(f"Expected element, not {element}") + el = element + context = self.context + assert context is not None + if default_key == "/": + context.used_keys.clear() + context.child_order_counter = 0 + key = el._key + if key is None: + key = default_key + el._key_frozen = True + used_keys = context.used_keys + if key in used_keys: + raise KeyError(f"Duplicate key {key!r}") + used_keys.add(key) + if el.is_shared: + # rendered once for the whole tree, by the two phase walk: undo this pass's mounts + self._mount_failed = True + context.has_shared = True + if el in self._shared_elements_next: + return None + self._shared_elements_next.add(el) + context.elements[key] = el + el._render_count += 1 # for testing only + + component = el.component + if isinstance(component, ComponentWidget): + assert not el.args, "no positional args supported for widgets" + elements_seen = self._elements_seen + kwargs = {} + on_kwargs = False + for name, value in el.kwargs.items(): + if name.startswith("on_"): + on_kwargs = True + if type(value) in _SCALAR_TYPES: + kwargs[name] = value + else: + kwargs[name] = self._visit_children_values(value, f"{key}{name}/", parent_key, self._mount_node) + has_elements = self._elements_seen != elements_seen + el._leaf = not has_elements + if self._rerender_needed or self._mount_failed: + # this pass will be undone, do not create more widgets + return None + if type(el)._create_widget is _element_create_widget: + # Element._create_widget, with the recording of this mount + listeners = None + if on_kwargs: + kwargs, listeners = el._split_kwargs(kwargs) + el._on_kwargs = on_kwargs + recording = self._mount_recording + count = len(recording) + try: + widget = component.widget(**kwargs) + except Exception: + # let reconciliation create it (and handle the exception) as it always does + self._mount_failed = True + return None + cls = type(widget) + if not getattr(cls.hold_trait_notifications, "_reacton_batched", False): + _install_batched_hold(cls) + widget._reacton_rc = self + if el._meta: + widget._react_meta = dict(el._meta) + if listeners: + for name, callback in listeners.items(): + if callback is not None: + el._add_widget_event_listener(widget, name, callback) + for handler in el._event_handlers: + handler._reacton_attach(widget) + orphan_ids = None + if len(recording) > count + 1 or (len(recording) == count + 1 and recording[count] is not widget): + widgets_dict = _get_widgets_dict() + orphan_ids = {w.model_id for w in recording[count:] if w is not widget and w.comm is not None and w.model_id in widgets_dict} + else: + try: + widget, orphan_ids = el._create_widget(kwargs) + except BaseException: + self._mount_failed = True + return None + self._mount_created.append((context, key, el, widget)) + context.widgets[key] = widget + context.element_to_widget[el] = widget + if has_elements: + context.resolved_kwargs[key] = kwargs + if orphan_ids: + for orphan_widget in [_get_widgets_dict()[k] for k in orphan_ids]: + if _is_shared_ipyvue_template(orphan_widget): + orphan_ids.discard(orphan_widget.model_id) + if orphan_ids: + self._orphans.setdefault(widget.model_id, set()).update(orphan_ids) + return widget + + # a component element + if el.is_shared and (el.args or el.kwargs): + self._visit_children_values(el.kwargs, key, parent_key, self._mount_node) + self._visit_children_values(el.args, key, parent_key, self._mount_node) + order = context.child_order_counter + context.child_order_counter = order + 1 + children_next = context.children_next + precreated = children_next.pop(key, None) if children_next else None + widget = self._mount_component(el, key, context, parent_key, order, precreated) + if widget is not None: + context.widgets[key] = widget + context.element_to_widget[el] = widget + if el._meta or getattr(widget, "_react_meta", {}): + widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} + return widget + + def _finish_mount(self, root: ComponentContext): + # Reconciliation of a mounted subtree (self.context is the parent of root): the widgets + # and the bookkeeping are done, run the effects, children first, as the reconciliation + # walk would, and hook the root widget into the parent. + parent_context = self.context + assert parent_context is not None + mount_order = root.mount_order + assert mount_order is not None + try: + for context in mount_order: + parent = context.parent + assert parent is not None + context.mounted = False + effects = context.effects + if effects: + self.context = context + if context.exceptions_self or context.exceptions_children: + self._process_effects(context, parent) + else: + # _process_effects for effects that all run for the first time + for effect in effects: + if effect.next is not None or effect.executed: + self._process_effects(context, parent) + break + try: + effect._cleanup = effect.callable() + effect.executed = True + except BaseException as e: + logger.exception("Effect %r raised exception %r", effect.callable, e) + parent.exceptions_self.append(e) + self._set_rerender_needed("Exception ocurred during effect") + self._mark_dirty(parent) + parent.needs_render = True + if context.exceptions_self or context.exceptions_children and not context.exception_handler: + parent.exceptions_children.extend(context.exceptions_self) + parent.exceptions_children.extend(context.exceptions_children) + widget = root.mount_widget + el = root.invoke_element + key = root.key_in_parent + assert el is not None and widget is not None and key is not None + if el._meta or getattr(widget, "_react_meta", {}): + widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} + parent_context.widgets[key] = widget + parent_context.element_to_widget[el] = widget + finally: + self.context = parent_context + for context in mount_order: + context.mount_widget = None + root.mount_order = None + self._mount_roots.pop(root, None) + + def _render_dirty_children(self, context: ComponentContext, dirty_children: Dict[ComponentContext, None], parent_key: str): + # the render phase of a component that does not render again (self.context is its + # context): render only the child components that are dirty, as the walk over its + # (unchanged) element tree would, in the same order + if len(dirty_children) > 1: + children = sorted(dirty_children, key=lambda child: child.order_in_parent) + else: + children = list(dirty_children) + for child in children: + key = child.key_in_parent + if key is None or context.children.get(key) is not child: + # not (or no longer) mounted here, e.g. the setter of a removed component + continue + el = child.invoke_element + assert el is not None + # reconciliation takes it from there, like after a walk of the element tree + context.elements_next[key] = el + el._render_count += 1 # for testing only + self._render_component(el, key, parent_key, el, child.order_in_parent) + + def _call_component(self, el: Element) -> Optional[Element]: + """Run the component function, with an implicit container when it returns None.""" + component: ComponentFunction = el.component # type: ignore[assignment] + default_container = _default_container + if default_container is None: + component.render_count += 1 + return component.f(*el.args, **el.kwargs) + # Only a body that returns None needs the implicit container. Building it for every + # body (an extra element, and collecting the top level elements from all elements the + # body made) costs more than a typical component body, so first only record the + # elements the body makes, like the container would. + adder = self._body_adder + created: List[Element] = [] + adder.created = created + container_adders = self.container_adders + container_adders.append(adder) + try: + component.render_count += 1 + root_element = component.f(*el.args, **el.kwargs) + finally: + container_adders.pop() + adder.created = _NO_ELEMENTS + if root_element is None: + with default_container() as container: + # the container collects the same elements, the same way + self.container_adders[-1].created.extend(created) + if len(container.kwargs["children"]) == 1: + root_element = container.kwargs["children"][0] + else: + root_element = container + return root_element + def _reconsolidate(self, el: Element, default_key: str, parent_key: str): key = el._key if key is None: @@ -2688,20 +3704,83 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): context = self.context assert context is not None - if el.is_shared and el in self._shared_elements and el is not self.element: - # shared elements reconcile once, all other uses share the widget - return self._shared_widgets[el] + if el.is_shared: + if el in self._shared_elements and el is not self.element: + # shared elements reconcile once, all other uses share the widget + return self._shared_widgets[el] + else: + child_context_next = context.children_next.get(key) + if child_context_next is None: + # a skipped child is only in children (see _render) + child_context_next = context.children.get(key) + if child_context_next is not None and child_context_next.clean_subtree: + el_prev = context.elements.get(key) + if el_prev is None or not el_prev.is_shared: + # subtree was skipped during the render phase: the previous + # reconciliation result stands (the lean version of the path below) + child_context_next.clean_subtree = False + children_next = context.children_next + if key in children_next: + context.children[key] = children_next[key] + del children_next[key] + widget = context.widgets[key] + if el is not el_prev: + # a new element with equal arguments, used by get_widget + element_to_widget = context.element_to_widget + if el_prev is not None: + element_to_widget.pop(el_prev, None) + element_to_widget[el] = widget + elements_next = context.elements_next + context.elements[key] = elements_next[key] + del elements_next[key] + return widget el_prev = context.elements.get(key) try: if isinstance(el.component, ComponentFunction): child_context_next = context.children_next.get(key) + if child_context_next is None: + # a skipped child is only in children (see _render) + child_context_next = context.children.get(key) if child_context_next is not None and child_context_next.clean_subtree: # subtree was skipped during the render phase: the previous # reconciliation result stands child_context_next.clean_subtree = False + if key in context.children_next: + context.children[key] = context.children_next.pop(key) + widget = context.widgets[key] + if el is not el_prev: + # a new element with equal arguments, used by get_widget + if el_prev is not None: + context.element_to_widget.pop(el_prev, None) + context.element_to_widget[el] = widget + return widget + if child_context_next is not None and child_context_next.mount_order is not None: + # mounted in the render phase: finish it + if el_prev is not None and isinstance(el_prev.component, ComponentWidget): + # a widget element was replaced by a component element at this key + self._remove_element(el_prev, default_key=key, parent_key=parent_key) + child_context_prev = context.children.get(key) + if child_context_prev is not None and child_context_prev is not child_context_next: + # the component type changed, remove the old subtree + assert el_prev is not None, "prev child is not None, but element is" + self._remove_element(el_prev, default_key=key, parent_key=parent_key) + self._finish_mount(child_context_next) context.children[key] = context.children_next.pop(key) - return context.widgets[key] + widget = context.widgets[key] + if el_prev is not None and el_prev is not el: + context.element_to_widget.pop(el_prev, None) + context.element_to_widget[el] = widget + return widget + if child_context_next is not None and child_context_next.partial: + # the render phase only walked the dirty children of this component + self._reconsolidate_partial(el, key, parent_key, child_context_next) + context.children[key] = context.children_next.pop(key) + widget = context.widgets[key] + if el_prev is not None and el_prev is not el: + context.element_to_widget.pop(el_prev, None) + context.element_to_widget[el] = widget + return widget if el_prev and isinstance(el_prev.component, ComponentWidget): # a widget element was replaced by a component element at this key @@ -2736,7 +3815,12 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): # remove elements that are no longer part of this component's tree # NOTE: sorted for reproducibility - stale_keys = sorted(set(child_context.elements) - child_context.used_keys) + # (all used keys are in elements now, and a shared element used twice has one + # element for two keys: only then the sizes do not tell) + if len(child_context.elements) > len(child_context.used_keys) or child_context.has_shared: + stale_keys = sorted(set(child_context.elements) - child_context.used_keys) + else: + stale_keys = [] if stale_keys: logger.info("elements to be removed: %r", stale_keys) for stale_key in stale_keys: @@ -2780,9 +3864,13 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): widget_previous = context.widgets[key] orphan_ids: Set[str] = set() + # the visitor counts the elements it resolves: a widget element whose kwargs + # hold elements (a container) keeps its resolved kwargs to compare against + elements_seen = self._elements_seen if widget_previous is None: # initial create - kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) + kwargs = self._reconsolidate_children(el.kwargs, key, parent_key) + el._leaf = self._elements_seen == elements_seen if el.is_shared and el in self._shared_widgets: raise RuntimeError(f"Element ({el}) was already in self._shared_widgets") widget = None @@ -2792,27 +3880,47 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): except BaseException as e: context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during reconciliation (creating widget)") - _mark_needs_render_ancestors(context) + self._mark_dirty(context) + else: + if self._elements_seen != elements_seen and not el.is_shared: + context.resolved_kwargs[key] = kwargs self._store_widget(context, el, key, widget) - elif el_prev is not None and el_prev.component == el.component: + elif el_prev is not None and (el_prev.component is el.component or el_prev.component == el.component): # update the existing widget in place - kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) + kwargs = self._reconsolidate_children(el.kwargs, key, parent_key) + has_elements = self._elements_seen != elements_seen + el._leaf = not has_elements if not context.exceptions_children: - if el is not el_prev or not _values_identical(kwargs, el.kwargs): + identical = False + if el is el_prev: + # the same element: its widget can only need an update when an + # element in its kwargs now resolves to a different widget + if not has_elements: + identical = _values_identical(kwargs, el.kwargs) + elif not el.is_shared: + resolved_previous = context.resolved_kwargs.get(key) + identical = resolved_previous is not None and _values_identical(kwargs, resolved_previous) + if not identical: try: el._update_widget(widget_previous, el_prev, kwargs) except BaseException as e: + # the widget may be half updated: compare against nothing next time + context.resolved_kwargs.pop(key, None) context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)") - _mark_needs_render_ancestors(context) - # else: identical element and all children reconciled to the - # same widgets, nothing can have changed + self._mark_dirty(context) + else: + if has_elements and not el.is_shared: + context.resolved_kwargs[key] = kwargs + elif key in context.resolved_kwargs: + del context.resolved_kwargs[key] self._store_widget(context, el, key, widget_previous) else: assert el_prev is not None, "widget_previous is not None, but el_prev is" # a different widget type at the same key: replace self._remove_element(el_prev, key, parent_key=parent_key) - kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) + kwargs = self._reconsolidate_children(el.kwargs, key, parent_key) + el._leaf = self._elements_seen == elements_seen widget = None if not context.exceptions_children: try: @@ -2820,7 +3928,10 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): except BaseException as e: context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)") - _mark_needs_render_ancestors(context) + self._mark_dirty(context) + else: + if self._elements_seen != elements_seen and not el.is_shared: + context.resolved_kwargs[key] = kwargs self._store_widget(context, el, key, widget) if orphan_ids: # widgets created as a side effect (like Layout and Style); we track @@ -2867,6 +3978,80 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): assert el in self._shared_elements_next self._shared_elements_next.remove(el) + def _reconsolidate_partial(self, el: Element, key: str, parent_key: str, child_context: ComponentContext): + # Reconcile a component that did not render again, and of which the render phase only + # walked the dirty children (see _render_component). Its element tree is the one of the + # last reconciliation, so no widget of its own changes, no element becomes stale, and + # it has no new effects. Only when the root widget of a dirty child changes, the + # widgets that hold it need an update. + context = self.context + assert context is not None + child_context.partial = False + new_parent_key = join_key(parent_key, key) + self.context = child_context + try: + rewire = False + widgets = child_context.widgets + for child_key, grandchild in list(child_context.children_next.items()): + widget_before = widgets.get(child_key) + fragment_children = tuple(widget_before.children) if isinstance(widget_before, FragmentWidget) else None + assert grandchild.invoke_element is not None + widget_after = self._reconsolidate(grandchild.invoke_element, child_key, new_parent_key) + if widget_after is not widget_before: + rewire = True + elif fragment_children is not None and not _values_identical(fragment_children, tuple(widget_after.children)): + # its children are spliced into the list holding the fragment + rewire = True + assert child_context.root_element is not None + if rewire: + widget = self._rewire(child_context.root_element, "/") + else: + widget = context.widgets[key] + child_context.root_element_next = None + if el._meta or getattr(widget, "_react_meta", {}): + widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} + context.widgets[key] = widget + if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler: + # child does not handle exceptions, so bubble up + context.exceptions_children.extend(child_context.exceptions_self) + context.exceptions_children.extend(child_context.exceptions_children) + finally: + self.context = context + + def _rewire(self, el: Element, default_key: str): + # Reconciliation of an unchanged element tree (of self.context) in which a child + # component got a different root widget: every widget stays, but a container whose + # kwargs now resolve to other widgets gets them. + context = self.context + assert context is not None + key = el._key + if key is None: + key = default_key + widget = context.widgets[key] + if isinstance(el.component, ComponentFunction): + return widget + elements_seen = self._elements_seen + kwargs = self._visit_children_values(el.kwargs, key, "", self._rewire_child) + if self._elements_seen == elements_seen or context.exceptions_children: + # no elements in the kwargs, nothing changed (or, like reconciliation, do not + # update while a child has an exception) + return widget + resolved_previous = context.resolved_kwargs.get(key) + if resolved_previous is None or not _values_identical(kwargs, resolved_previous): + try: + el._update_widget(widget, el, kwargs) + except BaseException as e: + context.resolved_kwargs.pop(key, None) + context.exceptions_self.append(e) + self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)") + self._mark_dirty(context) + else: + context.resolved_kwargs[key] = kwargs + return widget + + def _rewire_child(self, el: Element, key: str, parent_key: str): + return self._rewire(el, key) + def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"): # NOTE: effect/cleanup exceptions are recorded on the context of the # component's *parent* (`context`), unlike render exceptions: this is @@ -2887,7 +4072,7 @@ def _process_effects(self, child_context: "ComponentContext", context: "Componen logger.exception("Effect cleanup %r raised exception %r", effect.callable, e) context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during effect") - _mark_needs_render_ancestors(context) + self._mark_dirty(context) context.needs_render = True effect = child_context.effects[effect_index] = effect.next if child_context.exceptions_self or child_context.exceptions_children: @@ -2899,7 +4084,7 @@ def _process_effects(self, child_context: "ComponentContext", context: "Componen logger.exception("Effect %r raised exception %r", effect.callable, e) context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during effect") - _mark_needs_render_ancestors(context) + self._mark_dirty(context) context.needs_render = True def _store_widget(self, context: "ComponentContext", el: Element, key: str, widget: Optional[widgets.Widget]): @@ -2943,7 +4128,15 @@ def _remove_element(self, el: Element, default_key: str, parent_key): logger.exception("Effect cleanup %r raised exception %r", effect.callable, e) child_context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during effect") - _mark_needs_render_ancestors(child_context) + self._mark_dirty(child_context) + for handler in child_context.event_handlers: + try: + handler._reacton_detach() + except BaseException as e: + logger.exception("Removing event handler %r raised exception %r", handler, e) + child_context.exceptions_self.append(e) + self._set_rerender_needed("Exception ocurred during effect") + self._mark_dirty(child_context) assert child_context.root_element is not None self._remove_element(child_context.root_element, "/", parent_key=join_key(parent_key, key)) finally: @@ -2979,24 +4172,257 @@ def _remove_element(self, el: Element, default_key: str, parent_key): del self._shared_widgets[el] else: del context.widgets[key] + # it references the child widgets, which are closed now + context.resolved_kwargs.pop(key, None) # elements can be removed multiple times, since they can be added multiple times # (even non-shared elements can) if el in context.element_to_widget: del context.element_to_widget[el] del context.elements[key] + # The visitors below are the hottest code of a mount: they dispatch on the exact type, + # skip scalars without a call, and only build key strings for values that can hold + # elements. Subclasses of list/tuple/dict take the generic path, like before. + def _visit_children(self, el: Element, default_key: str, parent_key: str, f: Callable): + # calls f for every element in the arguments of el (without building new values) key = el._key if key is None: key = default_key assert key is not None - self._visit_children_values(el.kwargs, key, parent_key, f) - self._visit_children_values(el.args, key, parent_key, f) + self._walk_children_values(el.kwargs, key, parent_key, f) + if el.args: + self._walk_children_values(el.args, key, parent_key, f) + + # The two visitors below are _walk_children_values(value, key, parent_key, self._render) + # and _visit_children_values(value, key, parent_key, self._reconsolidate), with the + # common case of a component that renders again handled inline: a child component + # with equal arguments and nothing dirty is skipped without a call per child (keep the + # inline code in sync with _render and _reconsolidate). + + def _render_children(self, value: Any, key: str, parent_key: str): + t = type(value) + if t is dict: + items: Any = value.items() + elif t is list or t is tuple: + items = enumerate(value) + elif t in _SCALAR_TYPES: + return + elif isinstance(value, Element): + self._render(value, key, parent_key) + return + elif isinstance(value, dict): + items = value.items() + elif isinstance(value, (list, tuple)): + items = enumerate(value) + else: + return + context = self.context + assert context is not None + skip_possible = not self._walk_all and not self._replacing + for k, v in items: + tv = type(v) + if tv in _SCALAR_TYPES: + continue + if not isinstance(v, Element): + if isinstance(v, (list, tuple, dict)): + self._render_children(v, f"{key}{k}/", parent_key) + continue + if not skip_possible or v.is_shared or not isinstance(v.component, ComponentFunction): + self._render(v, f"{key}{k}/", parent_key) + continue + # _render for a component element, inline up to the skip + child_key = v._key + if child_key is None: + child_key = f"{key}{k}/" + v._key_frozen = True + used_keys = context.used_keys + if child_key in used_keys: + if DEBUG: + self.tracebacks.append(v.traceback) + raise KeyError(f"Duplicate key {child_key!r}") + used_keys.add(child_key) + elements_next = context.elements_next + el_prev = elements_next.get(child_key) + if el_prev is None: + el_prev = context.elements.get(child_key) + elements_next[child_key] = v + v._render_count += 1 # for testing only + order = context.child_order_counter + context.child_order_counter = order + 1 + child = context.children.get(child_key) + if ( + child is not None + and not child.needs_render + and not child.needs_render_descendant + and child.root_element_next is None + and child.root_element is not None + and not child.exceptions_self + and not child.exceptions_children + and ( + v is el_prev + or ( + el_prev is not None + and el_prev is child.invoke_element + and not v._meta + and (el_prev.component is v.component or same_component(el_prev.component, v.component)) + and not v._arguments_changed(el_prev) + ) + ) + ): + child_next = context.children_next.get(child_key) + if child_next is None or child_next is child: + # (a skipped child stays in children only, see _reconsolidate) + child.clean_subtree = True + child.invoke_element = v + child.order_in_parent = order + continue + self._render_component(v, child_key, parent_key, el_prev, order) + + def _reconsolidate_children(self, value: Any, key: str, parent_key: str): + t = type(value) + if t is not dict and t is not list and t is not tuple: + return self._visit_children_values(value, key, parent_key, self._reconsolidate) + context = self.context + assert context is not None + if t is dict: + new_dict = {} + for k, v in value.items(): + if type(v) in _SCALAR_TYPES: + new_dict[k] = v + elif isinstance(v, Element): + self._elements_seen += 1 + new_dict[k] = self._reconsolidate(v, f"{key}{k}/", parent_key) + else: + new_dict[k] = self._reconsolidate_children(v, f"{key}{k}/", parent_key) + return new_dict + values: List[Any] = [] + append = values.append + children_next = context.children_next + children = context.children + elements_seen = 0 + for index, v in enumerate(value): + tv = type(v) + if tv in _SCALAR_TYPES: + append(v) + continue + if isinstance(v, Element): + elements_seen += 1 + child_key = v._key + if child_key is None: + child_key = f"{key}{index}/" + child = children_next.get(child_key) + if child is None: + # a skipped child is only in children (see _render) + child = children.get(child_key) + if child is not None and child.clean_subtree and not v.is_shared: + el_prev = context.elements.get(child_key) + if el_prev is None or not el_prev.is_shared: + # the skipped child of _reconsolidate, inline + child.clean_subtree = False + if child_key in children_next: + children[child_key] = child + del children_next[child_key] + new_value = context.widgets[child_key] + if v is not el_prev: + element_to_widget = context.element_to_widget + if el_prev is not None: + element_to_widget.pop(el_prev, None) + element_to_widget[v] = new_value + elements_next = context.elements_next + context.elements[child_key] = elements_next[child_key] + del elements_next[child_key] + else: + new_value = self._reconsolidate(v, f"{key}{index}/", parent_key) + else: + new_value = self._reconsolidate(v, f"{key}{index}/", parent_key) + else: + new_value = self._reconsolidate_children(v, f"{key}{index}/", parent_key) + if isinstance(new_value, FragmentWidget): + values.extend(new_value.children) + else: + append(new_value) + self._elements_seen += elements_seen + if t is tuple: + return tuple(values) + return values + + def _walk_children_values(self, value: Any, key: str, parent_key: str, f: Callable): + t = type(value) + if t is dict: + items: Any = value.items() + elif t is list or t is tuple: + items = enumerate(value) + elif t in _SCALAR_TYPES: + return + elif isinstance(value, Element): + f(value, key, parent_key) + return + elif isinstance(value, dict): + items = value.items() + elif isinstance(value, (list, tuple)): + items = enumerate(value) + else: + return + for k, v in items: + tv = type(v) + if tv in _SCALAR_TYPES: + continue + if isinstance(v, Element): + f(v, f"{key}{k}/", parent_key) + elif isinstance(v, (list, tuple, dict)): + self._walk_children_values(v, f"{key}{k}/", parent_key, f) + + # number of elements the visitor handed to its callback (see _reconsolidate) + _elements_seen = 0 def _visit_children_values(self, value: Any, key: str, parent_key: str, f: Callable): + # returns value with every element replaced by f(element), and the children of a + # FragmentWidget spliced into the list that holds it + t = type(value) + if t is dict: + new_dict = {} + for k, v in value.items(): + tv = type(v) + if tv in _SCALAR_TYPES: + new_dict[k] = v + elif isinstance(v, Element): + self._elements_seen += 1 + new_dict[k] = f(v, f"{key}{k}/", parent_key) + else: + new_dict[k] = self._visit_children_values(v, f"{key}{k}/", parent_key, f) + return new_dict + if t is list or t is tuple: + values = [] + for index, v in enumerate(value): + tv = type(v) + if tv in _SCALAR_TYPES: + values.append(v) + continue + if isinstance(v, Element): + self._elements_seen += 1 + new_value = f(v, f"{key}{index}/", parent_key) + else: + new_value = self._visit_children_values(v, f"{key}{index}/", parent_key, f) + if isinstance(new_value, FragmentWidget): + values.extend(new_value.children) + else: + values.append(new_value) + if t is tuple: + return tuple(values) + return values + if t in _SCALAR_TYPES: + return value if isinstance(value, Element): + self._elements_seen += 1 return f(value, key, parent_key) - elif isinstance(value, (list, tuple)): + if isinstance(value, (list, tuple, dict)): + return self._visit_children_values_generic(value, key, parent_key, f) + return value + + def _visit_children_values_generic(self, value: Any, key: str, parent_key: str, f: Callable): + # subclasses of list/tuple/dict (the result is a plain list/tuple/dict) + if isinstance(value, (list, tuple)): values = [] for index, v in enumerate(value): new_value = self._visit_children_values(v, f"{key}{index}/", parent_key, f) @@ -3007,10 +4433,7 @@ def _visit_children_values(self, value: Any, key: str, parent_key: str, f: Calla if isinstance(value, tuple): return tuple(values) return values - elif isinstance(value, dict): - return {k: self._visit_children_values(v, f"{key}{k}/", parent_key, f) for k, v in value.items()} - else: - return value + return {k: self._visit_children_values(v, f"{key}{k}/", parent_key, f) for k, v in value.items()} def _remove_stale_root_elements(self, parent_key): # remove stale elements of the root context itself @@ -3021,6 +4444,13 @@ def _remove_stale_root_elements(self, parent_key): self._remove_element(self.context_root.elements[stale_key], stale_key, parent_key) +# values that cannot hold elements (the child visitors skip them) +_SCALAR_TYPES = frozenset([str, int, float, bool, complex, bytes, type(None)]) +_element_cleanup_callbacks = Element._cleanup_callbacks +_element_create_widget = Element._create_widget +_element_close_widget = Element._close_widget + + def _render_context_class(): import os diff --git a/reacton/core_test.py b/reacton/core_test.py index 9e24e35..7d7056a 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -1,4 +1,6 @@ import gc +import os +import random import sys import threading import time @@ -6,7 +8,7 @@ import unittest.mock import weakref from concurrent.futures import ThreadPoolExecutor -from typing import Callable, Generic, List, Optional, Tuple, TypeVar, cast +from typing import Any, Callable, Dict, Generic, List, Optional, Tuple, TypeVar, cast import ipyvuetify import ipywidgets @@ -3110,7 +3112,9 @@ def Infinite(): rc.render(rc.element) assert "Too many renders triggered" in str(e) - assert len(rc._rerender_needed_reasons) >= 50 + assert "Last reason: Reason: state changed" in str(e) + assert "Previous reasons: Reason: state changed" in str(e) + assert Infinite.render_count >= 50 # type: ignore rc.close() @@ -3247,6 +3251,36 @@ def test_no_reference_cycles_after_close(component): gc.enable() +def test_state_changes_do_not_keep_old_values_alive(): + # every state change used to append a RerenderReason with the previous and next + # value to the render context, so a long-lived page kept every old state value + # alive until it was closed + class Big: + pass + + set_value: Callable[[Big], None] = lambda x: None # noqa + + @react.component + def Test(): + nonlocal set_value + value, set_value = react.use_state(Big()) + return w.Button(description=str(id(value))) + + box, rc = react.render(Test(), handle_error=False) + refs = [] + for i in range(20): + big = Big() + refs.append(weakref.ref(big)) + set_value(big) + del big + gc.collect() + alive = sum(ref() is not None for ref in refs) + # the current value, and the values of the last two rerender reasons + assert alive <= 3 + assert len(rc._rerender_needed_reasons) <= 2 + rc.close() + + def test_fragment(): @reacton.component def Children(): @@ -3534,3 +3568,1189 @@ def cleanup(): finally: thread.join(5) assert not thread.is_alive() + + +# Tests for the update path: what a state change re-renders and which widgets it touches. +# Some of these pin properties of the fast renderer only (REACTON_FAST=1); the default +# renderer walks and updates the whole tree on every render. +fast_renderer_only = pytest.mark.skipif(core._render_context_class() is not core._RenderContextFast, reason="a property of the fast renderer (REACTON_FAST=1)") + + +class UpdateSpy: + """Records the widgets that get a (re)assignment of their kwargs via Element._update_widget.""" + + def __init__(self): + self.updated: List[widgets.Widget] = [] + + def __enter__(self): + original = core.Element._update_widget + spy = self + + def _update_widget(self, widget, el_prev, kwargs): + spy.updated.append(widget) + return original(self, widget, el_prev, kwargs) + + self._patch = unittest.mock.patch.object(core.Element, "_update_widget", _update_widget) + self._patch.__enter__() + return self + + def __exit__(self, *args): + self._patch.__exit__(*args) + + def types(self): + return sorted(type(widget).__name__ for widget in self.updated) + + +@fast_renderer_only +def test_leaf_update_does_not_update_sibling_containers(): + set_value = lambda x: None # noqa + + @react.component + def Row(i): + return w.HBox(children=[w.Button(description=f"button-{i}"), w.Label(value=f"label-{i}")]) + + @react.component + def Leaf(): + nonlocal set_value + value, set_value = react.use_state(0) + return w.Button(description=f"leaf-{value}") + + @react.component + def App(): + # the HBox is a container next to the leaf, in the same (not re-rendered) component + return w.VBox(children=[w.HBox(children=[w.Label(value="sibling")]), Row(0), Row(1), Leaf()]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + children_before = vbox.children + with UpdateSpy() as spy: + set_value(1) + assert rc.find(widgets.Button, description="leaf-1").widget is vbox.children[-1] + # only the leaf button gets new kwargs, the containers keep their children + assert spy.types() == ["Button"] + assert vbox.children == children_before + rc.close() + + +def test_container_updates_when_child_widget_changes(Container): + # the component holding the containers does not re-render, but the root widget of + # a child component changes type: the container must get the new widget + setters = {} + + @react.component + def Switch(name): + label, set_label = react.use_state(False) + setters[name] = set_label + if label: + return w.Label(value=name) + return w.Button(description=name) + + @react.component + def App(): + return w.VBox(children=[Container(children=[w.Button(description="sibling"), Switch("inner")]), Switch("outer")]) + + def describe(widget): + return (type(widget).__name__, widget.value if isinstance(widget, widgets.Label) else widget.description) + + vbox, rc = react.render_fixed(App(), handle_error=False) + box = vbox.children[0] + assert isinstance(box, widgets.HBox) + assert [describe(child) for child in box.children] == [("Button", "sibling"), ("Button", "inner")] + assert describe(vbox.children[1]) == ("Button", "outer") + + setters["outer"](True) + assert vbox.children[0] is box + assert describe(vbox.children[1]) == ("Label", "outer") + + setters["inner"](True) + assert vbox.children[0] is box + assert [describe(child) for child in box.children] == [("Button", "sibling"), ("Label", "inner")] + label = box.children[1] + + setters["inner"](False) + assert [describe(child) for child in box.children] == [("Button", "sibling"), ("Button", "inner")] + assert label.comm is None # closed + setters["outer"](False) + assert describe(vbox.children[1]) == ("Button", "outer") + rc.close() + + +def test_container_updates_when_fragment_child_changes(): + # a child component returns a fragment: its widgets are spliced into the parent + # container, which must follow when the fragment changes + set_count = lambda x: None # noqa + + @react.component + def Items(): + nonlocal set_count + count, set_count = react.use_state(1) + return reacton.Fragment(children=[w.Button(description=str(i)) for i in range(count)]) + + @react.component + def App(): + return w.VBox(children=[w.Label(value="first"), Items(), w.Label(value="last")]) + + def describe(vbox): + return [child.value if isinstance(child, widgets.Label) else child.description for child in vbox.children] + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert describe(vbox) == ["first", "0", "last"] + set_count(3) + assert describe(vbox) == ["first", "0", "1", "2", "last"] + set_count(0) + assert describe(vbox) == ["first", "last"] + set_count(2) + assert describe(vbox) == ["first", "0", "1", "last"] + rc.close() + + +def test_replace_parent_same_child_element(): + # like test_replace_parent, but the child element is the same object in every render + # (it comes from outside): replacing its parent widget removes the child's subtree, + # so the child cannot keep its previous widget + set_vertical = lambda x: None # noqa + child = ButtonComponentFunction(description="Hi") + + @react.component + def Test(child): + nonlocal set_vertical + vertical, set_vertical = react.use_state(True) + Container = w.VBox if vertical else w.HBox + with w.VBox() as main: + Container(children=[child]) + return main + + box, rc = react.render(Test(child), handle_error=False) + assert len(rc.find(widgets.Button)) == 1 + set_vertical(False) + assert len(rc.find(widgets.HBox).find(widgets.Button)) == 1 + set_vertical(True) + assert len(rc.find(widgets.Button)) == 1 + rc.close() + + +def test_equal_args_child_get_widget(): + # the parent re-renders and makes a new element for a child with equal arguments: + # get_widget must find the widget for the new element + set_value = lambda x: None # noqa + found = [] + + @react.component + def Child(label): + return w.HBox(children=[w.Button(description=label)]) + + @react.component + def App(): + nonlocal set_value + value, set_value = react.use_state(0) + child = Child("child") + + def effect(): + found.append(react.get_widget(child)) + + react.use_effect(effect, [value]) + return w.VBox(children=[w.Label(value=str(value)), child]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + hbox = vbox.children[1] + set_value(1) + set_value(2) + assert found == [hbox, hbox, hbox] + assert vbox.children[1] is hbox + rc.close() + + +@fast_renderer_only +def test_equal_args_child_is_not_walked(): + set_value = lambda x: None # noqa + button = None + + @react.component + def Child(label): + nonlocal button + button = w.Button(description=label) + return w.HBox(children=[button]) + + @react.component + def App(): + nonlocal set_value + value, set_value = react.use_state(0) + return w.VBox(children=[w.Label(value=str(value)), Child("child")]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert button is not None + first_button = button + assert first_button._render_count == 1 + child_render_count = Child.render_count # type: ignore + with UpdateSpy() as spy: + set_value(1) + assert vbox.children[0].value == "1" + # the child did not re-render, and its subtree was not walked or updated + assert Child.render_count == child_render_count # type: ignore + assert button is first_button + assert first_button._render_count == 1 + assert spy.types() == ["Label", "VBox"] + rc.close() + + +def test_implicit_container_only_for_none(): + made = [] + + def Container(**kwargs): + el = w.VBox(**kwargs) + made.append(el) + return el + + @react.component + def Implicit(): + with w.HBox(): + w.Button(description="in hbox") + w.Label(value="top") + w.Button(description="also top") + + @react.component + def Explicit(): + w.Label(value="not used") + return w.Button(description="returned") + + @react.component + def Single(): + w.Button(description="single") + + @react.component + def App(): + with w.VBox() as main: + Implicit() + Explicit() + Single() + return main + + with unittest.mock.patch.object(reacton.core, "_default_container", Container): + vbox, rc = react.render_fixed(App(), handle_error=False) + implicit = vbox.children[0] + assert [type(child) for child in implicit.children] == [widgets.HBox, widgets.Label, widgets.Button] + assert implicit.children[0].children[0].description == "in hbox" + assert implicit.children[2].description == "also top" + assert vbox.children[1].description == "returned" + # a single element becomes the root itself + assert vbox.children[2].description == "single" + assert len(vbox.children) == 3 + if core._render_context_class() is core._RenderContextFast: + # only the components that return None get a container + assert len(made) == 2 + else: + assert len(made) == 4 + rc.close() + + +def test_fast_child_visitors_match_default(): + import collections + + Point = collections.namedtuple("Point", "x y") + + class MyList(list): + pass + + class MyDict(dict): + pass + + button = w.Button(description="a") + label = w.Label(value="b") + widgets_before = set(_get_widgets_dict()) + fragment_children = [widgets.Button(), widgets.Button()] + fragment = core.FragmentWidget(children=fragment_children) + value = { + "children": [button, "text", 1, None, True, (label, [button, label]), label], + "slots": [{"name": "x", "children": label}, {"name": "y", "children": [button]}], + "tuple": (button, 2.0, b"bytes"), + "point": Point(button, label), + "mylist": MyList([label, "x"]), + "mydict": MyDict(a=button, b=1), + "callback": print, + "widget": fragment, + "nested": {"deep": {"el": button, 3: [label]}}, + } + + def f(el, key, parent_key): + # the label becomes a fragment, to check that its children are spliced into lists + if el is label: + return fragment + return (el.component.name, key, parent_key) + + default_rc = core._RenderContext(w.Button()) + fast_rc = core._RenderContextFast(w.Button()) + expected = core._RenderContext._visit_children_values(default_rc, value, "K/", "P", f) + got = core._RenderContextFast._visit_children_values(fast_rc, value, "K/", "P", f) + assert got == expected + assert [type(v) for v in got.values()] == [type(v) for v in expected.values()] + assert got["children"][-2:] == fragment_children + + calls_default: List[str] = [] + calls_fast: List[str] = [] + core._RenderContext._visit_children_values(default_rc, value, "K/", "P", lambda el, key, parent_key: calls_default.append(key)) + core._RenderContextFast._walk_children_values(fast_rc, value, "K/", "P", lambda el, key, parent_key: calls_fast.append(key)) + assert calls_fast == calls_default + for model_id in set(_get_widgets_dict()) - widgets_before: + _get_widgets_dict()[model_id].close() + + +def test_hold_trait_notifications_batches_renders(): + # a frontend update of several traits holds the trait notifications (Widget.set_state): + # the state changes of the listeners must result in a single render + @react.component + def Test(): + value, set_value = react.use_state(0) + description, set_description = react.use_state("a") + return w.IntSlider(value=value, on_value=set_value, description=description, on_description=set_description) + + slider, rc = react.render_fixed(Test(), handle_error=False) + render_count = rc.render_count + with slider.hold_trait_notifications(): + slider.value = 3 + slider.description = "b" + assert rc.render_count == render_count + assert rc.render_count == render_count + 1 + assert slider.value == 3 + assert slider.description == "b" + + # a widget of the same class that reacton did not create is not affected + other = widgets.IntSlider() + with other.hold_trait_notifications(): + other.value = 2 + assert other.value == 2 + assert rc.render_count == render_count + 1 + + rc.close() + # a closed widget does not keep the render context alive + assert "_reacton_rc" not in slider.__dict__ + other.close() + other.layout.close() + other.style.close() + + +def test_orphans_are_recorded_per_thread(): + # widgets made as a side effect of creating a widget (like its Layout) are closed with + # it; renders in other threads construct widgets at the same time, and must not end up + # as orphans of our widget (or ours of theirs) + class SlowBox(widgets.Box): + def __init__(self, **kwargs): + super().__init__(**kwargs) + # let the other threads construct their widgets now + time.sleep(0.01) + self._extra = widgets.Layout() + + @react.component + def Test(i): + return SlowBox.element(children=[w.Button(description=str(i))]) + + def worker(i): + box, rc = react.render_fixed(Test(i), handle_error=False) + return box, rc + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(worker, range(8))) + for box, rc in results: + orphans = rc._orphans[box.model_id] + assert orphans == {box.layout.model_id, box._extra.model_id} + for box, rc in results: + rc.close() + assert box._extra.comm is None + + +@pytest.mark.parametrize("n_managers", [0, 1, 2]) +def test_component_context_managers_count(n_managers): + # plain reacton has no component context managers, solara one (this test module + # registers one for all other tests) + seen: List[tuple] = [] + + class Manager: + def __init__(self, el): + self.name = el.component.name + + def __enter__(self): + seen.append(("enter", self.name)) + + def __exit__(self, exc_type, *args): + seen.append(("exit", self.name, exc_type)) + + @react.component + def Fail(): + raise ValueError("fail") + + @react.component + def App(): + value, set_value = react.use_state(0) + exception, clear = react.use_exception() + if exception: + return w.Label(value=str(exception)) + return Fail() + + saved = list(core._component_context_manager_classes) + core._component_context_manager_classes[:] = [Manager] * n_managers + try: + label, rc = react.render_fixed(App(), handle_error=False) + assert label.value == "fail" + if n_managers: + # every manager is entered and exited, and sees the exception of the component body + fail_exits = [entry for entry in seen if entry[:2] == ("exit", "Fail")] + assert fail_exits and all(entry[2] is ValueError for entry in fail_exits) + assert len(fail_exits) % n_managers == 0 + assert seen.count(("enter", "Fail")) == len(fail_exits) + app_enters = seen.count(("enter", "App")) + assert app_enters > 0 and app_enters % n_managers == 0 + assert seen.count(("exit", "App", None)) == app_enters + else: + assert seen == [] + rc.close() + finally: + core._component_context_manager_classes[:] = saved + + +def test_component_context_containers(): + context = core.ComponentContext(state={"0": 1}) + assert context.state == {"0": 1} + assert context.parent is None + assert context.invoke_element is None + assert context.needs_render + # the rarely used containers are made on first use, one per context + other = core.ComponentContext(parent=context) + assert other.parent is context + assert other.state == {} + assert other.owns == set() + assert other.user_contexts is not context.user_contexts + listener = unittest.mock.Mock() + user_context = react.create_context(1) + other.context_listeners[user_context].add(listener) # a defaultdict(set) + assert other.context_listeners == {user_context: {listener}} + with pytest.raises(TypeError): + core.ComponentContext(no_such_field=1) # type: ignore + with pytest.raises(AttributeError): + context.no_such_field # type: ignore + + +def test_render_logging_when_enabled(caplog): + # the hot paths only build their log messages when logging is enabled + set_value = lambda x: None # noqa + + @react.component + def Test(): + nonlocal set_value + value, set_value = react.use_state(0) + react.use_memo(lambda: value, [value]) + react.use_effect(lambda: None, [value]) + return w.Button(description=str(value)) + + button, rc = react.render_fixed(Test(), handle_error=False) + with caplog.at_level("DEBUG", logger="reacton"): + set_value(1) + messages = [record.getMessage() for record in caplog.records if record.name == "reacton"] + assert any(message.startswith("Set state = 1 for key '0' (previous value was 0)") for message in messages) + assert any(message.startswith("Render phase: ") for message in messages) + assert any(message.startswith("Got state = 1 for key '0'") for message in messages) + assert any(message.startswith("Replace memo with") for message in messages) + assert any(message.startswith("Setting next effect") for message in messages) + assert any(message.startswith("Done with render phase") for message in messages) + caplog.clear() + set_value(2) + assert [record for record in caplog.records if record.name == "reacton"] == [] + rc.close() + + +@fast_renderer_only +def test_leaf_update_does_not_walk_siblings(): + # a component that does not render again is not walked: only the path to the dirty + # component is, so the cost of an update does not depend on the number of siblings + set_value = lambda x: None # noqa + vbox_el = None + rows = [] + + @react.component + def Row(i): + el = w.HBox(children=[w.Button(description=f"button-{i}")]) + rows.append(el) + return el + + @react.component + def Leaf(): + nonlocal set_value + value, set_value = react.use_state(0) + return w.Button(description=f"leaf-{value}") + + @react.component + def App(): + nonlocal vbox_el + vbox_el = w.VBox(children=[Row(0), Row(1), Leaf()]) + return vbox_el + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert vbox_el is not None + assert vbox_el._render_count == 1 + assert [row._render_count for row in rows] == [1, 1] + with UpdateSpy() as spy: + set_value(1) + set_value(2) + assert vbox.children[-1].description == "leaf-2" + assert spy.types() == ["Button", "Button"] + assert vbox_el._render_count == 1 + assert [row._render_count for row in rows] == [1, 1] + rc.close() + + +def test_dirty_children_render_in_element_order(): + # several dirty siblings (of a component that does not render again) render, and run + # their effects, in the order of the element tree + log = [] + setters = {} + + @react.component + def Item(name): + value, set_value = react.use_state(0) + setters[name] = set_value + log.append(("render", name, value)) + + def effect(): + log.append(("effect", name, value)) + + react.use_effect(effect, [value]) + return w.Button(description=f"{name}-{value}") + + @react.component + def Other(name): + return w.Label(value=name) + + set_middle = lambda x: None # noqa + + @react.component + def App(): + nonlocal set_middle + middle, set_middle = react.use_state(True) + return w.VBox(children=[Item("a"), Item("b") if middle else Other("b"), Item("c")]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + # replacing the middle child (and back) changes the order in which the child + # contexts were made, not the order of the elements + set_middle(False) + set_middle(True) + log.clear() + with rc: + setters["c"](1) + setters["b"](1) + setters["a"](1) + assert log == [("render", "a", 1), ("render", "b", 1), ("render", "c", 1), ("effect", "a", 1), ("effect", "b", 1), ("effect", "c", 1)] + assert [child.description for child in vbox.children] == ["a-1", "b-1", "c-1"] + rc.close() + + +def test_nested_child_widget_changes(): + # the root widget of a component changes, and the component above it returns it as its + # own root: the container of the component above that must get the new widget + set_label = lambda x: None # noqa + + @react.component + def Switch(): + nonlocal set_label + label, set_label = react.use_state(False) + if label: + return w.Label(value="label") + return w.Button(description="button") + + @react.component + def Wrapper(): + return Switch() + + @react.component + def App(): + return w.VBox(children=[w.Label(value="first"), Wrapper()]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert isinstance(vbox.children[1], widgets.Button) + set_label(True) + assert isinstance(vbox.children[1], widgets.Label) + assert vbox.children[1].value == "label" + set_label(False) + assert isinstance(vbox.children[1], widgets.Button) + rc.close() + + +def test_setter_of_removed_component(): + setters = [] + + @react.component + def Child(): + value, set_value = react.use_state(0) + setters.append(set_value) + return w.Button(description=str(value)) + + set_show = lambda x: None # noqa + + @react.component + def App(): + nonlocal set_show + show, set_show = react.use_state(True) + return w.VBox(children=[w.Label(value="x"), *([Child()] if show else [])]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert len(vbox.children) == 2 + set_show(False) + assert len(vbox.children) == 1 + widgets_before = set(_get_widgets_dict()) + setters[0](5) # the component is gone, nothing should happen + assert set(_get_widgets_dict()) == widgets_before + assert len(vbox.children) == 1 + set_show(True) + assert vbox.children[1].description == "0" + rc.close() + + +def test_exception_in_dirty_child_caught_above(): + set_value = lambda x: None # noqa + + @react.component + def Thrower(): + nonlocal set_value + value, set_value = react.use_state(0) + if value == 1: + raise ValueError("boom") + return w.Button(description=str(value)) + + @react.component + def Catcher(): + exception, clear = react.use_exception() + if exception: + return w.Label(value=str(exception)) + return w.HBox(children=[Thrower()]) + + @react.component + def App(): + return w.VBox(children=[w.Label(value="first"), Catcher()]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert isinstance(vbox.children[1], widgets.HBox) + set_value(1) + assert isinstance(vbox.children[1], widgets.Label) + assert vbox.children[1].value == "boom" + rc.close() + + +def test_render_sets_state_of_sibling(): + # a component that sets the state of a sibling during its render: a second render pass + setters = {} + + @react.component + def Source(): + value, set_value = react.use_state(0) + setters["source"] = set_value + if value: + setters["target"](value * 10) + return w.Button(description=f"source-{value}") + + @react.component + def Target(): + value, set_value = react.use_state(0) + setters["target"] = set_value + return w.Button(description=f"target-{value}") + + @react.component + def App(): + return w.VBox(children=[Target(), Source()]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + setters["source"](1) + assert [child.description for child in vbox.children] == ["target-10", "source-1"] + setters["source"](2) + assert [child.description for child in vbox.children] == ["target-20", "source-2"] + rc.close() + + +def _random_app(registry, log): + # component trees that change shape with their state (container type flips, a changing + # number of children, keys, shuffles, fragments, components whose root is a component, + # leaves whose root widget changes type, effects that set state, caught exceptions) + def h(*args): + return hash(args) & 0xFFFFFFFF + + @react.component + def Leaf(id): + value, set_value = react.use_state(0) + registry[id] = set_value + if value == 0 and h(id) % 5 == 0: + # state set during the first render (a second render pass) + set_value(1) + kind = h(id, value) % 4 + if kind == 0: + return w.Label(value=f"leaf {id} {value}") + if kind == 1: + return reacton.Fragment(children=[w.Button(description=f"f{id}.{i}") for i in range(value % 3)]) + return w.Button(description=f"leaf {id} {value}") + + @react.component + def Wrapper(id): + return Leaf(id * 7 + 1) + + @react.component + def Thrower(id): + value, set_value = react.use_state(0) + registry[id] = set_value + if value == 7 or (value == 0 and h(id) % 3 == 0): + # also raises in its first render (in a new subtree) + raise ValueError(f"boom {id}") + return w.Button(description=f"thrower {id} {value}") + + @react.component + def Catcher(id): + exception, clear = react.use_exception() + state, set_state = react.use_state(0) + + def set_value(value): + clear() + set_state(value) + + registry[id] = set_value + if exception: + return w.Label(value=f"caught {exception}") + return w.HBox(children=[Thrower(id * 3 + 2), Leaf(id * 3 + 1)]) + + @react.component + def Node(id, depth): + state, set_state = react.use_state(0) + registry[id] = set_state + seed = h(id, state) + rnd = random.Random(seed) + + def effect(): + log.append(("effect", id, state)) + if state % 5 == 4: + set_state(state + 1) + + react.use_effect(effect, [state]) + children: List[Any] = [w.Label(value=f"node {id} {state}")] + for i in range(rnd.randint(0, 4)): + child_id = id * 10 + i + r = rnd.random() + if depth < 3 and r < 0.4: + child = Node(child_id, depth + 1) + elif r < 0.6: + child = Wrapper(child_id) + elif r < 0.7: + child = Catcher(child_id) + else: + child = Leaf(child_id) + if rnd.random() < 0.3: + child = child.key(f"k{child_id}") + children.append(child) + if rnd.random() < 0.2: + rnd.shuffle(children) + if depth > 0 and seed % 7 == 0: + return reacton.Fragment(children=children) + return (w.VBox if seed % 3 else w.HBox)(children=children) + + return Node + + +def _widget_signature(widget): + if isinstance(widget, widgets.Box): + return (type(widget).__name__, [_widget_signature(child) for child in widget.children]) + return (type(widget).__name__, getattr(widget, "value", None), getattr(widget, "description", None)) + + +def _run_random_updates(fast: bool, seed: int, steps: int, batches: Optional[List] = None): + registry: Dict[int, Callable] = {} + log: List[tuple] = [] + Node = _random_app(registry, log) + record = batches is None + batches = [] if batches is None else batches + choices = random.Random(seed) + results = [] + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + widget, rc = react.render_fixed(Node(1, 0), handle_error=False) + assert isinstance(rc, core._RenderContextFast) == fast + for step in range(steps): + if record: + ids = sorted(registry) + batches.append([(choices.choice(ids), choices.randint(0, 9)) for _ in range(choices.choice([1, 1, 1, 2, 3]))]) + batch = batches[step] + log.clear() + with rc: + for id, value in batch: + if id in registry: + registry[id](value) + results.append((_widget_signature(rc.last_root_widget), sorted(registry), list(log))) + rc.close() + return results, batches + + +@pytest.mark.parametrize("seed", range(12)) +def test_renderers_agree_on_random_updates(seed): + # the fast renderer skips (and only partially walks) parts of the tree: after every + # (batch of) state change(s) it must give the same widgets, and run the same effects in + # the same order, as the default renderer that walks everything + level = core.logger.level + core.logger.setLevel(core.logging.CRITICAL) # the thrower logs tracebacks + try: + expected, batches = _run_random_updates(False, seed, 25) + got, _ = _run_random_updates(True, seed, 25, batches) + finally: + core.logger.setLevel(level) + for step, (a, b) in enumerate(zip(expected, got)): + assert a == b, f"step {step}, batch {batches[step]}" + + +# The fast renderer creates the widgets of a new subtree in the render phase (a mount), and +# goes back to the two phase path when the pass needs another pass or cannot be mounted. +# These tests pin the behavior of those cases (the same for both renderers). + + +def _effect_log_component(log, name): + @react.component + def Logged(i): + def effect(): + log.append(("effect", name, i)) + + def cleanup(): + log.append(("cleanup", name, i)) + + return cleanup + + react.use_effect(effect, []) + return w.Button(description=f"{name}-{i}") + + return Logged + + +def test_mount_state_set_during_render(): + # the last child sets its own state during its first render: the widgets of the siblings + # (made before) belong to a pass that is not reconciled + log: List[tuple] = [] + Logged = _effect_log_component(log, "row") + set_show = lambda x: None # noqa + + @react.component + def Setter(): + value, set_value = react.use_state(0) + if value == 0: + set_value(1) + return w.Label(value=f"setter-{value}") + + @react.component + def Section(): + return w.VBox(children=[Logged(0), Logged(1), Setter()]) + + @react.component + def App(): + nonlocal set_show + show, set_show = react.use_state(False) + return w.VBox(children=[Logged(-1), Section()] if show else [Logged(-1)]) + + widgets_before = set(_get_widgets_dict()) + vbox, rc = react.render_fixed(App(), handle_error=False) + assert log == [("effect", "row", -1)] + set_show(True) + section = vbox.children[1] + assert [child.description for child in section.children[:2]] == ["row-0", "row-1"] + assert section.children[2].value == "setter-1" + assert log == [("effect", "row", -1), ("effect", "row", 0), ("effect", "row", 1)] + rc.close() + # no widgets from the undone pass are left (cleanup_guard checks the others) + assert set(_get_widgets_dict()) == widgets_before + assert log[-3:] == [("cleanup", "row", -1), ("cleanup", "row", 0), ("cleanup", "row", 1)] + + +def test_mount_state_set_during_first_render(): + Logged = _effect_log_component([], "row") + + @react.component + def Setter(): + value, set_value = react.use_state(0) + if value < 3: + set_value(value + 1) + return w.Label(value=f"setter-{value}") + + @react.component + def App(): + return w.VBox(children=[Logged(0), w.HBox(children=[Logged(1), Setter()]), Logged(2)]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + assert vbox.children[1].children[1].value == "setter-3" + assert [vbox.children[0].description, vbox.children[1].children[0].description, vbox.children[2].description] == ["row-0", "row-1", "row-2"] + rc.close() + + +def test_mount_exception_caught_above(): + # a new subtree raises after its siblings made widgets; a parent catches it + log: List[tuple] = [] + Logged = _effect_log_component(log, "row") + + @react.component + def Thrower(): + raise ValueError("boom") + + @react.component + def Catcher(): + exception, clear = react.use_exception() + if exception: + return w.Label(value=f"caught {exception}") + return w.VBox(children=[Logged(0), Logged(1), Thrower()]) + + @react.component + def App(): + return w.VBox(children=[Logged(-1), Catcher()]) + + widgets_before = set(_get_widgets_dict()) + vbox, rc = react.render_fixed(App(), handle_error=False) + assert vbox.children[0].description == "row--1" + assert vbox.children[1].value == "caught boom" + assert ("effect", "row", 0) not in log + rc.close() + assert set(_get_widgets_dict()) == widgets_before + + +def test_mount_shared_element(): + @react.component + def Shared(): + button = w.Button(description="shared").shared() + return w.VBox(children=[w.HBox(children=[button, button]), w.Label(value="after")]) + + @react.component + def App(): + return w.VBox(children=[w.Label(value="before"), Shared()]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + hbox = vbox.children[1].children[0] + assert hbox.children[0] is hbox.children[1] + assert hbox.children[0].description == "shared" + rc.close() + + +def test_mount_widget_creation_error(): + class Broken(widgets.Button): + def __init__(self, **kwargs): + raise ValueError("cannot create") + + @react.component + def App(): + return w.VBox(children=[w.Button(description="fine"), Broken.element(description="broken")]) + + widgets_before = set(_get_widgets_dict()) + rc = core._render_context_class()(App(), handle_error=False) + with pytest.raises(RuntimeError, match="Could not create widget"): + rc.render(rc.element) + # close raises the pending exception too + with pytest.raises(RuntimeError, match="Could not create widget"): + rc.close() + # a constructor that raises leaves the widgets it made (Layout, style) behind, in both + # renderers: close those here + for model_id in set(_get_widgets_dict()) - widgets_before: + _get_widgets_dict()[model_id].close() + + +def _close_log(fast: bool, fail_in_cleanup: bool = False): + # close a tree with nested components, effects and listeners; log the order of the + # effect cleanups and the widget closes + log: List[str] = [] + + class LoggedButton(widgets.Button): + def close(self): + if self.comm is not None: + log.append(f"close {self.description}") + super().close() + + class LoggedBox(widgets.VBox): + def close(self): + if self.comm is not None: + log.append(f"close box {self.layout.width}") + super().close() + + def Box(name, children): + return LoggedBox.element(children=children, layout=w.Layout(width=name)) + + @react.component + def Leaf(name): + value, set_value = react.use_state(0) + + def effect(): + def cleanup(): + log.append(f"cleanup {name}") + if fail_in_cleanup and name == "b1": + raise ValueError(f"cleanup {name} failed") + + return cleanup + + react.use_effect(effect, []) + return LoggedButton.element(description=name, on_click=lambda: set_value(value + 1)) + + @react.component + def Group(name, n): + def effect(): + return lambda: log.append(f"cleanup {name}") + + react.use_effect(effect, []) + return Box(name, [Leaf(f"{name}{i}") for i in range(n)]) + + @react.component + def App(): + def effect(): + return lambda: log.append("cleanup app") + + react.use_effect(effect, []) + return Box("app", [LoggedButton.element(description="first"), Group("a", 2), Box("inner", [Group("b", 2)]), Leaf("last")]) + + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + box, rc = react.render_fixed(App(), handle_error=False) + assert isinstance(rc, core._RenderContextFast) == fast + error = None + try: + rc.close() + except ValueError as e: + error = str(e) + return log, error + + +@pytest.mark.parametrize("fail_in_cleanup", [False, True]) +def test_close_order_same_in_both_renderers(fail_in_cleanup): + level = core.logger.level + core.logger.setLevel(core.logging.CRITICAL) # a failing cleanup logs a traceback + try: + default_log, default_error = _close_log(False, fail_in_cleanup) + fast_log, fast_error = _close_log(True, fail_in_cleanup) + finally: + core.logger.setLevel(level) + assert "cleanup app" in default_log and "close first" in default_log + assert fast_log == default_log + assert fast_error == default_error + if fail_in_cleanup: + assert default_error == "cleanup b1 failed" + + +def test_get_widget_stale_element_message(): + stale = [] + set_value = lambda x: None # noqa + errors: List[str] = [] + + @react.component + def Test(): + nonlocal set_value + value, set_value = react.use_state(0) + button = w.Button(description=str(value)) + stale.append(button) + + def effect(): + if value == 1: + try: + react.get_widget(stale[0]) + except KeyError as e: + errors.append(str(e)) + + react.use_effect(effect, [value]) + return button + + button, rc = react.render_fixed(Test(), handle_error=False) + set_value(1) + assert len(errors) == 1 + assert "was found to be in a previous render" in errors[0] + rc.close() + + +def test_dynamic_widget_class_is_freed(): + # one ComponentWidget is shared per widget class: it must not keep a widget class that + # was made at runtime (like a hot reload does) alive after its elements are gone + def render_and_close(): + class Dynamic(widgets.Button): + pass + + component = react.core.ComponentWidget(widget=Dynamic) + assert react.core.ComponentWidget(widget=Dynamic) is component + + @react.component + def Test(): + return component(description="dynamic") + + widget, rc = react.render_fixed(Test(), handle_error=False) + assert isinstance(widget, Dynamic) + rc.close() + return weakref.ref(Dynamic) + + class_ref = render_and_close() + gc.collect() + assert class_ref() is None + + +def test_setter_is_stable(): + # like React's setState: the same setter every render, so a child that gets it as an + # argument sees equal arguments and does not render again + setters: List[Callable] = [] + child_renders = 0 + + @react.component + def Child(on_value): + nonlocal child_renders + child_renders += 1 + return w.Button(description="child", on_click=lambda: on_value(10)) + + @react.component + def App(): + value, set_value = react.use_state(0) + setters.append(set_value) + return w.VBox(children=[w.Label(value=str(value)), Child(on_value=set_value)]) + + vbox, rc = react.render_fixed(App(), handle_error=False) + setters[0](1) + setters[-1](2) + assert vbox.children[0].value == "2" + assert len(setters) == 3 + assert setters[0] is setters[1] is setters[2] + assert child_renders == 1 + # the child calls the setter it got in the first render + vbox.children[1].click() + assert vbox.children[0].value == "10" + rc.close() + + +def test_setter_uses_latest_eq(): + set_value = lambda x: None # noqa + render_count = 0 + + @react.component + def App(): + nonlocal set_value, render_count + render_count += 1 + # the first render compares by identity, later renders say everything is equal + eq = (lambda a, b: a is b) if render_count == 1 else (lambda a, b: True) + value, set_value = react.use_state([1], eq=eq) + return w.Label(value=str(value)) + + label, rc = react.render_fixed(App(), handle_error=False) + set_value([2]) # not identical: renders again, with the eq that finds all equal + assert label.value == "[2]" + set_value([3]) # equal for the latest eq: no render + assert label.value == "[2]" + assert render_count == 2 + rc.close() + + +def test_effect_dependencies_back_to_previous_in_second_pass(): + # the dependencies change in a render pass, and change back in the next pass of the same + # render call: the effect does not run again (its dependencies at reconciliation are equal) + runs: List[str] = [] + set_value = lambda x: None # noqa + + @react.component + def Test(): + nonlocal set_value + value, set_value = react.use_state(0) + dependency = "b" if value == 1 else "a" + if value == 1: + set_value(2) # a second render pass, where the dependency is "a" again + + def effect(): + runs.append(dependency) + + react.use_effect(effect, [dependency]) + return w.Label(value=str(value)) + + label, rc = react.render_fixed(Test(), handle_error=False) + assert runs == ["a"] + set_value(1) + assert label.value == "2" + assert runs == ["a"] + set_value(3) + assert runs == ["a"] + rc.close() diff --git a/reacton/ipyvue.py b/reacton/ipyvue.py index d805834..188a044 100644 --- a/reacton/ipyvue.py +++ b/reacton/ipyvue.py @@ -1,22 +1,92 @@ -from typing import Any, Callable, cast +from typing import Any, Callable, Optional import ipyvue import reacton as react -from reacton.core import ComponentWidget, get_render_context +from reacton.core import ComponentWidget, _add_event_handlers, local + + +class _EventHandler: + """The handler of one use_event hook, made once (like a stable setter). + + The renderer registers it on the widget of the element when that widget is created or + updated (Element._event_handlers), and removes it when the component of the hook goes + away. It calls the latest callback given to use_event. + """ + + __slots__ = ("rc", "context", "callback", "event", "widget", "registered_event", "removed") + + def __init__(self, rc, context, event_and_modifiers: str, callback: Callable[[Any], Any]): + self.rc = rc + self.context = context + self.callback = callback + self.event = event_and_modifiers + self.widget: Optional[ipyvue.VueWidget] = None + self.registered_event: Optional[str] = None + # the hook is gone: never register again (an element can outlive the hook, e.g. a + # memoized element of a parent that gets a new widget later) + self.removed = False + + def __call__(self, *args): + try: + self.callback(*args) + except Exception as e: + # because widgets don't have a context, but are a child of a component + # we add it to exceptions_children, not exception_self + # this allows a component to catch the exception of a direct child + self.context.exceptions_children.append(e) + self.rc.force_update() + + def _reacton_attach(self, widget): + if self.removed: + return + event = self.event + previous = self.widget + if widget is previous and event == self.registered_event: + return + if previous is not None and previous.comm is not None and self.registered_event is not None: + previous.on_event(self.registered_event, self, remove=True) + widget.on_event(event, self) + self.widget = widget + self.registered_event = event + + def _reacton_detach(self): + self.removed = True + widget = self.widget + self.widget = None + if widget is None or self.rc._closing: + # the whole tree is going away: removing the handler would sync + # the _events trait to the frontend (one message per widget) + # right before the comm is closed anyway + return + if widget.comm is not None: + widget.on_event(self.registered_event, self, remove=True) def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[Any], Any]): - # to avoid add_event_handler having a stale reference to callback - callback_ref = react.use_ref(callback) - callback_ref.current = callback + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + context = rc.context + assert context is not None + ref = rc.use_ref(None) + handler = ref.current + if handler is None: + handler = ref.current = _EventHandler(rc, context, event_and_modifiers, callback) + context.event_handlers = (*context.event_handlers, handler) + else: + handler.callback = callback + if event_and_modifiers != handler.event: + handler.event = event_and_modifiers + if handler.widget is not None: + handler._reacton_attach(handler.widget) # Put the event name in the widget constructor arguments: the synced _events # trait then goes along with the comm open message. The later on_event call - # (in the effect below) only updates _events when the event set differs, so - # this saves one update message per widget per event. When the element is - # reused from a previous render (memoized) and the widget already exists, - # on_event falls back to syncing _events itself. + # only updates _events when the event set differs, so this saves one update + # message per widget per event. When the element is reused from a previous + # render (memoized) and the widget already exists, on_event falls back to + # syncing _events itself. if isinstance(el.component, ComponentWidget) and issubclass(el.component.widget, ipyvue.VueWidget): events = el.kwargs.get("_events") if events is None: @@ -25,34 +95,9 @@ def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[A # do not mutate the list, it could be shared with a previous element el.kwargs["_events"] = [*events, event_and_modifiers] - def add_event_handler(): - vue_widget = cast(ipyvue.VueWidget, react.core.get_widget(el)) - # we are basically copying the logic from reacton.core._event_handler_exception_wrapper - rc = get_render_context() - context = rc.context - assert context is not None - - def handler(*args): - try: - callback_ref.current(*args) - except Exception as e: - assert context is not None - # because widgets don't have a context, but are a child of a component - # we add it to exceptions_children, not exception_self - # this allows a component to catch the exception of a direct child - context.exceptions_children.append(e) - rc.force_update() - - vue_widget.on_event(event_and_modifiers, handler) - - def cleanup(): - if rc._closing: - # the whole tree is going away: removing the handler would sync - # the _events trait to the frontend (one message per widget) - # right before the comm is closed anyway - return - vue_widget.on_event(event_and_modifiers, handler, remove=True) - - return cleanup - - react.use_effect(add_event_handler, [event_and_modifiers]) + handlers = el._event_handlers + if handler not in handlers: + if el._key_frozen: + _add_event_handlers(el, (handler,), context, rc) + else: + el._event_handlers = (*handlers, handler) diff --git a/reacton/ipyvue_test.py b/reacton/ipyvue_test.py index f7ebbb2..510d4aa 100644 --- a/reacton/ipyvue_test.py +++ b/reacton/ipyvue_test.py @@ -83,8 +83,218 @@ def Test(): box, rc = react.render(Test(), handle_error=False) btn = rc.find(ipyvuetify.Btn).widget assert isinstance(btn, ipyvue.VueWidget) - # falls back to syncing _events from the effect + # falls back to syncing _events from on_event assert btn._events == ["click"] btn.fire_event("click", {}) on_click.assert_called_once() rc.close() + + +def test_use_event_latest_callback(): + """The handler is registered once, and calls the callback of the latest render.""" + calls = [] + set_count = None + + @react.component + def Test(): + nonlocal set_count + count, set_count = react.use_state(0) + btn = v.Btn(children=[f"count {count}"]) + use_event(btn, "click", lambda *_ignore: calls.append(count)) + return btn + + box, rc = react.render(Test(), handle_error=False) + btn = rc.find(ipyvuetify.Btn).widget + btn.fire_event("click", {}) + assert set_count is not None + set_count(1) + set_count(2) + btn.fire_event("click", {}) + # one handler, not one per render + assert calls == [0, 2] + assert len(btn._event_handlers_map["click"].callbacks) == 1 + rc.close() + + +def test_use_event_removed_when_component_goes(): + """A child registers an event on a widget of its parent; when the child goes, the handler goes.""" + on_click = unittest.mock.Mock() + set_show = None + + @react.component + def Child(btn): + use_event(btn, "click", on_click) + return v.Html(tag="span", children=["child"]) + + @react.component + def Test(): + nonlocal set_show + show, set_show = react.use_state(True) + btn = v.Btn(children=["click me"]) + children = [btn, Child(btn)] if show else [btn] + return v.Html(tag="div", children=children) + + box, rc = react.render(Test(), handle_error=False) + btn = rc.find(ipyvuetify.Btn).widget + btn.fire_event("click", {}) + assert on_click.call_count == 1 + assert set_show is not None + set_show(False) + assert rc.find(ipyvuetify.Btn).widget is btn + # the handler is gone (the old use_event could not do this: its effect only looked for + # the widget in the subtree of the component, and raised) + assert "click" not in btn._event_handlers_map + assert on_click.call_count == 1 + rc.close() + + +def test_use_event_widget_replaced(): + """When the element's widget is replaced (another key), the handler moves to the new widget.""" + on_click = unittest.mock.Mock() + set_key = None + + @react.component + def Test(): + nonlocal set_key + key, set_key = react.use_state("a") + btn = v.Btn(children=["click me"]).key(key) + use_event(btn, "click", on_click) + return v.Html(tag="div", children=[btn]) + + box, rc = react.render(Test(), handle_error=False) + first = rc.find(ipyvuetify.Btn).widget + assert set_key is not None + set_key("b") + second = rc.find(ipyvuetify.Btn).widget + assert second is not first + second.fire_event("click", {}) + on_click.assert_called_once() + rc.close() + + +def test_use_event_on_existing_widget(): + """A new child hooks into an element (and widget) of its parent that exists already.""" + on_click = unittest.mock.Mock() + set_show = None + + @react.component + def Child(btn): + use_event(btn, "click", on_click) + return v.Html(tag="span", children=["child"]) + + @react.component + def Test(): + nonlocal set_show + show, set_show = react.use_state(False) + btn = react.use_memo(lambda: v.Btn(children=["click me"]), []) + children = [btn, Child(btn)] if show else [btn] + return v.Html(tag="div", children=children) + + box, rc = react.render(Test(), handle_error=False) + btn = rc.find(ipyvuetify.Btn).widget + assert set_show is not None + set_show(True) + assert rc.find(ipyvuetify.Btn).widget is btn + btn.fire_event("click", {}) + on_click.assert_called_once() + set_show(False) + assert "click" not in btn._event_handlers_map + rc.close() + + +def test_use_event_target_changes_kind(): + """The element changes between a widget and a component element: the hook counts stay equal.""" + on_click = unittest.mock.Mock() + set_kind = None + + @react.component + def Card(children=[]): + return v.Btn(children=children) + + @react.component + def Test(): + nonlocal set_kind + kind, set_kind = react.use_state("widget") + target = v.Btn(children=["widget"]) if kind == "widget" else Card(children=["component"]) + use_event(target, "click", on_click) + return v.Html(tag="div", children=[target]) + + box, rc = react.render(Test(), handle_error=False) + assert set_kind is not None + for kind in ["component", "widget", "component", "widget"]: + before = rc.find(ipyvuetify.Btn).widget + set_kind(kind) + btn = rc.find(ipyvuetify.Btn).widget + assert btn is not before + assert btn.children == [kind] + on_click.reset_mock() + btn.fire_event("click", {}) + on_click.assert_called_once() + rc.close() + + +def test_use_event_not_registered_after_removal(): + """A removed hook does not come back when the element (kept by a parent) gets a new widget.""" + on_click = unittest.mock.Mock() + set_show = None + + @react.component + def Child(btn): + use_event(btn, "click", on_click) + return v.Html(tag="span", children=["child"]) + + @react.component + def Test(): + nonlocal set_show + show, set_show = react.use_state(True) + btn = react.use_memo(lambda: v.Btn(children=["click me"]), []) + # the button moves (another default key) when the child goes: a new widget + children = [btn, Child(btn)] if show else [v.Html(tag="span", children=["x"]), btn] + return v.Html(tag="div", children=children) + + box, rc = react.render(Test(), handle_error=False) + first = rc.find(ipyvuetify.Btn).widget + first.fire_event("click", {}) + on_click.assert_called_once() + assert set_show is not None + set_show(False) + btn = rc.find(ipyvuetify.Btn).widget + assert btn is not first + assert "click" not in btn._event_handlers_map + rc.close() + + +def test_use_event_component_element_widget_changes(): + """On a component element, the handler goes to the widget of the element its body returns + (also through a nested component), and moves when that widget changes.""" + on_click = unittest.mock.Mock() + set_key = None + + @react.component + def Inner(): + nonlocal set_key + key, set_key = react.use_state("a") + return v.Btn(children=[key]).key(key) + + @react.component + def Outer(): + return Inner() + + @react.component + def Test(): + target = Outer() + use_event(target, "click", on_click) + return v.Html(tag="div", children=[target]) + + box, rc = react.render(Test(), handle_error=False) + first = rc.find(ipyvuetify.Btn).widget + first.fire_event("click", {}) + on_click.assert_called_once() + assert set_key is not None + set_key("b") + second = rc.find(ipyvuetify.Btn).widget + assert second is not first + on_click.reset_mock() + second.fire_event("click", {}) + on_click.assert_called_once() + rc.close() diff --git a/reacton/utils.py b/reacton/utils.py index f11c0d3..c955903 100644 --- a/reacton/utils.py +++ b/reacton/utils.py @@ -36,14 +36,48 @@ def wrap(mod, globals): globals[cls_name] = component(cls) -def equals(a, b): - from reacton.core import Element, same_component +_core = None +# types whose == gives a plain bool and never raises +_SIMPLE_TYPES = frozenset([str, int, float, bool, bytes, complex, type(None)]) + +def equals(a, b): if a is b: return True # ignore E721 for now - if type(a) != type(b): # noqa: E721 # is this always true? after a == b failed? + type_a = type(a) + if type_a != type(b): # noqa: E721 # is this always true? after a == b failed? return False + # the common cases first (arguments and hook dependencies), with the same result as + # the general code below + if type_a in _SIMPLE_TYPES: + return a == b + if type_a is list or type_a is tuple: + if len(a) != len(b): + return False + for x, y in zip(a, b): + if x is not y and not equals(x, y): + return False + return True + if type_a is dict: + if len(a) != len(b): + return False + for key, value in a.items(): + if key not in b: + return False + other = b[key] + if value is not other and not equals(value, other): + return False + return True + # an import statement costs more than the rest of a typical call (this is called for + # every argument of every re-rendered child, and every hook dependency) + global _core + if _core is None: + import reacton.core + + _core = reacton.core + Element = _core.Element + same_component = _core.same_component if isinstance(a, Element): return same_component(a.component, b.component) and equals(a.args, b.args) and equals(a.kwargs, b.kwargs) elif isinstance(a, types.FunctionType) and isinstance(b, types.FunctionType): @@ -101,7 +135,20 @@ def import_item(name: str): return getattr(module, parts[-1]) +_lazy_types: dict = {} + + def isinstance_lazy(value, types): + if isinstance(types, str): + # the common case (e.g. "pandas.DataFrame" in every state change) + cls = _lazy_types.get(types) + if cls is None: + cls = import_item(types) + if cls is None: + # not imported (yet) + return False + _lazy_types[types] = cls + return isinstance(value, cls) if not isinstance(types, (list, tuple)): types = [types] types = [import_item(t) if isinstance(t, str) else t for t in types]