From 877f710c78225274636ea737bde4519fc88f4f8d Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 28 Sep 2026 15:47:45 +0200 Subject: [PATCH 1/3] Compare the fast renderer with the default one on random trees The fast renderer was tested by running the test suite with REACTON_FAST=1, but those tests were written for the default renderer and rarely hit the cases the fast renderer skips: an element that is the same object in the next render, in a subtree that did not change. This test (from widgetti/reacton#58) renders random component trees that change shape with their state, sets random state, and checks that both renderers give the same widgets and run the same effects in the same order after every step. Seed 9 fails on master: the fast renderer raises a KeyError, fixed in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/fuzz_test.py | 157 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 reacton/fuzz_test.py diff --git a/reacton/fuzz_test.py b/reacton/fuzz_test.py new file mode 100644 index 0000000..fc7390c --- /dev/null +++ b/reacton/fuzz_test.py @@ -0,0 +1,157 @@ +# Compare the fast renderer (REACTON_FAST=1) with the default renderer on random component +# trees: the fast renderer skips parts of the tree that did not change, and 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. +import os +import random +import unittest.mock +import zlib +from typing import Any, Callable, Dict, List, Optional + +import ipywidgets as widgets +import pytest + +import reacton +import reacton as react + +from . import core +from . import ipywidgets as w + + +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): + # not hash(): the hash of a tuple differs between Python versions, and every version + # must generate the same trees + return zlib.crc32(repr(args).encode()) + + @react.component + def Leaf(id): + value, set_value = react.use_state(0) + registry[id] = set_value + if value == 0 and h(id) % 5 == 0: + # state set during the first render (a second render pass) + set_value(1) + kind = h(id, value) % 4 + if kind == 0: + return w.Label(value=f"leaf {id} {value}") + if kind == 1: + return reacton.Fragment(children=[w.Button(description=f"f{id}.{i}") for i in range(value % 3)]) + return w.Button(description=f"leaf {id} {value}") + + @react.component + def Wrapper(id): + return Leaf(id * 7 + 1) + + @react.component + def Thrower(id): + value, set_value = react.use_state(0) + registry[id] = set_value + if value == 7 or (value == 0 and h(id) % 3 == 0): + # also raises in its first render (in a new subtree) + raise ValueError(f"boom {id}") + return w.Button(description=f"thrower {id} {value}") + + @react.component + def Catcher(id): + exception, clear = react.use_exception() + state, set_state = react.use_state(0) + + def set_value(value): + clear() + set_state(value) + + registry[id] = set_value + if exception: + return w.Label(value=f"caught {exception}") + return w.HBox(children=[Thrower(id * 3 + 2), Leaf(id * 3 + 1)]) + + @react.component + def Node(id, depth): + state, set_state = react.use_state(0) + registry[id] = set_state + seed = h(id, state) + rnd = random.Random(seed) + + def effect(): + log.append(("effect", id, state)) + if state % 5 == 4: + set_state(state + 1) + + react.use_effect(effect, [state]) + children: List[Any] = [w.Label(value=f"node {id} {state}")] + for i in range(rnd.randint(0, 4)): + child_id = id * 10 + i + r = rnd.random() + if depth < 3 and r < 0.4: + child = Node(child_id, depth + 1) + elif r < 0.6: + child = Wrapper(child_id) + elif r < 0.7: + child = Catcher(child_id) + else: + child = Leaf(child_id) + if rnd.random() < 0.3: + child = child.key(f"k{child_id}") + children.append(child) + if rnd.random() < 0.2: + rnd.shuffle(children) + if depth == 0: + # the root keeps its widget type: without a container, reacton does not support a + # root component whose widget changes + return w.VBox(children=children) + if 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 90a830cf2d9ec6a470366cc12d422114017bcc65 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 28 Sep 2026 15:31:39 +0200 Subject: [PATCH 2/3] Do not keep a child's subtree when its parent widget is replaced In the fast renderer, a child element that is the same object in every render (it comes from outside, e.g. a prop, or it is the root element of a component that did not render again) keeps its previous subtree when nothing in it changed. But when the widget that holds it is replaced by a widget of another type (a VBox becomes an HBox), reconciliation first removes the old subtree, including the child's component contexts. Keeping the child as it was then made the render fail with a KeyError. The default renderer does not have this problem. The fuzz test from the previous commit finds it: on master, 62 of 300 seeds (25 steps each) fail with this KeyError; with this fix all pass. While the render phase walks the children of a widget that replaces a widget of another type, the fast path for unchanged children is now off, so the child is rendered again. Taken from widgetti/reacton#58 (part of "Skip a child component with equal arguments without walking it"), without the skip itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 18 +++++++++++++++++- reacton/core_test.py | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/reacton/core.py b/reacton/core.py index e35d171..4211e13 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2489,6 +2489,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 @@ -2537,7 +2541,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) @@ -2555,6 +2570,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): 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 diff --git a/reacton/core_test.py b/reacton/core_test.py index 21f3784..68b2bed 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3794,3 +3794,28 @@ def App(): set_offset(0) assert [child.description for child in vbox.children[1:]] == ["0", "1"] 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() From 194e10901a3f0dd4d04b9d76c3e433cdf4b1b7e1 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 28 Sep 2026 17:13:57 +0200 Subject: [PATCH 3/3] Check for leaked and closed widgets in the fuzz test Review found two holes: the leak check (the autouse cleanup_guard fixture) only ran for core_test.py, and the widget signature reads traits that a closed widget still has, so a renderer that returned a closed widget would match an open one. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/fuzz_test.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/reacton/fuzz_test.py b/reacton/fuzz_test.py index fc7390c..a10f8d5 100644 --- a/reacton/fuzz_test.py +++ b/reacton/fuzz_test.py @@ -16,6 +16,7 @@ from . import core from . import ipywidgets as w +from .core_test import cleanup_guard # noqa: F401 (autouse: no leaked widgets or callbacks) def _random_app(registry, log): @@ -110,9 +111,11 @@ def effect(): def _widget_signature(widget): + # a closed widget still has its traits: compare that it is open too + closed = widget.comm is None 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)) + return (type(widget).__name__, closed, [_widget_signature(child) for child in widget.children]) + return (type(widget).__name__, closed, getattr(widget, "value", None), getattr(widget, "description", None)) def _run_random_updates(fast: bool, seed: int, steps: int, batches: Optional[List] = None):