From e25fd35cc4e376b39c3010ac4d4785b496a0ab90 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 29 Sep 2026 10:17:53 +0200 Subject: [PATCH] Fast renderer: an effect may change its parent element's kwargs Effects of child components run while the reconciler walks the kwargs of their parent widget element. When such an effect changed those kwargs (like adding a key), the fast renderer crashed with "dictionary changed size during iteration". The default renderer walks a copy of the kwargs, so it never had this problem; the fast renderer now does the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 11 +++++++---- reacton/core_test.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 08b1d01..98f46d5 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -2827,10 +2827,13 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): else: widget_previous = context.widgets[key] + # effects of child components run while we walk the kwargs, and they may + # change this element's kwargs (like the default renderer, walk a copy) + el_kwargs = el.kwargs.copy() orphan_ids: Set[str] = set() if widget_previous is None: # initial create - kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate) + kwargs = self._visit_children_values(el_kwargs, key, parent_key, self._reconsolidate) if el.is_shared and el in self._shared_widgets: raise RuntimeError(f"Element ({el}) was already in self._shared_widgets") widget = None @@ -2844,13 +2847,13 @@ 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._visit_children_values(el_kwargs, key, parent_key, self._reconsolidate) if not context.exceptions_children: # the same element whose kwargs hold no elements: nothing can have changed. # 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 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: @@ -2862,7 +2865,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._visit_children_values(el_kwargs, key, parent_key, self._reconsolidate) widget = None if not context.exceptions_children: try: diff --git a/reacton/core_test.py b/reacton/core_test.py index e988750..9f0c835 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3973,3 +3973,33 @@ def click_during_render(): assert isinstance(box.children[0], widgets.HTML) assert "click boom" in box.children[0].value rc.close() + + +def test_child_effect_changes_parent_element_kwargs(): + # the effect runs while the parent's kwargs are walked; changing them (adding a key) + # must not break that walk + set_count = lambda x: None # noqa + holder: dict = {} + + @react.component + def Child(): + def effect(): + holder["box"].kwargs.update(box_style="success") + + use_effect(effect) + return w.Label(value="child") + + @react.component + def App(): + nonlocal set_count + count, set_count = react.use_state(0) + holder["box"] = w.VBox(children=[Child(), w.Label(value=str(count))]) + return holder["box"] + + box, rc = react.render(App(), handle_error=False) + vbox = box.children[0] + assert vbox.children[0].value == "child" + set_count(1) + assert box.children[0] is vbox + assert vbox.children[1].value == "1" + rc.close()