From de7b0740380be678433c605ebbb4a9648efc9793 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 29 Sep 2026 10:23:23 +0200 Subject: [PATCH 1/3] Fast renderer: force_update() sets widgets back to their element values With the default renderer, a forced full walk (force_update(), update(), an explicit rc.render(...) or the first render) applies every widget element's kwargs again, so a trait changed from the frontend or from Python goes back to the element's value. The fast renderer walked the whole tree in that case, but still skipped the widget update for an element that was the same object as last time, so the changed value stayed. Code that calls force_update() to resync widgets did not work. A forced walk now also forces the widget updates in the reconcile phase. Renders triggered by state changes are not forced, so they keep skipping unchanged subtrees and widgets. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 20 +++++++++++++++++--- reacton/core_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 4937bea..d523a53 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1591,7 +1591,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) + self.render(self.element, self.container, _walk_all=False) else: logger.info("No render phase triggered, already rendering") @@ -1624,9 +1624,11 @@ def _discard_aborted_pass(self): self._shared_elements_next = set() self.context = self.context_root - def render(self, element: Element, container: widgets.Widget = None): + def render(self, element: Element, container: widgets.Widget = None, *, _walk_all: bool = True): # render + consolidate widget = None + if _walk_all: + self._walk_all = True if container is None: container = self.container was_locked = False @@ -2501,6 +2503,10 @@ class _RenderContextFast(_RenderContext): # shared element, that replaces an element of another type (see _render_arguments) _replacing = 0 + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._reconsolidate_forced_walk = False + def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) self._rerender_needed = True @@ -2518,6 +2524,8 @@ def _render(self, element: Element, default_key: str, parent_key: str): self._old_element_ids.add(id(element)) context = self.context assert context is not None + if parent_key == ROOT_KEY and default_key == "/" and context is self.context_root and self._walk_all: + self._reconsolidate_forced_walk = True if default_key == "/": # the root element of a component determines which keys are in use, @@ -2857,7 +2865,11 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): # With elements (a container), the kwargs resolve to widgets, so compare # them with what the widget holds: equal means setting them is a no-op # (and a value changed from the frontend is still set back) - if el is not el_prev or not (_values_identical(kwargs, el.kwargs) or _widget_holds_values(widget_previous, kwargs)): + if ( + self._reconsolidate_forced_walk + or el is not el_prev + or not (_values_identical(kwargs, el.kwargs) or _widget_holds_values(widget_previous, kwargs)) + ): try: el._update_widget(widget_previous, el_prev, kwargs) except BaseException as e: @@ -2923,6 +2935,8 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): self._shared_elements.add(el) assert el in self._shared_elements_next self._shared_elements_next.remove(el) + if parent_key == ROOT_KEY and default_key == "/": + self._reconsolidate_forced_walk = False def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"): # NOTE: effect/cleanup exceptions are recorded on the context of the diff --git a/reacton/core_test.py b/reacton/core_test.py index fb4b9cb..6fd425d 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4088,3 +4088,27 @@ def cleanup(): assert box.children[0].value == "value 1" finally: rc.close() + + +def test_rc_render_same_element_reasserts_widget_kwargs(): + @react.component + def App(): + return w.IntSlider(value=1) + + slider, rc = react.render_fixed(App(), handle_error=False) + slider.value = 7 + rc.render(rc.element) + assert slider.value == 1 + rc.close() + + +def test_force_update_reasserts_widget_kwargs(): + @react.component + def App(): + return w.IntSlider(value=1) + + slider, rc = react.render_fixed(App(), handle_error=False) + slider.value = 7 + rc.force_update() + assert slider.value == 1 + rc.close() From 03f024de8d09ebc02dd43e136fcf3624446ef697 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 29 Sep 2026 10:44:35 +0200 Subject: [PATCH 2/3] Keep render() unchanged and make the forced walk request safe Review found three problems in the first version: - render() got a keyword argument, so code that patches or overrides render(self, element, container=None) failed on every state change. - A forced render asked for its walk before taking the render lock, so the end of a render on another thread could undo it. - A forced render that raised before reconciliation left the forced flag on, so the next state-triggered render also applied all kwargs again. A render caused by a state change is now marked per thread instead, the walk is requested under the lock, and the flag is cleared when render() ends. Two tests cover the lost request and the flag after a failure. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 25 +++++++++----- reacton/core_test.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index d523a53..eab3957 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1327,6 +1327,11 @@ def __init__(self, element: Element, container: widgets.Widget = None, children_ # when set, the next render phase walks the whole tree instead of # skipping subtrees in which no state changed (see _render) self._walk_all = True + # set (per thread) while a render runs because state changed: such a render does not + # force a full walk, an explicit render() does (see render) + self._state_render = threading.local() + # fast renderer: this render is forced, so reconciliation applies all widget kwargs again + self._reconsolidate_forced_walk = False if initial_state: self.state_set(self.context_root, initial_state) @@ -1591,7 +1596,11 @@ 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, _walk_all=False) + self._state_render.active = True + try: + self.render(self.element, self.container) + finally: + self._state_render.active = False else: logger.info("No render phase triggered, already rendering") @@ -1624,11 +1633,9 @@ def _discard_aborted_pass(self): self._shared_elements_next = set() self.context = self.context_root - def render(self, element: Element, container: widgets.Widget = None, *, _walk_all: bool = True): + def render(self, element: Element, container: widgets.Widget = None): # render + consolidate widget = None - if _walk_all: - self._walk_all = True if container is None: container = self.container was_locked = False @@ -1651,6 +1658,10 @@ def render(self, element: Element, container: widgets.Widget = None, *, _walk_al # torn down, there is nothing to render into anymore logger.info("Render requested on a closing/closed render context, ignoring") return container + if not getattr(self._state_render, "active", False): + # an explicit render(), like force_update() and update(), walks the whole tree; + # set under the lock, so a render on another thread cannot reset it + self._walk_all = True prev_rc = getattr(local, "rc", None) # an exception that escapes while this is True aborted a render pass (see the except below) in_render_phase = True @@ -1805,6 +1816,8 @@ def format(reason: RerenderReason): finally: local.rc = prev_rc # type: ignore self._is_rendering = False + # a forced render that raised before reconciliation must not force the next one + self._reconsolidate_forced_walk = False # clear before the lock is released: a stale _lock_thread makes the # recursion guard above fire for a thread that merely rendered last, # while a *different* thread holds the lock (false "Recursive render") @@ -2503,10 +2516,6 @@ class _RenderContextFast(_RenderContext): # shared element, that replaces an element of another type (see _render_arguments) _replacing = 0 - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._reconsolidate_forced_walk = False - def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) self._rerender_needed = True diff --git a/reacton/core_test.py b/reacton/core_test.py index 6fd425d..443e650 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4112,3 +4112,82 @@ def App(): rc.force_update() assert slider.value == 1 rc.close() + + +@fast_renderer_only +def test_state_render_after_failed_forced_render_is_not_forced(): + # only a forced render applies the element kwargs again; one that raised before + # reconciliation must not turn the next state-triggered render into a forced one + set_count = lambda x: None # noqa + fail = [False] + + @react.component + def App(): + nonlocal set_count + count, set_count = react.use_state(0) + # the same element every render: an unforced render keeps the widget as it is + slider = react.use_memo(lambda: w.IntSlider(value=1), []) + if fail[0]: + raise ValueError("boom") + return w.VBox(children=[slider, w.Button(description=str(count))]) + + box, rc = react.render_fixed(App(), handle_error=False) + slider = rc.find(widgets.IntSlider).widget + fail[0] = True + with pytest.raises(ValueError, match="boom"): + set_count(1) + # App still needs a render, so the forced render runs it and raises again + with pytest.raises(ValueError, match="boom"): + rc.force_update() + fail[0] = False + slider.value = 7 + set_count(2) + assert rc.find(widgets.Button).widget.description == "2" + assert slider.value == 7 + rc.force_update() + assert slider.value == 1 + rc.close() + + +def test_forced_render_waiting_for_a_state_render_on_another_thread(): + # the explicit render waits for the lock while a state-triggered render runs, and the + # end of that render must not undo the request for a forced walk + entered = threading.Event() + release = threading.Event() + set_count = lambda x: None # noqa + + @react.component + def Slow(count): + if count == 1: + entered.set() + assert release.wait(5) + return w.Label(value=str(count)) + + @react.component + def App(): + nonlocal set_count + count, set_count = react.use_state(0) + slider = react.use_memo(lambda: w.IntSlider(value=1), []) + return w.VBox(children=[slider, Slow(count=count)]) + + box, rc = react.render_fixed(App(), handle_error=False) + slider = rc.find(widgets.IntSlider).widget + state_thread = threading.Thread(target=lambda: set_count(1)) + state_thread.start() + forced_thread = None + try: + assert entered.wait(5) + slider.value = 7 + forced_thread = threading.Thread(target=lambda: rc.render(rc.element)) + forced_thread.start() + # give it time to block on the render lock + time.sleep(0.2) + finally: + release.set() + state_thread.join(5) + if forced_thread is not None: + forced_thread.join(5) + assert not state_thread.is_alive() + assert forced_thread is not None and not forced_thread.is_alive() + assert slider.value == 1 + rc.close() From c10f2da2eb5461ac1218115638751929fd968845 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 29 Sep 2026 10:53:13 +0200 Subject: [PATCH 3/3] Do not end the forced walk at a child component with key "" A child component with key "" reconciles its root element with the same keys as the root element of the render context. The forced flag was cleared there, so widgets after that child were not updated. Only the root context now clears it. The test for a forced render that waits for a state render on another thread now waits until that render really blocks on the lock, instead of sleeping, so it cannot pass without testing the race. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 4 +++- reacton/core_test.py | 57 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index eab3957..7085a5f 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2944,7 +2944,9 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): self._shared_elements.add(el) assert el in self._shared_elements_next self._shared_elements_next.remove(el) - if parent_key == ROOT_KEY and default_key == "/": + if parent_key == ROOT_KEY and default_key == "/" and context is self.context_root: + # only the end of the root element; a child component with key "" also + # gets here with these keys, in its own context self._reconsolidate_forced_walk = False def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"): diff --git a/reacton/core_test.py b/reacton/core_test.py index 443e650..486dbe1 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4170,6 +4170,21 @@ def App(): slider = react.use_memo(lambda: w.IntSlider(value=1), []) return w.VBox(children=[slider, Slow(count=count)]) + import logging as std_logging # this module imports reacton.logging as logging + + # render() logs this right before it blocks on the lock another thread holds + waiting = threading.Event() + + class WaitingHandler(std_logging.Handler): + def emit(self, record): + if "waiting for mutex" in record.getMessage(): + waiting.set() + + handler = WaitingHandler(level=std_logging.INFO) + logger = std_logging.getLogger("reacton") + level = logger.level + logger.addHandler(handler) + logger.setLevel(std_logging.INFO) box, rc = react.render_fixed(App(), handle_error=False) slider = rc.find(widgets.IntSlider).widget state_thread = threading.Thread(target=lambda: set_count(1)) @@ -4180,14 +4195,52 @@ def App(): slider.value = 7 forced_thread = threading.Thread(target=lambda: rc.render(rc.element)) forced_thread.start() - # give it time to block on the render lock - time.sleep(0.2) + assert waiting.wait(5) finally: release.set() state_thread.join(5) if forced_thread is not None: forced_thread.join(5) + logger.removeHandler(handler) + logger.setLevel(level) assert not state_thread.is_alive() assert forced_thread is not None and not forced_thread.is_alive() assert slider.value == 1 rc.close() + + +def test_force_update_with_empty_key_child(): + # a child component with key "" reconciles its root with the same keys as the root + # element; that must not end the forced walk before the siblings after it + @react.component + def Child(): + return w.Label(value="child") + + box, rc = react.render_fixed(w.VBox(children=[Child().key(""), w.IntSlider(value=1)]), handle_error=False) + slider = box.children[1] + slider.value = 7 + rc.force_update() + assert slider.value == 1 + rc.close() + + +@fast_renderer_only +def test_state_render_after_force_update_is_not_forced(): + set_count = lambda x: None # noqa + + @react.component + def App(): + nonlocal set_count + count, set_count = react.use_state(0) + slider = react.use_memo(lambda: w.IntSlider(value=1), []) + return w.VBox(children=[slider, w.Button(description=str(count))]) + + box, rc = react.render_fixed(App(), handle_error=False) + slider = rc.find(widgets.IntSlider).widget + slider.value = 7 + rc.force_update() + assert slider.value == 1 + slider.value = 7 + set_count(1) + assert slider.value == 7 + rc.close()