From 000e0f29f21195c362faf79be5711a87483462c4 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 29 Sep 2026 10:22:30 +0200 Subject: [PATCH] Fast renderer: retry an effect that raised, like the default renderer When an effect raises, it stays marked as not executed, and the default renderer runs it again on the next tree walk. The fast renderer skipped a component whose element and state did not change, so for the root component and for memoized children a failed effect was never retried. A component with effect work left to do is now never skipped as clean. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 7 +++ reacton/core_test.py | 115 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/reacton/core.py b/reacton/core.py index 08b1d01..4937bea 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2505,6 +2505,12 @@ def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) self._rerender_needed = True + def _has_effect_work(self, context: "ComponentContext") -> bool: + for effect in context.effects: + if not effect.executed or effect.next is not None: + return True + return False + def _render(self, element: Element, default_key: str, parent_key: str): if not isinstance(element, Element): raise TypeError(f"Expected element, not {element}") @@ -2572,6 +2578,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): and context.children.get(key) is context_previous and not context_previous.needs_render and not context_previous.needs_render_descendant + and not self._has_effect_work(context_previous) and not context_previous.exceptions_self and not context_previous.exceptions_children and context_previous.root_element is not None diff --git a/reacton/core_test.py b/reacton/core_test.py index e988750..fb4b9cb 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3973,3 +3973,118 @@ def click_during_render(): assert isinstance(box.children[0], widgets.HTML) assert "click boom" in box.children[0].value rc.close() + + +def test_failed_root_effect_is_retried_on_later_tree_walk(): + log = [] + fail = [False] + set_value = lambda x: None # noqa + + @react.component + def App(): + nonlocal set_value + value, set_value = react.use_state(0) + + def effect(): + log.append(value) + if fail[0]: + raise RuntimeError("effect boom") + + react.use_effect(effect, [value]) + return w.Label(value=f"value {value}") + + box, rc = react.render(App(), handle_error=False) + try: + assert log == [0] + + fail[0] = True + with pytest.raises(RuntimeError, match="effect boom"): + set_value(1) + assert log == [0, 1] + + fail[0] = False + rc.render(rc.element) + assert log == [0, 1, 1] + assert box.children[0].value == "value 1" + finally: + rc.close() + + +def test_failed_effect_in_memoized_child_is_retried_on_later_tree_walk(): + log = [] + fail = [False] + set_child_value = lambda x: None # noqa + + @react.component + def Child(): + nonlocal set_child_value + value, set_child_value = react.use_state(0) + + def effect(): + log.append(value) + if fail[0]: + raise RuntimeError("child effect boom") + + react.use_effect(effect, [value]) + return w.Label(value=f"child {value}") + + @react.component + def App(): + child = react.use_memo(lambda: Child(), []) + return w.VBox(children=[child]) + + box, rc = react.render(App(), handle_error=False) + try: + assert log == [0] + + fail[0] = True + with pytest.raises(RuntimeError, match="child effect boom"): + set_child_value(1) + assert log == [0, 1] + + fail[0] = False + rc.render(rc.element) + assert log == [0, 1, 1] + assert box.children[0].children[0].value == "child 1" + finally: + rc.close() + + +def test_effect_cleanup_exception_does_not_retry_cleaned_effect(): + log = [] + fail_cleanup = [False] + set_value = lambda x: None # noqa + + @react.component + def App(): + nonlocal set_value + value, set_value = react.use_state(0) + + def effect(): + log.append(f"effect {value}") + + def cleanup(): + log.append(f"cleanup {value}") + if fail_cleanup[0]: + raise RuntimeError("cleanup boom") + + return cleanup + + react.use_effect(effect, [value]) + return w.Label(value=f"value {value}") + + box, rc = react.render(App(), handle_error=False) + try: + assert log == ["effect 0"] + + fail_cleanup[0] = True + with pytest.raises(RuntimeError, match="cleanup boom"): + set_value(1) + assert log == ["effect 0", "cleanup 0", "effect 1"] + + fail_cleanup[0] = False + rc.render(rc.element) + assert log == ["effect 0", "cleanup 0", "effect 1"] + assert box.children[0].value == "value 1" + finally: + rc.close()