From 1fc06d34779b302dc432d5fb86cab7b63031b806 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:30:53 +0200 Subject: [PATCH 01/25] Keep only the last two rerender reasons Every state change appended a RerenderReason with the previous and the next state value to the render context, and nothing ever removed them. A long-lived page therefore kept every old state value alive until the page was closed: 1000 state sets kept 1000 old values (a dataframe, a big list, ...) in memory, in both renderers. Only the last two reasons are ever read, for the "too many renders" error message, so a deque with maxlen=2 keeps the message and bounds the memory. test_debug_infinite_loop checked the length of the list to see that the loop ran; it now checks the error message and the component render count instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 7 ++++++- reacton/core_test.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 93441c8..9b70570 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -7,6 +7,7 @@ """ +import collections import contextlib import copy import functools @@ -24,6 +25,7 @@ Any, Callable, ContextManager, + Deque, Dict, Generic, List, @@ -1274,7 +1276,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] = [] diff --git a/reacton/core_test.py b/reacton/core_test.py index 9e24e35..9b0f9ef 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3110,7 +3110,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 +3249,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(): From 4e10de0802cda9e4ff496af2cca3fe73fbee52de Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:35:48 +0200 Subject: [PATCH 02/25] Do not re-set container children that resolve to the same widgets The fast renderer skips the update of a widget when its element is the same object as last render and its kwargs resolve to the same values. But it compared the resolved kwargs (elements replaced by widgets) with the element kwargs (still elements), so for every container the compare failed and its children were assigned again. A leaf update next to 300 rows re-set the children of the VBox (~170 us for 301 real widgets), a root update re-set the children of all 300 HBoxes. A container widget now remembers the kwargs it was last created or updated with, and an unchanged element is compared against those. Only widget elements whose kwargs hold elements keep them, so leaf widgets cost no extra memory. Shared elements keep the old behavior. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 41 ++++++++++++- reacton/core_test.py | 133 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 9b70570..dc56ee8 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1168,6 +1168,10 @@ 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]] = field(default_factory=dict) # elements created in this context go there owns: Set[Element] = field(default_factory=set) @@ -1222,6 +1226,7 @@ def _teardown_component_context(context: ComponentContext): context.user_contexts_prev = {} context.context_listeners = defaultdict(set) context.used_keys = set() + context.resolved_kwargs = {} context.owns = set() context.exceptions_self = [] context.exceptions_children = [] @@ -2785,6 +2790,9 @@ 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) @@ -2798,20 +2806,38 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during reconciliation (creating widget)") _mark_needs_render_ancestors(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: # update the existing widget in place kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) + has_elements = self._elements_seen != elements_seen 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 + 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" @@ -2826,6 +2852,9 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)") _mark_needs_render_ancestors(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 @@ -2984,6 +3013,8 @@ 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: @@ -2998,8 +3029,12 @@ def _visit_children(self, el: Element, default_key: str, parent_key: str, f: Cal self._visit_children_values(el.kwargs, key, parent_key, f) self._visit_children_values(el.args, key, 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): if isinstance(value, Element): + self._elements_seen += 1 return f(value, key, parent_key) elif isinstance(value, (list, tuple)): values = [] diff --git a/reacton/core_test.py b/reacton/core_test.py index 9b0f9ef..08f5608 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3566,3 +3566,136 @@ 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() From c247581212a06f89d060b7e4bb469cf5f5af6161 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:38:27 +0200 Subject: [PATCH 03/25] Skip a child component with equal arguments without walking it When a component re-renders, it makes new elements for its children. A child whose arguments are equal does not re-render, but the fast renderer still walked its whole subtree in both phases, only to find nothing changed. A root update of a list of 300 rows walked all 300 rows (~20 us each). Such a child now keeps its previous result like an unchanged element already did, when nothing in its subtree is dirty. The new element takes over the context (and get_widget finds its widget). This needs a guard: when a widget at some key is replaced by a widget of another type, reconciliation first removes the old subtree, with the component contexts in it, so those contexts cannot be kept as they are. The same problem already existed for a child element that is the same object in every render (it comes from a parent's props): that case raised a KeyError, test_replace_parent_same_child_element covers it now. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 47 ++++++++++++++++++++--- reacton/core_test.py | 89 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index dc56ee8..49a31d5 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2464,6 +2464,10 @@ class _RenderContextFast(_RenderContext): # without walking either. # ------------------------------------------------------------------ + # > 0 while the render phase walks the new children of a widget that replaces a + # widget of another type (see _render) + _replacing = 0 + def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) self._rerender_needed = True @@ -2512,7 +2516,18 @@ 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 != 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._visit_children(el, key, parent_key, self._render) + finally: + self._replacing -= 1 + else: + self._visit_children(el, key, parent_key, self._render) return assert isinstance(el.component, ComponentFunction) @@ -2528,8 +2543,8 @@ def _render(self, element: Element, default_key: str, parent_key: str): if ( not self._walk_all - and el is el_prev and not el.is_shared + and not self._replacing and context_previous is not None and context.children.get(key) is context_previous and not context_previous.needs_render @@ -2538,11 +2553,25 @@ def _render(self, element: Element, default_key: str, parent_key: str): and not context_previous.exceptions_children and context_previous.root_element is not None and context_previous.root_element_next is None + 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 context_previous.invoke_element + and not el._meta + and same_component(el_prev.component, el.component) + and not el._arguments_changed(el_prev) + ) + ) ): - # 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 + # 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 context_previous.clean_subtree = True + context_previous.invoke_element = el context.children_next[key] = context_previous return @@ -2711,7 +2740,13 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): # reconciliation result stands child_context_next.clean_subtree = False context.children[key] = context.children_next.pop(key) - return context.widgets[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 el_prev and isinstance(el_prev.component, ComponentWidget): # a widget element was replaced by a component element at this key diff --git a/reacton/core_test.py b/reacton/core_test.py index 08f5608..1f77895 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3699,3 +3699,92 @@ def describe(vbox): 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() From dcbde64c23fe73ac3bbf1dc6f0bef529b7655f5b Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:42:21 +0200 Subject: [PATCH 04/25] Build the implicit container only when a component returns None A component body that returns None gets an implicit container (reacton's Fragment, or solara's Column) with the top level elements it made. The fast renderer built that container for every body: an extra element, entering it, and at exit a walk over all elements the body made to find the top level ones. Most bodies return an element, and then all of that was thrown away. In a mount of 1024 small components this was about 16% of the time. Now the body only records the elements it makes, and the container is made (and fed the same elements) only when the body returns None. The default renderer keeps the eager container. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 42 ++++++++++++++++++++++++++----------- reacton/core_test.py | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 49a31d5..bbe63cd 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2629,18 +2629,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): 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) + root_element = self._call_component(el) assert root_element is not None except BaseException as e: if DEBUG: @@ -2719,6 +2708,35 @@ 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 _call_component(self, el: Element) -> Optional[Element]: + """Run the component function, with an implicit container when it returns None.""" + component = cast(ComponentFunction, el.component) + 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: ContainerAdder = ContainerAdder(cast(Element, None), "children") + 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() + 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(adder.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: diff --git a/reacton/core_test.py b/reacton/core_test.py index 1f77895..61e811c 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3788,3 +3788,53 @@ def App(): 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() From b04ddb163980163cbf005e4d596c3684a0753ef3 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:44:26 +0200 Subject: [PATCH 05/25] Make the fast renderer's child visitor cheaper Walking the arguments of widget elements is the hottest code of a mount: every widget element is visited twice (render, reconcile) and removed once, and each visit went through generic isinstance checks, built a key string for every value (also for strings and numbers that can never hold an element), and built new lists and dicts even in the render and remove walks, which throw the result away. The visitors now dispatch on the exact type, skip scalars without a call, build keys only for values that can hold elements, and the render and remove walks no longer build values. Subclasses of list/tuple/dict take the old generic path, so the result is the same (test_fast_child_visitors_match_default compares against the default renderer's visitor). Element.is_shared becomes a plain class attribute instead of a property (read ~14k times per mount of 1024 buttons), with _shared kept as an alias. _values_identical first compares lists of child widgets in C (map(operator.is_)), which keeps the container compare of the previous commit cheap for large lists. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 109 ++++++++++++++++++++++++++++++++++++++----- reacton/core_test.py | 51 ++++++++++++++++++++ 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index bbe63cd..da65a78 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -13,6 +13,7 @@ import functools import inspect import logging +import operator import sys import threading import traceback @@ -251,7 +252,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 @@ -298,7 +302,8 @@ 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 def __init__(self, component, args=None, kwargs=None): self.component = component @@ -363,12 +368,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): @@ -3074,22 +3084,96 @@ def _remove_element(self, el: Element, default_key: str, parent_key): 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) + + 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) @@ -3100,10 +3184,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 @@ -3114,6 +3195,10 @@ 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)]) + + def _render_context_class(): import os diff --git a/reacton/core_test.py b/reacton/core_test.py index 61e811c..394efd4 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3838,3 +3838,54 @@ def App(): 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() From 46492e61e9409b7f854a6ad2cab57314a4bce6e6 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:47:33 +0200 Subject: [PATCH 06/25] Create widgets without a lock and without a per-widget wrapper Element._create_widget did three things per widget that cost more than the rest of reacton's widget bookkeeping (about 3 us per widget on top of the constructor, in both renderers): - it wrapped hold_trait_notifications with a new @contextmanager closure per widget, so a frontend update of several traits renders once. The same batching is now installed once per widget class; it finds the render context in widget._reacton_rc and leaves widgets that reacton did not create alone. Closing the widget drops the reference. - it took a global lock, because the recording of widgets made as a side effect (Layout, Style) was a module global. The recording is now per thread, which also stops a render in another thread (another kernel) from recording its widgets as our orphans, even without the lock: a widget constructor that switched threads could do that. - it asked the widget class for all its trait names on every create, update and close, to tell on_ listeners from on_ traits. Now only when a kwarg starts with "on_" at all. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 184 ++++++++++++++++++++++++++++++------------- reacton/core_test.py | 63 +++++++++++++++ 2 files changed, 192 insertions(+), 55 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index da65a78..b17e1bc 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -88,30 +88,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 @@ -463,43 +527,44 @@ 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) + break 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} + 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): @@ -507,27 +572,33 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): assert isinstance(el_prev.component, ComponentWidget) assert 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 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_"): + 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) + 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) def _update_widget_prop(self, widget, name, value): setattr(widget, name, value) @@ -564,10 +635,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): diff --git a/reacton/core_test.py b/reacton/core_test.py index 394efd4..785ac23 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3889,3 +3889,66 @@ def f(el, key, parent_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 From 7c97a1357ef9dafb2f55d8c9d21798044966829b Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:49:13 +0200 Subject: [PATCH 07/25] Enter component context managers without an ExitStack The fast renderer entered the component context managers (solara registers one, plain reacton none) through a contextlib.ExitStack for every component render. The ExitStack costs more than a typical component body. With no managers the body now runs directly, with one it runs in a plain with statement, and only with more managers an ExitStack is used. The managers see the same enter/exit calls and the same exceptions (test_component_context_managers_count). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 17 ++++++++++++--- reacton/core_test.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index b17e1bc..1ef7046 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2710,11 +2710,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) + # 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... diff --git a/reacton/core_test.py b/reacton/core_test.py index 785ac23..39d405d 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3952,3 +3952,52 @@ def worker(i): 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 From d1b80843bb36fe8c0f43cd03b183bd4bb6c1e1ae Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:51:15 +0200 Subject: [PATCH 08/25] Make ComponentContext a plain class One ComponentContext is made per component instance. As a dataclass with 16 default factories it cost ~1.2 us to make (in both renderers), and most of its containers (state_metadata, owns, context_listeners, user_contexts, ...) are never used by a typical component. It is now a plain class that makes the containers every rendered component uses, and the rarely used ones on first access. It also stops comparing contexts field by field: the dataclass __eq__ compared all fields (recursively, including the parent) whenever a context was compared, e.g. as a use_effect dependency in use_context. Contexts now compare (and hash) by identity. The constructor still accepts the old field names as keyword arguments. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 114 ++++++++++++++++++++++++++++++++----------- reacton/core_test.py | 22 +++++++++ 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 1ef7046..d502ad5 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -17,6 +17,7 @@ import sys import threading import traceback +import typing import weakref from collections import defaultdict from dataclasses import dataclass, field @@ -1203,9 +1204,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"] # this is the element in the parent context invoke_element: Optional[Element] = None @@ -1214,36 +1221,36 @@ 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 # lazy + 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, @@ -1255,22 +1262,76 @@ class ComponentContext: # 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]] = field(default_factory=dict) + 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) + context_managers: List[ContextManager] # lazy + + 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 = [] + # 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, + "state_metadata": dict, + "user_contexts": dict, + "user_contexts_prev": dict, + "context_listeners": lambda: defaultdict(set), + "resolved_kwargs": dict, + "owns": set, + "context_managers": list, +} +_COMPONENT_CONTEXT_FIELDS = set(ComponentContext.__annotations__) TEffect = TypeVar("TEffect", bound="Effect") @@ -1302,19 +1363,14 @@ def _teardown_component_context(context: ComponentContext): 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.resolved_kwargs = {} - context.owns = set() context.exceptions_self = [] context.exceptions_children = [] - context.context_managers = [] + # the rarely used containers are made again (empty) when used + for name in _COMPONENT_CONTEXT_LAZY: + context.__dict__.pop(name, None) @dataclass diff --git a/reacton/core_test.py b/reacton/core_test.py index 39d405d..e1e156c 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4001,3 +4001,25 @@ def App(): 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 From 5828bd897a0b8f426e37f22cc0d3c491e79f1bfd Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 21:56:41 +0200 Subject: [PATCH 09/25] Lower the fixed cost of every render and state change A state change that re-renders one small component cost ~50 us, most of it in code that runs for every render, independent of the tree: - utils.equals did an import statement on every call (~0.5 us). It is called for every argument of a re-rendered child, and for every memo and effect dependency. - utils.isinstance_lazy (every state change checks for a DataFrame) built lists for a single type name. - render(), the setter and the hooks called the logger with arguments (and looped over the shared elements) also with logging off. - every state change made a RerenderReason dataclass. The log messages are the same when logging is on. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 101 +++++++++++++++++++++++++++++-------------- reacton/core_test.py | 28 ++++++++++++ reacton/utils.py | 18 +++++++- 3 files changed, 112 insertions(+), 35 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index d502ad5..9975584 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -20,7 +20,6 @@ import typing import weakref from collections import defaultdict -from dataclasses import dataclass, field from inspect import isclass from types import TracebackType from typing import ( @@ -1373,13 +1372,29 @@ def _teardown_component_context(context: ComponentContext): context.__dict__.pop(name, None) -@dataclass 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: @@ -1547,15 +1562,18 @@ 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 @@ -1573,11 +1591,13 @@ def use_state(self, initial, key: str = None, eq: Callable[[Any, Any], bool] = N 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)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Initial state = %r for key %r (%r)", initial, key, id(self.context)) return initial, self.make_setter(key, self.context, eq) else: state = self.context.state[key] - logger.debug("Got state = %r for key %r (%r)", state, key, id(self.context)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Got state = %r for key %r (%r)", state, key, id(self.context)) return state, self.make_setter(key, self.context, eq) def make_setter(self, key, context: ComponentContext, eq: Callable[[Any, Any], bool] = None): @@ -1598,7 +1618,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 @@ -1633,7 +1654,7 @@ 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 @@ -1673,12 +1694,14 @@ def use_effect(self, effect: EffectCallable, dependencies=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) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Initial effect = %r for index %r (%r)", effect, self.context.effect_index - 1, 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) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Setting next effect = %r for index %r (%r)", effect, self.context.effect_index, dependencies) if previous_effect.executed: # line up... previous_effect.next = Effect(effect, dependencies) @@ -1766,7 +1789,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 @@ -1809,7 +1836,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 @@ -1818,21 +1846,24 @@ def format(reason: RerenderReason): self._render(self.element, "/", parent_key=ROOT_KEY) 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: @@ -1840,7 +1871,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 @@ -1850,9 +1882,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: @@ -1880,14 +1913,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, diff --git a/reacton/core_test.py b/reacton/core_test.py index e1e156c..9196755 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4023,3 +4023,31 @@ def test_component_context_containers(): 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() diff --git a/reacton/utils.py b/reacton/utils.py index f11c0d3..2b02e12 100644 --- a/reacton/utils.py +++ b/reacton/utils.py @@ -36,14 +36,24 @@ def wrap(mod, globals): globals[cls_name] = component(cls) -def equals(a, b): - from reacton.core import Element, same_component +_core = 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? return False + # 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): @@ -102,6 +112,10 @@ def import_item(name: str): def isinstance_lazy(value, types): + if isinstance(types, str): + # the common case (e.g. "pandas.DataFrame" in every state change) + cls = import_item(types) + return cls is not None and 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] From 957e11b02b45e41be944625df1bb733c6d3ae5cd Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 22:10:45 +0200 Subject: [PATCH 10/25] Walk the context tree for updates, not the element tree When a state change re-renders one component, every component on the path from the root to it has a dirty descendant, and the fast renderer walked the whole element tree of each of them, in both phases, to find the dirty child. A leaf update next to 300 rows cost ~500 us (null widgets), and next to 1024 buttons ~2 ms: the cost grew with the number of siblings, not with the work. A component that does not render again has the element tree of its last reconciliation. For such a component the render phase now only visits its dirty child contexts (setters record the dirty child in each parent on their way up), in the order of the element tree, and reconciliation only reconciles those. A leaf update is now the same ~15 us (null widgets) with 1 or with 1024 siblings. The one thing that can change in the unchanged element tree is the root widget of a dirty child (a new widget type, or a fragment with other children). Then the widgets that hold it get their new kwargs, using the resolved kwargs that containers keep (see "Do not re-set container children that resolve to the same widgets"), in _rewire. Shared elements, pending exceptions, forced updates and renders under a replaced widget keep the full walk. test_renderers_agree_on_random_updates drives both renderers with the same random state changes on random trees (type flips, keys, shuffles, fragments, component roots, effects that set state, caught exceptions) and compares widgets and effect order after every step. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 232 +++++++++++++++++++++++++++-- reacton/core_test.py | 339 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 555 insertions(+), 16 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 9975584..b82d5a9 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1278,6 +1278,23 @@ class ComponentContext: context_managers: List[ContextManager] # lazy + # 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 + def __init__( self, parent: Optional["ComponentContext"] = None, @@ -1367,6 +1384,7 @@ def _teardown_component_context(context: ComponentContext): context.used_keys = set() context.exceptions_self = [] context.exceptions_children = [] + context.dirty_children = None # the rarely used containers are made again (empty) when used for name in _COMPONENT_CONTEXT_LAZY: context.__dict__.pop(name, None) @@ -1659,7 +1677,7 @@ def set_(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() @@ -1680,6 +1698,10 @@ def set_(value): 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 @@ -2631,12 +2653,20 @@ 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). # ------------------------------------------------------------------ # > 0 while the render phase walks the new children of a widget that replaces a @@ -2647,6 +2677,36 @@ 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 _discard_aborted_pass(self): + 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}") @@ -2659,6 +2719,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 @@ -2673,6 +2735,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 @@ -2711,7 +2776,13 @@ def _render(self, element: Element, default_key: str, parent_key: str): # 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 + 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): + 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) @@ -2747,6 +2818,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): # subtree in both phases context_previous.clean_subtree = True context_previous.invoke_element = el + context_previous.order_in_parent = order context.children_next[key] = context_previous return @@ -2784,6 +2856,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 @@ -2840,8 +2929,16 @@ 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 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) @@ -2868,6 +2965,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]: @@ -2885,7 +2984,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: @@ -2894,6 +2993,26 @@ 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 _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 = cast(ComponentFunction, el.component) @@ -2951,6 +3070,15 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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 @@ -3044,7 +3172,7 @@ 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 @@ -3071,7 +3199,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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) + self._mark_dirty(context) else: if has_elements and not el.is_shared: context.resolved_kwargs[key] = kwargs @@ -3090,7 +3218,7 @@ 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 @@ -3140,6 +3268,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 @@ -3160,7 +3362,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: @@ -3172,7 +3374,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]): @@ -3216,7 +3418,7 @@ 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) assert child_context.root_element is not None self._remove_element(child_context.root_element, "/", parent_key=join_key(parent_key, key)) finally: diff --git a/reacton/core_test.py b/reacton/core_test.py index 9196755..45ccaaf 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 @@ -4051,3 +4053,338 @@ def Test(): 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 + 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: + 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]}" From 801de3418de1380eca03f7268caf7384c7b8ea27 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 22:13:17 +0200 Subject: [PATCH 11/25] Describe the new fast renderer paths in the benchmark README The README documents how the fast renderer differs from the default one and which contract both keep. Update it for the context tree walk, the equal-arguments skip, the mount path changes and the widget creation changes that both renderers share, and note a shared element problem (in both renderers) that the random update test ran into. Co-Authored-By: Claude Opus 5.5 (1M context) --- benchmarks/README.md | 56 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) 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 From 3eea3d1b09e589b496c96e71432844cf6c506355 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 23:50:24 +0200 Subject: [PATCH 12/25] Mount new components in one pass A new component (a first render, a new list item, another component type at a key) went through two walks: the render phase ran the bodies and wrote the next element tree, and the reconciliation walked the same elements again to create the widgets and move the bookkeeping. For a new subtree nothing is compared, so the second walk was pure overhead. The fast renderer now creates the widgets of a new subtree during the render phase, children first, and reconciliation only moves the bookkeeping and runs the effects in the same order as before (_finish_mount). The render bookkeeping is still written, so a mount can go back to the plain two phase state (_unmount): when a state change during the render needs another pass, when a body raises, for shared elements, and when a widget fails to be created. Then the widgets of that pass are closed and reconciliation creates them again, as before. This keeps the order of bodies, effects and exceptions. The new tests pin those fallbacks (for both renderers), and the random update test now also mounts subtrees that set state or raise in their first render. A mount of 300 rows (null widgets) is ~1.3x faster. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 233 +++++++++++++++++++++++++++++++++++++++++++ reacton/core_test.py | 154 +++++++++++++++++++++++++++- 2 files changed, 386 insertions(+), 1 deletion(-) diff --git a/reacton/core.py b/reacton/core.py index b82d5a9..346d95e 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1294,6 +1294,13 @@ class ComponentContext: 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, @@ -1507,9 +1514,16 @@ 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(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 @@ -1830,6 +1844,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: @@ -1867,6 +1882,7 @@ def format(reason: RerenderReason): self.context.exceptions_self = [] self._render(self.element, "/", parent_key=ROOT_KEY) + self._end_render_pass() self._walk_all = False if log_info: logger.info("Render done: %r %r", self._rerender_needed, self._rerender_needed_reasons[-1]) @@ -2667,11 +2683,36 @@ class _RenderContextFast(_RenderContext): # 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 pass: the + # render phase runs the bodies as always, and also creates the widgets + # of the new subtree (_mount_element), children first. It still writes + # the bookkeeping of the render phase, so a mount can be undone into the + # plain two phase state (_unmount): that happens when the pass needs a + # next pass (state set during render), or raised, or met something the + # mount does not handle (shared elements, a widget that fails to be + # created). Reconciliation then only finishes a mounted subtree + # (_finish_mount): it moves the bookkeeping and runs the effects, in the + # same order as reconciliation would. # ------------------------------------------------------------------ # > 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 + # something in this pass cannot be mounted: undo the mounts at the end of the pass + self._mount_failed = False + super().__init__(*args, **kwargs) def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) @@ -2695,7 +2736,48 @@ def _mark_dirty(self, context: ComponentContext): child = parent parent = parent.parent + def _end_render_pass(self): + if self._mount_created or self._mount_roots: + 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_failed = False + + def _unmount(self): + widgets_dict = _get_widgets_dict() + touched: Dict[ComponentContext, None] = {} + 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) + touched[context] = None + for context in touched: + context.widgets = {} + context.element_to_widget = {} + context.__dict__.pop("resolved_kwargs", None) + for root in self._mount_roots: + for context in root.mount_order or (): + context.mounted = False + context.mount_widget = None + root.mount_order = None + self._mount_roots = {} + self._mount_created = [] + self._mount_list = None + self._mount_failed = False + + def _before_close(self): + if self._mount_created or self._mount_roots: + # a reconciliation that raised did not finish them + self._unmount() + def _discard_aborted_pass(self): + self._unmount() super()._discard_aborted_pass() contexts: List[ComponentContext] = [self.context_root] while contexts: @@ -2824,6 +2906,7 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt parent_context = context del context + new = True 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 @@ -2841,6 +2924,10 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt else: context = context_previous context.parent = parent_context + new = False + # a new component without a widget yet: mount it (render + create widgets) in one pass + mount = new and self._mount_enabled and not el.is_shared and not DEBUG + mount_root = mount and self._mount_list is None context.clean_subtree = False context.invoke_element = el @@ -2877,6 +2964,8 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt self.container_adders = [] self.context = context render_count_check = self.render_count + if mount_root: + self._mount_list = [] try: context.exceptions_self = [] root_element: Optional[Element] = None @@ -2936,6 +3025,15 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt context.partial = True if dirty_children: self._render_dirty_children(context, dirty_children, join_key(parent_key, key)) + elif mount: + context.partial = False + widget = self._mount_element(root_element, "/", join_key(parent_key, key)) + if widget is not None: + context.mount_widget = widget + context.mounted = True + mount_list = self._mount_list + assert mount_list is not None + mount_list.append(context) else: context.partial = False self._render(root_element, "/", parent_key=join_key(parent_key, key)) # depth first @@ -2974,7 +3072,21 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt for unused in [k for k in context.elements_next if k not in used_keys]: del context.elements_next[unused] context.user_contexts_prev = context.user_contexts + if context.mounted: + return context.mount_widget + return None finally: + if mount_root: + mount_list = self._mount_list + self._mount_list = None + if context.mounted: + assert mount_list is not None + context.mount_order = mount_list + self._mount_roots[context] = None + elif mount_list: + # the mount did not finish, but components in it were mounted: undo at + # the end of the pass + self._mount_failed = True assert context.parent is parent_context self.context = parent_context if context.exceptions_self or context.exceptions_children and not context.exception_handler: @@ -2993,6 +3105,110 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt self._set_rerender_needed("Exception ocurred during render") parent_context.needs_render = True + def _mount_element(self, element: Element, default_key: str, parent_key: str): + # The render phase walk of an element of a new component (self.context), which also + # creates the widgets, children first. It writes the same render bookkeeping as + # _render, so _unmount can go back to the plain two phase state. Returns the widget + # (None when widgets are no longer created in this pass). + if not isinstance(element, Element): + raise TypeError(f"Expected element, not {element}") + el = element + if el.is_shared: + # shared elements are rendered once for the whole tree: the two phase walk + self._mount_failed = True + self._render(el, default_key, parent_key) + return None + self._old_element_ids.add(id(el)) + context = self.context + assert context is not None + if default_key == "/": + context.used_keys.clear() + context.has_shared = False + 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) + context.elements_next[key] = el + el._render_count += 1 # for testing only + + if isinstance(el.component, ComponentWidget): + assert not el.args, "no positional args supported for widgets" + children_next = context.children_next + if children_next and key in children_next: + # a pre-created (state_set) component context at this key + del children_next[key] + elements_seen = self._elements_seen + kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._mount_element) + if self._rerender_needed or self._mount_failed: + # this pass will be undone, do not create more widgets + return None + try: + widget, orphan_ids = el._create_widget(kwargs) + except BaseException: + # let reconciliation create it (and handle the exception) as it always does + 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 self._elements_seen != elements_seen: + 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 + + assert isinstance(el.component, ComponentFunction) + order = context.child_order_counter + context.child_order_counter = order + 1 + return self._render_component(el, key, parent_key, None, order) + + def _finish_mount(self, root: ComponentContext): + # Reconciliation of a mounted subtree (self.context is the parent of root): its widgets + # exist, move the render bookkeeping to the reconciled state, and run the effects, + # children first, as the reconciliation walk would. + parent_context = self.context + 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.elements = context.elements_next + context.elements_next = {} + context.children = context.children_next + context.children_next = {} + context.root_element = context.root_element_next + context.root_element_next = None + widget = context.mount_widget + el = context.invoke_element + assert el is not None and widget is not None + if el._meta or getattr(widget, "_react_meta", {}): + widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} + key = context.key_in_parent + assert key is not None + parent.widgets[key] = widget + parent.element_to_widget[el] = widget + context.mounted = False + context.mount_widget = None + self.context = context + self._process_effects(context, parent) + 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) + finally: + self.context = parent_context + 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 @@ -3070,6 +3286,23 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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) + 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) diff --git a/reacton/core_test.py b/reacton/core_test.py index 45ccaaf..e6fb3c9 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4272,6 +4272,9 @@ def h(*args): 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}") @@ -4287,7 +4290,8 @@ def Wrapper(id): def Thrower(id): value, set_value = react.use_state(0) registry[id] = set_value - if value == 7: + 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}") @@ -4388,3 +4392,151 @@ def test_renderers_agree_on_random_updates(seed): 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() From 5705ed08e06cc18bef14be19c0aaed96803e81f7 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 23:54:19 +0200 Subject: [PATCH 13/25] Make element creation and the common hooks cheaper Every component body makes elements and calls hooks, and after the renderer changes these are a large part of what reacton costs in a mount: for 1024 solara-like buttons, the reacton calls made from the bodies take about 3 ms, as much as all of reacton should cost. - Element.__init__ set nine attributes; most have a fixed default that is never changed in place, so they are class attributes now (handlers, never read in reacton or solara, is made on first use). The render context lookup no longer goes through a function call. - ComponentWidget/ComponentFunction only set mime_bundle when it is not the default. - use_ref was use_memo with a closure, four calls deep; it is now one method on the render context with the same memo entry. - use_state/use_effect/use_memo look up the render context directly, and use_state keys come from a table instead of str() per call. - Effect keeps its defaults on the class. - get_widget first looks in the current component, instead of first copying the children of the component into its search list. - _arguments_changed skips utils.equals for identical values. - ComponentContext makes state and resolved_kwargs right away: nearly every component uses them, and making them on first use costs more. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 147 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 106 insertions(+), 41 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 346d95e..940c822 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -368,22 +368,25 @@ class Element(Generic[W]): create_lock: ContextManager = threading.Lock() # 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 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: + container_adders[-1].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 @@ -400,17 +403,25 @@ 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): + # 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 + if len(args) != len(other_args): return True - if len(self.kwargs) != len(other.kwargs): + kwargs = self.kwargs + other_kwargs = other.kwargs + if len(kwargs) != len(other_kwargs): return True - for k, v in self.kwargs.items(): - if k not in other.kwargs: + equals = utils.equals + for k, v in kwargs.items(): + if k not in other_kwargs: return True - if not utils.equals(v, other.kwargs[k]): + other_v = other_kwargs[k] + if v is not other_v and not equals(v, other_v): return True - for a, b in zip(self.args, other.args): - if not utils.equals(a, b): + for a, b in zip(args, other_args): + if a is not b and not equals(a, b): return True return False @@ -481,6 +492,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 @@ -653,7 +671,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 @@ -728,8 +746,12 @@ def collect(self): class ComponentWidget(Component): + # the generated element factories make one per element + mime_bundle: Dict[str, Any] = mime_bundle_default + def __init__(self, widget: Type[widgets.Widget], mime_bundle=mime_bundle_default): - self.mime_bundle = mime_bundle + if mime_bundle is not mime_bundle_default: + self.mime_bundle = mime_bundle self.widget = widget self.name = widget.__name__ @@ -744,10 +766,11 @@ 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 @@ -837,10 +860,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 @@ -914,8 +938,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: @@ -970,13 +1000,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): @@ -1029,9 +1063,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("__")} @@ -1076,11 +1112,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]): @@ -1326,6 +1361,10 @@ def __init__( 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.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: @@ -1355,6 +1394,8 @@ def __getattr__(self, name): "context_managers": 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") @@ -1423,13 +1464,17 @@ def __repr__(self): 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: @@ -1612,11 +1657,31 @@ def use_memo(self, f, dependencies, debug_name: str = None, use_nonlocals=False) 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)): From a13d3b9a5540dc40abfce790d0d65c0b40a94c5e Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Sep 2026 23:57:37 +0200 Subject: [PATCH 14/25] Make the path of an unchanged child component leaner When a component renders again, every child component with equal arguments is skipped in both phases, but it still went through the generic code: _render called _render_component, which looked the child up twice, compared the components with a Python __eq__, and _reconsolidate entered a try/finally with the shared element checks. That was ~2 us per child, the whole cost of a root update of 300 rows. The skip check now runs in _render itself, with an identity check before the component compare, and _reconsolidate handles a skipped child before anything else. suppress_events() (every widget update) is a plain context manager instead of a generator, and the stale key check of a component only builds sets when there can be stale keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 140 ++++++++++++++++++++++++++++++------------------ 1 file changed, 89 insertions(+), 51 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 940c822..1569c5f 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -216,14 +216,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) @@ -2917,58 +2926,59 @@ def _render(self, element: Element, default_key: str, parent_key: str): self._visit_children(el, key, parent_key, self._render) 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) + ) + ) + ): + children_next = context.children_next + child_next = 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 + child.clean_subtree = True + child.invoke_element = el + child.order_in_parent = order + children_next[key] = child + 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 not el.is_shared - and not self._replacing - 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 - 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 context_previous.invoke_element - and not el._meta - and same_component(el_prev.component, el.component) - and not el._arguments_changed(el_prev) - ) - ) - ): - # 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 - context_previous.clean_subtree = True - context_previous.invoke_element = el - context_previous.order_in_parent = order - context.children_next[key] = context_previous - return - parent_context = context del context new = True @@ -3331,9 +3341,32 @@ 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 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 + 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: @@ -3411,7 +3444,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: From 5e410ca229e28a066a4b400cb33ba0574c3de109 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 00:03:04 +0200 Subject: [PATCH 15/25] Close a whole tree without the bookkeeping of a partial removal close() removed the tree with _remove_element, the code that also removes a single subtree during an update: per element it walked all kwargs, deleted the key from four dicts, checked a set of asserts, looked up the trait names to remove listeners, and afterwards every component context was emptied field by field. For 1024 buttons that was ~50 calls per component. The fast renderer now closes the whole tree with its own walk: the same order of effect cleanups and widget closes, the same exception handling (test_close_order_same_in_both_renderers checks both against the default renderer), but no bookkeeping for contexts that are dropped right after. A widget element whose kwargs held no elements (learned when its widget was made or updated) is not walked, and the listener cleanup only runs when a kwarg starts with on_. Shared elements keep the old path. Emptying a context is now __dict__.clear(): every container is made again, empty, when it is used after close (as before), and parent and the elements fall back to the class default None. This is shared, so the default renderer's close gets cheaper too. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 153 ++++++++++++++++++++++++++++++++++--------- reacton/core_test.py | 78 ++++++++++++++++++++++ 2 files changed, 201 insertions(+), 30 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 1569c5f..7b608b9 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -385,6 +385,10 @@ class Element(Generic[W]): # 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 def __init__(self, component, args=None, kwargs=None): self.component = component @@ -564,7 +568,10 @@ def _create_widget(self, 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) rc = get_render_context(required=True) recorded: List[widgets.Widget] = [] @@ -601,10 +608,12 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): # used_kwargs, _ = el_prev.split_kwargs(el_prev.kwargs) # 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_"): + on_kwargs = True if args is None: args = self.component.widget.class_trait_names() if name not in args: @@ -626,6 +635,8 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): 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 def _update_widget_prop(self, widget, name, value): setattr(widget, name, value) @@ -1255,7 +1266,7 @@ class ComponentContext: _COMPONENT_CONTEXT_LAZY are made on first use (see __getattr__). """ - parent: Optional["ComponentContext"] + parent: Optional["ComponentContext"] = None # this is the element in the parent context invoke_element: Optional[Element] = None @@ -1401,6 +1412,18 @@ def __getattr__(self, name): "resolved_kwargs": dict, "owns": set, "context_managers": list, + # 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)) @@ -1425,26 +1448,11 @@ 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.effects = [] - context.memo = [] - context.used_keys = set() - context.exceptions_self = [] - context.exceptions_children = [] - context.dirty_children = None - # the rarely used containers are made again (empty) when used - for name in _COMPONENT_CONTEXT_LAZY: - context.__dict__.pop(name, None) + # 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: @@ -1574,6 +1582,10 @@ def _end_render_pass(self): 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 @@ -1581,16 +1593,16 @@ def close(self): # 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 @@ -2850,6 +2862,81 @@ def _before_close(self): # 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() @@ -3219,6 +3306,7 @@ def _mount_element(self, element: Element, default_key: str, parent_key: str): del children_next[key] elements_seen = self._elements_seen kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._mount_element) + el._leaf = self._elements_seen == elements_seen if self._rerender_needed or self._mount_failed: # this pass will be undone, do not create more widgets return None @@ -3499,6 +3587,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): if widget_previous is None: # initial create kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) + 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 @@ -3517,6 +3606,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): # update the existing widget in place kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) has_elements = self._elements_seen != elements_seen + el._leaf = not has_elements if not context.exceptions_children: identical = False if el is el_prev: @@ -3547,6 +3637,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): # 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) + el._leaf = self._elements_seen == elements_seen widget = None if not context.exceptions_children: try: @@ -3911,6 +4002,8 @@ def _remove_stale_root_elements(self, 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_close_widget = Element._close_widget def _render_context_class(): diff --git a/reacton/core_test.py b/reacton/core_test.py index e6fb3c9..ee76d28 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4540,3 +4540,81 @@ def App(): # 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" From ff11966eb80c41d67eef47b8cdceafec6bbbd1b8 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 00:17:40 +0200 Subject: [PATCH 16/25] Skip unchanged child components without a call per child A component that renders again (a list owner, a context consumer) walks its element tree in both phases, and for each child component with equal arguments the walk called _render and later _reconsolidate only to find that the child is skipped. With hundreds of children the calls themselves were a large part of the update. The two visitors used for the element tree of a widget now handle that case inline: _render_children does what _render does for a component element up to the skip, and _reconsolidate_children does the skip of _reconsolidate. Every other element still goes through _render and _reconsolidate. A root update of 300 rows is ~1.1x faster. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 160 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 6 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 7b608b9..6906c6e 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1844,7 +1844,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): @@ -3006,11 +3006,11 @@ def _render(self, element: Element, default_key: str, parent_key: str): # must not keep one of those as it is (see the fast path further down) self._replacing += 1 try: - self._visit_children(el, key, parent_key, self._render) + self._render_children(el.kwargs, key, parent_key) finally: self._replacing -= 1 else: - self._visit_children(el, key, parent_key, self._render) + self._render_children(el.kwargs, key, parent_key) return order = context.child_order_counter @@ -3586,7 +3586,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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") @@ -3604,7 +3604,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): self._store_widget(context, el, key, widget) elif el_prev is not None and 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: @@ -3636,7 +3636,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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: @@ -3903,6 +3903,154 @@ def _visit_children(self, el: Element, default_key: str, parent_key: str, f: Cal 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 + self._old_element_ids.add(id(v)) + 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) + ) + ) + ): + children_next = context.children_next + child_next = children_next.get(child_key) + if child_next is None or child_next is child: + child.clean_subtree = True + child.invoke_element = v + child.order_in_parent = order + children_next[child_key] = child + 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 + for index, v in enumerate(value): + tv = type(v) + if tv in _SCALAR_TYPES: + append(v) + continue + if isinstance(v, Element): + self._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 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 + context.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) + 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: From fde2b8e939809ff2532846fbc2869f17015865fe Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 00:18:44 +0200 Subject: [PATCH 17/25] Compare element arguments with fewer calls _arguments_changed runs for every child of a component that renders again. Most children have either no positional or no keyword arguments, and the length checks were four builtin calls; checking emptiness first skips them. Elements made in a component body are also appended directly to the plain ContainerAdder (other adders still get .add()). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 6906c6e..82d3e9d 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -399,7 +399,11 @@ def __init__(self, component, args=None, kwargs=None): if rc is not None: container_adders = rc.container_adders if container_adders: - container_adders[-1].add(self) + 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 @@ -420,22 +424,28 @@ def _arguments_changed(self, other: "Element"): # (small ints, interned strings, the same callbacks) need no utils.equals call args = self.args other_args = other.args - if len(args) != len(other_args): - return True kwargs = self.kwargs other_kwargs = other.kwargs - if len(kwargs) != len(other_kwargs): - return True - equals = utils.equals - for k, v in kwargs.items(): - if k not in other_kwargs: + if args: + if len(args) != len(other_args): return True - other_v = other_kwargs[k] - if v is not other_v and not equals(v, other_v): - return True - for a, b in zip(args, other_args): - if a is not b and not 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): From ae26b46730cd57721cf665f41a5170697d83980a Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 00:24:19 +0200 Subject: [PATCH 18/25] Do less per child in the walks of a component that renders again Three things that ran for every child element in the update walks: - the fast renderer recorded id(element) of every element it rendered (a set that only grows until close), only for a friendlier get_widget error. Every rendered element is already marked _key_frozen, which get_widget now uses for that message. - a skipped child component was put in children_next in the render phase and taken out again in reconciliation; it is in children already, so reconciliation now looks there. - the reconciliation visitor counted elements with one attribute update per element instead of one per list. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 47 +++++++++++++++++++++++++++----------------- reacton/core_test.py | 29 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 82d3e9d..c21e053 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -987,7 +987,8 @@ 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}") @@ -2963,8 +2964,7 @@ def _discard_aborted_pass(self): 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 @@ -3055,16 +3055,15 @@ def _render(self, element: Element, default_key: str, parent_key: str): ) ) ): - children_next = context.children_next - child_next = children_next.get(key) + 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 + # 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 - children_next[key] = child return self._render_component(el, key, parent_key, el_prev, order) @@ -3290,7 +3289,6 @@ def _mount_element(self, element: Element, default_key: str, parent_key: str): self._mount_failed = True self._render(el, default_key, parent_key) return None - self._old_element_ids.add(id(el)) context = self.context assert context is not None if default_key == "/": @@ -3445,6 +3443,9 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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: @@ -3452,8 +3453,9 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): # reconciliation result stands (the lean version of the path below) child_context_next.clean_subtree = False children_next = context.children_next - context.children[key] = children_next[key] - del children_next[key] + 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 @@ -3470,11 +3472,15 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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 - context.children[key] = context.children_next.pop(key) + 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 @@ -3951,7 +3957,6 @@ def _render_children(self, value: Any, key: str, parent_key: str): self._render(v, f"{key}{k}/", parent_key) continue # _render for a component element, inline up to the skip - self._old_element_ids.add(id(v)) child_key = v._key if child_key is None: child_key = f"{key}{k}/" @@ -3990,13 +3995,12 @@ def _render_children(self, value: Any, key: str, parent_key: str): ) ) ): - children_next = context.children_next - child_next = children_next.get(child_key) + 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 - children_next[child_key] = child continue self._render_component(v, child_key, parent_key, el_prev, order) @@ -4020,24 +4024,30 @@ def _reconsolidate_children(self, value: Any, key: str, parent_key: str): 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): - self._elements_seen += 1 + 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 - context.children[child_key] = child - del children_next[child_key] + 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 @@ -4057,6 +4067,7 @@ def _reconsolidate_children(self, value: Any, key: str, parent_key: str): values.extend(new_value.children) else: append(new_value) + self._elements_seen += elements_seen if t is tuple: return tuple(values) return values diff --git a/reacton/core_test.py b/reacton/core_test.py index ee76d28..c234d52 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4618,3 +4618,32 @@ def test_close_order_same_in_both_renderers(fail_in_cleanup): 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() From 31972b4342c9a012f06dfff98ec617341795dcad Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 00:27:09 +0200 Subject: [PATCH 19/25] Compare values and components with exact-type fast paths A component that renders again compares the arguments of its child elements and the dependencies of its hooks with utils.equals, which went through seven isinstance checks before comparing two strings or two lists. It now handles the exact builtin types first, with the same result, and skips the recursive call for identical items. The generated element factories make a ComponentWidget per element. Keeping one per widget class (ComponentWidget.__new__) makes it cheaper, and makes the component compares of the walks identity checks instead of calls to __eq__. isinstance_lazy (every state change asks for pandas.DataFrame) keeps the class once it is imported, and _update_widget finds dropped kwargs without building two sets. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 32 ++++++++++++++++++++++++-------- reacton/utils.py | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index c21e053..e63ab67 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -614,7 +614,7 @@ def _create_widget(self, kwargs): 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) # the trait names are only needed for on_* names (listener or trait?) args = None @@ -633,7 +633,8 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): # if we previously gave an argument, but now we don't # we have to restore the default values, and remove listeners - dropped_arguments = set(el_prev.kwargs) - set(self.kwargs) + 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() @@ -776,14 +777,25 @@ def collect(self): class ComponentWidget(Component): - # the generated element factories make one per element mime_bundle: Dict[str, Any] = mime_bundle_default - - def __init__(self, widget: Type[widgets.Widget], mime_bundle=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: @@ -804,6 +816,10 @@ def __call__(self, *args, **kwargs): return el +# ComponentWidget per widget class (see ComponentWidget.__new__) +_component_widgets: Dict[type, ComponentWidget] = {} + + class ComponentFunction(Component): def __init__(self, f: Callable[[], Element], mime_bundle=mime_bundle_default, value_name=None): self.f = f @@ -2521,7 +2537,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 @@ -3010,7 +3026,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): # the element arguments are part of this component's element tree if el.kwargs: el_reconciled = context.elements.get(key) - if el_reconciled is not None and el_reconciled.component != el.component: + 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) @@ -3618,7 +3634,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): 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._reconsolidate_children(el.kwargs, key, parent_key) has_elements = self._elements_seen != elements_seen diff --git a/reacton/utils.py b/reacton/utils.py index 2b02e12..c955903 100644 --- a/reacton/utils.py +++ b/reacton/utils.py @@ -37,14 +37,38 @@ def wrap(mod, globals): _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 @@ -111,11 +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 = import_item(types) - return cls is not None and isinstance(value, cls) + 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] From 668989e1d2c335b3fa1324ce7f52aeddd4aea0bd Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 00:29:26 +0200 Subject: [PATCH 20/25] Do not keep widget classes alive through the shared ComponentWidget The previous commit keeps one ComponentWidget per widget class in a module level dict, which also kept every widget class alive forever. Classes made at runtime (solara's hot reload makes new component_vue and VuetifyTemplate subclasses on every reload, and user code can make classes dynamically) could then never be freed. The cache now has weak values: an entry goes away when no element uses its ComponentWidget any more. Compares stay correct without the cache (they try `is` first and fall back to ==). The lookup costs ~40 ns more; the gain on the update scenarios stays within noise. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 6 ++++-- reacton/core_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index e63ab67..870c56a 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -816,8 +816,10 @@ def __call__(self, *args, **kwargs): return el -# ComponentWidget per widget class (see ComponentWidget.__new__) -_component_widgets: Dict[type, ComponentWidget] = {} +# 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): diff --git a/reacton/core_test.py b/reacton/core_test.py index c234d52..b523679 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4647,3 +4647,27 @@ def effect(): 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 From 990da174c08193ab2daa501fce55b8d725ec4cf2 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 01:15:18 +0200 Subject: [PATCH 21/25] Mount a new subtree in one tight walk The first version of the one-pass mount still sent every component of a new subtree through the generic _render_component (skip checks, partial walks, hook count checks, pruning of the *_next dicts), wrote the render bookkeeping, and moved it to the reconciled state in reconciliation. A mount stayed ~2x above the floorc prototype (a fused mount that keeps reacton's bookkeeping). _mount_component and _mount_node now do the whole mount in one walk: make the context, run the body with its hooks, resolve child elements to widgets inline, create the widget (one recording of constructed widgets per mount instead of one per widget), and write elements, children, widgets and element_to_widget directly in their reconciled form. Reconciliation only runs the effects (in the same order) and hooks the root widget into its parent. The rare cases still go back to the two phase walk: _unmount now turns the reconciled bookkeeping of the pass's mounts into the render bookkeeping. context_managers is a class default, so a context without component context managers does not make an empty list on first use. A mount of 1024 buttons or 300 rows (null widgets) is ~1.25x faster. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 352 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 250 insertions(+), 102 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 870c56a..2620d82 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -31,6 +31,7 @@ Generic, List, Optional, + Sequence, Set, Tuple, Type, @@ -1360,7 +1361,8 @@ class ComponentContext: # it will always bubble up to the parent component. exception_handler: bool = False - context_managers: List[ContextManager] # lazy + # (only a component context manager class makes a list, see _render_component) + context_managers: Sequence[ContextManager] = () # For the fast renderer, which walks the context tree (not the element tree) of a # component that does not render again: @@ -1440,7 +1442,6 @@ def __getattr__(self, name): "context_listeners": lambda: defaultdict(set), "resolved_kwargs": dict, "owns": set, - "context_managers": list, # made in __init__, and made again after _teardown_component_context "elements_next": dict, "elements": dict, @@ -2800,16 +2801,14 @@ class _RenderContextFast(_RenderContext): # 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 pass: the - # render phase runs the bodies as always, and also creates the widgets - # of the new subtree (_mount_element), children first. It still writes - # the bookkeeping of the render phase, so a mount can be undone into the - # plain two phase state (_unmount): that happens when the pass needs a - # next pass (state set during render), or raised, or met something the - # mount does not handle (shared elements, a widget that fails to be - # created). Reconciliation then only finishes a mounted subtree - # (_finish_mount): it moves the bookkeeping and runs the effects, in the - # same order as reconciliation would. + # 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 @@ -2825,6 +2824,10 @@ def __init__(self, *args, **kwargs): 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 super().__init__(*args, **kwargs) @@ -2852,18 +2855,18 @@ def _mark_dirty(self, context: ComponentContext): parent = parent.parent def _end_render_pass(self): - if self._mount_created or self._mount_roots: + 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() - touched: Dict[ComponentContext, None] = {} for context, key, el, widget in self._mount_created: for orphan in self._orphans.pop(widget.model_id, ()): orphan_widget = widgets_dict.get(orphan) @@ -2871,23 +2874,28 @@ def _unmount(self): close_widget(orphan_widget) el._cleanup_callbacks(widget) el._close_widget(widget) - touched[context] = None - for context in touched: + 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.__dict__.pop("resolved_kwargs", None) - for root in self._mount_roots: - for context in root.mount_order or (): - context.mounted = False - context.mount_widget = None - root.mount_order = None + 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_created or self._mount_roots: + if self._mount_contexts or self._mount_roots: # a reconciliation that raised did not finish them self._unmount() @@ -3095,7 +3103,17 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt parent_context = context del context - new = True + 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 @@ -3113,10 +3131,6 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt else: context = context_previous context.parent = parent_context - new = False - # a new component without a widget yet: mount it (render + create widgets) in one pass - mount = new and self._mount_enabled and not el.is_shared and not DEBUG - mount_root = mount and self._mount_list is None context.clean_subtree = False context.invoke_element = el @@ -3153,8 +3167,6 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt self.container_adders = [] self.context = context render_count_check = self.render_count - if mount_root: - self._mount_list = [] try: context.exceptions_self = [] root_element: Optional[Element] = None @@ -3214,15 +3226,6 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt context.partial = True if dirty_children: self._render_dirty_children(context, dirty_children, join_key(parent_key, key)) - elif mount: - context.partial = False - widget = self._mount_element(root_element, "/", join_key(parent_key, key)) - if widget is not None: - context.mount_widget = widget - context.mounted = True - mount_list = self._mount_list - assert mount_list is not None - mount_list.append(context) else: context.partial = False self._render(root_element, "/", parent_key=join_key(parent_key, key)) # depth first @@ -3261,21 +3264,7 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt for unused in [k for k in context.elements_next if k not in used_keys]: del context.elements_next[unused] context.user_contexts_prev = context.user_contexts - if context.mounted: - return context.mount_widget - return None finally: - if mount_root: - mount_list = self._mount_list - self._mount_list = None - if context.mounted: - assert mount_list is not None - context.mount_order = mount_list - self._mount_roots[context] = None - elif mount_list: - # the mount did not finish, but components in it were mounted: undo at - # the end of the pass - self._mount_failed = True assert context.parent is parent_context self.context = parent_context if context.exceptions_self or context.exceptions_children and not context.exception_handler: @@ -3294,24 +3283,117 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt self._set_rerender_needed("Exception ocurred during render") parent_context.needs_render = True - def _mount_element(self, element: Element, default_key: str, parent_key: str): - # The render phase walk of an element of a new component (self.context), which also - # creates the widgets, children first. It writes the same render bookkeeping as - # _render, so _unmount can go back to the plain two phase state. Returns the widget - # (None when widgets are no longer created in this pass). + 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: + 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 - if el.is_shared: - # shared elements are rendered once for the whole tree: the two phase walk - self._mount_failed = True - self._render(el, default_key, parent_key) - return None context = self.context assert context is not None if default_key == "/": context.used_keys.clear() - context.has_shared = False context.child_order_counter = 0 key = el._key if key is None: @@ -3321,31 +3403,72 @@ def _mount_element(self, element: Element, default_key: str, parent_key: str): if key in used_keys: raise KeyError(f"Duplicate key {key!r}") used_keys.add(key) - context.elements_next[key] = el + 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 - if isinstance(el.component, ComponentWidget): + component = el.component + if isinstance(component, ComponentWidget): assert not el.args, "no positional args supported for widgets" - children_next = context.children_next - if children_next and key in children_next: - # a pre-created (state_set) component context at this key - del children_next[key] elements_seen = self._elements_seen - kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._mount_element) - el._leaf = self._elements_seen == 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 - try: - widget, orphan_ids = el._create_widget(kwargs) - except BaseException: - # let reconciliation create it (and handle the exception) as it always does - self._mount_failed = True - 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) + 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 self._elements_seen != elements_seen: + if has_elements: context.resolved_kwargs[key] = kwargs if orphan_ids: for orphan_widget in [_get_widgets_dict()[k] for k in orphan_ids]: @@ -3355,46 +3478,70 @@ def _mount_element(self, element: Element, default_key: str, parent_key: str): self._orphans.setdefault(widget.model_id, set()).update(orphan_ids) return widget - assert isinstance(el.component, ComponentFunction) + # 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 - return self._render_component(el, key, parent_key, None, order) + 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): its widgets - # exist, move the render bookkeeping to the reconciled state, and run the effects, - # children first, as the reconciliation walk would. + # 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.elements = context.elements_next - context.elements_next = {} - context.children = context.children_next - context.children_next = {} - context.root_element = context.root_element_next - context.root_element_next = None - widget = context.mount_widget - el = context.invoke_element - assert el is not None and widget is not None - if el._meta or getattr(widget, "_react_meta", {}): - widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} - key = context.key_in_parent - assert key is not None - parent.widgets[key] = widget - parent.element_to_widget[el] = widget context.mounted = False - context.mount_widget = None - self.context = context - self._process_effects(context, parent) + 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) @@ -4190,6 +4337,7 @@ def _remove_stale_root_elements(self, 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 From 206d6c30dfb98d6a34020bb79422dc3a62229bea Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 01:37:49 +0200 Subject: [PATCH 22/25] Keep one setter per state key use_state made a new setter closure in every render, for every state key. React's setState is stable: the same function for the life of the component. The setter is now made once per key and kept in the component context (a new eq argument is still picked up). This changes little in behavior: utils.equals compares functions by code and closure cells, so the setters of two renders already compared equal (a child that got a setter as an argument was already skipped, and an effect with a setter in its dependencies did not run again). What changes is the identity: the setter `is` the one of the previous render, and the compare is an identity check instead of a walk over the closure. In DEBUG mode the reason stack of a setter is that of its first render. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 27 +++++++++++++++++++--- reacton/core_test.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 2620d82..8d80058 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1321,7 +1321,9 @@ class ComponentContext: element_to_widget: Dict[Element, "ipywidgets.Widget"] # hooks data - state: Dict # lazy + 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"] @@ -1415,6 +1417,7 @@ def __init__( # 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(): @@ -1436,6 +1439,7 @@ def __getattr__(self, name): _COMPONENT_CONTEXT_LAZY: Dict[str, Callable[[], Any]] = { "state": dict, + "setters": dict, "state_metadata": dict, "user_contexts": dict, "user_contexts_prev": dict, @@ -1741,16 +1745,30 @@ def use_state(self, initial, key: str = None, eq: Callable[[Any, Any], bool] = N self.context.state_metadata[key] = utils.dataframe_fingerprint(initial) if logger.isEnabledFor(logging.DEBUG): logger.debug("Initial state = %r for key %r (%r)", initial, key, id(self.context)) - return initial, self.make_setter(key, self.context, eq) + state = initial else: state = self.context.state[key] if logger.isEnabledFor(logging.DEBUG): logger.debug("Got state = %r for key %r (%r)", state, key, id(self.context)) - return state, self.make_setter(key, self.context, eq) + # 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 @@ -1759,6 +1777,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: @@ -1826,6 +1845,8 @@ 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): diff --git a/reacton/core_test.py b/reacton/core_test.py index b523679..2a6466d 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4671,3 +4671,56 @@ def Test(): 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() From 8eb6602c9672b943f73069c8509ae09796250b25 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 01:38:54 +0200 Subject: [PATCH 23/25] Compare effect dependencies when use_effect is called Every render made a new Effect object for every use_effect call, and reconciliation then compared its dependencies with those of the effect that ran, and dropped it when they were equal (most renders). The compare now happens in use_effect itself: with equal dependencies no Effect is made, and a new one from an earlier pass of the same render call is dropped. Reconciliation runs the same effects in the same order as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 32 +++++++++++++++++++------------- reacton/core_test.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 8d80058..2d3540b 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1863,25 +1863,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)) + 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, self.context.effect_index - 1, dependencies) + 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) + previous_effect = effects[index] if logger.isEnabledFor(logging.DEBUG): - logger.debug("Setting next effect = %r for index %r (%r)", effect, self.context.effect_index, dependencies) + 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 diff --git a/reacton/core_test.py b/reacton/core_test.py index 2a6466d..7d7056a 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4724,3 +4724,33 @@ def App(): 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() From 7e8de016222df3f6e945f3de202c0e3879fba5ae Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 01:48:35 +0200 Subject: [PATCH 24/25] Register use_event handlers when the widget is made use_event made a new Effect, a closure and a get_widget search on every render, only to register the same handler again after the widget existed. On a page of buttons this was about a fifth of the reacton time of a mount. Now use_event keeps one handler object per hook (like a stable setter) that calls the latest callback, and the renderer registers it on the widget of the element when it creates or updates that widget. A component element hands its handlers on to the element its body returns, so the handler ends up on the widget of the component, as get_widget found before. When the component of the hook goes away, the handler is removed. use_event is now one hook (a ref) for every kind of element. A reserved no-op effect slot would also keep the hook counts equal, but measured about 0.5 us per button, a third of the gain. Behavior changes: - When the element gets a new widget (another key or type, also the root widget of a component element), the handler moves to the new widget. Before, it stayed on the old one. - A child component can hook into an element of its parent, also when that widget exists already. Before, get_widget raised KeyError. - The handler is registered when the widget is made, before effects run, and it is removed after the effect cleanups of the component. - use_event makes no effect: a component has one effect less per use_event call (the hook count check sees the same count every render, also when the element changes between a widget and a component element). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 73 ++++++++++++++ reacton/ipyvue.py | 119 +++++++++++++++-------- reacton/ipyvue_test.py | 212 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 363 insertions(+), 41 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 2d3540b..261edc0 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -390,6 +390,9 @@ class Element(Generic[W]): # 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 @@ -603,6 +606,8 @@ def _create_widget(self, kwargs): for name, callback in listeners.items(): if callback is not None: self._add_widget_event_listener(widget, name, callback) + 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): @@ -649,6 +654,8 @@ def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs): 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) @@ -1012,6 +1019,46 @@ def get_widget(el: 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. @@ -1365,6 +1412,8 @@ class ComponentContext: # (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: @@ -2327,6 +2376,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 @@ -2713,6 +2764,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) @@ -3249,6 +3308,8 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt dirty_children = context.dirty_children context.dirty_children = None if root_element is not None: + if el._event_handlers: + _add_event_handlers(root_element, el._event_handlers, context, self) if partial: context.partial = True if dirty_children: @@ -3374,6 +3435,8 @@ def _mount_component( 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: @@ -3482,6 +3545,8 @@ def _mount_node(self, element: Element, default_key: str, parent_key: str) -> Op 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() @@ -4054,6 +4119,14 @@ def _remove_element(self, el: Element, default_key: str, parent_key): child_context.exceptions_self.append(e) self._set_rerender_needed("Exception ocurred during effect") 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: diff --git a/reacton/ipyvue.py b/reacton/ipyvue.py index d805834..89571f4 100644 --- a/reacton/ipyvue.py +++ b/reacton/ipyvue.py @@ -1,22 +1,90 @@ -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, get_render_context + + +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 = get_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 +93,5 @@ 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]) + if handler not in el._event_handlers: + _add_event_handlers(el, (handler,), context, rc) 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() From 7d801eeb77ca318e694f9311d4d41dc06322d0ef Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 02:10:54 +0200 Subject: [PATCH 25/25] Make the plumbing around a component body and use_event cheaper The fast renderer made a new ContainerAdder for every component body, only to record the elements the body makes; bodies of one render context never nest, so one adder with a new list per body does the same job. use_event went through get_render_context and a helper call for the common case of a new element; now it reads the thread-local directly and only takes the helper for an element that was rendered before. Together about 5% of a mount of 1024 buttons. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 16 +++++++++++++--- reacton/ipyvue.py | 14 ++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 261edc0..2f6342b 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -767,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 @@ -2916,6 +2920,9 @@ def __init__(self, *args, **kwargs): 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): @@ -3659,7 +3666,7 @@ def _render_dirty_children(self, context: ComponentContext, dirty_children: Dict def _call_component(self, el: Element) -> Optional[Element]: """Run the component function, with an implicit container when it returns None.""" - component = cast(ComponentFunction, el.component) + component: ComponentFunction = el.component # type: ignore[assignment] default_container = _default_container if default_container is None: component.render_count += 1 @@ -3668,7 +3675,9 @@ def _call_component(self, el: Element) -> Optional[Element]: # 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: ContainerAdder = ContainerAdder(cast(Element, None), "children") + adder = self._body_adder + created: List[Element] = [] + adder.created = created container_adders = self.container_adders container_adders.append(adder) try: @@ -3676,10 +3685,11 @@ def _call_component(self, el: Element) -> Optional[Element]: 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(adder.created) + self.container_adders[-1].created.extend(created) if len(container.kwargs["children"]) == 1: root_element = container.kwargs["children"][0] else: diff --git a/reacton/ipyvue.py b/reacton/ipyvue.py index 89571f4..188a044 100644 --- a/reacton/ipyvue.py +++ b/reacton/ipyvue.py @@ -3,7 +3,7 @@ import ipyvue import reacton as react -from reacton.core import ComponentWidget, _add_event_handlers, get_render_context +from reacton.core import ComponentWidget, _add_event_handlers, local class _EventHandler: @@ -64,7 +64,9 @@ def _reacton_detach(self): def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[Any], Any]): - rc = get_render_context() + 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) @@ -93,5 +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] - if handler not in el._event_handlers: - _add_event_handlers(el, (handler,), context, rc) + 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)