From d70269674fb597f9e0af8b26ba9b50bdf04c45df Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 03:31:27 +0200 Subject: [PATCH 01/16] Derive the frozen element key from the render count Every render of an element wrote two attributes: the frozen-key flag and the render count, always next to each other. The flag is now a property (rendered at least once), so a render writes one attribute. For a mount this is one write less per element; it matters more once the mount is compiled, where the remaining work per element is a few field writes. Behavior: an element whose render raised a duplicate-key KeyError (the pass is aborted) is no longer frozen, so .key() on it works again. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 31 ++++++++++++++++--------------- reacton/core_test.py | 18 ++++++++++++++++++ reacton/ipyvue.py | 2 +- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 2f6342b..023b1c0 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -383,9 +383,8 @@ class Element(Generic[W]): mime_bundle: Dict[str, Any] = mime_bundle_default _key: Optional[str] = None _meta: Dict[str, Any] = {} - # for debugging/testing only + # how often the element was rendered (also for testing) _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) @@ -452,12 +451,18 @@ def _arguments_changed(self, other: "Element"): return True return False + @property + def _key_frozen(self) -> bool: + # rendered at least once. The renderers used to set a flag next to every + # _render_count += 1, one attribute write per element render. + return self._render_count > 0 + def key(self, value: str): """Returns the same element with a custom key set. This can help render performance. See documentation for details. """ - if self._key_frozen: + if self._render_count: raise RuntimeError("Element keys should not be mutated after rendering") self._key = value return self @@ -1017,7 +1022,7 @@ 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 or el._key_frozen: + if id(el) in rc._old_element_ids or el._render_count: # (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}") @@ -1033,7 +1038,7 @@ def _add_event_handlers(el: Element, handlers: Tuple[Any, ...], context: "Compon if handler in current: continue current = el._event_handlers = (*current, handler) - if el._key_frozen: + if el._render_count: # 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). @@ -2225,7 +2230,6 @@ def _render(self, element: Element, default_key: str, parent_key: str): key = el._key if key is None: key = default_key - el._key_frozen = True logger.debug("Render: (%s,%s) - %r", parent_key, key, element) @@ -2246,7 +2250,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): if el_prev is None: el_prev = context.elements.get(key) context.elements_next[key] = el - # used for testing + # used for testing, and it freezes the key (see Element._key_frozen) el._render_count += 1 if isinstance(el.component, ComponentWidget): @@ -3083,7 +3087,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}") - # (no _old_element_ids: get_widget recognizes a stale element by _key_frozen) + # (no _old_element_ids: get_widget recognizes a stale element by _render_count) context = self.context assert context is not None @@ -3098,7 +3102,6 @@ def _render(self, element: Element, default_key: str, parent_key: str): key = el._key if key is None: key = default_key - el._key_frozen = True if key in context.used_keys: if DEBUG: @@ -3119,7 +3122,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): if el_prev is None: el_prev = context.elements.get(key) context.elements_next[key] = el - el._render_count += 1 # for testing only + el._render_count += 1 # (also freezes the key, see Element._key_frozen) if isinstance(el.component, ComponentWidget): assert not el.args, "no positional args supported for widgets" @@ -3495,7 +3498,6 @@ def _mount_node(self, element: Element, default_key: str, parent_key: str) -> Op 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}") @@ -3508,7 +3510,7 @@ def _mount_node(self, element: Element, default_key: str, parent_key: str) -> Op return None self._shared_elements_next.add(el) context.elements[key] = el - el._render_count += 1 # for testing only + el._render_count += 1 # (also freezes the key, see Element._key_frozen) component = el.component if isinstance(component, ComponentWidget): @@ -3661,7 +3663,7 @@ def _render_dirty_children(self, context: ComponentContext, dirty_children: Dict 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 + el._render_count += 1 # (also freezes the key, see Element._key_frozen) self._render_component(el, key, parent_key, el, child.order_in_parent) def _call_component(self, el: Element) -> Optional[Element]: @@ -4235,7 +4237,6 @@ def _render_children(self, value: Any, key: str, parent_key: str): 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: @@ -4247,7 +4248,7 @@ def _render_children(self, value: Any, key: str, parent_key: str): if el_prev is None: el_prev = context.elements.get(child_key) elements_next[child_key] = v - v._render_count += 1 # for testing only + v._render_count += 1 # (also freezes the key, see Element._key_frozen) order = context.child_order_counter context.child_order_counter = order + 1 child = context.children.get(child_key) diff --git a/reacton/core_test.py b/reacton/core_test.py index 7d7056a..aed0cb8 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -3325,6 +3325,24 @@ def Test(): rc.close() +def test_key_frozen_follows_render_count(): + # the frozen key is derived from the render count (no flag written per element render) + button = w.Button(description="a") + assert not button._key_frozen + button.key("before-render") + + @react.component + def Test(): + return w.VBox(children=[button]) + + box, rc = react.render(Test(), handle_error=False) + assert button._render_count == 1 + assert button._key_frozen + with pytest.raises(RuntimeError, match="Element keys should not be mutated after rendering"): + button.key("after-render") + rc.close() + + def test_render_nested_pass_widget_to_component_flip(): # One render() call, two passes: the first pass puts a widget element at a # slot, a state write during that pass opens a nested pass, and the nested diff --git a/reacton/ipyvue.py b/reacton/ipyvue.py index 188a044..8ccd84a 100644 --- a/reacton/ipyvue.py +++ b/reacton/ipyvue.py @@ -97,7 +97,7 @@ def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[A handlers = el._event_handlers if handler not in handlers: - if el._key_frozen: + if el._render_count: _add_event_handlers(el, (handler,), context, rc) else: el._event_handlers = (*handlers, handler) From 934b919f6aa4e42973029db33d7307144818f559 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 03:32:19 +0200 Subject: [PATCH 02/16] Keep the shared ComponentWidget on its widget class The generated element factories call ComponentWidget(widget=cls) for every element they make (ipyvuetify's generated components, which solara uses for every v.* element, do too). The lookup of the shared instance in a WeakValueDictionary is a Python-level method call; reading a class attribute is a fraction of that. A subclass inherits the attribute, so the cached instance is only used when its widget is that exact class. The class -> instance -> class cycle is freed by gc like any class, so a widget class made at runtime is still not kept alive (test_dynamic_widget_class_is_freed). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 28 +++++++++++++++++----------- reacton/core_test.py | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/reacton/core.py b/reacton/core.py index 023b1c0..4ec1948 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -800,18 +800,30 @@ class ComponentWidget(Component): 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). + # checks (__eq__ compares the widget classes). It is kept on the widget class itself: + # a class attribute read is much cheaper than a WeakValueDictionary lookup, and the + # class -> instance -> class cycle is freed by gc like any class (a class made at + # runtime, e.g. by a hot reload, is not kept alive). if cls is ComponentWidget and mime_bundle is mime_bundle_default: - self = _component_widgets.get(widget) - if self is not None: - return self + try: + self = widget._reacton_component_widget # type: ignore[attr-defined] + except AttributeError: + pass + else: + # (a subclass inherits the attribute of its base class) + if self.widget is widget: + 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 + try: + widget._reacton_component_widget = self # type: ignore[attr-defined] + except (AttributeError, TypeError): + # a class that does not take attributes: no cache + pass return self def __eq__(self, rhs): @@ -833,12 +845,6 @@ def __call__(self, *args, **kwargs): return el -# 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): def __init__(self, f: Callable[[], Element], mime_bundle=mime_bundle_default, value_name=None): self.f = f diff --git a/reacton/core_test.py b/reacton/core_test.py index aed0cb8..ca696b6 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4691,6 +4691,27 @@ def Test(): assert class_ref() is None +def test_component_widget_per_class(): + # one ComponentWidget per widget class, kept on the class: a subclass gets its own + class Base(widgets.Button): + pass + + class Sub(Base): + pass + + base = react.core.ComponentWidget(widget=Base) + assert react.core.ComponentWidget(widget=Base) is base + sub = react.core.ComponentWidget(widget=Sub) + assert sub is not base + assert sub.widget is Sub + assert react.core.ComponentWidget(widget=Sub) is sub + assert react.core.ComponentWidget(widget=Base) is base + # a custom mime bundle is not shared + custom = react.core.ComponentWidget(widget=Base, mime_bundle={"text/plain": "custom"}) + assert custom is not base + assert react.core.ComponentWidget(widget=Base) is base + + 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 From 9ea7e1d26d48fd6e5f5b7fcb0aef12b571b5cb3c Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 03:34:15 +0200 Subject: [PATCH 03/16] Make the generated element factories reuse one component per class Every generated factory (reacton.ipywidgets, bqplot, ipycanvas) looked up the shared ComponentWidget of its widget class for every element it made. The component is now made once, when the module is imported, and the factory passes it to the element directly: one call less per element. The generator template does the same, so ipyvuetify's components (solara's v.* elements) get it when they are generated again with this reacton. Behavior: the widget class is resolved when the module is imported, not at every call, so replacing a widget class in its module at runtime is no longer seen by the factory (solara only patches methods of widget classes). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/bqplot.py | 210 +++++++++++--------------- reacton/generate.py | 11 +- reacton/generate_test.py | 22 ++- reacton/ipycanvas.py | 25 ++-- reacton/ipywidgets.py | 315 ++++++++++++++++----------------------- 5 files changed, 235 insertions(+), 348 deletions(-) diff --git a/reacton/bqplot.py b/reacton/bqplot.py index 91e747f..3eb8bc3 100644 --- a/reacton/bqplot.py +++ b/reacton/bqplot.py @@ -89,12 +89,11 @@ def _Albers( @implements(_Albers) def Albers(**kwargs): - widget_cls = bqplot.scales.Albers - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Albers_component, kwargs=kwargs) del _Albers +_Albers_component = reacton.core.ComponentWidget(widget=bqplot.scales.Albers) def _AlbersUSA( @@ -128,12 +127,11 @@ def _AlbersUSA( @implements(_AlbersUSA) def AlbersUSA(**kwargs): - widget_cls = bqplot.scales.AlbersUSA - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_AlbersUSA_component, kwargs=kwargs) del _AlbersUSA +_AlbersUSA_component = reacton.core.ComponentWidget(widget=bqplot.scales.AlbersUSA) def _Axis( @@ -232,12 +230,11 @@ def _Axis( @implements(_Axis) def Axis(**kwargs): - widget_cls = bqplot.axes.Axis - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Axis_component, kwargs=kwargs) del _Axis +_Axis_component = reacton.core.ComponentWidget(widget=bqplot.axes.Axis) def _Bars( @@ -398,12 +395,11 @@ def _Bars( @implements(_Bars) def Bars(**kwargs): - widget_cls = bqplot.marks.Bars - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Bars_component, kwargs=kwargs) del _Bars +_Bars_component = reacton.core.ComponentWidget(widget=bqplot.marks.Bars) def _BaseAxis() -> Element[bqplot.axes.BaseAxis]: @@ -413,12 +409,11 @@ def _BaseAxis() -> Element[bqplot.axes.BaseAxis]: @implements(_BaseAxis) def BaseAxis(**kwargs): - widget_cls = bqplot.axes.BaseAxis - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_BaseAxis_component, kwargs=kwargs) del _BaseAxis +_BaseAxis_component = reacton.core.ComponentWidget(widget=bqplot.axes.BaseAxis) def _Bins( @@ -563,12 +558,11 @@ def _Bins( @implements(_Bins) def Bins(**kwargs): - widget_cls = bqplot.marks.Bins - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Bins_component, kwargs=kwargs) del _Bins +_Bins_component = reacton.core.ComponentWidget(widget=bqplot.marks.Bins) def _Boxplot( @@ -652,12 +646,11 @@ def _Boxplot( @implements(_Boxplot) def Boxplot(**kwargs): - widget_cls = bqplot.marks.Boxplot - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Boxplot_component, kwargs=kwargs) del _Boxplot +_Boxplot_component = reacton.core.ComponentWidget(widget=bqplot.marks.Boxplot) def _ColorAxis( @@ -712,12 +705,11 @@ def _ColorAxis( @implements(_ColorAxis) def ColorAxis(**kwargs): - widget_cls = bqplot.axes.ColorAxis - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_ColorAxis_component, kwargs=kwargs) del _ColorAxis +_ColorAxis_component = reacton.core.ComponentWidget(widget=bqplot.axes.ColorAxis) def _ColorScale( @@ -772,12 +764,11 @@ def _ColorScale( @implements(_ColorScale) def ColorScale(**kwargs): - widget_cls = bqplot.scales.ColorScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_ColorScale_component, kwargs=kwargs) del _ColorScale +_ColorScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.ColorScale) def _DOMWidget( @@ -792,12 +783,11 @@ def _DOMWidget( def DOMWidget(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.domwidget.DOMWidget - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_DOMWidget_component, kwargs=kwargs) del _DOMWidget +_DOMWidget_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.domwidget.DOMWidget) def _DateColorScale( @@ -845,12 +835,11 @@ def _DateColorScale( @implements(_DateColorScale) def DateColorScale(**kwargs): - widget_cls = bqplot.scales.DateColorScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_DateColorScale_component, kwargs=kwargs) del _DateColorScale +_DateColorScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.DateColorScale) def _DateScale( @@ -888,12 +877,11 @@ def _DateScale( @implements(_DateScale) def DateScale(**kwargs): - widget_cls = bqplot.scales.DateScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_DateScale_component, kwargs=kwargs) del _DateScale +_DateScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.DateScale) def _EquiRectangular( @@ -924,12 +912,11 @@ def _EquiRectangular( @implements(_EquiRectangular) def EquiRectangular(**kwargs): - widget_cls = bqplot.scales.EquiRectangular - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_EquiRectangular_component, kwargs=kwargs) del _EquiRectangular +_EquiRectangular_component = reacton.core.ComponentWidget(widget=bqplot.scales.EquiRectangular) def _Figure( @@ -1065,12 +1052,11 @@ def _Figure( def Figure(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = bqplot.figure.Figure - comp = reacton.core.ComponentWidget(widget=widget_cls) - return FigureElement(comp, kwargs=kwargs) + return FigureElement(_Figure_component, kwargs=kwargs) del _Figure +_Figure_component = reacton.core.ComponentWidget(widget=bqplot.figure.Figure) def _FlexLine( @@ -1155,12 +1141,11 @@ def _FlexLine( @implements(_FlexLine) def FlexLine(**kwargs): - widget_cls = bqplot.marks.FlexLine - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_FlexLine_component, kwargs=kwargs) del _FlexLine +_FlexLine_component = reacton.core.ComponentWidget(widget=bqplot.marks.FlexLine) def _GeoScale( @@ -1181,12 +1166,11 @@ def _GeoScale( @implements(_GeoScale) def GeoScale(**kwargs): - widget_cls = bqplot.scales.GeoScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_GeoScale_component, kwargs=kwargs) del _GeoScale +_GeoScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.GeoScale) def _Gnomonic( @@ -1226,12 +1210,11 @@ def _Gnomonic( @implements(_Gnomonic) def Gnomonic(**kwargs): - widget_cls = bqplot.scales.Gnomonic - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Gnomonic_component, kwargs=kwargs) del _Gnomonic +_Gnomonic_component = reacton.core.ComponentWidget(widget=bqplot.scales.Gnomonic) def _Graph( @@ -1350,12 +1333,11 @@ def _Graph( @implements(_Graph) def Graph(**kwargs): - widget_cls = bqplot.marks.Graph - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Graph_component, kwargs=kwargs) del _Graph +_Graph_component = reacton.core.ComponentWidget(widget=bqplot.marks.Graph) def _GridHeatMap( @@ -1483,12 +1465,11 @@ def _GridHeatMap( @implements(_GridHeatMap) def GridHeatMap(**kwargs): - widget_cls = bqplot.marks.GridHeatMap - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_GridHeatMap_component, kwargs=kwargs) del _GridHeatMap +_GridHeatMap_component = reacton.core.ComponentWidget(widget=bqplot.marks.GridHeatMap) def _HeatMap( @@ -1561,12 +1542,11 @@ def _HeatMap( @implements(_HeatMap) def HeatMap(**kwargs): - widget_cls = bqplot.marks.HeatMap - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_HeatMap_component, kwargs=kwargs) del _HeatMap +_HeatMap_component = reacton.core.ComponentWidget(widget=bqplot.marks.HeatMap) def _Hist( @@ -1668,12 +1648,11 @@ def _Hist( @implements(_Hist) def Hist(**kwargs): - widget_cls = bqplot.marks.Hist - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Hist_component, kwargs=kwargs) del _Hist +_Hist_component = reacton.core.ComponentWidget(widget=bqplot.marks.Hist) def _Image( @@ -1741,12 +1720,11 @@ def _Image( @implements(_Image) def Image(**kwargs): - widget_cls = bqplot.marks.Image - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Image_component, kwargs=kwargs) del _Image +_Image_component = reacton.core.ComponentWidget(widget=bqplot.marks.Image) def _Interaction() -> Element[bqplot.interacts.Interaction]: @@ -1773,12 +1751,11 @@ def _Interaction() -> Element[bqplot.interacts.Interaction]: @implements(_Interaction) def Interaction(**kwargs): - widget_cls = bqplot.interacts.Interaction - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Interaction_component, kwargs=kwargs) del _Interaction +_Interaction_component = reacton.core.ComponentWidget(widget=bqplot.interacts.Interaction) def _Label( @@ -1926,12 +1903,11 @@ def _Label( @implements(_Label) def Label(**kwargs): - widget_cls = bqplot.marks.Label - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Label_component, kwargs=kwargs) del _Label +_Label_component = reacton.core.ComponentWidget(widget=bqplot.marks.Label) def _LinearScale( @@ -1991,12 +1967,11 @@ def _LinearScale( @implements(_LinearScale) def LinearScale(**kwargs): - widget_cls = bqplot.scales.LinearScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_LinearScale_component, kwargs=kwargs) del _LinearScale +_LinearScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.LinearScale) def _Lines( @@ -2142,12 +2117,11 @@ def _Lines( @implements(_Lines) def Lines(**kwargs): - widget_cls = bqplot.marks.Lines - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Lines_component, kwargs=kwargs) del _Lines +_Lines_component = reacton.core.ComponentWidget(widget=bqplot.marks.Lines) def _LogScale( @@ -2183,12 +2157,11 @@ def _LogScale( @implements(_LogScale) def LogScale(**kwargs): - widget_cls = bqplot.scales.LogScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_LogScale_component, kwargs=kwargs) del _LogScale +_LogScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.LogScale) def _Map( @@ -2272,12 +2245,11 @@ def _Map( @implements(_Map) def Map(**kwargs): - widget_cls = bqplot.marks.Map - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Map_component, kwargs=kwargs) del _Map +_Map_component = reacton.core.ComponentWidget(widget=bqplot.marks.Map) def _Mark( @@ -2396,12 +2368,11 @@ def _Mark( @implements(_Mark) def Mark(**kwargs): - widget_cls = bqplot.marks.Mark - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Mark_component, kwargs=kwargs) del _Mark +_Mark_component = reacton.core.ComponentWidget(widget=bqplot.marks.Mark) def _Mercator( @@ -2442,12 +2413,11 @@ def _Mercator( @implements(_Mercator) def Mercator(**kwargs): - widget_cls = bqplot.scales.Mercator - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Mercator_component, kwargs=kwargs) del _Mercator +_Mercator_component = reacton.core.ComponentWidget(widget=bqplot.scales.Mercator) def _OHLC( @@ -2545,12 +2515,11 @@ def _OHLC( @implements(_OHLC) def OHLC(**kwargs): - widget_cls = bqplot.marks.OHLC - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_OHLC_component, kwargs=kwargs) del _OHLC +_OHLC_component = reacton.core.ComponentWidget(widget=bqplot.marks.OHLC) def _OrdinalColorScale( @@ -2596,12 +2565,11 @@ def _OrdinalColorScale( @implements(_OrdinalColorScale) def OrdinalColorScale(**kwargs): - widget_cls = bqplot.scales.OrdinalColorScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_OrdinalColorScale_component, kwargs=kwargs) del _OrdinalColorScale +_OrdinalColorScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.OrdinalColorScale) def _OrdinalScale( @@ -2633,12 +2601,11 @@ def _OrdinalScale( @implements(_OrdinalScale) def OrdinalScale(**kwargs): - widget_cls = bqplot.scales.OrdinalScale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_OrdinalScale_component, kwargs=kwargs) del _OrdinalScale +_OrdinalScale_component = reacton.core.ComponentWidget(widget=bqplot.scales.OrdinalScale) def _Orthographic( @@ -2683,12 +2650,11 @@ def _Orthographic( @implements(_Orthographic) def Orthographic(**kwargs): - widget_cls = bqplot.scales.Orthographic - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Orthographic_component, kwargs=kwargs) del _Orthographic +_Orthographic_component = reacton.core.ComponentWidget(widget=bqplot.scales.Orthographic) def _PanZoom( @@ -2719,12 +2685,11 @@ def _PanZoom( @implements(_PanZoom) def PanZoom(**kwargs): - widget_cls = bqplot.interacts.PanZoom - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_PanZoom_component, kwargs=kwargs) del _PanZoom +_PanZoom_component = reacton.core.ComponentWidget(widget=bqplot.interacts.PanZoom) def _Pie( @@ -2859,12 +2824,11 @@ def _Pie( @implements(_Pie) def Pie(**kwargs): - widget_cls = bqplot.marks.Pie - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Pie_component, kwargs=kwargs) del _Pie +_Pie_component = reacton.core.ComponentWidget(widget=bqplot.marks.Pie) def _Scale( @@ -2900,12 +2864,11 @@ def _Scale( @implements(_Scale) def Scale(**kwargs): - widget_cls = bqplot.scales.Scale - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Scale_component, kwargs=kwargs) del _Scale +_Scale_component = reacton.core.ComponentWidget(widget=bqplot.scales.Scale) def _Scatter( @@ -3113,12 +3076,11 @@ def _Scatter( @implements(_Scatter) def Scatter(**kwargs): - widget_cls = bqplot.marks.Scatter - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Scatter_component, kwargs=kwargs) del _Scatter +_Scatter_component = reacton.core.ComponentWidget(widget=bqplot.marks.Scatter) def _ScatterGL( @@ -3227,12 +3189,11 @@ def _ScatterGL( @implements(_ScatterGL) def ScatterGL(**kwargs): - widget_cls = bqplot.marks.ScatterGL - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_ScatterGL_component, kwargs=kwargs) del _ScatterGL +_ScatterGL_component = reacton.core.ComponentWidget(widget=bqplot.marks.ScatterGL) def _Stereographic( @@ -3277,12 +3238,11 @@ def _Stereographic( @implements(_Stereographic) def Stereographic(**kwargs): - widget_cls = bqplot.scales.Stereographic - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Stereographic_component, kwargs=kwargs) del _Stereographic +_Stereographic_component = reacton.core.ComponentWidget(widget=bqplot.scales.Stereographic) def _Toolbar( @@ -3328,12 +3288,11 @@ def _Toolbar( def Toolbar(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = bqplot.toolbar.Toolbar - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Toolbar_component, kwargs=kwargs) del _Toolbar +_Toolbar_component = reacton.core.ComponentWidget(widget=bqplot.toolbar.Toolbar) def _Tooltip( @@ -3376,9 +3335,8 @@ def _Tooltip( def Tooltip(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = bqplot.default_tooltip.Tooltip - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Tooltip_component, kwargs=kwargs) del _Tooltip +_Tooltip_component = reacton.core.ComponentWidget(widget=bqplot.default_tooltip.Tooltip) diff --git a/reacton/generate.py b/reacton/generate.py index 9fc7ea9..3decde6 100644 --- a/reacton/generate.py +++ b/reacton/generate.py @@ -258,14 +258,14 @@ def get_type(trait): if "v_model" in traits: element_type = f'ValueElement[{class_name}, {types["v_model"]}]' element_class_name = "ValueElement" - create_element = f'{element_class_name}("v_model", comp, kwargs=kwargs)' + create_element = f'{element_class_name}("v_model", _{method_name}_component, kwargs=kwargs)' elif "value" in traits: element_type = f'ValueElement[{class_name}, {types["value"]}]' element_class_name = "ValueElement" - create_element = f'{element_class_name}("value", comp, kwargs=kwargs)' + create_element = f'{element_class_name}("value", _{method_name}_component, kwargs=kwargs)' else: element_type = f"Element[{class_name}]" - create_element = f"{element_class_name}(comp, kwargs=kwargs)" + create_element = f"{element_class_name}(_{method_name}_component, kwargs=kwargs)" docstring_args_template = Template( """ @@ -276,6 +276,8 @@ def get_type(trait): docstring_args = docstring_args_template.render(docargs=docargs) docstring_args = indent(docstring_args, " ").strip() + # The element factory uses one ComponentWidget per widget class, made when the module + # is imported (not one lookup per element). code_method = Template( """ @@ -288,12 +290,11 @@ def _{{ method_name }}({{ signature }}) -> {{element_type}}: @implements(_{{ method_name }}) def {{ method_name }}(**kwargs): {{InstanceDict_fixes}} - widget_cls = {{class_name}} - comp = reacton.core.ComponentWidget(widget=widget_cls) return {{create_element}} del _{{ method_name }} +_{{ method_name }}_component = reacton.core.ComponentWidget(widget={{class_name}}) """ ) diff --git a/reacton/generate_test.py b/reacton/generate_test.py index 64bb62c..5a751a8 100644 --- a/reacton/generate_test.py +++ b/reacton/generate_test.py @@ -25,12 +25,11 @@ def _MyTest( @implements(_MyTest) def MyTest(**kwargs): - widget_cls = reacton.generate_test.MyTest - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_MyTest_component, kwargs=kwargs) del _MyTest +_MyTest_component = reacton.core.ComponentWidget(widget=reacton.generate_test.MyTest) ''' assert code.strip() == code_expected.strip() @@ -54,12 +53,11 @@ def _MyTest( @implements(_MyTest) def MyTest(**kwargs): - widget_cls = reacton.generate_test.MyTest - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _MyTest_component, kwargs=kwargs) del _MyTest +_MyTest_component = reacton.core.ComponentWidget(widget=reacton.generate_test.MyTest) ''' assert code.strip() == code_expected.strip() @@ -88,12 +86,11 @@ def _MyTest( @implements(_MyTest) def MyTest(**kwargs): - widget_cls = reacton.generate_test.MyTest - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_MyTest_component, kwargs=kwargs) -del _MyTest''' +del _MyTest +_MyTest_component = reacton.core.ComponentWidget(widget=reacton.generate_test.MyTest)''' assert code.strip() == code_expected.strip() @@ -119,12 +116,11 @@ def _MyTest( @implements(_MyTest) def MyTest(**kwargs): - widget_cls = reacton.generate_test.MyTest - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_MyTest_component, kwargs=kwargs) del _MyTest +_MyTest_component = reacton.core.ComponentWidget(widget=reacton.generate_test.MyTest) ''' assert code.strip() == code_expected.strip() diff --git a/reacton/ipycanvas.py b/reacton/ipycanvas.py index b02b72a..b7a36b6 100644 --- a/reacton/ipycanvas.py +++ b/reacton/ipycanvas.py @@ -82,12 +82,11 @@ def _Canvas( def Canvas(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = ipycanvas.canvas.Canvas - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Canvas_component, kwargs=kwargs) del _Canvas +_Canvas_component = reacton.core.ComponentWidget(widget=ipycanvas.canvas.Canvas) def _MultiCanvas( @@ -118,12 +117,11 @@ def _MultiCanvas( def MultiCanvas(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = ipycanvas.canvas.MultiCanvas - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_MultiCanvas_component, kwargs=kwargs) del _MultiCanvas +_MultiCanvas_component = reacton.core.ComponentWidget(widget=ipycanvas.canvas.MultiCanvas) def _MultiRoughCanvas( @@ -154,12 +152,11 @@ def _MultiRoughCanvas( def MultiRoughCanvas(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = ipycanvas.canvas.MultiRoughCanvas - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_MultiRoughCanvas_component, kwargs=kwargs) del _MultiRoughCanvas +_MultiRoughCanvas_component = reacton.core.ComponentWidget(widget=ipycanvas.canvas.MultiRoughCanvas) def _Path2D(value: str = "", on_value: typing.Callable[[str], Any] = None) -> Element[ipycanvas.canvas.Path2D]: @@ -175,12 +172,11 @@ def _Path2D(value: str = "", on_value: typing.Callable[[str], Any] = None) -> El @implements(_Path2D) def Path2D(**kwargs): - widget_cls = ipycanvas.canvas.Path2D - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Path2D_component, kwargs=kwargs) del _Path2D +_Path2D_component = reacton.core.ComponentWidget(widget=ipycanvas.canvas.Path2D) def _RoughCanvas( @@ -252,9 +248,8 @@ def _RoughCanvas( def RoughCanvas(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = w.Layout(**kwargs["layout"]) - widget_cls = ipycanvas.canvas.RoughCanvas - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_RoughCanvas_component, kwargs=kwargs) del _RoughCanvas +_RoughCanvas_component = reacton.core.ComponentWidget(widget=ipycanvas.canvas.RoughCanvas) diff --git a/reacton/ipywidgets.py b/reacton/ipywidgets.py index 99ece34..fdb7e5a 100644 --- a/reacton/ipywidgets.py +++ b/reacton/ipywidgets.py @@ -159,12 +159,11 @@ def _Accordion( def Accordion(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_selectioncontainer.Accordion - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Accordion_component, kwargs=kwargs) del _Accordion +_Accordion_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selectioncontainer.Accordion) def _AppLayout( @@ -257,12 +256,11 @@ def _AppLayout( def AppLayout(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_templates.AppLayout - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_AppLayout_component, kwargs=kwargs) del _AppLayout +_AppLayout_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_templates.AppLayout) def _Audio( @@ -302,12 +300,11 @@ def _Audio( def Audio(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_media.Audio - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Audio_component, kwargs=kwargs) del _Audio +_Audio_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_media.Audio) def _BoundedFloatText( @@ -368,12 +365,11 @@ def BoundedFloatText(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_float.BoundedFloatText - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _BoundedFloatText_component, kwargs=kwargs) del _BoundedFloatText +_BoundedFloatText_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_float.BoundedFloatText) def _BoundedIntText( @@ -419,12 +415,11 @@ def BoundedIntText(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int.BoundedIntText - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _BoundedIntText_component, kwargs=kwargs) del _BoundedIntText +_BoundedIntText_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.BoundedIntText) def _Box( @@ -466,12 +461,11 @@ def _Box( def Box(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_box.Box - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Box_component, kwargs=kwargs) del _Box +_Box_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_box.Box) def _Button( @@ -522,12 +516,11 @@ def Button(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = ButtonStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_button.Button - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ButtonElement(comp, kwargs=kwargs) + return ButtonElement(_Button_component, kwargs=kwargs) del _Button +_Button_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_button.Button) def _ButtonStyle( @@ -545,12 +538,11 @@ def _ButtonStyle( @implements(_ButtonStyle) def ButtonStyle(**kwargs): - widget_cls = ipywidgets.widgets.widget_button.ButtonStyle - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_ButtonStyle_component, kwargs=kwargs) del _ButtonStyle +_ButtonStyle_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_button.ButtonStyle) def _Checkbox( @@ -596,12 +588,11 @@ def Checkbox(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_bool.Checkbox - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Checkbox_component, kwargs=kwargs) del _Checkbox +_Checkbox_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_bool.Checkbox) def _ColorPicker( @@ -637,12 +628,11 @@ def ColorPicker(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_color.ColorPicker - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _ColorPicker_component, kwargs=kwargs) del _ColorPicker +_ColorPicker_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_color.ColorPicker) def _Combobox( @@ -688,12 +678,11 @@ def Combobox(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.Combobox - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Combobox_component, kwargs=kwargs) del _Combobox +_Combobox_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.Combobox) def _Controller( @@ -730,12 +719,11 @@ def _Controller( def Controller(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_controller.Controller - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Controller_component, kwargs=kwargs) del _Controller +_Controller_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_controller.Controller) def _CoreWidget() -> Element[ipywidgets.widgets.widget_core.CoreWidget]: @@ -745,12 +733,11 @@ def _CoreWidget() -> Element[ipywidgets.widgets.widget_core.CoreWidget]: @implements(_CoreWidget) def CoreWidget(**kwargs): - widget_cls = ipywidgets.widgets.widget_core.CoreWidget - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_CoreWidget_component, kwargs=kwargs) del _CoreWidget +_CoreWidget_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_core.CoreWidget) def _DOMWidget( @@ -765,12 +752,11 @@ def _DOMWidget( def DOMWidget(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.domwidget.DOMWidget - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_DOMWidget_component, kwargs=kwargs) del _DOMWidget +_DOMWidget_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.domwidget.DOMWidget) def _DatePicker( @@ -821,12 +807,11 @@ def DatePicker(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_date.DatePicker - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _DatePicker_component, kwargs=kwargs) del _DatePicker +_DatePicker_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_date.DatePicker) def _Dropdown( @@ -902,12 +887,11 @@ def Dropdown(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.Dropdown - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Dropdown_component, kwargs=kwargs) del _Dropdown +_Dropdown_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.Dropdown) def _FileUpload( @@ -961,12 +945,11 @@ def FileUpload(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = ButtonStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_upload.FileUpload - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _FileUpload_component, kwargs=kwargs) del _FileUpload +_FileUpload_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_upload.FileUpload) def _FloatLogSlider( @@ -1046,12 +1029,11 @@ def FloatLogSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = SliderStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_float.FloatLogSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _FloatLogSlider_component, kwargs=kwargs) del _FloatLogSlider +_FloatLogSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_float.FloatLogSlider) def _FloatProgress( @@ -1109,12 +1091,11 @@ def FloatProgress(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = ProgressStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_float.FloatProgress - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _FloatProgress_component, kwargs=kwargs) del _FloatProgress +_FloatProgress_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_float.FloatProgress) def _FloatRangeSlider( @@ -1189,12 +1170,11 @@ def FloatRangeSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = SliderStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_float.FloatRangeSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _FloatRangeSlider_component, kwargs=kwargs) del _FloatRangeSlider +_FloatRangeSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_float.FloatRangeSlider) def _FloatSlider( @@ -1269,12 +1249,11 @@ def FloatSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = SliderStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_float.FloatSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _FloatSlider_component, kwargs=kwargs) del _FloatSlider +_FloatSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_float.FloatSlider) def _FloatText( @@ -1324,12 +1303,11 @@ def FloatText(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_float.FloatText - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _FloatText_component, kwargs=kwargs) del _FloatText +_FloatText_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_float.FloatText) def _GridBox( @@ -1367,12 +1345,11 @@ def _GridBox( def GridBox(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_box.GridBox - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_GridBox_component, kwargs=kwargs) del _GridBox +_GridBox_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_box.GridBox) def _GridspecLayout( @@ -1447,12 +1424,11 @@ def _GridspecLayout( def GridspecLayout(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_templates.GridspecLayout - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_GridspecLayout_component, kwargs=kwargs) del _GridspecLayout +_GridspecLayout_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_templates.GridspecLayout) def _HBox( @@ -1492,12 +1468,11 @@ def _HBox( def HBox(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_box.HBox - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_HBox_component, kwargs=kwargs) del _HBox +_HBox_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_box.HBox) def _HTML( @@ -1530,12 +1505,11 @@ def HTML(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.HTML - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _HTML_component, kwargs=kwargs) del _HTML +_HTML_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.HTML) def _HTMLMath( @@ -1568,12 +1542,11 @@ def HTMLMath(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.HTMLMath - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _HTMLMath_component, kwargs=kwargs) del _HTMLMath +_HTMLMath_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.HTMLMath) def _Image( @@ -1610,12 +1583,11 @@ def _Image( def Image(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_media.Image - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Image_component, kwargs=kwargs) del _Image +_Image_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_media.Image) def _IntProgress( @@ -1657,12 +1629,11 @@ def IntProgress(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = ProgressStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int.IntProgress - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _IntProgress_component, kwargs=kwargs) del _IntProgress +_IntProgress_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.IntProgress) def _IntRangeSlider( @@ -1726,12 +1697,11 @@ def IntRangeSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = SliderStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int.IntRangeSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _IntRangeSlider_component, kwargs=kwargs) del _IntRangeSlider +_IntRangeSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.IntRangeSlider) def _IntSlider( @@ -1785,12 +1755,11 @@ def IntSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = SliderStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int.IntSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _IntSlider_component, kwargs=kwargs) del _IntSlider +_IntSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.IntSlider) def _IntText( @@ -1829,12 +1798,11 @@ def IntText(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int.IntText - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _IntText_component, kwargs=kwargs) del _IntText +_IntText_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.IntText) def _Label( @@ -1871,12 +1839,11 @@ def Label(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.Label - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Label_component, kwargs=kwargs) del _Label +_Label_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.Label) def _Layout( @@ -2014,12 +1981,11 @@ def _Layout( @implements(_Layout) def Layout(**kwargs): - widget_cls = ipywidgets.widgets.widget_layout.Layout - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Layout_component, kwargs=kwargs) del _Layout +_Layout_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_layout.Layout) def _Output( @@ -2068,12 +2034,11 @@ def func(): def Output(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_output.Output - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Output_component, kwargs=kwargs) del _Output +_Output_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_output.Output) def _Password( @@ -2112,12 +2077,11 @@ def Password(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.Password - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Password_component, kwargs=kwargs) del _Password +_Password_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.Password) def _Play( @@ -2166,12 +2130,11 @@ def Play(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int.Play - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Play_component, kwargs=kwargs) del _Play +_Play_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.Play) def _RadioButtons( @@ -2249,12 +2212,11 @@ def RadioButtons(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.RadioButtons - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _RadioButtons_component, kwargs=kwargs) del _RadioButtons +_RadioButtons_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.RadioButtons) def _Select( @@ -2337,12 +2299,11 @@ def Select(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.Select - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Select_component, kwargs=kwargs) del _Select +_Select_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.Select) def _SelectMultiple( @@ -2432,12 +2393,11 @@ def SelectMultiple(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.SelectMultiple - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _SelectMultiple_component, kwargs=kwargs) del _SelectMultiple +_SelectMultiple_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.SelectMultiple) def _SelectionRangeSlider( @@ -2542,12 +2502,11 @@ def SelectionRangeSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.SelectionRangeSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _SelectionRangeSlider_component, kwargs=kwargs) del _SelectionRangeSlider +_SelectionRangeSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.SelectionRangeSlider) def _SelectionSlider( @@ -2644,12 +2603,11 @@ def SelectionSlider(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.SelectionSlider - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _SelectionSlider_component, kwargs=kwargs) del _SelectionSlider +_SelectionSlider_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.SelectionSlider) def _SliderStyle( @@ -2667,12 +2625,11 @@ def _SliderStyle( @implements(_SliderStyle) def SliderStyle(**kwargs): - widget_cls = ipywidgets.widgets.widget_int.SliderStyle - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_SliderStyle_component, kwargs=kwargs) del _SliderStyle +_SliderStyle_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.SliderStyle) def _Style() -> Element[ipywidgets.widgets.widget_style.Style]: @@ -2682,12 +2639,11 @@ def _Style() -> Element[ipywidgets.widgets.widget_style.Style]: @implements(_Style) def Style(**kwargs): - widget_cls = ipywidgets.widgets.widget_style.Style - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Style_component, kwargs=kwargs) del _Style +_Style_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_style.Style) def _Tab( @@ -2712,12 +2668,11 @@ def _Tab( def Tab(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_selectioncontainer.Tab - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_Tab_component, kwargs=kwargs) del _Tab +_Tab_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selectioncontainer.Tab) def _Text( @@ -2756,12 +2711,11 @@ def Text(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.Text - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Text_component, kwargs=kwargs) del _Text +_Text_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.Text) def _Textarea( @@ -2803,12 +2757,11 @@ def Textarea(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_string.Textarea - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Textarea_component, kwargs=kwargs) del _Textarea +_Textarea_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_string.Textarea) def _ToggleButton( @@ -2862,12 +2815,11 @@ def ToggleButton(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_bool.ToggleButton - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _ToggleButton_component, kwargs=kwargs) del _ToggleButton +_ToggleButton_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_bool.ToggleButton) def _ToggleButtons( @@ -2969,12 +2921,11 @@ def ToggleButtons(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = ToggleButtonsStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_selection.ToggleButtons - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _ToggleButtons_component, kwargs=kwargs) del _ToggleButtons +_ToggleButtons_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.ToggleButtons) def _ToggleButtonsStyle( @@ -3006,12 +2957,11 @@ def _ToggleButtonsStyle( @implements(_ToggleButtonsStyle) def ToggleButtonsStyle(**kwargs): - widget_cls = ipywidgets.widgets.widget_selection.ToggleButtonsStyle - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_ToggleButtonsStyle_component, kwargs=kwargs) del _ToggleButtonsStyle +_ToggleButtonsStyle_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_selection.ToggleButtonsStyle) def _TwoByTwoLayout( @@ -3094,12 +3044,11 @@ def _TwoByTwoLayout( def TwoByTwoLayout(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_templates.TwoByTwoLayout - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_TwoByTwoLayout_component, kwargs=kwargs) del _TwoByTwoLayout +_TwoByTwoLayout_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_templates.TwoByTwoLayout) def _VBox( @@ -3139,12 +3088,11 @@ def _VBox( def VBox(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_box.VBox - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_VBox_component, kwargs=kwargs) del _VBox +_VBox_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_box.VBox) def _Valid( @@ -3187,12 +3135,11 @@ def Valid(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_bool.Valid - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Valid_component, kwargs=kwargs) del _Valid +_Valid_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_bool.Valid) def _ValueWidget(value: Any = None, on_value: typing.Callable[[Any], Any] = None) -> ValueElement[ipywidgets.widgets.valuewidget.ValueWidget, Any]: @@ -3204,12 +3151,11 @@ def _ValueWidget(value: Any = None, on_value: typing.Callable[[Any], Any] = None @implements(_ValueWidget) def ValueWidget(**kwargs): - widget_cls = ipywidgets.widgets.valuewidget.ValueWidget - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _ValueWidget_component, kwargs=kwargs) del _ValueWidget +_ValueWidget_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.valuewidget.ValueWidget) def _Video( @@ -3255,12 +3201,11 @@ def _Video( def Video(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.widget_media.Video - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", _Video_component, kwargs=kwargs) del _Video +_Video_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_media.Video) def _interactive( @@ -3301,12 +3246,11 @@ def _interactive( def interactive(**kwargs): if isinstance(kwargs.get("layout"), dict): kwargs["layout"] = Layout(**kwargs["layout"]) - widget_cls = ipywidgets.widgets.interaction.interactive - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_interactive_component, kwargs=kwargs) del _interactive +_interactive_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.interaction.interactive) def _DescriptionStyle( @@ -3320,12 +3264,11 @@ def _DescriptionStyle( @implements(_DescriptionStyle) def DescriptionStyle(**kwargs): - widget_cls = ipywidgets.widgets.widget_description.DescriptionStyle - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_DescriptionStyle_component, kwargs=kwargs) del _DescriptionStyle +_DescriptionStyle_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_description.DescriptionStyle) def _DescriptionWidget( @@ -3352,12 +3295,11 @@ def DescriptionWidget(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_description.DescriptionWidget - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_DescriptionWidget_component, kwargs=kwargs) del _DescriptionWidget +_DescriptionWidget_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_description.DescriptionWidget) def _ProgressStyle( @@ -3375,12 +3317,11 @@ def _ProgressStyle( @implements(_ProgressStyle) def ProgressStyle(**kwargs): - widget_cls = ipywidgets.widgets.widget_int.ProgressStyle - comp = reacton.core.ComponentWidget(widget=widget_cls) - return Element(comp, kwargs=kwargs) + return Element(_ProgressStyle_component, kwargs=kwargs) del _ProgressStyle +_ProgressStyle_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int.ProgressStyle) def __BoundedInt( @@ -3417,12 +3358,11 @@ def _BoundedInt(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int._BoundedInt - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", __BoundedInt_component, kwargs=kwargs) del __BoundedInt +__BoundedInt_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int._BoundedInt) def __BoundedIntRange( @@ -3458,12 +3398,11 @@ def _BoundedIntRange(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int._BoundedIntRange - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", __BoundedIntRange_component, kwargs=kwargs) del __BoundedIntRange +__BoundedIntRange_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int._BoundedIntRange) def __Int( @@ -3493,12 +3432,11 @@ def _Int(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int._Int - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", __Int_component, kwargs=kwargs) del __Int +__Int_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int._Int) def __IntRange( @@ -3528,9 +3466,8 @@ def _IntRange(**kwargs): kwargs["layout"] = Layout(**kwargs["layout"]) if isinstance(kwargs.get("style"), dict): kwargs["style"] = DescriptionStyle(**kwargs["style"]) - widget_cls = ipywidgets.widgets.widget_int._IntRange - comp = reacton.core.ComponentWidget(widget=widget_cls) - return ValueElement("value", comp, kwargs=kwargs) + return ValueElement("value", __IntRange_component, kwargs=kwargs) del __IntRange +__IntRange_component = reacton.core.ComponentWidget(widget=ipywidgets.widgets.widget_int._IntRange) From 4ea37e3332a2637e007f034e0e26f317447a9685 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 03:48:21 +0200 Subject: [PATCH 04/16] Move the element building blocks into a module that Cython can compile The target for mounts is 10x less reacton time, and the prototypes showed that plain Python stops at about 9-10x, while the same code compiled with Cython in pure Python mode gets 15-30x. reacton/_fastcore.py is that module: plain Python that runs as it is (PyPy, no compiler, development), and a C extension when built with the optional setup_cython.py. All typing is in _fastcore.pxd, so the plain Python version does not pay for it. REACTON_CYTHON=0 forces the plain version when a compiled one is there (reacton/_fastcore_import.py). This first step moves the data of an element and the methods the renderers call for every element (the constructor with the container recording, key(), meta(), shared(), _arguments_changed()) into ElementBase and ValueElementBase, plus ContainerAdder, find_elements, the render thread-local and the component element call. reacton.core.Element and ValueElement are Python subclasses of those, so user code sees the same classes (subclassing, arbitrary attributes, weak references and Element[...] type hints keep working, see fastcore_test.py). Setting reacton.core.DEBUG also reaches the elements. Compiled, the element fields are C fields: the update paths, which stay Python, measured the same or a bit faster (the argument compare is compiled). Elements and their components can be pickled and copied again (the shared ComponentWidget needed its widget class in __new__). Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 5 + reacton/_fastcore.pxd | 30 ++++ reacton/_fastcore.py | 307 ++++++++++++++++++++++++++++++++++++ reacton/_fastcore_import.py | 31 ++++ reacton/core.py | 217 ++++++------------------- reacton/fastcore_test.py | 67 ++++++++ setup_cython.py | 60 +++++++ 7 files changed, 544 insertions(+), 173 deletions(-) create mode 100644 reacton/_fastcore.pxd create mode 100644 reacton/_fastcore.py create mode 100644 reacton/_fastcore_import.py create mode 100644 reacton/fastcore_test.py create mode 100644 setup_cython.py diff --git a/.gitignore b/.gitignore index dfd9a91..becd08a 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,8 @@ examples site _site .mypy_cache + +# optional Cython build (setup_cython.py) +/build/ +/reacton/_fastcore.c +/reacton/_fastcore.html diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd new file mode 100644 index 0000000..e32517a --- /dev/null +++ b/reacton/_fastcore.pxd @@ -0,0 +1,30 @@ +# Cython declarations for _fastcore.py (pure Python mode). Everything that makes the compiled +# module fast is declared here, so the .py runs as plain Python without paying for it. +cimport cython + + +cdef class ContainerAdder: + cdef dict __dict__ + cdef object __weakref__ + cdef public object el, prop_name + cdef public list created + + +cdef class ElementBase: + cdef dict __dict__ + cdef object __weakref__ + cdef public object component + cdef public object args + cdef public object kwargs + cdef public object mime_bundle + cdef public object _key + cdef public object _meta + cdef public object _on_kwargs + cdef public object _leaf + cdef public object _event_handlers + cdef public bint is_shared + cdef public Py_ssize_t _render_count + + +cdef class ValueElementBase(ElementBase): + cdef public object value_property diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py new file mode 100644 index 0000000..1ee361c --- /dev/null +++ b/reacton/_fastcore.py @@ -0,0 +1,307 @@ +"""The hot building blocks of reacton, written for Cython's pure Python mode. + +This module is plain Python and works as it is (PyPy, no compiler, development). When it is +compiled (``python setup_cython.py build_ext --inplace``, see ``_fastcore.pxd``), the same +source becomes a C extension: elements become extension types with typed fields, and the +code that makes and walks them runs without the interpreter. Rules for this file: + +- all typing lives in ``_fastcore.pxd``: the .py must not pay for it when it is not compiled + (``cython.cast``/``cython.declare``/decorators are real calls in plain Python); +- class defaults that are typed fields when compiled go in an ``if not cython.compiled:`` + block of the class body (it runs once, at import), the compiled defaults in ``__cinit__`` + (never called when not compiled); +- no closures inside functions that the .pxd declares ``cpdef``. + +reacton.core builds the public classes on top of these (``Element`` is a Python subclass of +``ElementBase``), so everything that user code sees stays a normal Python class. +""" + +import sys +import threading +from types import TracebackType +from typing import Any, Dict, Optional + +try: + import cython +except ImportError: # plain Python without Cython installed + # (through globals(): a plain assignment would redeclare the name for the compiler) + globals()["cython"] = type("cython", (), {"compiled": False}) + + +from . import utils + +# the render context of the thread that renders (local.rc), see reacton.core +local = threading.local() +# set together with reacton.core.DEBUG: keep the stack where every element was made +DEBUG = 0 + +widget_render_error_msg = ( + """Cannot show widget. You probably want to rerun the code cell above (Click in the code cell, and press Shift+Enter ⇧+↩).""" +) +mime_bundle_default: Dict[str, Any] = {"text/plain": "Cannot show ipywidgets in text", "text/html": widget_render_error_msg} +# the default meta of an element (never changed in place: meta() makes a new dict) +_NO_META: dict = {} +# reacton.core's Element and ValueElement (see _register) +_Element: Any = None +_ValueElement: Any = None + + +def _register(element_class, value_element_class): + global _Element, _ValueElement + _Element = element_class + _ValueElement = value_element_class + + +def find_elements(value): + if isinstance(value, ElementBase): + el = value + elements = {el} + if not isinstance(el.kwargs, dict): + raise RuntimeError("keyword arguments for {el} should be a dict, not {el.kwargs}") + elements |= find_elements(el.args) + elements |= find_elements(el.kwargs) + return elements + elif isinstance(value, (tuple, list)): + elements = set() + for child in value: + if isinstance(child, (ElementBase, tuple, list, dict)): + elements |= find_elements(child) + return elements + elif isinstance(value, dict): + elements = set() + for child in value.values(): + if isinstance(child, (ElementBase, tuple, list, dict)): + elements |= find_elements(child) + return elements + + +class ContainerAdder: + """Collects the elements made inside a ``with element:`` block (and a component body). + + Every new element is added to the innermost one on ``rc.container_adders``: to ``created`` + for this class, through ``add()`` for any other kind of adder. + """ + + def __init__(self, el, prop_name): + self.el = el + self.prop_name = prop_name + self.created = [] + + def __class_getitem__(cls, item): + # (ContainerAdder[W](...), it used to be a typing.Generic) + return cls + + def add(self, el): + self.created.append(el) + + def collect(self): + children = set() + for el in self.created: + children |= find_elements(el) - {el} + top_level = [k for k in self.created if k not in children] + return top_level + + +class ElementBase: + """The data of an element (reacton.core.Element adds the rest).""" + + if not cython.compiled: + # (compiled: typed fields, see _fastcore.pxd, with the defaults set in __cinit__) + component: Any + args: Any + kwargs: Dict[str, Any] + # 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] = _NO_META + # how often the element was rendered (also for testing), see _key_frozen + _render_count: int = 0 + # 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 + # 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 = () + # (only in DEBUG mode: where the element was made) + traceback: TracebackType + + def __cinit__(self): + # (compiled only: the defaults of the typed fields) + self.mime_bundle = mime_bundle_default + self._meta = _NO_META + self._event_handlers = () + + def __init__(self, component, args=None, kwargs=None): + self.component = component + self.args = args or [] + self.kwargs = kwargs or {} + # the elements made in a `with container:` block (or a component body) go there + rc = getattr(local, "rc", None) + if rc is not None: + container_adders = rc.container_adders + if container_adders: + adder = container_adders[-1] + if type(adder) is ContainerAdder: + adder.created.append(self) + else: + adder.add(self) + if DEBUG: + _keep_traceback(self) + + @property + def _key_frozen(self): + # rendered at least once. The renderers used to set a flag next to every + # _render_count += 1, one attribute write per element render. + return self._render_count > 0 + + def key(self, value): + """Returns the same element with a custom key set. + + This can help render performance. See documentation for details. + """ + if self._render_count: + raise RuntimeError("Element keys should not be mutated after rendering") + self._key = value + return self + + def meta(self, **kwargs): + """Add metadata to the created widget. + + This can be used to find a widget for testing. + """ + self._meta = {**self._meta, **kwargs} + return self + + # the old name of is_shared + @property + def _shared(self): + return self.is_shared + + @_shared.setter + def _shared(self, value): + self.is_shared = value + + def shared(self): + self.is_shared = True + return self + + def _arguments_changed(self, other): + # 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 + kwargs = self.kwargs + other_kwargs = other.kwargs + if args: + if len(args) != len(other_args): + return True + 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 __reduce__(self): + # (explicit, the same in both modes: a compiled element has no automatic pickling) + return (_rebuild_element, (type(self), _element_state(self))) + + +class ValueElementBase(ElementBase): + """An element for a widget with a value (reacton.core.ValueElement adds the rest).""" + + if not cython.compiled: + value_property: str + + def __init__(self, value_property, component, args=None, kwargs=None): + self.value_property = value_property + # ElementBase.__init__, inline + self.component = component + self.args = args or [] + self.kwargs = kwargs or {} + rc = getattr(local, "rc", None) + if rc is not None: + container_adders = rc.container_adders + if container_adders: + adder = container_adders[-1] + if type(adder) is ContainerAdder: + adder.created.append(self) + else: + adder.add(self) + if DEBUG: + _keep_traceback(self) + + +_ELEMENT_FIELDS = ( + "component", + "args", + "kwargs", + "is_shared", + "mime_bundle", + "_key", + "_meta", + "_render_count", + "_on_kwargs", + "_leaf", + "_event_handlers", + "value_property", +) +_MISSING = object() + + +def _element_state(el): + state = {} + for name in _ELEMENT_FIELDS: + value = getattr(el, name, _MISSING) + if value is not _MISSING: + state[name] = value + state.update(el.__dict__) + return state + + +def _rebuild_element(cls, state): + el = cls.__new__(cls) + for name, value in state.items(): + setattr(el, name, value) + return el + + +def _keep_traceback(el, depth=-1): + # DEBUG: keep the frame of the code that made the element (the caller of the factory), to + # show where an element came from when rendering it fails. Compiled functions have no + # frame of their own, so the frame of that code is closer to the top of the stack. + # (depth -1: called from ElementBase.__init__, which is called by a factory) + if depth == -1: + depth = 1 if cython.compiled else 3 + frame = sys._getframe(depth) + el.traceback = TracebackType(tb_next=None, tb_frame=frame, tb_lasti=frame.f_lasti, tb_lineno=frame.f_lineno) + + +def component_call(self, *args, **kwargs): + # ComponentFunction.__call__: make the element of a component + if self.value_name is not None: + el = _ValueElement(self.value_name, self, args, kwargs) + else: + el = _Element(self, args, kwargs) + if self.mime_bundle is not mime_bundle_default: + el.mime_bundle = self.mime_bundle + if DEBUG: + # the code that called the component (see _keep_traceback) + _keep_traceback(el, 0 if cython.compiled else 2) + return el diff --git a/reacton/_fastcore_import.py b/reacton/_fastcore_import.py new file mode 100644 index 0000000..a9ee3d3 --- /dev/null +++ b/reacton/_fastcore_import.py @@ -0,0 +1,31 @@ +"""Import reacton._fastcore: compiled when it was built with Cython, else the plain Python. + +REACTON_CYTHON=0 in the environment forces the plain Python version, also when a compiled +module is there (to test both, or to debug). +""" + +import importlib.util +import os +import sys + + +def _load(): + if os.environ.get("REACTON_CYTHON", "1") == "0": + name = __package__ + "._fastcore" + existing = sys.modules.get(name) + if existing is not None: + return existing + path = os.path.join(os.path.dirname(__file__), "_fastcore.py") + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + from . import _fastcore as module + + return module + + +_fastcore = _load() +compiled: bool = not _fastcore.__file__.endswith(".py") diff --git a/reacton/core.py b/reacton/core.py index 4ec1948..ae49b72 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -17,6 +17,7 @@ import sys import threading import traceback +import types import typing import weakref from collections import defaultdict @@ -52,6 +53,19 @@ import reacton.patch_display # noqa: F401 # has sidefx from . import _version, patch, utils # noqa: F401 +from ._fastcore_import import _fastcore + +# the hot building blocks (compiled when reacton was built with Cython, see _fastcore.py) +if typing.TYPE_CHECKING: + from ._fastcore import ContainerAdder, ElementBase, ValueElementBase, find_elements, local, mime_bundle_default, widget_render_error_msg +else: + ContainerAdder = _fastcore.ContainerAdder + ElementBase = _fastcore.ElementBase + ValueElementBase = _fastcore.ValueElementBase + find_elements = _fastcore.find_elements + local = _fastcore.local + widget_render_error_msg = _fastcore.widget_render_error_msg + mime_bundle_default = _fastcore.mime_bundle_default __version__ = _version.__version__ @@ -180,7 +194,6 @@ def hold_trait_notifications(self, *args, **kwargs): _last_rc = None # used for testing -local = threading.local() T = TypeVar("T") U = TypeVar("U") W = TypeVar("W") # used for widgets @@ -202,13 +215,6 @@ def hold_trait_notifications(self, *args, **kwargs): MIME_WIDGETS = "application/vnd.jupyter.widget-view+json" -widget_render_error_msg = ( - """Cannot show widget. You probably want to rerun the code cell above (Click in the code cell, and press Shift+Enter ⇧+↩).""" -) - -mime_bundle_default = {"text/plain": "Cannot show ipywidgets in text", "text/html": widget_render_error_msg} - - def element(cls, **kwargs): return ComponentWidget(cls)(**kwargs) @@ -370,123 +376,15 @@ def __call__(self, *args, **kwargs) -> Union[widgets.Widget, "Element"]: pass -class Element(Generic[W]): +class Element(ElementBase, Generic[W]): + # (the data of an element, and the methods the renderers call for every element, are in + # _fastcore.ElementBase: component, args, kwargs, _key, is_shared, _meta, _render_count, + # key(), meta(), shared(), _arguments_changed()) child_prop_name = "children" # to make every unique on_value callback to a unique wrapper # so that we can remove the listeners _callback_wrappers: Dict[Tuple[str, str, Callable], Callable] = {} 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] = {} - # how often the element was rendered (also for testing) - _render_count = 0 - # 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 - # 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 - self.args = args or [] - self.kwargs = kwargs or {} - - rc = getattr(local, "rc", None) - if rc is not None: - container_adders = rc.container_adders - if container_adders: - 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 - try: - assert False - except AssertionError: - self.traceback = cast(TracebackType, sys.exc_info()[2]) - - assert self.traceback is not None - assert self.traceback.tb_frame is not None - assert self.traceback.tb_frame.f_back is not None - frame_py = self.traceback.tb_frame.f_back.f_back - assert frame_py is not 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"): - # 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 - kwargs = self.kwargs - other_kwargs = other.kwargs - if args: - if len(args) != len(other_args): - return True - 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 - - @property - def _key_frozen(self) -> bool: - # rendered at least once. The renderers used to set a flag next to every - # _render_count += 1, one attribute write per element render. - return self._render_count > 0 - - def key(self, value: str): - """Returns the same element with a custom key set. - - This can help render performance. See documentation for details. - """ - if self._render_count: - raise RuntimeError("Element keys should not be mutated after rendering") - self._key = value - return self - - def meta(self, **kwargs): - """Add metadata to the created widget. - - This can be used to find a widget for testing. - """ - self._meta = {**self._meta, **kwargs} - return self - - # the old name of is_shared - @property - def _shared(self) -> bool: - return self.is_shared - - @_shared.setter - def _shared(self, value: bool): - self.is_shared = value - - def shared(self): - self.is_shared = True - return self def __repr__(self): def format_arg(value): @@ -540,7 +438,7 @@ def _ipython_display_(self, **kwargs): def __enter__(self): rc = _get_render_context() - ca = ContainerAdder[W](self, "children") + ca = ContainerAdder(self, "children") assert rc.context is not None rc.container_adders.append(ca) return self @@ -712,10 +610,8 @@ def get(self) -> V: ... 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 - Element.__init__(self, component, args, kwargs) # type: ignore[arg-type] # (faster than super()) +class ValueElement(ValueElementBase, Element[W], Generic[W, V]): + # (the value_property and __init__ are in _fastcore.ValueElementBase) # TODO: we want to enable something like this, but requires a good hash function # for the key @@ -749,50 +645,10 @@ def connect(self, value: Value[V]): FuncT = TypeVar("FuncT", bound=Callable[..., Element]) -def find_elements(value: Union[Element, List, Tuple, Dict]) -> Set[Element]: - if isinstance(value, Element): - el = value - elements = {el} - if not isinstance(el.kwargs, dict): - raise RuntimeError("keyword arguments for {el} should be a dict, not {el.kwargs}") - elements |= find_elements(el.args) - elements |= find_elements(el.kwargs) - return elements - elif isinstance(value, (tuple, list)): - elements = set() - for child in value: - if isinstance(child, (Element, tuple, list, dict)): - elements |= find_elements(child) - return elements - elif isinstance(value, dict): - elements = set() - for child in value.values(): - if isinstance(child, (Element, tuple, list, dict)): - elements |= find_elements(child) - 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 - self.prop_name = prop_name - self.created: List[Element] = [] - - def add(self, el): - self.created.append(el) - - def collect(self): - children = set() - for el in self.created: - children |= find_elements(el) - {el} - top_level = [k for k in self.created if k not in children] - return top_level - - class ComponentWidget(Component): mime_bundle: Dict[str, Any] = mime_bundle_default widget: Type[widgets.Widget] @@ -836,6 +692,13 @@ def __eq__(self, rhs): def __repr__(self): return f"Component[{self.widget!r}]" + def __getnewargs__(self): + # (pickle/copy: __new__ needs the widget class; the default mime bundle is compared by + # identity, so it is not passed on) + if self.mime_bundle is mime_bundle_default: + return (self.widget,) + return (self.widget, self.mime_bundle) + def __call__(self, *args, **kwargs): el: Element = Element(self, args, kwargs) # TODO: temporary, we cannot change the constructor @@ -929,14 +792,8 @@ def __eq__(self, rhs): def __repr__(self): return f"react.component({self.f.__module__}.{self.f.__name__})" - def __call__(self, *args, **kwargs): - if self.value_name is not None: - el: Element = ValueElement(self.value_name, self, args, kwargs) - else: - el = Element(self, args, kwargs) - if self.mime_bundle is not mime_bundle_default: - el.mime_bundle = self.mime_bundle - return el + # make an element of this component (see _fastcore.component_call) + __call__ = _fastcore.component_call @overload @@ -4607,3 +4464,17 @@ def Fragment(children: List[Element]): # not a public api yet, used in solara for now only. # lifecycle of context objects are linked to the lifecycle of the component _component_context_manager_classes: List[Any] = [] + + +_fastcore._register(Element, ValueElement) + + +class _CoreModule(types.ModuleType): + # reacton.core.DEBUG = ... also sets it for the elements (made in _fastcore) + def __setattr__(self, name, value): + super().__setattr__(name, value) + if name == "DEBUG": + _fastcore.DEBUG = value + + +sys.modules[__name__].__class__ = _CoreModule diff --git a/reacton/fastcore_test.py b/reacton/fastcore_test.py new file mode 100644 index 0000000..10177eb --- /dev/null +++ b/reacton/fastcore_test.py @@ -0,0 +1,67 @@ +"""The building blocks in reacton._fastcore, compiled (setup_cython.py) or plain Python.""" + +import copy +import pickle +import sys +import weakref + +import ipywidgets as widgets + +import reacton +import reacton.core +from reacton import ipywidgets as w +from reacton._fastcore_import import _fastcore + + +def test_element_is_a_normal_python_object(): + button = w.Button(description="hi") + assert isinstance(button, reacton.core.Element) + assert isinstance(button, _fastcore.ElementBase) + # arbitrary attributes, weak references, generic aliases (used in type hints) + button.custom = 1 + assert button.custom == 1 + assert weakref.ref(button)() is button + assert reacton.core.Element[widgets.Button] is not None + assert reacton.core.ValueElement[widgets.IntSlider, int] is not None + slider = w.IntSlider(value=1) + assert isinstance(slider, reacton.core.ValueElement) + assert slider.value_property == "value" + # defaults + element: reacton.core.Element = reacton.core.Element(reacton.core.ComponentWidget(widget=widgets.Button)) + assert element.args == [] and element.kwargs == {} + assert element._key is None and element._meta == {} and element._event_handlers == () + assert not element.is_shared and element._render_count == 0 and not element._key_frozen + assert element.shared() is element and element.is_shared and element._shared + + +def test_element_pickle_and_copy(): + element = w.Button(description="hi").key("my-key").meta(name="x") + element.custom = [1, 2] + for clone in [pickle.loads(pickle.dumps(element)), copy.copy(element)]: + assert type(clone) is type(element) + assert clone.kwargs == {"description": "hi"} + assert clone._key == "my-key" + assert clone._meta == {"name": "x"} + assert clone.custom == [1, 2] + assert clone.component is element.component + slider = pickle.loads(pickle.dumps(w.IntSlider(value=3))) + assert slider.value_property == "value" and slider.kwargs == {"value": 3} + + +def test_debug_keeps_the_frame_that_made_the_element(): + @reacton.component + def Child(): + return w.Button() + + assert _fastcore.DEBUG == reacton.core.DEBUG + reacton.core.DEBUG = True + try: + assert _fastcore.DEBUG + button = w.Button(description="made here") + child = Child() + finally: + reacton.core.DEBUG = False + assert not _fastcore.DEBUG + this_function = sys._getframe(0).f_code.co_name + assert button.traceback.tb_frame.f_code.co_name == this_function + assert child.traceback.tb_frame.f_code.co_name == this_function diff --git a/setup_cython.py b/setup_cython.py new file mode 100644 index 0000000..59250a7 --- /dev/null +++ b/setup_cython.py @@ -0,0 +1,60 @@ +"""Optional: compile reacton's hot modules with Cython (pure Python mode). + + pip install "cython>=3.1,<3.2" setuptools + python setup_cython.py build_ext --inplace + +reacton does not need this: the same modules run as plain Python (pip install -e . works +without Cython). With the compiled modules next to the .py files, they are imported instead. +REACTON_CYTHON=0 forces the plain Python modules (reacton/_fastcore_import.py). Remove the +compiled modules with: python setup_cython.py clean_inplace +""" + +import glob +import os +import sys + +from Cython.Build import cythonize +from setuptools import Distribution, Extension +from setuptools.command.build_ext import build_ext + +HERE = os.path.dirname(os.path.abspath(__file__)) +MODULES = ["reacton._fastcore"] + + +def clean_inplace(): + for module in MODULES: + base = module.replace(".", "/") + for path in glob.glob(base + ".*.so") + glob.glob(base + ".*.pyd") + [base + ".c", base + ".html"]: + if os.path.exists(path): + os.remove(path) + print("removed", path) + + +def build_inplace(): + extensions = [Extension(module, [module.replace(".", "/") + ".py"]) for module in MODULES] + ext_modules = cythonize( + extensions, + compiler_directives={ + "language_level": 3, + # compiled functions behave like Python functions (methods bind, inspect.signature) + "binding": True, + }, + annotate=os.environ.get("REACTON_CYTHON_ANNOTATE") == "1", + ) + # (a Distribution of its own: setuptools.setup() would read reacton's hatch pyproject.toml) + dist = Distribution({"name": "reacton-cython-build", "ext_modules": ext_modules}) + command = build_ext(dist) + command.inplace = True + command.ensure_finalized() + command.run() + + +if __name__ == "__main__": + os.chdir(HERE) + args = sys.argv[1:] + if args == ["clean_inplace"]: + clean_inplace() + elif args in (["build_ext", "--inplace"], []): + build_inplace() + else: + sys.exit("usage: python setup_cython.py [build_ext --inplace | clean_inplace]") From 1c8f39d36f0824e6ea392c0e7cfce0833f341e59 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 04:17:42 +0200 Subject: [PATCH 05/16] Mount new components without the bookkeeping of the two phase walk The fused mount of phase 2 still wrote everything the update paths use, for every element of a new subtree: string keys, used_keys, and the elements, widgets, children, element_to_widget and resolved_kwargs dicts of every component. Most mounted components are never updated, so this was most of the reacton time of a mount. The mount is now in _fastcore (so it can be compiled). A mounted component (_MountedContext) keeps only its element tree positionally: the widgets and child contexts in the order they were made. The dicts are made from that, with the same keys, when an update path, get_widget or state_get first uses them; a mounted subtree that goes away is removed and closed from it, in the same order as before. The hook containers are made when a hook first needs them. The rare cases (state set or an exception during the mount, shared elements, a widget that fails to be made, an explicit key that could match a positional key) undo the mounts of the pass into the render bookkeeping of the two phase walk, as before. Restored state (state_set) is mounted the same way. The lazily made containers are slots: CPython shares the key table of instance dicts for up to 30 names per class, and more names made every attribute access of the update paths slower. Tests: the dicts made from a mounted tree have the keys of the two phase walk, removal (with some components whose dicts were made) cleans up and closes in the same order in both renderers, nested restored state. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.py | 800 ++++++++++++++++++++++++++++++++++++++++++- reacton/bqplot.py | 2 +- reacton/core.py | 569 +++++++++++------------------- reacton/core_test.py | 121 ++++++- 4 files changed, 1107 insertions(+), 385 deletions(-) diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index 1ee361c..b56e286 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -19,7 +19,7 @@ import sys import threading from types import TracebackType -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional # noqa: F401 (List: in a type comment) try: import cython @@ -305,3 +305,801 @@ def component_call(self, *args, **kwargs): # the code that called the component (see _keep_traceback) _keep_traceback(el, 0 if cython.compiled else 2) return el + + +# ============================================================================================ +# The mount of the fast renderer (reacton.core._RenderContextFast) +# +# A new component (a first render, a new list item, another component type at a key) is +# mounted in one walk: the bodies run as in the render phase, and the widgets of the new +# subtree are made children first. A mounted component (a _MountedContext) keeps its element +# tree positionally: `nodes` holds, in the order the widgets were made (children first), the +# widget of each widget element and the context of each component element. The dicts the +# update paths use (elements, widgets, children, element_to_widget, used_keys, +# resolved_kwargs, ...) are made from that only when they are used (materialize), with the +# same keys as the two phase walk. Reconciliation of the pass runs the effects (finish_mount). +# +# When the pass cannot keep the mounted widgets (a body sets state or raises, a shared +# element, a widget that fails to be made, an explicit key that could match a positional key), +# the mounts of the pass are undone (undo_mounts): the widgets are closed and the contexts +# get the render bookkeeping of the two phase walk, which then takes over. +# ============================================================================================ + +# what the mount uses from reacton.core (set by _register_core) +_core: Any = None +_ComponentFunction: Any = None +_ComponentWidget: Any = None +_ComponentContext: Any = None +_MountedContext: Any = None +_FragmentWidget: Any = None +_logger: Any = None +# values that cannot hold elements (the child visitors skip them) +_SCALAR_TYPES = frozenset([str, int, float, bool, complex, bytes, type(None)]) +# the dicts of a component context that are made from its nodes (see materialize) +_MATERIALIZED = frozenset(["elements", "widgets", "children", "element_to_widget", "used_keys", "resolved_kwargs", "elements_next", "children_next"]) +# the slots of reacton.core._MountedContext +_MOUNTED_SLOTS = ( + "nodes", + "compact_widget", + "elements", + "widgets", + "children", + "element_to_widget", + "used_keys", + "resolved_kwargs", + "elements_next", + "children_next", +) +# element classes that use the Element methods to make and remove a widget (see _plain_class) +_plain_classes: dict = {} +_EMPTY: dict = {} + + +def _register_core(core): + global _core, _ComponentFunction, _ComponentWidget, _ComponentContext, _MountedContext, _FragmentWidget, _logger + _core = core + _ComponentFunction = core.ComponentFunction + _ComponentWidget = core.ComponentWidget + _ComponentContext = core.ComponentContext + _MountedContext = core._MountedContext + _FragmentWidget = core.FragmentWidget + _logger = core.logger + + +def _plain_class(cls): + # an element class that makes, updates and removes its widget with the Element methods: the + # mount does that inline (a subclass that overrides one of them gets its own method called) + plain = _plain_classes.get(cls) + if plain is None: + element = _core.Element + plain = _plain_classes[cls] = ( + cls._create_widget is element._create_widget + and cls._close_widget is element._close_widget + and cls._cleanup_callbacks is element._cleanup_callbacks + and cls._split_kwargs is element._split_kwargs + and cls._get_widget_args is element._get_widget_args + ) + return plain + + +def _trait_names(component): + # the trait names of the widget class of a ComponentWidget (kept on it) + names = component.__dict__.get("_reacton_trait_names") + if names is None: + names = component._reacton_trait_names = frozenset(component.widget.class_trait_names()) + return names + + +class _Mount: + """The walk that mounts one new subtree (see mount_component).""" + + def __init__(self, rc): + self.rc = rc + # the widgets made during the mount (to find those made as a side effect, like Layout) + self.recording = [] + # every context made by a mount in this pass (to undo them), and the tops of the mounts + self.contexts = rc._mount_contexts + # the contexts of this mount, children first (effects run in this order) + self.order = [] + self.failed = False + # bodies that raised in this mount (then exceptions bubble up, see _mount_component) + self.raised = 0 + self.shared_next = rc._shared_elements_next + # context -> the explicit keys in its tree (the duplicate check) + self.keys = None + + +def mount_component(rc, el, key, parent_context, order, context): + """Mount the new component element el at key in parent_context (which renders in two phases). + + context: a context made by state_set (restored state), or None. Returns the root widget, + or None when this pass cannot keep the mount. + """ + m = _Mount(rc) + previous_recording = _core._start_recording_constructed(m.recording) + try: + c = _mount_component(m, el, parent_context, context, key) + c.order_in_parent = order + finally: + _core._stop_recording_constructed(previous_recording) + widget = c.compact_widget + if widget is not None and not m.failed: + c.mount_order = m.order + rc._mount_roots[c] = None + else: + # parts of the subtree were mounted, not all: undo at the end of the pass + rc._mount_failed = True + return widget + + +def _new_context(parent): + c = _MountedContext.__new__(_MountedContext) + c.parent = parent + c.nodes = [] + c.compact_widget = None + return c + + +def _adopt(precreated, parent): + # a context made by state_set (restored state) is mounted as a new one with its state; the + # component elements in its tree look up the pre-made contexts of their keys + context = _new_context(parent) + context.state = precreated.state + context.setters = precreated.setters + precreated_children = precreated.__dict__.get("children_next") + if precreated_children: + context.precreated_children = precreated_children + return context + + +def _mount_component(m, el, parent, context, key): + # key: the key of the top of a mount (in a parent that renders in two phases), else None + rc = m.rc + if context is None: + context = _new_context(parent) + else: + context = _adopt(context, parent) + context.invoke_element = el + m.contexts.append(context) + if key is not None: + # the top of the mount + context.key_in_parent = key + parent.children_next[key] = context + rc._mount_tops.append(context) + else: + parent.nodes.append(context) + manager_classes = _core._component_context_manager_classes + if manager_classes: + context.context_managers = [cm(el) for cm in manager_classes] + adders = rc.container_adders + if adders: + del adders[:] + rc.context = context + render_count = rc.render_count + raised = m.raised + root = None + try: + root = _call_body(rc, el, context) + except BaseException as e: + _logger.exception("Component %r raised exception %r", el.component, e) + context.exceptions_self.append(e) + rc._set_rerender_needed("Exception ocurred during render") + context.needs_render = True + m.raised += 1 + if rc.render_count != render_count: + raise RuntimeError("Recursive render detected, possible a bug in react") + widget = None + if root is not None: + if el._event_handlers: + _core._add_event_handlers(root, el._event_handlers, context, rc) + context.root_element = root + widget = _mount_node(m, root, context, "/" if context.precreated_children else None) + elif el.is_shared: + m.shared_next.discard(el) + rc.context = parent + if context.precreated_children is not None: + # pre-made (state_set) children that were not used + context.precreated_children = None + user_contexts = context.user_contexts + if user_contexts is not _EMPTY: + # (provide() made them) + context.user_contexts_prev = user_contexts + if m.raised != raised: + # exceptions in this subtree: as in _render_component + 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) + if context.exceptions_self or context.exceptions_children: + rc._mark_dirty(context) + if parent.exceptions_self or parent.exceptions_children: + if not rc._rerender_needed: + rc._set_rerender_needed("Exception ocurred during render") + parent.needs_render = True + if widget is not None: + context.compact_widget = widget + m.order.append(context) + return context + + +def _call_body(rc, el, context): + # the component function, inside its context managers (solara registers one) + managers = context.context_managers + if not managers: + root = _call_component(rc, el) + assert root is not None + elif len(managers) == 1: + with managers[0]: + root = _call_component(rc, el) + assert root is not None + else: + import contextlib + + with contextlib.ExitStack() as stack: + for manager in managers: + stack.enter_context(manager) + root = _call_component(rc, el) + assert root is not None + return root + + +def _call_component(rc, el): + """Run the component function, with an implicit container when it returns None.""" + component = el.component + default_container = _core._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 = rc._body_adder + created = [] # type: List[Any] + adder.created = created + container_adders = rc.container_adders + container_adders.append(adder) + try: + component.render_count += 1 + root_element = component.f(*el.args, **el.kwargs) + finally: + container_adders.pop() + adder.created = _core._NO_ELEMENTS + if root_element is None: + with default_container() as container: + # the container collects the same elements, the same way + rc.container_adders[-1].created.extend(created) + if len(container.kwargs["children"]) == 1: + root_element = container.kwargs["children"][0] + else: + root_element = container + return root_element + + +def _mount_node(m, el, c, dkey): + # The mount walk of an element in the tree of the component context c; returns its widget + # (None when this pass does not make widgets any more). dkey: the positional key of el, + # only in a context with pre-made children (else None: keys are made when needed). + if not isinstance(el, ElementBase): + raise TypeError(f"Expected element, not {el}") + key = el._key + if key is not None: + all_keys = m.keys + if all_keys is None: + all_keys = m.keys = {} + keys = all_keys.get(c) + if keys is None: + all_keys[c] = {key} + elif key in keys: + raise KeyError(f"Duplicate key {key!r}") + else: + keys.add(key) + if "/" in key: + # could be the same as a positional key: the two phase walk checks all keys + m.failed = True + else: + key = dkey + if el.is_shared: + # rendered once for the whole tree, by the two phase walk + m.failed = True + c.has_shared = True + if el in m.shared_next: + return None + m.shared_next.add(el) + el._render_count += 1 # (also freezes the key, see Element._key_frozen) + component = el.component + if type(component) is not _ComponentWidget and not isinstance(component, _ComponentWidget): + # a component element + if el.is_shared and (el.args or el.kwargs): + # the arguments of a shared element belong to the context it is rendered in + _mount_value(m, el.kwargs, c, key) + _mount_value(m, el.args, c, key) + precreated = None + precreated_children = c.precreated_children + if precreated_children: + precreated = precreated_children.pop(key, None) + child = _mount_component(m, el, c, precreated, None) + widget = child.compact_widget + if widget is not None and el._meta: + widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} + return widget + + # a widget element: first the elements in its kwargs (children first) + assert not el.args, "no positional args supported for widgets" + kwargs = el.kwargs + resolved = None + for name, value in kwargs.items(): + t = type(value) + if t in _SCALAR_TYPES: + continue + if t is list: + new_value = _mount_list(m, value, c, None if key is None else f"{key}{name}/") + elif isinstance(value, ElementBase): + new_value = _mount_node(m, value, c, None if key is None else f"{key}{name}/") + elif t is tuple or t is dict or isinstance(value, (list, tuple, dict)): + new_value = _mount_value(m, value, c, None if key is None else f"{key}{name}/") + else: + continue + if resolved is None: + resolved = dict(kwargs) + resolved[name] = new_value + rc = m.rc + if m.failed or rc._rerender_needed: + # this pass will be undone, do not make more widgets + return None + if resolved is None: + # (no copy: the constructor gets the kwargs unpacked) + resolved = kwargs + element_class = type(el) + plain = _plain_classes.get(element_class) + if plain is None: + plain = _plain_class(element_class) + recording = m.recording + count = len(recording) + if plain: + # Element._create_widget, with the recording of this mount + listeners = None + traits = _trait_names(component) + if not traits.issuperset(resolved): + for name in list(resolved): + if name.startswith("on_") and name not in traits: + if resolved is kwargs: + resolved = dict(kwargs) + if listeners is None: + listeners = {} + listeners[name] = resolved.pop(name) + try: + widget = component.widget(**resolved) + except Exception: + # let reconciliation make it (and handle the exception) as it always does + m.failed = True + return None + widget_class = type(widget) + if component.__dict__.get("_reacton_batched") is not widget_class: + if not getattr(widget_class.hold_trait_notifications, "_reacton_batched", False): + _core._install_batched_hold(widget_class) + component._reacton_batched = widget_class + widget._reacton_rc = rc + if el._meta: + widget._react_meta = dict(el._meta) + if listeners is None: + c.nodes.append(widget) + else: + for name, callback in listeners.items(): + if callback is not None: + el._add_widget_event_listener(widget, name, callback) # type: ignore[attr-defined] + c.nodes.append((el, widget)) + handlers = el._event_handlers + if handlers: + for handler in 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 = _core._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(dict(resolved)) # type: ignore[attr-defined] + except BaseException: + m.failed = True + return None + c.nodes.append((el, widget)) + if orphan_ids: + widgets_dict = _core._get_widgets_dict() + for orphan_widget in [widgets_dict[k] for k in orphan_ids]: + if _core._is_shared_ipyvue_template(orphan_widget): + orphan_ids.discard(orphan_widget.model_id) + if orphan_ids: + rc._orphans.setdefault(widget.model_id, set()).update(orphan_ids) + return widget + + +def _mount_list(m, value, c, dkey): + values = [] + index = 0 + for x in value: + if isinstance(x, ElementBase): + w = _mount_node(m, x, c, None if dkey is None else f"{dkey}{index}/") + if type(w) is _FragmentWidget: + values.extend(w.children) + else: + values.append(w) + elif type(x) in _SCALAR_TYPES: + values.append(x) + else: + w = _mount_value(m, x, c, None if dkey is None else f"{dkey}{index}/") + if type(w) is _FragmentWidget: + values.extend(w.children) + else: + values.append(w) + index += 1 + return values + + +def _mount_value(m, value, c, dkey): + # (as core._visit_children_values: lists, tuples and dicts become new plain ones) + t = type(value) + if t is list: + return _mount_list(m, value, c, dkey) + if t is tuple: + return tuple(_mount_list(m, value, c, dkey)) + if t is dict: + return {k: _mount_value(m, v, c, None if dkey is None else f"{dkey}{k}/") for k, v in value.items()} + if t in _SCALAR_TYPES: + return value + if isinstance(value, ElementBase): + return _mount_node(m, value, c, dkey) + if isinstance(value, (list, tuple)): + values = _mount_list(m, value, c, dkey) + return tuple(values) if isinstance(value, tuple) else values + if isinstance(value, dict): + return {k: _mount_value(m, v, c, None if dkey is None else f"{dkey}{k}/") for k, v in value.items()} + return value + + +# -------------------------------------------------------------------------------------------- +# after the mount: the effects (reconciliation), the dicts (materialize), the undo + + +def finish_mount(rc, root): + """Reconciliation of a mounted subtree (rc.context is the parent of root): run the effects, + children first, and hook the root widget into the parent.""" + parent_context = rc.context + order = root.mount_order + raised = False + try: + for context in order: + effects = context.effects + parent = context.parent + if effects: + rc.context = context + for effect in effects: + if effect.next is not None or effect.executed: + rc._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) + rc._set_rerender_needed("Exception ocurred during effect") + rc._mark_dirty(parent) + parent.needs_render = True + raised = True + if raised: + 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.compact_widget + el = root.invoke_element + key = root.key_in_parent + if el._meta: + widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} + parent_context.widgets[key] = widget + parent_context.element_to_widget[el] = widget + finally: + rc.context = parent_context + root.mount_order = None + rc._mount_roots.pop(root, None) + + +def materialize(c): + """Make the dicts of a mounted context from its nodes (the same keys as the two phase walk). + + For the update paths, get_widget, state_get: the first time they use one of the dicts. + """ + nodes = c.nodes + c.nodes = None + w = _Materialize(c, nodes) + root = c.root_element + if root is not None: + w.node(root, "/") + c.elements = w.elements + c.widgets = w.widgets + c.children = w.children + c.element_to_widget = w.element_to_widget + c.used_keys = w.used_keys + c.resolved_kwargs = w.resolved_kwargs + c.elements_next = {} + c.children_next = {} + + +def partial_element_to_widget(c): + """element -> widget of a context that is still being mounted: the elements the walk made a + widget for so far (it goes in the same order). For _find_widget (use_event on an element + of a parent, whose widget exists already).""" + w = _Materialize(c, c.nodes, partial=True) + root = c.root_element + if root is not None: + try: + w.node(root, "/") + except _StopWalk: + pass + return w.element_to_widget + + +class _StopWalk(Exception): + pass + + +class _Materialize: + def __init__(self, c, nodes, partial=False): + self.partial = partial + self.nodes = nodes + self.index = 0 + self.order = 0 + self.elements = {} + self.widgets = {} + self.children = {} + self.element_to_widget = {} + self.used_keys = set() + self.resolved_kwargs = {} + + def node(self, el, default_key): + key = el._key + if key is None: + key = default_key + self.used_keys.add(key) + self.elements[key] = el + component = el.component + if not isinstance(component, _ComponentWidget): + if self.partial and (self.index >= len(self.nodes) or self.nodes[self.index].compact_widget is None): + raise _StopWalk() + child = self.nodes[self.index] + self.index += 1 + self.children[key] = child + child.key_in_parent = key + child.order_in_parent = self.order + self.order += 1 + widget = child.compact_widget + self.widgets[key] = widget + self.element_to_widget[el] = widget + return widget + resolved = None + start = self.index + for name, value in el.kwargs.items(): + if type(value) in _SCALAR_TYPES: + continue + new_value = self.value(value, f"{key}{name}/") + if resolved is None: + resolved = dict(el.kwargs) + resolved[name] = new_value + if self.partial and self.index >= len(self.nodes): + raise _StopWalk() + node = self.nodes[self.index] + self.index += 1 + widget = node[1] if type(node) is tuple else node + self.widgets[key] = widget + self.element_to_widget[el] = widget + if self.index - 1 != start: + # elements in the kwargs (each one took a node): the kwargs the widget was made + # with, as the update path compares them (see _mount_node: without listeners) + assert resolved is not None + traits = _trait_names(component) + if not traits.issuperset(resolved): + for name in list(resolved): + if name.startswith("on_") and name not in traits: + del resolved[name] + self.resolved_kwargs[key] = resolved + return widget + + def value(self, value, key): + t = type(value) + if t is list or t is tuple or (t is not dict and isinstance(value, (list, tuple))): + values = [] + index = 0 + for x in value: + if type(x) in _SCALAR_TYPES: + values.append(x) + else: + w = self.node(x, f"{key}{index}/") if isinstance(x, ElementBase) else self.value(x, f"{key}{index}/") + if type(w) is _FragmentWidget: + values.extend(w.children) + else: + values.append(w) + index += 1 + return tuple(values) if isinstance(value, tuple) else values + if t is dict or isinstance(value, dict): + return {k: (x if type(x) in _SCALAR_TYPES else self.value(x, f"{key}{k}/")) for k, x in value.items()} + if isinstance(value, ElementBase): + return self.node(value, key) + return value + + +def undo_mounts(rc): + """Undo the mounts of a pass that cannot keep them (see the module comment): close their + widgets, and give their contexts the render bookkeeping of the two phase walk.""" + undo = _Undo(rc) + for top in rc._mount_tops: + if top.nodes is not None: + undo.component(top) + rc._mount_roots = {} + rc._mount_tops = [] + rc._mount_contexts = [] + rc._mount_failed = False + + +class _Undo: + # the walk of the mount again (depth first, the same order, for the shared elements), with + # the keys of the two phase walk + def __init__(self, rc): + self.rc = rc + self.widgets_dict = _core._get_widgets_dict() + self.shared_seen = set() + + def component(self, c): + nodes = c.nodes + c.nodes = None + for node in nodes: + if not isinstance(node, _ComponentContext): + _close_widget_node(self.rc, node, self.widgets_dict) + state = _UndoContext([node for node in nodes if isinstance(node, _ComponentContext)]) + root = c.root_element + if root is not None: + self.node(state, root, "/") + c.elements_next = state.elements_next + c.children_next = state.children_next + c.used_keys = state.used_keys + c.child_order_counter = state.order + c.root_element_next = root + c.root_element = None + c.elements = {} + c.children = {} + c.widgets = {} + c.element_to_widget = {} + c.resolved_kwargs = {} + c.compact_widget = None + c.mount_order = None + + def node(self, state, el, default_key): + key = el._key + if key is None: + key = default_key + state.used_keys.add(key) + if el.is_shared: + if el in self.shared_seen: + return + self.shared_seen.add(el) + state.elements_next[key] = el + if isinstance(el.component, _ComponentWidget): + for name, value in el.kwargs.items(): + if type(value) not in _SCALAR_TYPES: + self.value(state, value, f"{key}{name}/") + return + if el.is_shared and (el.args or el.kwargs): + self.value(state, el.kwargs, key) + self.value(state, el.args, key) + if state.index >= len(state.contexts): + # (the mount did not get this far) + return + child = state.contexts[state.index] + state.index += 1 + state.children_next[key] = child + child.key_in_parent = key + child.order_in_parent = state.order + state.order += 1 + if child.nodes is not None: + self.component(child) + + def value(self, state, value, key): + if isinstance(value, ElementBase): + self.node(state, value, key) + elif isinstance(value, (list, tuple)): + index = 0 + for x in value: + if type(x) not in _SCALAR_TYPES: + self.value(state, x, f"{key}{index}/") + index += 1 + elif isinstance(value, dict): + for k, x in value.items(): + if type(x) not in _SCALAR_TYPES: + self.value(state, x, f"{key}{k}/") + + +class _UndoContext: + def __init__(self, contexts): + self.contexts = contexts + self.index = 0 + self.order = 0 + self.elements_next = {} + self.children_next = {} + self.used_keys = set() + + +def _close_widget_node(rc, node, widgets_dict): + # close the widget of a node (and the widgets it made as a side effect) + if type(node) is tuple: + el, widget = node + else: + el = None + widget = node + orphans = rc._orphans.pop(widget.model_id, None) if rc._orphans else None + if orphans: + for orphan in orphans: + orphan_widget = widgets_dict.get(orphan) + if orphan_widget: + _core.close_widget(orphan_widget) + if el is not None: + el._cleanup_callbacks(widget) + el._close_widget(widget) + else: + # Element._close_widget, inline + close = widget.close + if callable(close): + close() + else: + _core.close_widget(widget) # logs the warning + widget.__dict__.pop("_reacton_rc", None) + + +def remove_mounted(rc, child_context, closing): + """Remove a mounted component (rc.context is its parent): the same order of effect + cleanups, handler removals and widget closes as reacton.core's _remove_element (or + _close_element when closing), from its nodes.""" + context = rc.context + child_context.exceptions_self = [] + child_context.exceptions_children = [] + rc.context = child_context + widgets_dict = _core._get_widgets_dict() + try: + for effect in child_context.effects: + if not effect._cleaned_up: + 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) + if not closing: + rc._set_rerender_needed("Exception ocurred during effect") + rc._mark_dirty(child_context) + effect._cleaned_up = True + if not closing: + 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) + rc._set_rerender_needed("Exception ocurred during effect") + rc._mark_dirty(child_context) + nodes = child_context.nodes + child_context.nodes = None + for node in nodes: + if isinstance(node, _ComponentContext): + if node.nodes is not None: + remove_mounted(rc, node, closing) + elif closing: + # (its dicts were made, e.g. by get_widget): as the two phase walk does + rc._close_component_context(node) + else: + rc._remove_component_context(node) + else: + _close_widget_node(rc, node, widgets_dict) + finally: + rc.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) diff --git a/reacton/bqplot.py b/reacton/bqplot.py index 3eb8bc3..ff6d34f 100644 --- a/reacton/bqplot.py +++ b/reacton/bqplot.py @@ -18,7 +18,7 @@ class FigureElement(Element[bqplot.Figure]): def __enter__(self): rc = _get_render_context() - ca = ContainerAdder[bqplot.Figure](self, "marks") + ca = ContainerAdder(self, "marks") rc.container_adders.append(ca) return self diff --git a/reacton/core.py b/reacton/core.py index ae49b72..0117cd8 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1097,7 +1097,11 @@ def provide(self, obj: T): context = rc.context assert context is not None prev = context.user_contexts_prev.get(self, self._default_value) - context.user_contexts[self] = obj + user_contexts = context.user_contexts + if user_contexts is _fastcore._EMPTY: + # (a mounted component shares one empty dict until it provides something) + user_contexts = context.user_contexts = {} + user_contexts[self] = obj if not utils.equals(prev, obj): for listener in context.context_listeners.get(self, []): listener() @@ -1303,13 +1307,16 @@ 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 + # A component mounted by the fast renderer (see _fastcore): its element tree in the order + # its widgets were made (children first), until the dicts are made from it + nodes: Optional[List[Any]] = None # 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 + compact_widget: Optional[ipywidgets.Widget] = None + # on the first component of a mounted subtree: its components, children first (the + # effects run in that order when reconciliation finishes the mount) mount_order: Optional[List["ComponentContext"]] = None + # during a mount: the contexts that state_set made for its children (restored state) + precreated_children: Optional[Dict[str, "ComponentContext"]] = None def __init__( self, @@ -1381,6 +1388,49 @@ def __getattr__(self, name): "exceptions_children": list, } _COMPONENT_CONTEXT_FIELDS = set(ComponentContext.__annotations__) + + +class _MountedContext(ComponentContext): + """A component mounted by the fast renderer in one walk (see _fastcore). + + Only what the mount needs is set. The dicts of the update paths (_fastcore._MATERIALIZED) + are made from its nodes when they are first used; the hook containers when a hook needs + them. The class defaults are the state after a first render. + + The containers made later are slots: reading one that is not set calls __getattr__ (which + makes them), and the instance dict keeps fewer names (CPython shares the key table of + instance dicts for up to 30 names per class; more makes every attribute access slower). + """ + + __slots__ = _fastcore._MOUNTED_SLOTS + needs_render = False + needs_render_descendant = False + state: Dict = None # type: ignore[assignment] # made by use_state + setters: Dict[str, Callable] = None # type: ignore[assignment] + memo: List[Any] = () # type: ignore[assignment] # made by use_memo/use_ref + effects: List["Effect"] = () # type: ignore[assignment] # made by use_effect + # one shared empty dict until provide() makes one (see UserContext.provide) + user_contexts = _fastcore._EMPTY + user_contexts_prev = _fastcore._EMPTY + + if not typing.TYPE_CHECKING: + + def __getattr__(self, name): + if name in _fastcore._MATERIALIZED: + if self.nodes is not None: + if self.compact_widget is None: + # still being mounted (or not kept): only look up what is there already + if name == "element_to_widget": + return _fastcore.partial_element_to_widget(self) + return {} if name != "used_keys" else set() + _fastcore.materialize(self) + return getattr(self, name) + elif name == "nodes" or name == "compact_widget": + # (not set: torn down, see _teardown_component_context) + return None + return ComponentContext.__getattr__(self, name) + + # use_state keys of the first 64 hooks of a component (str(index)) _STATE_KEYS = [str(i) for i in range(64)] @@ -1408,6 +1458,12 @@ def _teardown_component_context(context: ComponentContext): # (parent, invoke_element, root_element... are None), and every container is made # again, empty, when it is used (see ComponentContext.__getattr__). context.__dict__.clear() + if type(context) is _MountedContext: + for name in _fastcore._MOUNTED_SLOTS: + try: + delattr(context, name) + except AttributeError: + pass class RerenderReason: @@ -1553,6 +1609,10 @@ def close(self): while index < len(all_contexts): context = all_contexts[index] index += 1 + if context.nodes is not None: + # (a mounted component whose dicts were never made) + all_contexts.extend(node for node in context.nodes if isinstance(node, ComponentContext)) + continue all_contexts.extend(context.children.values()) if context.children_next: all_contexts.extend(context.children_next.values()) @@ -1594,6 +1654,10 @@ def state_get(self, context: Optional[ComponentContext] = None): if context is None: context = self.context_root data = {} + if context.state is None: + # (a mounted component without state: state_get hands out the live dict) + context.state = {} + context.setters = {} data["state"] = context.state if context.children: children_state = data["children"] = {} @@ -1613,6 +1677,9 @@ def use_memo(self, f, dependencies, debug_name: str = None, use_nonlocals=False) if len(self.context.memo) <= self.context.memo_index: value = f() memo = (value, dependencies) + if type(self.context.memo) is tuple: + # (a mounted component: made on first use) + self.context.memo = [] self.context.memo.append(memo) self.context.memo_index += 1 if logger.isEnabledFor(logging.DEBUG): @@ -1648,6 +1715,9 @@ def use_ref(self, initial_value): memo[index] = (value, []) else: value = Ref(initial_value) + if type(memo) is tuple: + # (a mounted component: made on first use) + memo = context.memo = [] memo.append((value, [])) context.memo_index = index + 1 return value @@ -1658,6 +1728,10 @@ def use_state(self, initial, key: str = None, eq: Callable[[Any, Any], bool] = N index = self.context.state_index key = _STATE_KEYS[index] if index < 64 else str(index) self.context.state_index = index + 1 + if self.context.state is None: + # (a mounted component: made on first use) + self.context.state = {} + self.context.setters = {} if key not in self.context.state: self.context.state[key] = initial if isinstance(initial, (list, dict, set)): @@ -1790,6 +1864,9 @@ def use_effect(self, effect: EffectCallable, dependencies=None): index = context.effect_index context.effect_index = index + 1 if len(effects) <= index: + if type(effects) is tuple: + # (a mounted component: made on first use) + effects = context.effects = [] effects.append(Effect(effect, dependencies)) if logger.isEnabledFor(logging.DEBUG): logger.debug("Initial effect = %r for index %r (%r)", effect, index, dependencies) @@ -2759,10 +2836,14 @@ class _RenderContextFast(_RenderContext): # # A new component (no previous context: a first render, a new list # 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 + # (_fastcore.mount_component): it runs the bodies as the render phase + # does, and creates the widgets of the new subtree children first. A + # mounted component (_MountedContext) keeps its element tree positionally + # (the widgets and child contexts in the order they were made); the dicts + # this class works with are made from that when they are first used + # (_fastcore.materialize), a mounted subtree that goes away is removed + # from it (_fastcore.remove_mounted). 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. @@ -2777,14 +2858,10 @@ class _RenderContextFast(_RenderContext): 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 + # the first components of the subtrees mounted in this render pass (see _unmount) + self._mount_tops: List[ComponentContext] = [] # 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 # records the elements a component body makes (see _call_component): one for all bodies, @@ -2821,38 +2898,13 @@ def _end_render_pass(self): # phase state, as if the widgets were never created self._unmount() else: - self._mount_created = [] + self._mount_tops = [] self._mount_contexts = [] self._mount_failed = False def _unmount(self): - widgets_dict = _get_widgets_dict() - 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) - 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.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 + # close the widgets of the mounts of this pass, back to the two phase render bookkeeping + _fastcore.undo_mounts(self) def _before_close(self): if self._mount_contexts or self._mount_roots: @@ -2878,33 +2930,11 @@ def _close_element(self, el: Element, default_key: str): 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) + if child_context.nodes is not None: + # a mounted component: its tree is in its nodes + _fastcore.remove_mounted(self, child_context, True) + else: + self._close_component_context(child_context) else: if el._leaf is not True: self._walk_children_values(el.kwargs, key, "", self._close_child) @@ -2934,6 +2964,38 @@ def _close_element(self, el: Element, default_key: str): def _close_child(self, el: Element, key: str, parent_key: str): self._close_element(el, key) + def _close_component_context(self, child_context: ComponentContext): + # the close of a component (self.context is its parent): effect cleanups, then its tree + context = self.context + assert context is not None + # 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) + def _discard_aborted_pass(self): self._unmount() super()._discard_aborted_pass() @@ -3065,14 +3127,14 @@ def _render_component(self, el: Element, key: str, parent_key: str, el_prev: Opt 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) + return _fastcore.mount_component(self, el, key, parent_context, order, None) 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) + return _fastcore.mount_component(self, el, key, parent_context, 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) + return _fastcore.mount_component(self, el, key, parent_context, order, None) 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 @@ -3244,275 +3306,17 @@ 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_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: - 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: - 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 - context = self.context - assert context is not None - if default_key == "/": - context.used_keys.clear() - context.child_order_counter = 0 - key = el._key - if key is None: - key = default_key - used_keys = context.used_keys - if key in used_keys: - raise KeyError(f"Duplicate key {key!r}") - used_keys.add(key) - 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 # (also freezes the key, see Element._key_frozen) - - component = el.component - if isinstance(component, ComponentWidget): - assert not el.args, "no positional args supported for widgets" - elements_seen = self._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 - 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) - 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() - 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 has_elements: - 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 - - # 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 - 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): 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.mounted = False - 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) + # Reconciliation of a mounted subtree (self.context is the parent of root): run the + # effects, children first, and hook the root widget into the parent + _fastcore.finish_mount(self, root) 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 + # (a mounted component makes its dicts here: they give the keys and the order) + context.children if len(dirty_children) > 1: children = sorted(dirty_children, key=lambda child: child.order_in_parent) else: @@ -3980,45 +3784,11 @@ def _remove_element(self, el: Element, default_key: str, parent_key): if el.is_shared: self._visit_children(el, key, parent_key, self._remove_element) child_context = context.children[key] - try: - self.context = child_context - # the element is going away, pending exceptions only matter if cleanup fails - child_context.exceptions_self = [] - child_context.exceptions_children = [] - for effect in child_context.effects: - try: - if not effect._cleaned_up: - effect.cleanup() - except BaseException as e: - 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") - 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: - try: - assert not child_context.elements, f"left over elements {child_context.elements}" - assert not child_context.element_to_widget, f"left over element_to_widget {child_context.element_to_widget}" - assert not child_context.widgets, f"left over widgets {child_context.widgets}" - assert not child_context.children, f"left over children {child_context.children}" - assert not child_context.owns, f"left over owns {child_context.owns}" - except Exception as e: - child_context.exceptions_self.append(e) - # restore context - 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) + if child_context.nodes is not None: + # a mounted component: its tree is in its nodes + _fastcore.remove_mounted(self, child_context, False) + else: + self._remove_component_context(child_context) del context.children[key] else: self._visit_children(el, key, parent_key, self._remove_element) @@ -4045,6 +3815,51 @@ def _remove_element(self, el: Element, default_key: str, parent_key): del context.element_to_widget[el] del context.elements[key] + def _remove_component_context(self, child_context: ComponentContext): + # the removal of a component (self.context is its parent): effect cleanups, use_event + # handlers, then its tree + context = self.context + assert context is not None + try: + self.context = child_context + # the element is going away, pending exceptions only matter if cleanup fails + child_context.exceptions_self = [] + child_context.exceptions_children = [] + for effect in child_context.effects: + try: + if not effect._cleaned_up: + effect.cleanup() + except BaseException as e: + 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") + 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="") + finally: + try: + assert not child_context.elements, f"left over elements {child_context.elements}" + assert not child_context.element_to_widget, f"left over element_to_widget {child_context.element_to_widget}" + assert not child_context.widgets, f"left over widgets {child_context.widgets}" + assert not child_context.children, f"left over children {child_context.children}" + assert not child_context.owns, f"left over owns {child_context.owns}" + except Exception as e: + child_context.exceptions_self.append(e) + # restore context + 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) + # 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. @@ -4311,7 +4126,6 @@ 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 @@ -4478,3 +4292,4 @@ def __setattr__(self, name, value): sys.modules[__name__].__class__ = _CoreModule +_fastcore._register_core(sys.modules[__name__]) diff --git a/reacton/core_test.py b/reacton/core_test.py index ca696b6..b70644d 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4560,10 +4560,11 @@ def App(): _get_widgets_dict()[model_id].close() -def _close_log(fast: bool, fail_in_cleanup: bool = False): +def _close_log(fast: bool, fail_in_cleanup: bool = False, remove: bool = False): # close a tree with nested components, effects and listeners; log the order of the - # effect cleanups and the widget closes + # effect cleanups and the widget closes. remove: first remove the tree in an update log: List[str] = [] + set_show = lambda value: None # noqa class LoggedButton(widgets.Button): def close(self): @@ -4583,8 +4584,13 @@ def Box(name, children): @react.component def Leaf(name): value, set_value = react.use_state(0) + button = LoggedButton.element(description=name, on_click=lambda: set_value(value + 1)) def effect(): + if name.endswith("1"): + # (the fast renderer makes the dicts of this mounted component here) + react.get_widget(button) + def cleanup(): log.append(f"cleanup {name}") if fail_in_cleanup and name == "b1": @@ -4593,7 +4599,7 @@ def cleanup(): return cleanup react.use_effect(effect, []) - return LoggedButton.element(description=name, on_click=lambda: set_value(value + 1)) + return button @react.component def Group(name, n): @@ -4605,16 +4611,27 @@ def effect(): @react.component def App(): + nonlocal set_show + show, set_show = react.use_state(True) + def effect(): return lambda: log.append("cleanup app") react.use_effect(effect, []) + if not show: + return Box("app", [LoggedButton.element(description="first")]) 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 + if remove: + try: + set_show(False) + except ValueError as e: + error = str(e) + log.append("removed") try: rc.close() except ValueError as e: @@ -4622,16 +4639,21 @@ def effect(): return log, error +@pytest.mark.parametrize("remove", [False, True]) @pytest.mark.parametrize("fail_in_cleanup", [False, True]) -def test_close_order_same_in_both_renderers(fail_in_cleanup): +def test_close_order_same_in_both_renderers(fail_in_cleanup, remove): + # (the fast renderer removes and closes a mounted subtree from its nodes; some of its + # components made their dicts, see Leaf) 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) + default_log, default_error = _close_log(False, fail_in_cleanup, remove) + fast_log, fast_error = _close_log(True, fail_in_cleanup, remove) finally: core.logger.setLevel(level) assert "cleanup app" in default_log and "close first" in default_log + if remove: + assert default_log.index("close b1") < default_log.index("removed") assert fast_log == default_log assert fast_error == default_error if fail_in_cleanup: @@ -4793,3 +4815,90 @@ def effect(): set_value(3) assert runs == ["a"] rc.close() + + +def _context_keys(rc): + # the keys of the dicts of every component context (the fast renderer makes the dicts of a + # mounted component from its nodes here) + out = {} + todo = [("", rc.context_root)] + while todo: + path, context = todo.pop() + out[path] = (sorted(context.elements), sorted(context.children), sorted(context.widgets), sorted(context.used_keys)) + todo.extend((f"{path}{key}|", child) for key, child in context.children.items()) + return out + + +def test_mounted_component_dicts_have_the_same_keys(): + # a mounted component keeps its tree positionally; the dicts made from it must have the + # keys of the two phase walk + @react.component + def Leaf(i): + return w.Button(description=f"leaf {i}") + + @react.component + def Wrapper(i): + return Leaf(i) + + @react.component + def App(): + return w.VBox( + children=[ + w.Label(value="first"), + Wrapper(1), + w.HBox(children=[Leaf(2), Leaf(3).key("three"), w.Button(description="x")]), + react.Fragment(children=[Leaf(4), w.Label(value="in fragment")]), + Leaf(5).key("five"), + ], + layout=w.Layout(width="10px"), + ) + + keys = {} + for fast in [False, True]: + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + box, rc = react.render_fixed(App(), handle_error=False) + keys[fast] = _context_keys(rc) + rc.close() + assert keys[True] == keys[False] + assert "three" in keys[True]["/|"][1] + + +def test_state_get_restores_nested_state(): + set_values: Dict[str, Callable] = {} + + @react.component + def Child(name): + value, set_value = react.use_state(0) + set_values[name] = set_value + return w.Button(description=f"{name} {value}") + + @react.component + def Parent(): + value, set_value = react.use_state(0) + set_values["parent"] = set_value + return w.VBox(children=[w.Label(value=f"parent {value}"), Child("a"), Child("b").key("b")]) + + @react.component + def App(): + return w.HBox(children=[Parent()]) + + def descriptions(widget): + if isinstance(widget, widgets.Box): + return [descriptions(child) for child in widget.children] + return getattr(widget, "description", None) or widget.value + + states = {} + for fast in [False, True]: + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + box, rc = react.render_fixed(App(), handle_error=False) + set_values["parent"](1) + set_values["a"](2) + set_values["b"](3) + state = states[fast] = rc.state_get() + rc.close() + # restored: the (mounted) components get their state back, at every level + container = widgets.VBox() + hbox, rc = react.render(App(), container, initial_state=state, handle_error=False) + assert descriptions(container.children[0]) == [["parent 1", "a 2", "b 3"]] + rc.close() + assert states[True] == states[False] From 557213ecb4ba4c654fae9d18f1bd6bd3ac713bfb Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 04:44:45 +0200 Subject: [PATCH 06/16] Compile the mount, the hooks and the listeners of the fast renderer With the mount in _fastcore, most of the time left was generic Python work per component and per element: attribute reads on the contexts and the render context, module attribute lookups, and the hooks and listener registration that were still interpreted. This types the mount in _fastcore.pxd (the elements are C fields, the walk is C functions), and moves the hot hooks there: use_state, use_ref, use_memo, use_effect and use_event do the work in _fastcore for the fast renderer (any other render context gets its own methods, as before), with the debug-log check made once per render. Setters of the fast renderer and the observers of on_ listeners are small objects instead of closures; the component element and ComponentWidget(widget=cls) are made without interpreted code. Uncompiled, the same code runs as plain Python. Behavior changes: - A use_state setter of the fast renderer is a callable object (_fastcore._Setter), not a function; it behaves the same (the same checks, warnings and render). The default renderer keeps its closure. - The observer of an on_ listener is a callable object (_fastcore._Listener), in both renderers. - reacton.core.use_state/use_ref/use_memo/use_effect and reacton.ipyvue.use_event are the _fastcore functions (compiled: not Python functions; inspect.signature and the docstrings work). - Assignments to reacton.core._default_container and _component_context_manager_classes are passed on to _fastcore. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 120 ++++++++ reacton/_fastcore.py | 655 ++++++++++++++++++++++++++++++++++++------ reacton/core.py | 113 ++++---- reacton/ipyvue.py | 106 +------ 4 files changed, 762 insertions(+), 232 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index e32517a..7fd3000 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -28,3 +28,123 @@ cdef class ElementBase: cdef class ValueElementBase(ElementBase): cdef public object value_property + + +# ---- the mount (reacton.core._RenderContextFast): contexts and the render context are plain +# Python objects (the update paths use them from Python), elements are typed + +cdef class _Mount: + cdef public object rc + cdef public list recording + cdef public list contexts + cdef public list order + cdef public bint failed + cdef public Py_ssize_t raised + cdef public set shared_next + cdef public dict keys + cdef public list adders + cdef public ContainerAdder body_adder + cdef public object top + + +cdef int _plain_class(object cls) except -1 +cdef object _trait_names(object component) + +@cython.locals(m=_Mount, c=object, widget=object) +cpdef object mount_component(object rc, ElementBase el, object key, object parent_context, object order, object context) + +cdef object _new_context(object parent) +cdef object _adopt(object precreated, object parent) + +@cython.locals(rc=object, precreated_children=object, nodes=list, managers=object, raised=Py_ssize_t, provides=Py_ssize_t, root=object, widget=object, adders=list, user_contexts=object) +cdef object _mount_component(_Mount m, ElementBase el, object parent, list parent_nodes, object context, object key) + +@cython.locals(root=object) +cdef object _call_body(_Mount m, ElementBase el, object managers) + +@cython.locals(component=object, default_container=object, adder=ContainerAdder, created=list, container_adders=list, root_element=object, kwargs=dict) +cdef object _call_component(_Mount m, ElementBase el) + +@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, callback_wrappers=dict, listener=object, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) +cdef object _mount_node(_Mount m, ElementBase el, object c, list nodes, object dkey) + +@cython.locals(values=list, index=Py_ssize_t, x=object, w=object) +cdef list _mount_list(_Mount m, object value, object c, list nodes, object dkey) + +cdef object _mount_value(_Mount m, object value, object c, list nodes, object dkey) + +@cython.locals(order=list, raised=bint, context=object, effects=object, parent=object, effect=object, widget=object, el=ElementBase, key=object) +cpdef object finish_mount(object rc, object root) + +@cython.locals(nodes=list, w=_Materialize, root=object) +cpdef object materialize(object c) + +cdef class _Materialize: + cdef public bint partial + cdef public list nodes + cdef public Py_ssize_t index + cdef public Py_ssize_t order + cdef public dict elements + cdef public dict widgets + cdef public dict children + cdef public dict element_to_widget + cdef public set used_keys + cdef public dict resolved_kwargs + + @cython.locals(key=object, component=object, child=object, widget=object, resolved=dict, start=Py_ssize_t, name=object, value=object, new_value=object, node=object, traits=frozenset) + cpdef object node(self, ElementBase el, object default_key) + + @cython.locals(t=object, values=list, index=Py_ssize_t, x=object, w=object) + cpdef object value(self, object value, object key) + + +@cython.locals(el=object, widget=object, orphans=object, orphan=object, orphan_widget=object, close=object) +cdef object _close_widget_node(object rc, object node, object widgets_dict) + +@cython.locals(context=object, widgets_dict=object, effect=object, cleanup=object, handler=object, nodes=list, node=object) +cpdef object remove_mounted(object rc, object child_context, bint closing) + + +# ---- the hooks + +cdef class RefBase: + cdef dict __dict__ + cdef object __weakref__ + cdef public object current + + +cdef class _EventHandler: + cdef public object rc, context, callback, event, widget, registered_event + cdef public bint removed + + +@cython.locals(rc=object, context=object, index=Py_ssize_t, state=dict, value=object, setters=dict, setter=object, eq_cell=list) +cpdef use_state(initial, key=*, eq=*) + +@cython.locals(rc=object) +cpdef use_ref(initial_value) + +@cython.locals(memo=object, index=Py_ssize_t, value=object, dependencies_previous=object) +cdef object _use_ref(object context, object initial_value) + +@cython.locals(rc=object, context=object, name=object, memo=object, index=Py_ssize_t, value=object, entry=tuple, dependencies_previous=object) +cpdef use_memo(f, dependencies=*, debug_name=*) + +@cython.locals(rc=object, context=object, effects=object, index=Py_ssize_t, previous_effect=object) +cpdef use_effect(effect, dependencies=*) + +@cython.locals(vue=object) +cdef object _is_vue(object component) + +@cython.locals(rc=object, context=object, ref=object, handler=object, component=object, events=object, handlers=tuple) +cpdef use_event(el, event_and_modifiers, callback) + + +cdef class _Listener: + cdef public object rc, context, name, widget, callback + + +cdef class _Setter: + cdef public object rc, context, key + cdef public list _reacton_eq + cdef public object created_stack diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index b56e286..973b4d2 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -44,6 +44,15 @@ # reacton.core's Element and ValueElement (see _register) _Element: Any = None _ValueElement: Any = None +# reacton.core._default_container (solara sets it; reacton.core forwards the assignment) +_default_container: Any = None +# reacton.core._component_context_manager_classes (the same list) +_component_context_manager_classes: list = [] +# the list of a ContainerAdder that is not on the stack (never appended to) +_NO_ELEMENTS: list = [] +# counts provide() calls (UserContext.provide): a mounted component only looks at its +# user_contexts when its body provided something +_provides = 0 def _register(element_class, value_element_class): @@ -298,7 +307,22 @@ def component_call(self, *args, **kwargs): if self.value_name is not None: el = _ValueElement(self.value_name, self, args, kwargs) else: - el = _Element(self, args, kwargs) + # ElementBase.__init__, inline (no type call). (A local annotation: not evaluated + # without Cython; the .pxd cannot declare a function with *args.) + new: ElementBase = ElementBase.__new__(_Element) + new.component = self + new.args = args or [] + new.kwargs = kwargs + rc = getattr(local, "rc", None) + if rc is not None: + container_adders = rc.container_adders + if container_adders: + adder = container_adders[-1] + if type(adder) is ContainerAdder: + adder.created.append(new) + else: + adder.add(new) + el = new if self.mime_bundle is not mime_bundle_default: el.mime_bundle = self.mime_bundle if DEBUG: @@ -307,6 +331,31 @@ def component_call(self, *args, **kwargs): return el +def component_widget_new(cls, widget, mime_bundle=mime_bundle_default): + """ComponentWidget.__new__ (see there).""" + if cls is _ComponentWidget and mime_bundle is mime_bundle_default: + try: + self = widget._reacton_component_widget + except AttributeError: + pass + else: + # (a subclass inherits the attribute of its base class) + if self.widget is widget: + return self + self = object.__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: + try: + widget._reacton_component_widget = self + except (AttributeError, TypeError): + # a class that does not take attributes: no cache + pass + return self + + # ============================================================================================ # The mount of the fast renderer (reacton.core._RenderContextFast) # @@ -364,27 +413,32 @@ def _register_core(core): _MountedContext = core._MountedContext _FragmentWidget = core.FragmentWidget _logger = core.logger + _register_hooks(core) def _plain_class(cls): - # an element class that makes, updates and removes its widget with the Element methods: the - # mount does that inline (a subclass that overrides one of them gets its own method called) - plain = _plain_classes.get(cls) - if plain is None: + # 0: an element class that overrides how its widget is made or removed (its own methods are + # called); 1: it makes and removes its widget with the Element methods (the mount does that + # inline); 2: also its event listeners + plain = _plain_classes.get(cls, -1) + if plain == -1: element = _core.Element - plain = _plain_classes[cls] = ( + plain = 0 + if ( cls._create_widget is element._create_widget and cls._close_widget is element._close_widget and cls._cleanup_callbacks is element._cleanup_callbacks and cls._split_kwargs is element._split_kwargs and cls._get_widget_args is element._get_widget_args - ) + ): + plain = 2 if cls._add_widget_event_listener is element._add_widget_event_listener else 1 + _plain_classes[cls] = plain return plain def _trait_names(component): # the trait names of the widget class of a ComponentWidget (kept on it) - names = component.__dict__.get("_reacton_trait_names") + names = component._reacton_trait_names if names is None: names = component._reacton_trait_names = frozenset(component.widget.class_trait_names()) return names @@ -407,6 +461,10 @@ def __init__(self, rc): self.shared_next = rc._shared_elements_next # context -> the explicit keys in its tree (the duplicate check) self.keys = None + self.adders = rc.container_adders + self.body_adder = rc._body_adder + # the context of the top of the mount + self.top = None # type: Any def mount_component(rc, el, key, parent_context, order, context): @@ -418,11 +476,11 @@ def mount_component(rc, el, key, parent_context, order, context): m = _Mount(rc) previous_recording = _core._start_recording_constructed(m.recording) try: - c = _mount_component(m, el, parent_context, context, key) + widget = _mount_component(m, el, parent_context, None, context, key) + c = m.top c.order_in_parent = order finally: _core._stop_recording_constructed(previous_recording) - widget = c.compact_widget if widget is not None and not m.failed: c.mount_order = m.order rc._mount_roots[c] = None @@ -433,10 +491,10 @@ def mount_component(rc, el, key, parent_context, order, context): def _new_context(parent): + # (compact_widget is set when the root widget is made) c = _MountedContext.__new__(_MountedContext) c.parent = parent c.nodes = [] - c.compact_widget = None return c @@ -447,18 +505,24 @@ def _adopt(precreated, parent): context.state = precreated.state context.setters = precreated.setters precreated_children = precreated.__dict__.get("children_next") - if precreated_children: - context.precreated_children = precreated_children + context.precreated_children = precreated_children if precreated_children else None return context -def _mount_component(m, el, parent, context, key): - # key: the key of the top of a mount (in a parent that renders in two phases), else None +def _mount_component(m, el, parent, parent_nodes, context, key): + # Returns the root widget (None when this pass cannot keep the mount). parent_nodes: the + # nodes of the parent (None for the top of a mount: key is its key in a parent that renders + # in two phases) rc = m.rc + precreated_children = None if context is None: - context = _new_context(parent) + context = _MountedContext.__new__(_MountedContext) + context.parent = parent + nodes = context.nodes = [] else: context = _adopt(context, parent) + precreated_children = context.precreated_children + nodes = context.nodes context.invoke_element = el m.contexts.append(context) if key is not None: @@ -466,44 +530,47 @@ def _mount_component(m, el, parent, context, key): context.key_in_parent = key parent.children_next[key] = context rc._mount_tops.append(context) + m.top = context else: - parent.nodes.append(context) - manager_classes = _core._component_context_manager_classes - if manager_classes: - context.context_managers = [cm(el) for cm in manager_classes] - adders = rc.container_adders + parent_nodes.append(context) + managers = None + if _component_context_manager_classes: + managers = context.context_managers = [cm(el) for cm in _component_context_manager_classes] + adders = m.adders if adders: del adders[:] rc.context = context - render_count = rc.render_count raised = m.raised + provides = _provides root = None try: - root = _call_body(rc, el, context) + root = _call_body(m, el, managers) except BaseException as e: _logger.exception("Component %r raised exception %r", el.component, e) context.exceptions_self.append(e) rc._set_rerender_needed("Exception ocurred during render") context.needs_render = True m.raised += 1 - if rc.render_count != render_count: - raise RuntimeError("Recursive render detected, possible a bug in react") + # (a nested render() from a body is refused by render() itself: it holds the lock) widget = None if root is not None: + if not isinstance(root, ElementBase): + raise TypeError(f"Expected element, not {root}") if el._event_handlers: _core._add_event_handlers(root, el._event_handlers, context, rc) context.root_element = root - widget = _mount_node(m, root, context, "/" if context.precreated_children else None) + widget = _mount_node(m, root, context, nodes, "/" if precreated_children else None) elif el.is_shared: m.shared_next.discard(el) rc.context = parent - if context.precreated_children is not None: + if precreated_children is not None: # pre-made (state_set) children that were not used context.precreated_children = None - user_contexts = context.user_contexts - if user_contexts is not _EMPTY: - # (provide() made them) - context.user_contexts_prev = user_contexts + if _provides != provides: + user_contexts = context.user_contexts + if user_contexts is not _EMPTY: + # (provide() made them) + context.user_contexts_prev = user_contexts if m.raised != raised: # exceptions in this subtree: as in _render_component if context.exceptions_self or context.exceptions_children and not context.exception_handler: @@ -518,18 +585,17 @@ def _mount_component(m, el, parent, context, key): if widget is not None: context.compact_widget = widget m.order.append(context) - return context + return widget -def _call_body(rc, el, context): +def _call_body(m, el, managers): # the component function, inside its context managers (solara registers one) - managers = context.context_managers if not managers: - root = _call_component(rc, el) + root = _call_component(m, el) assert root is not None elif len(managers) == 1: with managers[0]: - root = _call_component(rc, el) + root = _call_component(m, el) assert root is not None else: import contextlib @@ -537,15 +603,15 @@ def _call_body(rc, el, context): with contextlib.ExitStack() as stack: for manager in managers: stack.enter_context(manager) - root = _call_component(rc, el) + root = _call_component(m, el) assert root is not None return root -def _call_component(rc, el): +def _call_component(m, el): """Run the component function, with an implicit container when it returns None.""" component = el.component - default_container = _core._default_container + default_container = _default_container if default_container is None: component.render_count += 1 return component.f(*el.args, **el.kwargs) @@ -553,21 +619,25 @@ def _call_component(rc, el): # (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 = rc._body_adder + adder = m.body_adder created = [] # type: List[Any] adder.created = created - container_adders = rc.container_adders + container_adders = m.adders container_adders.append(adder) try: component.render_count += 1 - root_element = component.f(*el.args, **el.kwargs) + kwargs = el.kwargs + if kwargs: + root_element = component.f(*el.args, **kwargs) + else: + root_element = component.f(*el.args) finally: container_adders.pop() - adder.created = _core._NO_ELEMENTS + adder.created = _NO_ELEMENTS if root_element is None: with default_container() as container: # the container collects the same elements, the same way - rc.container_adders[-1].created.extend(created) + container_adders[-1].created.extend(created) if len(container.kwargs["children"]) == 1: root_element = container.kwargs["children"][0] else: @@ -575,12 +645,11 @@ def _call_component(rc, el): return root_element -def _mount_node(m, el, c, dkey): - # The mount walk of an element in the tree of the component context c; returns its widget - # (None when this pass does not make widgets any more). dkey: the positional key of el, - # only in a context with pre-made children (else None: keys are made when needed). - if not isinstance(el, ElementBase): - raise TypeError(f"Expected element, not {el}") +def _mount_node(m, el, c, nodes, dkey): + # The mount walk of an element in the tree of the component context c (nodes: its nodes); + # returns its widget (None when this pass does not make widgets any more). dkey: the + # positional key of el, only in a context with pre-made children (else None: keys are + # made when needed). key = el._key if key is not None: all_keys = m.keys @@ -611,14 +680,15 @@ def _mount_node(m, el, c, dkey): # a component element if el.is_shared and (el.args or el.kwargs): # the arguments of a shared element belong to the context it is rendered in - _mount_value(m, el.kwargs, c, key) - _mount_value(m, el.args, c, key) + _mount_value(m, el.kwargs, c, nodes, key) + _mount_value(m, el.args, c, nodes, key) precreated = None - precreated_children = c.precreated_children - if precreated_children: - precreated = precreated_children.pop(key, None) - child = _mount_component(m, el, c, precreated, None) - widget = child.compact_widget + if dkey is not None: + # (a context with pre-made children, see _adopt) + precreated_children = c.precreated_children + if precreated_children: + precreated = precreated_children.pop(key, None) + widget = _mount_component(m, el, c, nodes, precreated, None) if widget is not None and el._meta: widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta} return widget @@ -632,11 +702,11 @@ def _mount_node(m, el, c, dkey): if t in _SCALAR_TYPES: continue if t is list: - new_value = _mount_list(m, value, c, None if key is None else f"{key}{name}/") + new_value = _mount_list(m, value, c, nodes, None if key is None else f"{key}{name}/") elif isinstance(value, ElementBase): - new_value = _mount_node(m, value, c, None if key is None else f"{key}{name}/") + new_value = _mount_node(m, value, c, nodes, None if key is None else f"{key}{name}/") elif t is tuple or t is dict or isinstance(value, (list, tuple, dict)): - new_value = _mount_value(m, value, c, None if key is None else f"{key}{name}/") + new_value = _mount_value(m, value, c, nodes, None if key is None else f"{key}{name}/") else: continue if resolved is None: @@ -650,12 +720,12 @@ def _mount_node(m, el, c, dkey): # (no copy: the constructor gets the kwargs unpacked) resolved = kwargs element_class = type(el) - plain = _plain_classes.get(element_class) - if plain is None: + plain = _plain_classes.get(element_class, -1) + if plain == -1: plain = _plain_class(element_class) recording = m.recording count = len(recording) - if plain: + if plain != 0: # Element._create_widget, with the recording of this mount listeners = None traits = _trait_names(component) @@ -674,7 +744,7 @@ def _mount_node(m, el, c, dkey): m.failed = True return None widget_class = type(widget) - if component.__dict__.get("_reacton_batched") is not widget_class: + if component._reacton_batched is not widget_class: if not getattr(widget_class.hold_trait_notifications, "_reacton_batched", False): _core._install_batched_hold(widget_class) component._reacton_batched = widget_class @@ -682,12 +752,21 @@ def _mount_node(m, el, c, dkey): if el._meta: widget._react_meta = dict(el._meta) if listeners is None: - c.nodes.append(widget) + nodes.append(widget) else: - for name, callback in listeners.items(): - if callback is not None: - el._add_widget_event_listener(widget, name, callback) # type: ignore[attr-defined] - c.nodes.append((el, widget)) + if plain == 2: + # Element._add_widget_event_listener, inline (rc.context is c) + callback_wrappers = _Element._callback_wrappers + for name, callback in listeners.items(): + if callback is not None: + listener = _Listener(rc, c, name, widget, callback) + callback_wrappers[(widget.model_id, name, callback)] = listener + widget.observe(listener, name[3:]) + else: + for name, callback in listeners.items(): + if callback is not None: + el._add_widget_event_listener(widget, name, callback) # type: ignore[attr-defined] + nodes.append((el, widget)) handlers = el._event_handlers if handlers: for handler in handlers: @@ -702,7 +781,7 @@ def _mount_node(m, el, c, dkey): except BaseException: m.failed = True return None - c.nodes.append((el, widget)) + nodes.append((el, widget)) if orphan_ids: widgets_dict = _core._get_widgets_dict() for orphan_widget in [widgets_dict[k] for k in orphan_ids]: @@ -713,12 +792,12 @@ def _mount_node(m, el, c, dkey): return widget -def _mount_list(m, value, c, dkey): +def _mount_list(m, value, c, nodes, dkey): values = [] index = 0 for x in value: if isinstance(x, ElementBase): - w = _mount_node(m, x, c, None if dkey is None else f"{dkey}{index}/") + w = _mount_node(m, x, c, nodes, None if dkey is None else f"{dkey}{index}/") if type(w) is _FragmentWidget: values.extend(w.children) else: @@ -726,7 +805,7 @@ def _mount_list(m, value, c, dkey): elif type(x) in _SCALAR_TYPES: values.append(x) else: - w = _mount_value(m, x, c, None if dkey is None else f"{dkey}{index}/") + w = _mount_value(m, x, c, nodes, None if dkey is None else f"{dkey}{index}/") if type(w) is _FragmentWidget: values.extend(w.children) else: @@ -735,24 +814,24 @@ def _mount_list(m, value, c, dkey): return values -def _mount_value(m, value, c, dkey): +def _mount_value(m, value, c, nodes, dkey): # (as core._visit_children_values: lists, tuples and dicts become new plain ones) t = type(value) if t is list: - return _mount_list(m, value, c, dkey) + return _mount_list(m, value, c, nodes, dkey) if t is tuple: - return tuple(_mount_list(m, value, c, dkey)) + return tuple(_mount_list(m, value, c, nodes, dkey)) if t is dict: - return {k: _mount_value(m, v, c, None if dkey is None else f"{dkey}{k}/") for k, v in value.items()} + return {k: _mount_value(m, v, c, nodes, None if dkey is None else f"{dkey}{k}/") for k, v in value.items()} if t in _SCALAR_TYPES: return value if isinstance(value, ElementBase): - return _mount_node(m, value, c, dkey) + return _mount_node(m, value, c, nodes, dkey) if isinstance(value, (list, tuple)): - values = _mount_list(m, value, c, dkey) + values = _mount_list(m, value, c, nodes, dkey) return tuple(values) if isinstance(value, tuple) else values if isinstance(value, dict): - return {k: _mount_value(m, v, c, None if dkey is None else f"{dkey}{k}/") for k, v in value.items()} + return {k: _mount_value(m, v, c, nodes, None if dkey is None else f"{dkey}{k}/") for k, v in value.items()} return value @@ -1103,3 +1182,419 @@ def remove_mounted(rc, child_context, closing): # child does not handle exceptions, so bubble up context.exceptions_children.extend(child_context.exceptions_self) context.exceptions_children.extend(child_context.exceptions_children) + + +# ============================================================================================ +# The hooks. reacton.core's use_state, use_ref, use_memo, use_effect and reacton.ipyvue's +# use_event are these functions: for the fast renderer they do the work here (the same as the +# methods of reacton.core._RenderContext); any other render context gets its own methods. +# ============================================================================================ + +_FastRC: Any = None # reacton.core._RenderContextFast +_Ref: Any = None +_Effect: Any = None +_logging_debug = 10 +# logging.DEBUG enabled for reacton (set at the start of every render, see core.render) +_log_debug = False +_STATE_KEYS = [str(i) for i in range(64)] +_VueWidget: Any = None + + +class RefBase: + """The data of reacton.core.Ref (use_ref).""" + + if not cython.compiled: + current: Any + + def __init__(self, initial_value): + self.current = initial_value + + +def _register_hooks(core): + global _FastRC, _Ref, _Effect, _RerenderReason + _FastRC = core._RenderContextFast + _Ref = core.Ref + _Effect = core.Effect + _RerenderReason = core.RerenderReason + + +def use_state(initial, key=None, eq=None): + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + if type(rc) is not _FastRC: + return rc.use_state(initial, key, eq) + context = rc.context + if key is None: + index = context.state_index + key = _STATE_KEYS[index] if index < 64 else str(index) + context.state_index = index + 1 + state = context.state + if state is None: + # (a mounted component: made on first use) + state = context.state = {} + context.setters = {} + if key not in state: + state[key] = initial + if type(initial) not in _SCALAR_TYPES: + if isinstance(initial, (list, dict, set)): + context.state_metadata[key] = len(initial) + elif utils.isinstance_lazy(initial, "pandas.DataFrame"): + context.state_metadata[key] = utils.dataframe_fingerprint(initial) + if _log_debug: + _logger.debug("Initial state = %r for key %r (%r)", initial, key, id(context)) + value = initial + else: + value = state[key] + if _log_debug: + _logger.debug("Got state = %r for key %r (%r)", value, key, id(context)) + # one setter per state key, made once (see core._RenderContext.use_state) + setters = context.setters + setter = setters.get(key) + if setter is None: + setter = setters[key] = _Setter(rc, context, key, eq) + else: + eq_cell = setter._reacton_eq + if eq_cell[0] is not eq: + # the latest eq, as when a setter was made every render + eq_cell[0] = eq + return value, setter + + +def use_ref(initial_value): + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + if type(rc) is not _FastRC: + return rc.use_ref(initial_value) + return _use_ref(rc.context, initial_value) + + +def _use_ref(context, initial_value): + # use_memo(lambda: Ref(initial_value), []), without the closure and the extra calls + 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) + if type(memo) is tuple: + # (a mounted component: made on first use) + memo = context.memo = [] + memo.append((value, [])) + context.memo_index = index + 1 + return value + + +def use_memo(f, dependencies=None, debug_name=None): + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + if debug_name is None: + debug_name = f.__name__ + if dependencies is None: + import inspect + + dependencies = inspect.getclosurevars(f).nonlocals + dependencies = {k: v for k, v in dependencies.items() if not k.startswith("__")} + if type(rc) is not _FastRC: + return rc.use_memo(f, dependencies, debug_name) + context = rc.context + name = debug_name or "no-name" + memo = context.memo + index = context.memo_index + if len(memo) <= index: + value = f() + entry = (value, dependencies) + if type(memo) is tuple: + # (a mounted component: made on first use) + memo = context.memo = [] + memo.append(entry) + context.memo_index = index + 1 + if _log_debug: + _logger.debug("Initial memo = %r for index %r (debug-name: %r)", entry, index, name) + return value + entry = memo[index] + value, dependencies_previous = entry + if utils.equals(dependencies_previous, dependencies): + if _log_debug: + _logger.debug("Got memo hit = %r for index %r (debug-name: %r)", entry, index, name) + else: + if _log_debug: + _logger.debug("Replace memo with = %r for index %r (debug-name: %r)", entry, index, name) + value = f() + memo[index] = (value, dependencies) + context.memo_index = index + 1 + return value + + +def use_effect(effect, dependencies=None): + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + if type(rc) is not _FastRC: + return rc.use_effect(effect, dependencies) + context = rc.context + effects = context.effects + index = context.effect_index + context.effect_index = index + 1 + if len(effects) <= index: + if type(effects) is tuple: + # (a mounted component: made on first use) + effects = context.effects = [] + effects.append(_Effect(effect, dependencies)) + if _log_debug: + _logger.debug("Initial effect = %r for index %r (%r)", effect, index, dependencies) + else: + previous_effect = effects[index] + if _log_debug: + _logger.debug("Setting next effect = %r for index %r (%r)", effect, index, dependencies) + if previous_effect.executed: + if dependencies is not None and utils.equals(previous_effect.dependencies, dependencies): + # the same dependencies: the effect does not run again + 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 + effects[index] = _Effect(effect, dependencies) + + +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. + """ + + def __init__(self, rc, context, event_and_modifiers, callback): + self.rc = rc + self.context = context + self.callback = callback + self.event = event_and_modifiers + self.widget = None + self.registered_event = 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 _is_vue(component): + # the widget class of a ComponentWidget is an ipyvue widget (kept on the component) + vue = component._reacton_vue + if vue is None: + global _VueWidget + if _VueWidget is None: + import ipyvue + + _VueWidget = ipyvue.VueWidget + vue = component._reacton_vue = issubclass(component.widget, _VueWidget) + return vue + + +def use_event(el, event_and_modifiers, callback): + """reacton.ipyvue.use_event (see there).""" + rc = getattr(local, "rc", None) + if rc is None: + raise RuntimeError("No render context") + context = rc.context + if type(rc) is _FastRC: + ref = _use_ref(context, None) + else: + 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 + # 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. + component = el.component + if isinstance(component, _ComponentWidget) and _is_vue(component): + events = el.kwargs.get("_events") + if events is None: + el.kwargs["_events"] = [event_and_modifiers] + elif event_and_modifiers not in events: + # do not mutate the list, it could be shared with a previous element + el.kwargs["_events"] = [*events, event_and_modifiers] + + handlers = el._event_handlers + if handler not in handlers: + if el._render_count: + _core._add_event_handlers(el, (handler,), context, rc) + else: + el._event_handlers = (*handlers, handler) + + +class _Listener: + """The observer of an on_ event listener (Element._add_widget_event_listener). + + It calls the callback with the new value. An exception goes to the component that + rendered the element (a component can catch the exception of a direct child). + """ + + def __init__(self, rc, context, name, widget, callback): + self.rc = rc + self.context = context + self.name = name + self.widget = widget + self.callback = callback + + def __call__(self, change): + if getattr(local, "events_supressed", False): + return + _logger.info("event %r on %r with %r", self.name, self.widget, change) + try: + self.callback(change["new"]) + 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 + self.context.exceptions_children.append(e) + self.rc.force_update() + + +class _Setter: + """The setter of one use_state key of the fast renderer, made once per key (like React's + setState). The same as the set_ closure of reacton.core._RenderContext.make_setter.""" + + def __init__(self, rc, context, key, eq): + # NOTE: rc and context are held strongly, and that is a requirement: a setter may be + # the ONLY reference keeping its component context alive. close() empties the + # contexts to break the cycles. + self.rc = rc + self.context = context + self.key = key + # (the latest eq, updated by use_state; a list, as the closure version had) + self._reacton_eq = [eq] + self.created_stack = None + if DEBUG: + import traceback + + self.created_stack = traceback.format_stack() + + def __call__(self, value): + rc = self.rc + if rc._closing: + # the render context is closed (or closing) and the tree is (being) torn down: + # nothing to update. (After close, context.state is empty.) + return + context = self.context + key = self.key + eq = self._reacton_eq[0] + state = context.state + if callable(value): + value = value(state[key]) + if _logger.isEnabledFor(20): + _logger.info("Set state = %r for key %r (previous value was %r) (%r)", value, key, state[key], id(rc.context)) + should_update = False + new_metadata = None + if eq is None: + previous = state[key] + if previous is value and isinstance(value, (list, dict, set)): + new_metadata = len(value) + if context.state_metadata[key] != new_metadata: + _warn_mutated( + "You are setting the state with the same object, this will usually not trigger a rerender. " + f"The length of {value} changed compared to the previous time it was set. Are you mutating an existing state object? " + "A common mistake is appending to a list, mutating a dict or set, etc." + ) + should_update = True + if previous is value and utils.isinstance_lazy(value, "pandas.DataFrame"): + new_metadata = utils.dataframe_fingerprint(value) + if context.state_metadata[key] != new_metadata: + _warn_mutated( + "You are setting the state with the dataframe, this will usually not trigger a rerender. " + "We noticed the ids of the dataframe series are changed. Are you mutating an dataframe? " + "Consider making a copy of the dataframe." + ) + should_update = True + equals = eq or utils.equals + should_update = not equals(state[key], value) or should_update + if should_update: + prev_value = state[key] + state[key] = value + if isinstance(value, (list, dict, set)) and new_metadata is None: + new_metadata = len(value) + if new_metadata is None and utils.isinstance_lazy(value, "pandas.DataFrame"): + new_metadata = utils.dataframe_fingerprint(value) + context.state_metadata[key] = new_metadata + context.needs_render = True + rc._mark_dirty(context) + if rc._rerender_needed is False: + if DEBUG: + import traceback + + reason = _RerenderReason( + reason=f"state changed with key {key}", + prev_value=prev_value, + next_value=value, + created_stack=self.created_stack, + trigger_stack=traceback.format_stack(), + ) + else: + reason = _RerenderReason(reason=f"state changed with key {key}", prev_value=prev_value, next_value=value) + rc._rerender_needed_reasons.append(reason) + rc._rerender_needed = True + rc._possible_rerender() + + +_RerenderReason: Any = None + + +def _warn_mutated(message): + import warnings + + # (point at the code that called the setter: a compiled function has no frame of its own) + warnings.warn(message, UserWarning, stacklevel=2 if cython.compiled else 3) diff --git a/reacton/core.py b/reacton/core.py index 0117cd8..02ff6c7 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -57,8 +57,9 @@ # the hot building blocks (compiled when reacton was built with Cython, see _fastcore.py) if typing.TYPE_CHECKING: - from ._fastcore import ContainerAdder, ElementBase, ValueElementBase, find_elements, local, mime_bundle_default, widget_render_error_msg + from ._fastcore import ContainerAdder, ElementBase, RefBase, ValueElementBase, find_elements, local, mime_bundle_default, widget_render_error_msg else: + RefBase = _fastcore.RefBase ContainerAdder = _fastcore.ContainerAdder ElementBase = _fastcore.ElementBase ValueElementBase = _fastcore.ValueElementBase @@ -571,18 +572,15 @@ def _update_widget_event_listener(self, widget: widgets.Widget, name: str, callb self._add_widget_event_listener(widget, name, callback) def _add_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable): - target_name = name[3:] - callback_exception_safe = _event_handler_exception_wrapper(callback) - - def on_change(change): - if are_events_supressed(): - return - logger.info("event %r on %r with %r", name, widget, change) - callback_exception_safe(change["new"]) - + # the observer calls the callback with the new value (exceptions go to the component + # that renders this element, see _fastcore._Listener) + rc = get_render_context() + context = rc.context + assert context is not None + on_change = _fastcore._Listener(rc, context, name, widget, callback) key = (widget.model_id, name, callback) self._callback_wrappers[key] = on_change - widget.observe(on_change, target_name) + widget.observe(on_change, name[3:]) def _remove_widget_event_listener(self, widget: widgets.Widget, name: str, callback: Callable): target_name = name[3:] @@ -646,41 +644,31 @@ def connect(self, value: Value[V]): # the list of a ContainerAdder that is not on the stack (never appended to) -_NO_ELEMENTS: List["Element"] = [] +_NO_ELEMENTS: List["Element"] = _fastcore._NO_ELEMENTS class ComponentWidget(Component): mime_bundle: Dict[str, Any] = mime_bundle_default widget: Type[widgets.Widget] + # (for the mount, see _fastcore: the trait names, and the widget class whose + # hold_trait_notifications batches renders) + _reacton_trait_names: Optional[frozenset] = None + _reacton_batched: Optional[type] = None + # (for use_event: the widget class is an ipyvue widget) + _reacton_vue: Optional[bool] = None + + # 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). It is kept on the widget class itself: a class attribute + # read is much cheaper than a WeakValueDictionary lookup, and the class -> instance -> + # class cycle is freed by gc like any class (a class made at runtime, e.g. by a hot reload, + # is not kept alive). (see _fastcore.component_widget_new) + if typing.TYPE_CHECKING: + + def __new__(cls, widget: Type[widgets.Widget], mime_bundle=mime_bundle_default): ... - 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). It is kept on the widget class itself: - # a class attribute read is much cheaper than a WeakValueDictionary lookup, and the - # class -> instance -> class cycle is freed by gc like any class (a class made at - # runtime, e.g. by a hot reload, is not kept alive). - if cls is ComponentWidget and mime_bundle is mime_bundle_default: - try: - self = widget._reacton_component_widget # type: ignore[attr-defined] - except AttributeError: - pass - else: - # (a subclass inherits the attribute of its base class) - if self.widget is widget: - 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: - try: - widget._reacton_component_widget = self # type: ignore[attr-defined] - except (AttributeError, TypeError): - # a class that does not take attributes: no cache - pass - return self + else: + __new__ = staticmethod(_fastcore.component_widget_new) def __eq__(self, rhs): if self is rhs: @@ -1075,9 +1063,9 @@ def clear(): return exception, clear -class Ref(Generic[T]): - def __init__(self, initial_value: T): - self.current = initial_value +class Ref(RefBase, Generic[T]): + # (current, and __init__(initial_value), are in _fastcore.RefBase) + current: T def use_ref(initial_value: T) -> Ref[T]: @@ -1102,6 +1090,7 @@ def provide(self, obj: T): # (a mounted component shares one empty dict until it provides something) user_contexts = context.user_contexts = {} user_contexts[self] = obj + _fastcore._provides += 1 if not utils.equals(prev, obj): for listener in context.context_listeners.get(self, []): listener() @@ -1432,7 +1421,7 @@ def __getattr__(self, name): # use_state keys of the first 64 hooks of a component (str(index)) -_STATE_KEYS = [str(i) for i in range(64)] +_STATE_KEYS = _fastcore._STATE_KEYS TEffect = TypeVar("TEffect", bound="Effect") @@ -1460,10 +1449,9 @@ def _teardown_component_context(context: ComponentContext): context.__dict__.clear() if type(context) is _MountedContext: for name in _fastcore._MOUNTED_SLOTS: - try: - delattr(context, name) - except AttributeError: - pass + # (set first: deleting an unset slot raises) + setattr(context, name, None) + delattr(context, name) class RerenderReason: @@ -1969,6 +1957,8 @@ def render(self, element: Element, container: widgets.Widget = None): # 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) + # (the hooks of the fast renderer check this, see _fastcore) + _fastcore._log_debug = log_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 @@ -4282,13 +4272,34 @@ def Fragment(children: List[Element]): _fastcore._register(Element, ValueElement) +if not typing.TYPE_CHECKING: + # The hooks: for the fast renderer the work is done in _fastcore (compiled when reacton was + # built with Cython); any other render context gets its own methods, as above. + for _python_hook, _fast_hook in [ + (use_state, _fastcore.use_state), + (use_effect, _fastcore.use_effect), + (use_memo, _fastcore.use_memo), + (use_ref, _fastcore.use_ref), + ]: + _fast_hook.__doc__ = _python_hook.__doc__ + use_state = _fastcore.use_state + use_effect = _fastcore.use_effect + use_memo = _fastcore.use_memo + use_ref = _fastcore.use_ref + + +# what _fastcore reads as its own globals (assignments to reacton.core are forwarded) +_FORWARDED = ("DEBUG", "_default_container", "_component_context_manager_classes") +_fastcore._default_container = _default_container +_fastcore._component_context_manager_classes = _component_context_manager_classes + class _CoreModule(types.ModuleType): - # reacton.core.DEBUG = ... also sets it for the elements (made in _fastcore) + # reacton.core.DEBUG = ... (and the others in _FORWARDED) also sets it in _fastcore def __setattr__(self, name, value): super().__setattr__(name, value) - if name == "DEBUG": - _fastcore.DEBUG = value + if name in _FORWARDED: + setattr(_fastcore, name, value) sys.modules[__name__].__class__ = _CoreModule diff --git a/reacton/ipyvue.py b/reacton/ipyvue.py index 8ccd84a..dc7e496 100644 --- a/reacton/ipyvue.py +++ b/reacton/ipyvue.py @@ -1,103 +1,7 @@ -from typing import Any, Callable, Optional +from reacton.core import _fastcore -import ipyvue -import reacton as react -from reacton.core import ComponentWidget, _add_event_handlers, local - - -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]): - 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) - 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 - # 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: - el.kwargs["_events"] = [event_and_modifiers] - elif event_and_modifiers not in events: - # do not mutate the list, it could be shared with a previous element - el.kwargs["_events"] = [*events, event_and_modifiers] - - handlers = el._event_handlers - if handler not in handlers: - if el._render_count: - _add_event_handlers(el, (handler,), context, rc) - else: - el._event_handlers = (*handlers, handler) +# The handler object and the hook are in _fastcore (compiled when reacton was built with +# Cython): use_event is called for every button of a page. +_EventHandler = _fastcore._EventHandler +use_event = _fastcore.use_event From 9264151bce8b941217a7721fc767875f84e9d883 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 04:47:02 +0200 Subject: [PATCH 07/16] Test solara's writes to the mount globals, and remove without a lookup solara appends its context manager class to reacton.core._component_context_manager_classes after importing reacton, and assigns reacton.core._default_container; the compiled mount reads both as its own module globals. The new test checks both, for a first mount and for a later update, and runs in both modes. The .pxd says these must stay plain module globals. The removal of a mounted component looked up the widgets dict (a Python call) for every component, only needed when a widget has orphans. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 11 +++++-- reacton/_fastcore.py | 9 +++--- reacton/fastcore_test.py | 63 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index 7fd3000..4c3874a 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -1,5 +1,10 @@ # Cython declarations for _fastcore.py (pure Python mode). Everything that makes the compiled # module fast is declared here, so the .py runs as plain Python without paying for it. +# +# Do NOT declare these module globals here (keep them plain module globals): reacton.core +# assigns them from Python when solara writes reacton.core._default_container and +# reacton.core._component_context_manager_classes (see reacton.core._CoreModule), and +# reacton.core.DEBUG; and reacton.core writes _provides and _log_debug. cimport cython @@ -98,10 +103,10 @@ cdef class _Materialize: cpdef object value(self, object value, object key) -@cython.locals(el=object, widget=object, orphans=object, orphan=object, orphan_widget=object, close=object) -cdef object _close_widget_node(object rc, object node, object widgets_dict) +@cython.locals(el=object, widget=object, orphans=object, orphan=object, orphan_widget=object, close=object, widgets_dict=object) +cdef object _close_widget_node(object rc, object node) -@cython.locals(context=object, widgets_dict=object, effect=object, cleanup=object, handler=object, nodes=list, node=object) +@cython.locals(context=object, effect=object, cleanup=object, handler=object, nodes=list, node=object) cpdef object remove_mounted(object rc, object child_context, bint closing) diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index 973b4d2..1a03689 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -1023,7 +1023,6 @@ class _Undo: # the keys of the two phase walk def __init__(self, rc): self.rc = rc - self.widgets_dict = _core._get_widgets_dict() self.shared_seen = set() def component(self, c): @@ -1031,7 +1030,7 @@ def component(self, c): c.nodes = None for node in nodes: if not isinstance(node, _ComponentContext): - _close_widget_node(self.rc, node, self.widgets_dict) + _close_widget_node(self.rc, node) state = _UndoContext([node for node in nodes if isinstance(node, _ComponentContext)]) root = c.root_element if root is not None: @@ -1105,7 +1104,7 @@ def __init__(self, contexts): self.used_keys = set() -def _close_widget_node(rc, node, widgets_dict): +def _close_widget_node(rc, node): # close the widget of a node (and the widgets it made as a side effect) if type(node) is tuple: el, widget = node @@ -1114,6 +1113,7 @@ def _close_widget_node(rc, node, widgets_dict): widget = node orphans = rc._orphans.pop(widget.model_id, None) if rc._orphans else None if orphans: + widgets_dict = _core._get_widgets_dict() for orphan in orphans: orphan_widget = widgets_dict.get(orphan) if orphan_widget: @@ -1139,7 +1139,6 @@ def remove_mounted(rc, child_context, closing): child_context.exceptions_self = [] child_context.exceptions_children = [] rc.context = child_context - widgets_dict = _core._get_widgets_dict() try: for effect in child_context.effects: if not effect._cleaned_up: @@ -1175,7 +1174,7 @@ def remove_mounted(rc, child_context, closing): else: rc._remove_component_context(node) else: - _close_widget_node(rc, node, widgets_dict) + _close_widget_node(rc, node) finally: rc.context = context if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler: diff --git a/reacton/fastcore_test.py b/reacton/fastcore_test.py index 10177eb..eb81bad 100644 --- a/reacton/fastcore_test.py +++ b/reacton/fastcore_test.py @@ -65,3 +65,66 @@ def Child(): this_function = sys._getframe(0).f_code.co_name assert button.traceback.tb_frame.f_code.co_name == this_function assert child.traceback.tb_frame.f_code.co_name == this_function + + +def test_solara_context_manager_and_default_container(): + # solara appends a context manager class to reacton.core._component_context_manager_classes + # after importing reacton (solara/toestand.py), and assigns reacton.core._default_container + # (solara/components/__init__.py). The compiled mount reads both as its own globals. + log = [] + + class Manager: + def __init__(self, el): + self.name = el.component.name + + def __enter__(self): + log.append(("enter", self.name)) + + def __exit__(self, *args): + log.append(("exit", self.name)) + + @reacton.component + def MyColumn(children=[]): + log.append(("column", len(children))) + return w.VBox(children=children) + + set_value = lambda value: None # noqa + + @reacton.component + def Child(name): + # returns None: the implicit container gets the elements it made + w.Button(description=f"{name} a") + w.Button(description=f"{name} b") + + @reacton.component + def App(): + nonlocal set_value + value, set_value = reacton.use_state(0) + return w.HBox(children=[Child(f"child{value}").key(f"child{value}")]) + + previous_container = reacton.core._default_container + reacton.core._component_context_manager_classes.append(Manager) + reacton.core._default_container = MyColumn + try: + assert _fastcore._default_container is MyColumn + assert Manager in _fastcore._component_context_manager_classes + hbox, rc = reacton.render_fixed(App(), handle_error=False) + column = hbox.children[0] + assert isinstance(column, widgets.VBox) + assert [button.description for button in column.children] == ["child0 a", "child0 b"] + assert ("enter", "App") in log and ("exit", "App") in log + assert ("enter", "Child") in log and ("exit", "Child") in log + assert ("column", 2) in log + # a later update mounts a new child: the same container and manager + log.clear() + set_value(1) + column = hbox.children[0] + assert [button.description for button in column.children] == ["child1 a", "child1 b"] + assert ("enter", "Child") in log and ("exit", "Child") in log + assert ("column", 2) in log + rc.close() + finally: + reacton.core._component_context_manager_classes.remove(Manager) + reacton.core._default_container = previous_container + assert _fastcore._default_container is previous_container + assert Manager not in _fastcore._component_context_manager_classes From b21f1667ea6502bb8f43e68f52935a981c985e49 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 04:53:32 +0200 Subject: [PATCH 08/16] Do less per widget and per removed component in the compiled mount Profiles of the compiled mount (macOS sample) showed generic attribute reads and writes on Python objects as the main cost left besides the calls into user code. Per widget element the mount read the rerender flag of the render context and three facts on the ComponentWidget; the flag only changes in component bodies, so it is now checked after each body, and the facts of a widget class are one typed object on its ComponentWidget. The removal of a mounted component switched the render context's current component even when nothing could read it, and the render pass computed the stale keys of the root with a set difference even when the sizes show there are none. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 12 +++++-- reacton/_fastcore.py | 80 +++++++++++++++++++++++++++++++------------ reacton/core.py | 10 +++--- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index 4c3874a..54f9458 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -53,7 +53,13 @@ cdef class _Mount: cdef int _plain_class(object cls) except -1 -cdef object _trait_names(object component) +cdef class _WidgetInfo: + cdef public object widget + cdef public frozenset trait_names + cdef public object batched + +@cython.locals(info=_WidgetInfo) +cdef _WidgetInfo _widget_info(object component) @cython.locals(m=_Mount, c=object, widget=object) cpdef object mount_component(object rc, ElementBase el, object key, object parent_context, object order, object context) @@ -70,7 +76,7 @@ cdef object _call_body(_Mount m, ElementBase el, object managers) @cython.locals(component=object, default_container=object, adder=ContainerAdder, created=list, container_adders=list, root_element=object, kwargs=dict) cdef object _call_component(_Mount m, ElementBase el) -@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, callback_wrappers=dict, listener=object, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) +@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, callback_wrappers=dict, listener=object, info=_WidgetInfo, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) cdef object _mount_node(_Mount m, ElementBase el, object c, list nodes, object dkey) @cython.locals(values=list, index=Py_ssize_t, x=object, w=object) @@ -106,7 +112,7 @@ cdef class _Materialize: @cython.locals(el=object, widget=object, orphans=object, orphan=object, orphan_widget=object, close=object, widgets_dict=object) cdef object _close_widget_node(object rc, object node) -@cython.locals(context=object, effect=object, cleanup=object, handler=object, nodes=list, node=object) +@cython.locals(context=object, effect=object, cleanup=object, handler=object, nodes=list, node=object, switched=bint, effects=object, handlers=object) cpdef object remove_mounted(object rc, object child_context, bint closing) diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index 1a03689..8e5a502 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -155,7 +155,8 @@ def __init__(self, component, args=None, kwargs=None): if container_adders: adder = container_adders[-1] if type(adder) is ContainerAdder: - adder.created.append(self) + ca: ContainerAdder = adder + ca.created.append(self) else: adder.add(self) if DEBUG: @@ -250,7 +251,8 @@ def __init__(self, value_property, component, args=None, kwargs=None): if container_adders: adder = container_adders[-1] if type(adder) is ContainerAdder: - adder.created.append(self) + ca: ContainerAdder = adder + ca.created.append(self) else: adder.add(self) if DEBUG: @@ -319,7 +321,8 @@ def component_call(self, *args, **kwargs): if container_adders: adder = container_adders[-1] if type(adder) is ContainerAdder: - adder.created.append(new) + ca: ContainerAdder = adder + ca.created.append(new) else: adder.add(new) el = new @@ -436,12 +439,22 @@ def _plain_class(cls): return plain -def _trait_names(component): - # the trait names of the widget class of a ComponentWidget (kept on it) - names = component._reacton_trait_names - if names is None: - names = component._reacton_trait_names = frozenset(component.widget.class_trait_names()) - return names +class _WidgetInfo: + """What the mount keeps about the widget class of a ComponentWidget (on the component).""" + + def __init__(self, component): + self.widget = component.widget + self.trait_names = frozenset(component.widget.class_trait_names()) + # the widget class whose hold_trait_notifications batches renders (see + # core._install_batched_hold) + self.batched = None + + +def _widget_info(component): + info = component._reacton_info + if info is None: + info = component._reacton_info = _WidgetInfo(component) + return info class _Mount: @@ -474,6 +487,9 @@ def mount_component(rc, el, key, parent_context, order, context): or None when this pass cannot keep the mount. """ m = _Mount(rc) + if rc._rerender_needed: + # (state set earlier in this pass: it will be undone, do not make widgets) + m.failed = True previous_recording = _core._start_recording_constructed(m.recording) try: widget = _mount_component(m, el, parent_context, None, context, key) @@ -552,6 +568,10 @@ def _mount_component(m, el, parent, parent_nodes, context, key): context.needs_render = True m.raised += 1 # (a nested render() from a body is refused by render() itself: it holds the lock) + if rc._rerender_needed: + # the body set state (or raised): this pass will be undone, do not make more widgets + # (only a body changes the flag, so the widgets do not check it themselves) + m.failed = True widget = None if root is not None: if not isinstance(root, ElementBase): @@ -712,10 +732,10 @@ def _mount_node(m, el, c, nodes, dkey): if resolved is None: resolved = dict(kwargs) resolved[name] = new_value - rc = m.rc - if m.failed or rc._rerender_needed: + if m.failed: # this pass will be undone, do not make more widgets return None + rc = m.rc if resolved is None: # (no copy: the constructor gets the kwargs unpacked) resolved = kwargs @@ -728,7 +748,8 @@ def _mount_node(m, el, c, nodes, dkey): if plain != 0: # Element._create_widget, with the recording of this mount listeners = None - traits = _trait_names(component) + info = _widget_info(component) + traits = info.trait_names if not traits.issuperset(resolved): for name in list(resolved): if name.startswith("on_") and name not in traits: @@ -738,16 +759,16 @@ def _mount_node(m, el, c, nodes, dkey): listeners = {} listeners[name] = resolved.pop(name) try: - widget = component.widget(**resolved) + widget = info.widget(**resolved) except Exception: # let reconciliation make it (and handle the exception) as it always does m.failed = True return None widget_class = type(widget) - if component._reacton_batched is not widget_class: + if info.batched is not widget_class: if not getattr(widget_class.hold_trait_notifications, "_reacton_batched", False): _core._install_batched_hold(widget_class) - component._reacton_batched = widget_class + info.batched = widget_class widget._reacton_rc = rc if el._meta: widget._react_meta = dict(el._meta) @@ -974,7 +995,7 @@ def node(self, el, default_key): # elements in the kwargs (each one took a node): the kwargs the widget was made # with, as the update path compares them (see _mount_node: without listeners) assert resolved is not None - traits = _trait_names(component) + traits = _widget_info(component).trait_names if not traits.issuperset(resolved): for name in list(resolved): if name.startswith("on_") and name not in traits: @@ -1128,7 +1149,10 @@ def _close_widget_node(rc, node): close() else: _core.close_widget(widget) # logs the warning - widget.__dict__.pop("_reacton_rc", None) + try: + del widget._reacton_rc + except AttributeError: + pass def remove_mounted(rc, child_context, closing): @@ -1138,9 +1162,15 @@ def remove_mounted(rc, child_context, closing): context = rc.context child_context.exceptions_self = [] child_context.exceptions_children = [] - rc.context = child_context + # (rc.context is only switched to child_context when an effect cleanup, a handler or a + # child component can use it: most mounted components have none) + switched = False try: - for effect in child_context.effects: + effects = child_context.effects + if effects: + rc.context = child_context + switched = True + for effect in effects: if not effect._cleaned_up: cleanup = effect._cleanup try: @@ -1154,7 +1184,11 @@ def remove_mounted(rc, child_context, closing): rc._mark_dirty(child_context) effect._cleaned_up = True if not closing: - for handler in child_context.event_handlers: + handlers = child_context.event_handlers + if handlers and not switched: + rc.context = child_context + switched = True + for handler in handlers: try: handler._reacton_detach() except BaseException as e: @@ -1166,6 +1200,9 @@ def remove_mounted(rc, child_context, closing): child_context.nodes = None for node in nodes: if isinstance(node, _ComponentContext): + if not switched: + rc.context = child_context + switched = True if node.nodes is not None: remove_mounted(rc, node, closing) elif closing: @@ -1176,7 +1213,8 @@ def remove_mounted(rc, child_context, closing): else: _close_widget_node(rc, node) finally: - rc.context = context + if switched: + rc.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) diff --git a/reacton/core.py b/reacton/core.py index 02ff6c7..ad925ee 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -650,10 +650,8 @@ def connect(self, value: Value[V]): class ComponentWidget(Component): mime_bundle: Dict[str, Any] = mime_bundle_default widget: Type[widgets.Widget] - # (for the mount, see _fastcore: the trait names, and the widget class whose - # hold_trait_notifications batches renders) - _reacton_trait_names: Optional[frozenset] = None - _reacton_batched: Optional[type] = None + # (for the mount: what it keeps about the widget class, see _fastcore._WidgetInfo) + _reacton_info: Any = None # (for use_event: the widget class is an ipyvue widget) _reacton_vue: Optional[bool] = None @@ -4107,6 +4105,10 @@ def _visit_children_values_generic(self, value: Any, key: str, parent_key: str, def _remove_stale_root_elements(self, parent_key): # remove stale elements of the root context itself # (child contexts are swept during their reconciliation) + root = self.context_root + if len(root.elements) <= len(root.used_keys) and not root.has_shared: + # (all used keys are in elements: only then the sizes tell there are none) + return stale_keys = sorted(set(self.context_root.elements) - self.context_root.used_keys) for stale_key in stale_keys: if stale_key in self.context_root.elements: From 3ad6b6dd7c7a0f5b9e95e5dfda3fe685b524ff58 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 04:55:10 +0200 Subject: [PATCH 09/16] Keep one copy of the hooks and of the component body call The fast renderer's hooks in _fastcore were a copy of the methods of reacton.core._RenderContext, and the mount had its own copy of the body call with the implicit container. Both renderers now use the one implementation in _fastcore (the default renderer with its own setter closure, made by make_setter), which also compiles the hooks and the body call of the default renderer when reacton is built with Cython. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 21 ++++-- reacton/_fastcore.py | 42 +++++++++--- reacton/core.py | 149 +++--------------------------------------- 3 files changed, 59 insertions(+), 153 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index 54f9458..b4960df 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -73,8 +73,8 @@ cdef object _mount_component(_Mount m, ElementBase el, object parent, list paren @cython.locals(root=object) cdef object _call_body(_Mount m, ElementBase el, object managers) -@cython.locals(component=object, default_container=object, adder=ContainerAdder, created=list, container_adders=list, root_element=object, kwargs=dict) -cdef object _call_component(_Mount m, ElementBase el) +@cython.locals(component=object, default_container=object, created=list, root_element=object, kwargs=dict) +cpdef object call_component(list container_adders, ContainerAdder adder, ElementBase el) @cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, callback_wrappers=dict, listener=object, info=_WidgetInfo, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) cdef object _mount_node(_Mount m, ElementBase el, object c, list nodes, object dkey) @@ -129,21 +129,32 @@ cdef class _EventHandler: cdef public bint removed -@cython.locals(rc=object, context=object, index=Py_ssize_t, state=dict, value=object, setters=dict, setter=object, eq_cell=list) +@cython.locals(rc=object) cpdef use_state(initial, key=*, eq=*) +@cython.locals(context=object, index=Py_ssize_t, state=dict, value=object, setters=dict, setter=object, eq_cell=list) +cpdef rc_use_state(rc, initial, key, eq) + +cpdef rc_use_ref(rc, initial_value) + @cython.locals(rc=object) cpdef use_ref(initial_value) @cython.locals(memo=object, index=Py_ssize_t, value=object, dependencies_previous=object) cdef object _use_ref(object context, object initial_value) -@cython.locals(rc=object, context=object, name=object, memo=object, index=Py_ssize_t, value=object, entry=tuple, dependencies_previous=object) +@cython.locals(rc=object) cpdef use_memo(f, dependencies=*, debug_name=*) -@cython.locals(rc=object, context=object, effects=object, index=Py_ssize_t, previous_effect=object) +@cython.locals(context=object, name=object, memo=object, index=Py_ssize_t, value=object, entry=tuple, dependencies_previous=object) +cpdef rc_use_memo(rc, f, dependencies, debug_name) + +@cython.locals(rc=object) cpdef use_effect(effect, dependencies=*) +@cython.locals(context=object, effects=object, index=Py_ssize_t, previous_effect=object) +cpdef rc_use_effect(rc, effect, dependencies) + @cython.locals(vue=object) cdef object _is_vue(object component) diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index 8e5a502..c794d26 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -611,11 +611,11 @@ def _mount_component(m, el, parent, parent_nodes, context, key): def _call_body(m, el, managers): # the component function, inside its context managers (solara registers one) if not managers: - root = _call_component(m, el) + root = call_component(m.adders, m.body_adder, el) assert root is not None elif len(managers) == 1: with managers[0]: - root = _call_component(m, el) + root = call_component(m.adders, m.body_adder, el) assert root is not None else: import contextlib @@ -623,13 +623,17 @@ def _call_body(m, el, managers): with contextlib.ExitStack() as stack: for manager in managers: stack.enter_context(manager) - root = _call_component(m, el) + root = call_component(m.adders, m.body_adder, el) assert root is not None return root -def _call_component(m, el): - """Run the component function, with an implicit container when it returns None.""" +def call_component(container_adders, adder, el): + """Run the component function, with an implicit container when it returns None. + + container_adders: rc.container_adders, adder: the ContainerAdder of the bodies of the render + context (reacton.core._RenderContextFast._call_component). + """ component = el.component default_container = _default_container if default_container is None: @@ -639,10 +643,8 @@ def _call_component(m, el): # (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 = m.body_adder created = [] # type: List[Any] adder.created = created - container_adders = m.adders container_adders.append(adder) try: component.render_count += 1 @@ -1261,6 +1263,11 @@ def use_state(initial, key=None, eq=None): raise RuntimeError("No render context") if type(rc) is not _FastRC: return rc.use_state(initial, key, eq) + return rc_use_state(rc, initial, key, eq) + + +def rc_use_state(rc, initial, key, eq): + # reacton.core._RenderContext.use_state (both renderers) context = rc.context if key is None: index = context.state_index @@ -1289,7 +1296,11 @@ def use_state(initial, key=None, eq=None): setters = context.setters setter = setters.get(key) if setter is None: - setter = setters[key] = _Setter(rc, context, key, eq) + if type(rc) is _FastRC: + setter = _Setter(rc, context, key, eq) + else: + setter = rc.make_setter(key, context, eq) + setters[key] = setter else: eq_cell = setter._reacton_eq if eq_cell[0] is not eq: @@ -1307,6 +1318,11 @@ def use_ref(initial_value): return _use_ref(rc.context, initial_value) +def rc_use_ref(rc, initial_value): + # reacton.core._RenderContext.use_ref (both renderers) + return _use_ref(rc.context, initial_value) + + def _use_ref(context, initial_value): # use_memo(lambda: Ref(initial_value), []), without the closure and the extra calls memo = context.memo @@ -1341,6 +1357,11 @@ def use_memo(f, dependencies=None, debug_name=None): dependencies = {k: v for k, v in dependencies.items() if not k.startswith("__")} if type(rc) is not _FastRC: return rc.use_memo(f, dependencies, debug_name) + return rc_use_memo(rc, f, dependencies, debug_name) + + +def rc_use_memo(rc, f, dependencies, debug_name): + # reacton.core._RenderContext.use_memo (both renderers) context = rc.context name = debug_name or "no-name" memo = context.memo @@ -1376,6 +1397,11 @@ def use_effect(effect, dependencies=None): raise RuntimeError("No render context") if type(rc) is not _FastRC: return rc.use_effect(effect, dependencies) + rc_use_effect(rc, effect, dependencies) + + +def rc_use_effect(rc, effect, dependencies): + # reacton.core._RenderContext.use_effect (both renderers) context = rc.context effects = context.effects index = context.effect_index diff --git a/reacton/core.py b/reacton/core.py index ad925ee..78688e3 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1657,94 +1657,18 @@ def state_set(self, context: ComponentContext, state): context.children_next[name] = ComponentContext(parent=context) self.state_set(context.children_next[name], state) + # the hooks (the same for both renderers, see _fastcore: compiled when reacton was built + # with Cython). The setter is made by make_setter (a closure here; the fast renderer has + # its own setter object). + def use_memo(self, f, dependencies, debug_name: str = None, use_nonlocals=False): - assert self.context is not None - name = debug_name or "no-name" - if len(self.context.memo) <= self.context.memo_index: - value = f() - memo = (value, dependencies) - if type(self.context.memo) is tuple: - # (a mounted component: made on first use) - self.context.memo = [] - self.context.memo.append(memo) - self.context.memo_index += 1 - 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): - if logger.isEnabledFor(logging.DEBUG): - logger.debug("Got memo hit = %r for index %r (debug-name: %r)", memo, self.context.memo_index, name) - else: - 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 - self.context.memo_index += 1 - return value + return _fastcore.rc_use_memo(self, f, dependencies, debug_name) 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) - if type(memo) is tuple: - # (a mounted component: made on first use) - memo = context.memo = [] - memo.append((value, [])) - context.memo_index = index + 1 - return value + return _fastcore.rc_use_ref(self, initial_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: - index = self.context.state_index - key = _STATE_KEYS[index] if index < 64 else str(index) - self.context.state_index = index + 1 - if self.context.state is None: - # (a mounted component: made on first use) - self.context.state = {} - self.context.setters = {} - if key not in self.context.state: - self.context.state[key] = initial - if isinstance(initial, (list, dict, set)): - self.context.state_metadata[key] = len(initial) - elif utils.isinstance_lazy(initial, "pandas.DataFrame"): - 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)) - 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)) - # 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 + return _fastcore.rc_use_state(self, initial, key, eq) def make_setter(self, key, context: ComponentContext, eq: Callable[[Any, Any], bool] = None): if DEBUG: @@ -1844,34 +1768,7 @@ def force_update(self): self.render(self.element, self.container) def use_effect(self, effect: EffectCallable, dependencies=None): - context = self.context - assert context is not None - effects = context.effects - index = context.effect_index - context.effect_index = index + 1 - if len(effects) <= index: - if type(effects) is tuple: - # (a mounted component: made on first use) - effects = context.effects = [] - effects.append(Effect(effect, dependencies)) - if logger.isEnabledFor(logging.DEBUG): - logger.debug("Initial effect = %r for index %r (%r)", effect, index, dependencies) - else: - previous_effect = effects[index] - if logger.isEnabledFor(logging.DEBUG): - logger.debug("Setting next effect = %r for index %r (%r)", effect, index, dependencies) - if previous_effect.executed: - 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 - effects[index] = Effect(effect, dependencies) + _fastcore.rc_use_effect(self, effect, dependencies) def update(self, element: Element): self._walk_all = True @@ -3323,35 +3220,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: ComponentFunction = el.component # type: ignore[assignment] - 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 = self._body_adder - created: List[Element] = [] - adder.created = created - 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() - 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(created) - if len(container.kwargs["children"]) == 1: - root_element = container.kwargs["children"][0] - else: - root_element = container - return root_element + return _fastcore.call_component(self.container_adders, self._body_adder, el) def _reconsolidate(self, el: Element, default_key: str, parent_key: str): key = el._key From 926d1a896b373349347edc7a85e067cc649ec8ee Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 04:55:24 +0200 Subject: [PATCH 10/16] Describe the mount and the optional compiled core in the benchmark README Co-Authored-By: Claude Opus 5.5 (1M context) --- benchmarks/README.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 16b773c..a949b7f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -65,9 +65,23 @@ renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both: identical child widget objects (`_values_identical`), avoiding pointless 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. +- **Mount path.** A new component (first render, new list item, another + component type at a key) is mounted in one walk (`_fastcore.mount_component`): + bodies run as in the render phase, widgets are made children first. A mounted + component (`_MountedContext`) keeps its element tree positionally (`nodes`: + the widgets and child contexts in the order they were made); the dicts of the + update paths (`elements`, `widgets`, `children`, `element_to_widget`, + `used_keys`, `resolved_kwargs`, ...) are made from it, with the same keys, when + they are first used (`_fastcore.materialize`), and a mounted subtree that goes + away is removed from it (`_fastcore.remove_mounted`). A pass that cannot keep + its mounts (state set or an exception during the mount, shared elements, a + widget that fails to be made) undoes them into the two phase bookkeeping. The + implicit container is only built when a component body returns `None`. +- **Compiled core (optional).** `reacton/_fastcore.py` holds the element base + classes, the mount, the hooks and the listener/setter objects. It is plain + Python; `python setup_cython.py build_ext --inplace` compiles it with Cython + (pure Python mode, types in `_fastcore.pxd`). `REACTON_CYTHON=0` forces the + plain version when a compiled one is present. - **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 @@ -107,7 +121,8 @@ random state changes and compares the widgets and the effect order. **Keys.** `el._key` or a positional default. A context's root key is `"/"`. Children of an element with key `K`: list → `f"{K}{i}/"`, dict → `f"{K}{k}/"`. Component child contexts live in `context.children[key]`. A duplicate key in one -context raises `KeyError`. `el._key_frozen` is set once an element is rendered. +context raises `KeyError`. `el._key_frozen` is true once an element is rendered +(`el._render_count > 0`). **Render phase, per element:** - `el._render_count += 1` (a shared element is visited once; a non-shared one From f2866c8f7a6f1de63c3b6ef36733a978276837df Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 05:10:40 +0200 Subject: [PATCH 11/16] Give use_memo a Python frame when reacton is compiled solara.tasks does not warn about task() inside use_memo: it looks on the stack for a function named use_memo in a reacton module. Compiled functions have no frame, so the compiled fast renderer made solara's task_test warn. A thin Python use_memo keeps that frame; it costs one Python call per use_memo, only in the compiled mode. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 12 +++++++++++- reacton/fastcore_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/reacton/core.py b/reacton/core.py index 78688e3..5dfc1e9 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -4155,8 +4155,18 @@ def Fragment(children: List[Element]): _fast_hook.__doc__ = _python_hook.__doc__ use_state = _fastcore.use_state use_effect = _fastcore.use_effect - use_memo = _fastcore.use_memo use_ref = _fastcore.use_ref + if _fastcore.__file__.endswith(".py"): + use_memo = _fastcore.use_memo + else: + _use_memo = _fastcore.use_memo + + def use_memo(f, dependencies=None, debug_name=None): + # A Python frame named use_memo: a compiled function has none, and solara.tasks + # looks for it on the stack (task() called inside use_memo does not warn). + return _use_memo(f, dependencies, debug_name) + + use_memo.__doc__ = _use_memo.__doc__ # what _fastcore reads as its own globals (assignments to reacton.core are forwarded) diff --git a/reacton/fastcore_test.py b/reacton/fastcore_test.py index eb81bad..28d15fc 100644 --- a/reacton/fastcore_test.py +++ b/reacton/fastcore_test.py @@ -4,6 +4,8 @@ import pickle import sys import weakref +from types import FrameType +from typing import Optional import ipywidgets as widgets @@ -128,3 +130,29 @@ def App(): reacton.core._default_container = previous_container assert _fastcore._default_container is previous_container assert Manager not in _fastcore._component_context_manager_classes + + +def test_use_memo_has_a_frame(): + # solara.tasks: task() called inside use_memo does not warn. It checks the 5 frames above + # the user code that calls task() for a function named use_memo in a reacton module. + found = [] + + def make(): + frame: Optional[FrameType] = sys._getframe(2) # the frame above the lambda + for _ in range(5): + if frame is None: + break + if frame.f_code.co_name == "use_memo" and frame.f_globals.get("__name__", "").startswith("reacton."): + found.append(frame.f_globals["__name__"]) + break + frame = frame.f_back + return 1 + + @reacton.component + def Test(): + reacton.use_memo(lambda: make(), []) + return w.Button() + + box, rc = reacton.render(Test(), handle_error=False) + rc.close() + assert len(found) == 1 From 5e861d3ef5d5958c04e69fb3254e4031c99ce922 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 07:51:46 +0200 Subject: [PATCH 12/16] Make the first render and each mounted component cheaper In the interleaved run of all 15 scenarios the compiled mount was only 7.6-9.8x on rows300, deep100 and page: about 2x the compiled floor, and more so in a cold cache (the benchmark collects garbage before each sample, so the first touch of every function and object counts). The fixed cost of a render was mostly Python frames and generic walks for the root: render_fixed, the render context __init__ chain, reading REACTON_FAST through os.environ, render() with its _render, _reconsolidate and the other root walks. render_fixed and the first render of a fast render context now run compiled (render_first); when more passes are needed (state set or an exception during the mount, an effect that sets state) the loop of render() takes over, now a method of its own. Per component, the mount did work the floor does not: a Python frame for use_context and for each Effect, a list per body for the implicit container, a list per setter, an isinstance of a Python class that fails (slow), a dict per element that is rarely used, and the registration of every on_ observer in Element._callback_wrappers. The observers now stay in the node until the dicts are made (materialize registers them), and removing the node unobserves them. render() keeps the thread ident instead of the Thread object for its recursion check, and the reconsolidating flag tells an aborted render pass from a failed reconciliation (one attribute less on the render context: its instance dict stays within CPython's 30 shared keys). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 64 ++++++- reacton/_fastcore.py | 375 +++++++++++++++++++++++++++++++++++--- reacton/core.py | 415 +++++++++++++++++++++--------------------- reacton/core_test.py | 49 +++++ reacton/utils.py | 1 + 5 files changed, 658 insertions(+), 246 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index b4960df..9238be0 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -6,6 +6,23 @@ # reacton.core._component_context_manager_classes (see reacton.core._CoreModule), and # reacton.core.DEBUG; and reacton.core writes _provides and _log_debug. cimport cython +from libc.stdlib cimport getenv +from cpython.object cimport PyTypeObject +from cpython.ref cimport PyObject + + +cdef tuple _EMPTY_TUPLE = () + + +cdef inline object _object_new(object cls): + # object.__new__(cls), directly (cls must not override __new__) + return (cls).tp_new(cls, _EMPTY_TUPLE, NULL) + + +cdef inline bint _getenv_fast(): + # REACTON_FAST=1 + cdef const char* value = getenv(b"REACTON_FAST") + return value != NULL and value[0] == 49 and value[1] == 0 cdef class ContainerAdder: @@ -15,9 +32,9 @@ cdef class ContainerAdder: cdef public list created +# (no __dict__ and __weakref__ here: the Python subclasses in reacton.core get them, and make +# the dict when an attribute is first set; a dict declared here is made for every element) cdef class ElementBase: - cdef dict __dict__ - cdef object __weakref__ cdef public object component cdef public object args cdef public object kwargs @@ -41,7 +58,6 @@ cdef class ValueElementBase(ElementBase): cdef class _Mount: cdef public object rc cdef public list recording - cdef public list contexts cdef public list order cdef public bint failed cdef public Py_ssize_t raised @@ -52,6 +68,8 @@ cdef class _Mount: cdef public object top +cdef object _new_instance(object cls) + cdef int _plain_class(object cls) except -1 cdef class _WidgetInfo: cdef public object widget @@ -73,10 +91,10 @@ cdef object _mount_component(_Mount m, ElementBase el, object parent, list paren @cython.locals(root=object) cdef object _call_body(_Mount m, ElementBase el, object managers) -@cython.locals(component=object, default_container=object, created=list, root_element=object, kwargs=dict) +@cython.locals(component=object, default_container=object, created=list, root_element=object, kwargs=dict, container=object) cpdef object call_component(list container_adders, ContainerAdder adder, ElementBase el) -@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, callback_wrappers=dict, listener=object, info=_WidgetInfo, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) +@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, added=object, listener=object, info=_WidgetInfo, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) cdef object _mount_node(_Mount m, ElementBase el, object c, list nodes, object dkey) @cython.locals(values=list, index=Py_ssize_t, x=object, w=object) @@ -84,6 +102,22 @@ cdef list _mount_list(_Mount m, object value, object c, list nodes, object dkey) cdef object _mount_value(_Mount m, object value, object c, list nodes, object dkey) +cpdef object init_context(object c) + +@cython.locals(root=object, counter=object) +cpdef object init_render_context(object rc, object element, object container, object children_trait, object handle_error, bint fast) + +cpdef bint fast_selected() + +@cython.locals(cache=object, enabled=object) +cdef object _info_enabled() + +@cython.locals(rc=object, widget=object) +cpdef object render_fixed(object element, object handle_error=*) + +@cython.locals(root=object, lock=object, widget=object, prev_rc=object, key=object, more=bint) +cpdef object render_first(object rc, object element, object container) + @cython.locals(order=list, raised=bint, context=object, effects=object, parent=object, effect=object, widget=object, el=ElementBase, key=object) cpdef object finish_mount(object rc, object root) @@ -102,14 +136,14 @@ cdef class _Materialize: cdef public set used_keys cdef public dict resolved_kwargs - @cython.locals(key=object, component=object, child=object, widget=object, resolved=dict, start=Py_ssize_t, name=object, value=object, new_value=object, node=object, traits=frozenset) + @cython.locals(key=object, component=object, child=object, widget=object, resolved=dict, start=Py_ssize_t, name=object, value=object, new_value=object, node=object, traits=frozenset, callback_wrappers=dict, listener=object, added=object) cpdef object node(self, ElementBase el, object default_key) @cython.locals(t=object, values=list, index=Py_ssize_t, x=object, w=object) cpdef object value(self, object value, object key) -@cython.locals(el=object, widget=object, orphans=object, orphan=object, orphan_widget=object, close=object, widgets_dict=object) +@cython.locals(el=object, widget=object, added=object, mounted_listeners=bint, listener=object, orphans=object, orphan=object, orphan_widget=object, close=object, widgets_dict=object) cdef object _close_widget_node(object rc, object node) @cython.locals(context=object, effect=object, cleanup=object, handler=object, nodes=list, node=object, switched=bint, effects=object, handlers=object) @@ -119,8 +153,6 @@ cpdef object remove_mounted(object rc, object child_context, bint closing) # ---- the hooks cdef class RefBase: - cdef dict __dict__ - cdef object __weakref__ cdef public object current @@ -152,6 +184,18 @@ cpdef rc_use_memo(rc, f, dependencies, debug_name) @cython.locals(rc=object) cpdef use_effect(effect, dependencies=*) +@cython.locals(new=object) +cdef object _new_effect(object callable, object dependencies) + +@cython.locals(rc=object, context=object, value=object, user_contexts=object) +cpdef use_context(user_context) + +cdef class _ContextListener: + cdef public object set_counter + +cdef class _ContextConnect: + cdef public object context, user_context, listener + @cython.locals(context=object, effects=object, index=Py_ssize_t, previous_effect=object) cpdef rc_use_effect(rc, effect, dependencies) @@ -168,5 +212,5 @@ cdef class _Listener: cdef class _Setter: cdef public object rc, context, key - cdef public list _reacton_eq + cdef public object eq cdef public object created_stack diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index c794d26..1b595cb 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -16,8 +16,11 @@ ``ElementBase``), so everything that user code sees stays a normal Python class. """ +import os import sys import threading +import weakref +from collections import deque from types import TracebackType from typing import Any, Dict, List, Optional # noqa: F401 (List: in a type comment) @@ -385,6 +388,8 @@ def component_widget_new(cls, widget, mime_bundle=mime_bundle_default): _MountedContext: Any = None _FragmentWidget: Any = None _logger: Any = None +_RenderContextFast: Any = None +_ThreadSafeCounter = utils.ThreadSafeCounter # values that cannot hold elements (the child visitors skip them) _SCALAR_TYPES = frozenset([str, int, float, bool, complex, bytes, type(None)]) # the dicts of a component context that are made from its nodes (see materialize) @@ -407,8 +412,16 @@ def component_widget_new(cls, widget, mime_bundle=mime_bundle_default): _EMPTY: dict = {} +def _new_instance(cls): + # cls.__new__(cls) for a Python class that does not override __new__ (compiled: without + # the lookup and the checks of object.__new__, see _fastcore.pxd) + if not cython.compiled: + return cls.__new__(cls) + return _object_new(cls) # type: ignore[name-defined] # noqa: F821 (see _fastcore.pxd) + + def _register_core(core): - global _core, _ComponentFunction, _ComponentWidget, _ComponentContext, _MountedContext, _FragmentWidget, _logger + global _core, _ComponentFunction, _ComponentWidget, _ComponentContext, _MountedContext, _FragmentWidget, _logger, _RenderContextFast _core = core _ComponentFunction = core.ComponentFunction _ComponentWidget = core.ComponentWidget @@ -416,6 +429,7 @@ def _register_core(core): _MountedContext = core._MountedContext _FragmentWidget = core.FragmentWidget _logger = core.logger + _RenderContextFast = core._RenderContextFast _register_hooks(core) @@ -464,8 +478,6 @@ def __init__(self, rc): self.rc = rc # the widgets made during the mount (to find those made as a side effect, like Layout) self.recording = [] - # every context made by a mount in this pass (to undo them), and the tops of the mounts - self.contexts = rc._mount_contexts # the contexts of this mount, children first (effects run in this order) self.order = [] self.failed = False @@ -508,7 +520,7 @@ def mount_component(rc, el, key, parent_context, order, context): def _new_context(parent): # (compact_widget is set when the root widget is made) - c = _MountedContext.__new__(_MountedContext) + c = _new_instance(_MountedContext) c.parent = parent c.nodes = [] return c @@ -532,7 +544,7 @@ def _mount_component(m, el, parent, parent_nodes, context, key): rc = m.rc precreated_children = None if context is None: - context = _MountedContext.__new__(_MountedContext) + context = _new_instance(_MountedContext) context.parent = parent nodes = context.nodes = [] else: @@ -540,7 +552,6 @@ def _mount_component(m, el, parent, parent_nodes, context, key): precreated_children = context.precreated_children nodes = context.nodes context.invoke_element = el - m.contexts.append(context) if key is not None: # the top of the mount context.key_in_parent = key @@ -643,8 +654,10 @@ def call_component(container_adders, adder, el): # (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. - created = [] # type: List[Any] - adder.created = created + # (the adder's list is reused: bodies of one render context do not nest) + created = adder.created + if created: + del created[:] container_adders.append(adder) try: component.render_count += 1 @@ -655,7 +668,6 @@ def call_component(container_adders, adder, el): root_element = component.f(*el.args) 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 @@ -664,6 +676,8 @@ def call_component(container_adders, adder, el): root_element = container.kwargs["children"][0] else: root_element = container + if created: + del created[:] return root_element @@ -698,7 +712,7 @@ def _mount_node(m, el, c, nodes, dkey): m.shared_next.add(el) el._render_count += 1 # (also freezes the key, see Element._key_frozen) component = el.component - if type(component) is not _ComponentWidget and not isinstance(component, _ComponentWidget): + if type(component) is _ComponentFunction or (type(component) is not _ComponentWidget and not isinstance(component, _ComponentWidget)): # a component element if el.is_shared and (el.args or el.kwargs): # the arguments of a shared element belong to the context it is rendered in @@ -752,7 +766,13 @@ def _mount_node(m, el, c, nodes, dkey): listeners = None info = _widget_info(component) traits = info.trait_names - if not traits.issuperset(resolved): + for name in resolved: + if name not in traits: + break + else: + name = None + if name is not None: + # a kwarg that is not a trait: an event listener (on_) for name in list(resolved): if name.startswith("on_") and name not in traits: if resolved is kwargs: @@ -778,18 +798,27 @@ def _mount_node(m, el, c, nodes, dkey): nodes.append(widget) else: if plain == 2: - # Element._add_widget_event_listener, inline (rc.context is c) - callback_wrappers = _Element._callback_wrappers + # Element._add_widget_event_listener, inline (rc.context is c). The observers + # stay in the node (Element._callback_wrappers gets them when the dicts are + # made, see materialize; removing the node unobserves them) + added = None for name, callback in listeners.items(): if callback is not None: listener = _Listener(rc, c, name, widget, callback) - callback_wrappers[(widget.model_id, name, callback)] = listener widget.observe(listener, name[3:]) + # (one listener, or a list of them) + if added is None: + added = listener + elif type(added) is list: + added.append(listener) + else: + added = [added, listener] + nodes.append((el, widget, added)) else: for name, callback in listeners.items(): if callback is not None: el._add_widget_event_listener(widget, name, callback) # type: ignore[attr-defined] - nodes.append((el, widget)) + nodes.append((el, widget)) handlers = el._event_handlers if handlers: for handler in handlers: @@ -862,6 +891,215 @@ def _mount_value(m, value, c, nodes, dkey): # after the mount: the effects (reconciliation), the dicts (materialize), the undo +def init_context(c): + """The containers every component context that renders in two phases uses (ComponentContext).""" + c.elements_next = {} + c.elements = {} + c.children_next = {} + c.children = {} + c.widgets = {} + c.element_to_widget = {} + c.effects = [] + c.memo = [] + c.used_keys = set() + c.exceptions_self = [] + c.exceptions_children = [] + # most components use state (solara: every component), and have a container widget: making + # these on first use (see ComponentContext.__getattr__) costs more than an empty dict + c.state = {} + c.setters = {} + c.resolved_kwargs = {} + + +def init_render_context(rc, element, container, children_trait, handle_error, fast): + """The fields of a new render context (see reacton.core._RenderContext), without its + initial state. fast: a _RenderContextFast.""" + rc.element = element + rc.container = container + rc.children_next_trait = children_trait + rc.first_render = True + rc.container_adders = [] + root = _new_instance(_ComponentContext) + root.parent = None + # (init_context without the hook containers: the root runs no component body) + root.elements_next = {} + root.elements = {} + root.children_next = {} + root.children = {} + root.widgets = {} + root.element_to_widget = {} + root.used_keys = set() + root.exceptions_self = [] + root.exceptions_children = [] + rc.context = root + rc.context_root = root + rc.render_count = 0 + rc.last_root_widget = None + rc._is_rendering = False + rc._rerender_needed = False + rc._rerender_needed_reasons = deque(maxlen=2) + rc.thread_lock = threading.Lock() + rc._closing = False + rc.tracebacks = [] + rc.handle_error = handle_error + rc.reconsolidating = False + # utils.ThreadSafeCounter() without its __init__ frame + counter = _ThreadSafeCounter.__new__(_ThreadSafeCounter) + counter._value = 0 + counter._lock = threading.Lock() + rc._batch_counter = counter + rc._walk_all = True + rc._shared_widgets = {} + rc._shared_elements_next = set() + rc._shared_elements = set() + rc._orphans = {} + rc._old_element_ids = set() + if fast: + rc._mount_roots = {} + rc._mount_tops = [] + rc._mount_failed = False + rc._body_adder = ContainerAdder(None, "children") + + +def _info_enabled(): + # _logger.isEnabledFor(logging.INFO) without its frame: logging keeps the answer in + # Logger._cache (cleared when a level changes) + if _logger.disabled: + return False + cache = getattr(_logger, "_cache", None) + enabled = cache.get(20) if cache is not None else None + if enabled is None: + return _logger.isEnabledFor(20) + return enabled + + +def render_fixed(element, handle_error=True): + """reacton.core.render_fixed: render element, without a container widget.""" + if fast_selected(): + # (_RenderContextFast(element, handle_error=handle_error), without the __init__ frame) + rc = _RenderContextFast.__new__(_RenderContextFast) + init_render_context(rc, element, None, "children", handle_error, True) + widget = render_first(rc, element, None) + else: + rc = _core._RenderContext(element, handle_error=handle_error) + widget = rc.render(element) + local.last_rc = weakref.ref(rc) + return widget, rc + + +def fast_selected(): + """REACTON_FAST=1 in the environment (read for every render: it can change at runtime).""" + if not cython.compiled: + return os.environ.get("REACTON_FAST") == "1" + # (os.environ.get is a few Python calls; os.environ writes go to the C environment too) + return _getenv_fast() # type: ignore[name-defined] # noqa: F821 (C, see _fastcore.pxd) + + +def render_first(rc, element, container): + """render() of a new fast render context (render() and render_fixed() of reacton.core). + + When the root element is a component element, the first pass mounts it and the + reconciliation finishes the mount, without the walks of render(). When something needs + more passes (state set or an exception during the mount, an effect that sets state), the + loop of render() takes over; in every other case render() does it all. + """ + root = rc.context_root + if ( + rc._is_rendering + or not rc.first_render + or not rc._mount_enabled + or DEBUG + or rc._closing + or not isinstance(element, ElementBase) + or element.is_shared + or isinstance(element.component, _ComponentWidget) + # (state_set made contexts for the initial state) + or root.children_next + # (render() logs its phases) + or _info_enabled() + ): + return rc.render(element, container) + lock = rc.thread_lock + if not lock.acquire(False): + # (render() waits for it, or refuses a render from a component body) + return rc.render(element, container) + if container is None: + container = rc.container + widget = None + try: + prev_rc = getattr(local, "rc", None) + rc._lock_thread = threading.get_ident() + try: + local.rc = rc + rc.element = element + rc._rerender_needed = False + global _log_debug + _log_debug = False + rc.render_count += 1 + rc._is_rendering = True + root.exception_handler = False + root.root_element_next = element + # the first pass: _render and _render_component of a new component element + root.has_shared = False + root.child_order_counter = 1 + key = element._key + if key is None: + key = "/" + root.used_keys.add(key) + root.elements_next[key] = element + element._render_count += 1 + try: + widget = mount_component(rc, element, key, root, 0, None) + more = rc._rerender_needed or rc._mount_failed or root.exceptions_children + if more: + # (undoes the mount: the passes of render() follow) + rc._end_render_pass() + except BaseException: + rc._is_rendering = False + raise + rc.first_render = False + rc._walk_all = False + if more: + widget = rc._render_loop(container, None, False, False) + else: + rc._mount_tops = [] + # the reconciliation of the root: _reconsolidate of the mounted component + # (reconsolidating stays True when it raises, see below) + rc.reconsolidating = True + try: + finish_mount(rc, root.children_next[key]) + root.children[key] = root.children_next.pop(key) + widget = root.widgets[key] + finally: + root.elements[key] = root.elements_next.pop(key) + rc.reconsolidating = False + root.root_element = element + root.root_element_next = None + rc.last_root_widget = widget + if container: + container.children = [widget] + if rc._rerender_needed and not root.exceptions_children and not root.exceptions_self: + # an effect set state + widget = rc._render_loop(container, widget, False, False) + rc._is_rendering = False + except BaseException: + if rc.reconsolidating: + # (reconciliation raised halfway: the widgets it made need the normal removal) + rc.reconsolidating = False + else: + rc._discard_aborted_pass() + raise + finally: + local.rc = prev_rc + rc._is_rendering = False + rc._lock_thread = None + finally: + lock.release() + if root.exceptions_children or root.exceptions_self: + return rc._render_error() + return widget + + def finish_mount(rc, root): """Reconciliation of a mounted subtree (rc.context is the parent of root): run the effects, children first, and hook the root widget into the parent.""" @@ -990,7 +1228,16 @@ def node(self, el, default_key): raise _StopWalk() node = self.nodes[self.index] self.index += 1 - widget = node[1] if type(node) is tuple else node + if type(node) is tuple: + widget = node[1] + if len(node) == 3 and not self.partial: + # the observers the mount added: from now on removed as Element does it + callback_wrappers = _Element._callback_wrappers + added = node[2] + for listener in [added] if type(added) is _Listener else (added or ()): + callback_wrappers[(widget.model_id, listener.name, listener.callback)] = listener + else: + widget = node self.widgets[key] = widget self.element_to_widget[el] = widget if self.index - 1 != start: @@ -1037,7 +1284,6 @@ def undo_mounts(rc): undo.component(top) rc._mount_roots = {} rc._mount_tops = [] - rc._mount_contexts = [] rc._mount_failed = False @@ -1129,8 +1375,15 @@ def __init__(self, contexts): def _close_widget_node(rc, node): # close the widget of a node (and the widgets it made as a side effect) + # (a node (el, widget, listeners): the mount added the observers, see _mount_node) + mounted_listeners = False + added = None if type(node) is tuple: - el, widget = node + el = node[0] + widget = node[1] + if len(node) == 3: + mounted_listeners = True + added = node[2] else: el = None widget = node @@ -1141,7 +1394,15 @@ def _close_widget_node(rc, node): orphan_widget = widgets_dict.get(orphan) if orphan_widget: _core.close_widget(orphan_widget) - if el is not None: + if mounted_listeners: + # (Element._cleanup_callbacks: the observers the mount added) + for listener in [added] if type(added) is _Listener else (added or ()): + try: + widget.unobserve(listener, listener.name[3:]) + except ValueError: + _logger.error("Could not remove event listener %r from %r", listener.name, widget) + el._close_widget(widget) + elif el is not None: el._cleanup_callbacks(widget) el._close_widget(widget) else: @@ -1301,10 +1562,13 @@ def rc_use_state(rc, initial, key, eq): else: setter = rc.make_setter(key, context, eq) setters[key] = setter + elif type(setter) is _Setter: + if setter.eq is not eq: + # the latest eq, as when a setter was made every render + setter.eq = eq else: eq_cell = setter._reacton_eq if eq_cell[0] is not eq: - # the latest eq, as when a setter was made every render eq_cell[0] = eq return value, setter @@ -1400,6 +1664,65 @@ def use_effect(effect, dependencies=None): rc_use_effect(rc, effect, dependencies) +def _new_effect(callable, dependencies): + # reacton.core.Effect(callable, dependencies), without its __init__ frame + new = _new_instance(_Effect) + new.callable = callable + new.dependencies = dependencies + return new + + +def use_context(user_context): + """reacton.core.use_context (see there).""" + counter, set_counter = use_state(0) + rc = local.rc # (use_state checked there is one) + # the nearest ancestor that provides the context + context = rc.context.parent + value = None + while context is not None: + user_contexts = context.user_contexts + if user_context in user_contexts: + value = user_contexts.get(user_context) + break + context = context.parent + # listen to the provided value (provide() renders the listeners again) + use_effect(_ContextConnect(context, user_context, _ContextListener(set_counter)), [context]) + if context is None: + return user_context._default_value + return value + + +def _increment(x): + return x + 1 + + +class _ContextListener: + """use_context: render the component again (provide() calls it when the value changes).""" + + def __init__(self, set_counter): + self.set_counter = set_counter + + def __call__(self): + self.set_counter(_increment) + + +class _ContextConnect: + """The effect of use_context: add the listener to the providing context (and remove it).""" + + def __init__(self, context, user_context, listener): + self.context = context + self.user_context = user_context + self.listener = listener + + def __call__(self): + if self.context is not None: + self.context.context_listeners[self.user_context].add(self.listener) + return self.disconnect + + def disconnect(self): + self.context.context_listeners[self.user_context].remove(self.listener) + + def rc_use_effect(rc, effect, dependencies): # reacton.core._RenderContext.use_effect (both renderers) context = rc.context @@ -1410,7 +1733,7 @@ def rc_use_effect(rc, effect, dependencies): if type(effects) is tuple: # (a mounted component: made on first use) effects = context.effects = [] - effects.append(_Effect(effect, dependencies)) + effects.append(_new_effect(effect, dependencies)) if _log_debug: _logger.debug("Initial effect = %r for index %r (%r)", effect, index, dependencies) else: @@ -1423,10 +1746,10 @@ def rc_use_effect(rc, effect, dependencies): previous_effect.next = None else: # line up, reconciliation cleans up the previous one and runs this one - previous_effect.next = _Effect(effect, dependencies) + previous_effect.next = _new_effect(effect, dependencies) else: # replace - effects[index] = _Effect(effect, dependencies) + effects[index] = _new_effect(effect, dependencies) class _EventHandler: @@ -1579,8 +1902,8 @@ def __init__(self, rc, context, key, eq): self.rc = rc self.context = context self.key = key - # (the latest eq, updated by use_state; a list, as the closure version had) - self._reacton_eq = [eq] + # (the latest eq, updated by use_state) + self.eq = eq self.created_stack = None if DEBUG: import traceback @@ -1595,7 +1918,7 @@ def __call__(self, value): return context = self.context key = self.key - eq = self._reacton_eq[0] + eq = self.eq state = context.state if callable(value): value = value(state[key]) diff --git a/reacton/core.py b/reacton/core.py index 5dfc1e9..66e27a9 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -7,7 +7,6 @@ """ -import collections import contextlib import copy import functools @@ -1318,22 +1317,7 @@ def __init__( 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 = [] - # 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 = {} + _fastcore.init_context(self) # the dataclass constructor took every field as a keyword argument for name, value in fields.items(): if name not in _COMPONENT_CONTEXT_FIELDS: @@ -1506,54 +1490,48 @@ def __call__(self): class _RenderContext: context: Optional[ComponentContext] = None + # the ident of the thread that holds thread_lock (render) + _lock_thread: Optional[int] = None + + # the fields (set by _fastcore.init_render_context) + element: Element + container: Optional[widgets.Widget] + children_next_trait: str + first_render: bool + container_adders: List[ContainerAdder] + context_root: ComponentContext + render_count: int + last_root_widget: widgets.Widget + _is_rendering: bool + _rerender_needed: bool + # (only the last two reasons are read: the "too many renders" error message) + _rerender_needed_reasons: Deque[RerenderReason] + thread_lock: Any + _closing: bool + tracebacks: List[TracebackType] + handle_error: bool + reconsolidating: bool + _batch_counter: utils.ThreadSafeCounter + # when set, the next render phase walks the whole tree instead of skipping subtrees in + # which no state changed (see _render) + _walk_all: bool + # shared elements outlive their component context: their widgets are kept here + _shared_widgets: Dict[Element, widgets.Widget] + # the shared elements a render pass processed (one widget per element), and after + # reconciliation + _shared_elements_next: Set[Element] + _shared_elements: Set[Element] + # widgets created as side effect (like Layout and Style), by the model id of the widget + # that made them (not all widgets are hashable, like plotly), to close them + _orphans: Dict[str, Set[str]] + # for detecting stale elements used get_widget + _old_element_ids: Set[int] def __init__(self, element: Element, container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None): - self.element = element - self.container = container - self.children_next_trait = children_trait - self.first_render = True - self.container_adders: List[ContainerAdder] = [] - self.context = ComponentContext() - self.context_root = self.context - self.render_count = 0 - self._lock_thread = cast(Optional[threading.Thread], None) - self.last_root_widget: widgets.Widget = None - self._is_rendering = False - self._rerender_needed = False - # 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] = [] - self.handle_error = handle_error - self.reconsolidating = False - self._batch_counter = utils.ThreadSafeCounter() - # 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 + _fastcore.init_render_context(self, element, container, children_trait, handle_error, False) if initial_state: self.state_set(self.context_root, initial_state) - # element that are shared outlive the ComponentContext, so we - # store them in the RenderContext - self._shared_widgets: Dict[Element, widgets.Widget] = {} - - # each render phase, we track which (shared) elements we proccessed - # so we don't render them twice (only 1 widget per element) - self._shared_elements_next: Set[Element] = set() - - # once reconcilidated, shared elements move here. - self._shared_elements: Set[Element] = set() - - # widgets created as side effect (like Layout and Style) - # key is the widget model id (because some widgets are not hashable, like plotly) - # We keep track of this to make sure we clean up all widgets. - self._orphans: Dict[str, Set[str]] = {} - # for detecting stale elements used get_widget - self._old_element_ids: Set[int] = set() - def __enter__(self): counter = self._batch_counter.increment() if counter == 1: @@ -1821,16 +1799,17 @@ def render(self, element: Element, container: widgets.Widget = None): container = self.container was_locked = False if self.thread_lock.locked(): - if self._lock_thread == threading.current_thread(): + if self._lock_thread == threading.get_ident(): raise RuntimeError("Recursive render detected (avoided deadlock), current thread: %r" % threading.current_thread()) logger.info( - "Render phase still in progress, waiting for mutex to release (locked obtained by %r, we are in thread %r)", + "Render phase still in progress, waiting for mutex to release (locked by thread %r, we are in thread %r)", self._lock_thread, - threading.current_thread(), + threading.get_ident(), ) was_locked = True with self.thread_lock: - self._lock_thread = threading.current_thread() + # (the ident of the thread: threading.current_thread() is a Python call) + self._lock_thread = threading.get_ident() if was_locked: logger.info("Mutex released, continuing render phase") if self._closing or self.context is None: @@ -1840,8 +1819,6 @@ def render(self, element: Element, container: widgets.Widget = None): logger.info("Render requested on a closing/closed render context, ignoring") return container 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 try: local.rc = self self.element = element @@ -1878,111 +1855,7 @@ def render(self, element: Element, container: widgets.Widget = None): raise if main_render_phase: - stable = False - render_counts = 0 - while not stable and not self.context_root.exceptions_children: - # we started the rendering loop (main_render_phase is True), so we keep going - # but if an exception bubbled up, we should stop - while self._rerender_needed and not self.context_root.exceptions_children: - if render_counts > 50: - - def format(reason: RerenderReason): - f = f"Reason: {reason.reason}\nValue changed from {reason.prev_value} to {reason.next_value}\n" - if reason.created_stack: - f += f"Created at: {''.join(reason.created_stack)}\n" - if reason.trigger_stack: - f += f"Triggered at: {''.join(reason.trigger_stack)}\n" - return f - - self._rerender_needed_reasons[-1] - msg = f"Too many renders triggered, your render loop does not stop\nLast reason: {format(self._rerender_needed_reasons[-1])}\n" - if len(self._rerender_needed_reasons) >= 2: - msg += f"Previous reasons: {format(self._rerender_needed_reasons[-2])}\n" - raise RuntimeError(msg) - 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 - self.context.exceptions_children = [] - 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]) - assert self.context is self.context_root - render_counts += 1 - 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 - - if log_info: - logger.info("Render reconsolidate...") - self.reconsolidating = True - in_render_phase = False - try: - widget = self._reconsolidate(self.element, default_key="/", parent_key=ROOT_KEY) - finally: - self.reconsolidating = False - in_render_phase = True - if log_info: - logger.info("Render reconsolidate done") - self.context.root_element = self.context.root_element_next - self.context.root_element_next = None - - # remove stale elements of the root context itself - # (child contexts are swept during their reconciliation) - self._remove_stale_root_elements(ROOT_KEY) - - if self._shared_elements_next: - raise RuntimeError(f"Element not reconsolidated: {self._shared_elements_next}") - 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: - assert widget in self._shared_widgets.values() - else: - assert widget in self.context_root.widgets.values() - if self.last_root_widget is None: - self.last_root_widget = widget - else: - if container is None: - if self.last_root_widget != widget: - raise ValueError( - "You are not using a container, and the root component returned a new widget," - "make sure your root component always returns the same component type" - ) - if container: - if widget is None: - # Exception occurred, and we cannot render the widget - container.children = [] - else: - container.children = [widget] - - if self.context_root.exceptions_children or self.context_root.exceptions_self: - # an exception bubbled up during reconsolidate - break - - if self._rerender_needed: - if log_info: - logger.info("Need rerender after reconsolidation: %r", self._rerender_needed_reasons[-1]) - stable = False - else: - stable = True - + widget = self._render_loop(container, widget, log_info, log_debug) self._is_rendering = False self.context = context_prev if log_info: @@ -1995,7 +1868,10 @@ def format(reason: RerenderReason): # committed elements, but runs the effects the aborted pass chained, closed # over elements that were never reconciled (get_widget then fails with # "found in a previous render"). - if in_render_phase: + if self.reconsolidating: + # (reconciliation raised halfway: the widgets it made need the normal removal) + self.reconsolidating = False + else: self._discard_aborted_pass() if DEBUG: # construct a fake traceback (showing how the elements were constructed) @@ -2015,26 +1891,135 @@ def format(reason: RerenderReason): self._lock_thread = None assert self.context is self.context_root - exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self] - if exceptions: - exc = exceptions[0] - if DEBUG: - exc = _with_tracebacks(exc, self.tracebacks) + if self.context_root.exceptions_children or self.context_root.exceptions_self: + return self._render_error() + return widget + + def _render_error(self): + # the end of render() when a component raised (and no component handled it) + exc = [*self.context_root.exceptions_children, *self.context_root.exceptions_self][0] + if DEBUG: + exc = _with_tracebacks(exc, self.tracebacks) + + if self.handle_error: + logger.info("Exception occurred, rendering error message") + if exc.__traceback__ is None: + value = "Exception occurred, but no traceback available" + else: + error = "".join(traceback.format_exception(None, exc, exc.__traceback__)) + import html + + value = html.escape(error) + from . import ipywidgets as w - if self.handle_error: - logger.info("Exception occurred, rendering error message") - if exc.__traceback__ is None: - value = "Exception occurred, but no traceback available" + return self.render(w.HTML(value="
" + value + "
", layout=w.Layout(overflow="auto")), self.container) + else: + raise exc + + def _render_loop(self, container, widget, log_info, log_debug): + # the passes and reconciliations of render() after its first pass, until stable + stable = False + render_counts = 0 + while not stable and not self.context_root.exceptions_children: + # we started the rendering loop (main_render_phase is True), so we keep going + # but if an exception bubbled up, we should stop + while self._rerender_needed and not self.context_root.exceptions_children: + if render_counts > 50: + + def format(reason: RerenderReason): + f = f"Reason: {reason.reason}\nValue changed from {reason.prev_value} to {reason.next_value}\n" + if reason.created_stack: + f += f"Created at: {''.join(reason.created_stack)}\n" + if reason.trigger_stack: + f += f"Triggered at: {''.join(reason.trigger_stack)}\n" + return f + + self._rerender_needed_reasons[-1] + msg = f"Too many renders triggered, your render loop does not stop\nLast reason: {format(self._rerender_needed_reasons[-1])}\n" + if len(self._rerender_needed_reasons) >= 2: + msg += f"Previous reasons: {format(self._rerender_needed_reasons[-2])}\n" + raise RuntimeError(msg) + 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_root.exception_handler = False + self.context_root.exceptions_children = [] + self.context_root.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]) + assert self.context is self.context_root + render_counts += 1 + 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 + + if log_info: + logger.info("Render reconsolidate...") + # (reconsolidating stays True when it raises, see render()) + self.reconsolidating = True + widget = self._reconsolidate(self.element, default_key="/", parent_key=ROOT_KEY) + self.reconsolidating = False + if log_info: + logger.info("Render reconsolidate done") + self.context_root.root_element = self.context_root.root_element_next + self.context_root.root_element_next = None + + # remove stale elements of the root context itself + # (child contexts are swept during their reconciliation) + self._remove_stale_root_elements(ROOT_KEY) + + if self._shared_elements_next: + raise RuntimeError(f"Element not reconsolidated: {self._shared_elements_next}") + 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: + assert widget in self._shared_widgets.values() + else: + assert widget in self.context_root.widgets.values() + if self.last_root_widget is None: + self.last_root_widget = widget + else: + if container is None: + if self.last_root_widget != widget: + raise ValueError( + "You are not using a container, and the root component returned a new widget," + "make sure your root component always returns the same component type" + ) + if container: + if widget is None: + # Exception occurred, and we cannot render the widget + container.children = [] else: - error = "".join(traceback.format_exception(None, exc, exc.__traceback__)) - import html + container.children = [widget] - value = html.escape(error) - from . import ipywidgets as w + if self.context_root.exceptions_children or self.context_root.exceptions_self: + # an exception bubbled up during reconsolidate + break - return self.render(w.HTML(value="
" + value + "
", layout=w.Layout(overflow="auto")), self.container) + if self._rerender_needed: + if log_info: + logger.info("Need rerender after reconsolidation: %r", self._rerender_needed_reasons[-1]) + stable = False else: - raise exc + stable = True + return widget def _render(self, element: Element, default_key: str, parent_key: str): @@ -2740,19 +2725,20 @@ class _RenderContextFast(_RenderContext): # 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 first components of the subtrees mounted in this render pass (see _unmount) - self._mount_tops: List[ComponentContext] = [] - # every component context made by mounts in this render pass - self._mount_contexts: List[ComponentContext] = [] - # 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) + # the mounted subtrees (their first component) that reconciliation still has to finish + _mount_roots: Dict[ComponentContext, None] + # the first components of the subtrees mounted in this render pass (see _unmount) + _mount_tops: List[ComponentContext] + # something in this pass cannot be mounted: undo the mounts at the end of the pass + _mount_failed: bool + # records the elements a component body makes (see _call_component): one for all bodies, + # bodies of one render context do not nest + _body_adder: ContainerAdder + + def __init__(self, element: Element, container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None): + _fastcore.init_render_context(self, element, container, children_trait, handle_error, True) + if initial_state: + self.state_set(self.context_root, initial_state) def _set_rerender_needed(self, reason: str): self._rerender_needed_reasons.append(RerenderReason(reason=reason)) @@ -2777,14 +2763,13 @@ def _mark_dirty(self, context: ComponentContext): parent = parent.parent def _end_render_pass(self): - if self._mount_contexts: + if self._mount_tops: 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_tops = [] - self._mount_contexts = [] self._mount_failed = False def _unmount(self): @@ -2792,7 +2777,7 @@ def _unmount(self): _fastcore.undo_mounts(self) def _before_close(self): - if self._mount_contexts or self._mount_roots: + if self._mount_tops or self._mount_roots: # a reconciliation that raised did not finish them self._unmount() @@ -3991,9 +3976,7 @@ def _remove_stale_root_elements(self, parent_key): def _render_context_class(): - import os - - return _RenderContextFast if os.environ.get("REACTON_FAST") == "1" else _RenderContext + return _RenderContextFast if _fastcore.fast_selected() else _RenderContext @overload @@ -4010,15 +3993,22 @@ def render( def render(element: Element[T], container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None): container = container or widgets.VBox() - _rc = _render_context_class()(element, container, children_trait=children_trait, handle_error=handle_error, initial_state=initial_state) - _rc.render(element, _rc.container) + cls = _render_context_class() + _rc = cls(element, container, children_trait=children_trait, handle_error=handle_error, initial_state=initial_state) + if cls is _RenderContextFast: + _fastcore.render_first(_rc, element, _rc.container) + else: + _rc.render(element, _rc.container) local.last_rc = weakref.ref(_rc) return container, _rc def render_fixed(element: Element[T], handle_error: bool = True) -> Tuple[T, _RenderContext]: - _rc = _render_context_class()(element, handle_error=handle_error) - widget = _rc.render(element) + """Render element, without a container widget: returns its widget and the render context.""" + # (the implementation used is _fastcore.render_fixed, see the end of this module) + cls = _render_context_class() + _rc = cls(element, handle_error=handle_error) + widget = _fastcore.render_first(_rc, element, None) if cls is _RenderContextFast else _rc.render(element) local.last_rc = weakref.ref(_rc) return widget, _rc @@ -4153,6 +4143,11 @@ def Fragment(children: List[Element]): (use_ref, _fastcore.use_ref), ]: _fast_hook.__doc__ = _python_hook.__doc__ + # (the same, with the render context made and the first render done without Python frames) + _fastcore.render_fixed.__doc__ = render_fixed.__doc__ + render_fixed = _fastcore.render_fixed + _fastcore.use_context.__doc__ = use_context.__doc__ + use_context = _fastcore.use_context use_state = _fastcore.use_state use_effect = _fastcore.use_effect use_ref = _fastcore.use_ref diff --git a/reacton/core_test.py b/reacton/core_test.py index b70644d..a3face9 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4902,3 +4902,52 @@ def descriptions(widget): assert descriptions(container.children[0]) == [["parent 1", "a 2", "b 3"]] rc.close() assert states[True] == states[False] + + +def test_first_render_when_an_effect_or_body_sets_state(): + # the first render of the fast renderer (_fastcore.render_first) hands over to the loop of + # render() when an effect sets state, or a body sets state or raises during the mount + events: List[str] = [] + + @react.component + def EffectSets(): + value, set_value = react.use_state(0) + react.use_effect(lambda: set_value(1), []) + events.append(f"effect-sets {value}") + return w.Button(description=f"effect {value}") + + @react.component + def BodySets(): + value, set_value = react.use_state(0) + if value == 0: + set_value(2) + events.append(f"body-sets {value}") + return w.Button(description=f"body {value}") + + @react.component + def Raises(): + raise ValueError("oops") + + @react.component + def Catches(): + exception, clear = react.use_exception() + return w.Label(value=f"caught {exception!r}") if exception else Raises() + + for fast in [False, True]: + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + del events[:] + button, rc = react.render_fixed(EffectSets(), handle_error=False) + assert button.description == "effect 1" + assert events == ["effect-sets 0", "effect-sets 1"] + rc.close() + del events[:] + container = widgets.VBox() + box, rc = react.render(BodySets(), container, handle_error=False) + assert container.children[0].description == "body 2" + assert events == ["body-sets 0", "body-sets 2"] + rc.close() + label, rc = react.render_fixed(Catches(), handle_error=False) + assert label.value == "caught ValueError('oops')" + rc.close() + with pytest.raises(ValueError, match="oops"): + react.render_fixed(Raises(), handle_error=False) diff --git a/reacton/utils.py b/reacton/utils.py index c955903..890e78b 100644 --- a/reacton/utils.py +++ b/reacton/utils.py @@ -210,6 +210,7 @@ def environment() -> str: class ThreadSafeCounter: + # (reacton._fastcore.init_render_context makes one without calling __init__) def __init__(self): self._value = 0 self._lock = threading.Lock() From 1ba6ca83a468abf74f0530552ae66dba6d3a9809 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 08:09:34 +0200 Subject: [PATCH 13/16] Do less per widget and per removed component of a mount After the first batch, rows300, page and subtree_swap still spent more per unit than the compiled floor. Per widget: a bound method for the lookup of the element class flags (an untyped dict global), and the trait check of every kwarg, needed only when a value can be a listener (a callable or None). Per removed mounted component: two exception lists set on the context (and its dict grown for them) although almost no removal raises, an isinstance of a Python class that fails for every widget node, and a Python frame to stop the widget recording. The exceptions of a removal are now collected in local lists and bubble up as before (a test checks that a use_exception component above a removed child gets the exception of its effect cleanup, in both renderers). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 11 +++++-- reacton/_fastcore.py | 77 ++++++++++++++++++++++++++++++++----------- reacton/core_test.py | 38 +++++++++++++++++++++ 3 files changed, 104 insertions(+), 22 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index 9238be0..837e65d 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -70,6 +70,10 @@ cdef class _Mount: cdef object _new_instance(object cls) +# (only _fastcore uses these) +cdef dict _plain_classes +cdef frozenset _SCALAR_TYPES + cdef int _plain_class(object cls) except -1 cdef class _WidgetInfo: cdef public object widget @@ -94,7 +98,7 @@ cdef object _call_body(_Mount m, ElementBase el, object managers) @cython.locals(component=object, default_container=object, created=list, root_element=object, kwargs=dict, container=object) cpdef object call_component(list container_adders, ContainerAdder adder, ElementBase el) -@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, added=object, listener=object, info=_WidgetInfo, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object) +@cython.locals(key=object, all_keys=dict, keys=set, component=object, precreated=object, precreated_children=object, child=object, widget=object, kwargs=dict, resolved=dict, name=object, value=object, new_value=object, rc=object, element_class=object, plain=int, added=object, listener=object, info=_WidgetInfo, recording=list, count=Py_ssize_t, listeners=dict, traits=frozenset, callback=object, widget_class=object, handlers=tuple, handler=object, orphan_ids=object, widgets_dict=object, maybe_listener=bint, t=object) cdef object _mount_node(_Mount m, ElementBase el, object c, list nodes, object dkey) @cython.locals(values=list, index=Py_ssize_t, x=object, w=object) @@ -146,9 +150,12 @@ cdef class _Materialize: @cython.locals(el=object, widget=object, added=object, mounted_listeners=bint, listener=object, orphans=object, orphan=object, orphan_widget=object, close=object, widgets_dict=object) cdef object _close_widget_node(object rc, object node) -@cython.locals(context=object, effect=object, cleanup=object, handler=object, nodes=list, node=object, switched=bint, effects=object, handlers=object) +@cython.locals(errors=list) cpdef object remove_mounted(object rc, object child_context, bint closing) +@cython.locals(context=object, effect=object, cleanup=object, handler=object, nodes=list, node=object, switched=bint, effects=object, handlers=object, errors=list, errors_children=list, sub=list) +cdef list _remove_mounted(object rc, object child_context, bint closing) + # ---- the hooks diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index 1b595cb..fd324a9 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -508,7 +508,8 @@ def mount_component(rc, el, key, parent_context, order, context): c = m.top c.order_in_parent = order finally: - _core._stop_recording_constructed(previous_recording) + # (reacton.core._stop_recording_constructed, inline) + _core._construction_local.recording = previous_recording if widget is not None and not m.failed: c.mount_order = m.order rc._mount_roots[c] = None @@ -733,9 +734,13 @@ def _mount_node(m, el, c, nodes, dkey): assert not el.args, "no positional args supported for widgets" kwargs = el.kwargs resolved = None + # a value that can be an event listener (on_): a callable, or None + maybe_listener = False for name, value in kwargs.items(): t = type(value) if t in _SCALAR_TYPES: + if value is None: + maybe_listener = True continue if t is list: new_value = _mount_list(m, value, c, nodes, None if key is None else f"{key}{name}/") @@ -744,6 +749,7 @@ def _mount_node(m, el, c, nodes, dkey): elif t is tuple or t is dict or isinstance(value, (list, tuple, dict)): new_value = _mount_value(m, value, c, nodes, None if key is None else f"{key}{name}/") else: + maybe_listener = True continue if resolved is None: resolved = dict(kwargs) @@ -766,11 +772,13 @@ def _mount_node(m, el, c, nodes, dkey): listeners = None info = _widget_info(component) traits = info.trait_names - for name in resolved: - if name not in traits: - break - else: - name = None + name = None + if maybe_listener: + for name in resolved: + if name not in traits: + break + else: + name = None if name is not None: # a kwarg that is not a trait: an event listener (on_) for name in list(resolved): @@ -1422,9 +1430,19 @@ def remove_mounted(rc, child_context, closing): """Remove a mounted component (rc.context is its parent): the same order of effect cleanups, handler removals and widget closes as reacton.core's _remove_element (or _close_element when closing), from its nodes.""" + errors = _remove_mounted(rc, child_context, closing) + if errors: + rc.context.exceptions_children.extend(errors) + + +def _remove_mounted(rc, child_context, closing): + # Returns the exceptions that bubble up to the parent (None when there are none): the + # exceptions of the cleanups of this component, and those of its children when this + # component does not handle exceptions. (Local lists: most mounted components never have + # exceptions, and their contexts do not get the exception lists.) context = rc.context - child_context.exceptions_self = [] - child_context.exceptions_children = [] + errors = None + errors_children = None # (rc.context is only switched to child_context when an effect cleanup, a handler or a # child component can use it: most mounted components have none) switched = False @@ -1441,7 +1459,9 @@ def remove_mounted(rc, child_context, closing): cleanup() except BaseException as e: _logger.exception("Effect cleanup %r raised exception %r", effect.callable, e) - child_context.exceptions_self.append(e) + if errors is None: + errors = [] + errors.append(e) if not closing: rc._set_rerender_needed("Exception ocurred during effect") rc._mark_dirty(child_context) @@ -1456,32 +1476,49 @@ def remove_mounted(rc, child_context, closing): handler._reacton_detach() except BaseException as e: _logger.exception("Removing event handler %r raised exception %r", handler, e) - child_context.exceptions_self.append(e) + if errors is None: + errors = [] + errors.append(e) rc._set_rerender_needed("Exception ocurred during effect") rc._mark_dirty(child_context) nodes = child_context.nodes child_context.nodes = None for node in nodes: - if isinstance(node, _ComponentContext): + if type(node) is _MountedContext or (type(node) is not tuple and isinstance(node, _ComponentContext)): if not switched: rc.context = child_context switched = True if node.nodes is not None: - remove_mounted(rc, node, closing) - elif closing: - # (its dicts were made, e.g. by get_widget): as the two phase walk does - rc._close_component_context(node) + sub = _remove_mounted(rc, node, closing) + if sub: + if errors_children is None: + errors_children = [] + errors_children.extend(sub) else: - rc._remove_component_context(node) + # (its dicts were made, e.g. by get_widget): as the two phase walk does, + # which puts the exceptions that bubble up on rc.context (child_context) + sub = child_context.exceptions_children = [] + if closing: + rc._close_component_context(node) + else: + rc._remove_component_context(node) + if sub: + if errors_children is None: + errors_children = [] + errors_children.extend(sub) else: _close_widget_node(rc, node) finally: if switched: rc.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) + if errors is None: + if errors_children is not None and child_context.exception_handler: + # (a component that handles exceptions keeps those of its children) + return None + return errors_children + if errors_children is not None: + errors.extend(errors_children) + return errors # ============================================================================================ diff --git a/reacton/core_test.py b/reacton/core_test.py index a3face9..c95fba8 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4951,3 +4951,41 @@ def Catches(): rc.close() with pytest.raises(ValueError, match="oops"): react.render_fixed(Raises(), handle_error=False) + + +def test_exception_in_cleanup_of_a_removed_child_reaches_the_handler(): + # an effect cleanup that raises when its component is removed: a component above it that + # handles exceptions (use_exception) gets it, in both renderers (the fast renderer removes + # a mounted child from its nodes) + set_show: Dict[str, Callable] = {} + + @react.component + def Child(): + def effect(): + def cleanup(): + raise ValueError("cleanup failed") + + return cleanup + + react.use_effect(effect, []) + return w.Button(description="child") + + @react.component + def Inner(): + show, set_show["inner"] = react.use_state(True) + return w.VBox(children=[Child(), Child()] if show else []) + + @react.component + def Catcher(): + exception, clear = react.use_exception() + return w.Label(value=f"caught {exception!r}") if exception else w.HBox(children=[Inner()]) + + results = {} + for fast in [False, True]: + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + container = widgets.VBox() + box, rc = react.render(Catcher(), container, handle_error=False) + set_show["inner"](False) + results[fast] = container.children[0].value + rc.close() + assert results[True] == results[False] == "caught ValueError('cleanup failed')" From 46dcb72040b19b0e2063d03bc9e9a3b5b84b55ba Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 09:19:41 +0200 Subject: [PATCH 14/16] Make less for a new render context and visit fewer contexts after a mount deep100 was still at about 10x in the interleaved run: its mount walk now costs the same as the compiled floor's, so what is left is the fixed cost of a render, in a cold cache. Per render context, a deque for the rerender reasons (a 64 slot block from the system allocator, and keyword parsing) and a ThreadSafeCounter with its own lock were made although most render contexts never need them: both are made on first use now (the counter under a module lock, as two threads can start a batch). The logging check of the first render reads the cache dict of the logger instead of a Python call. After a mount, finish_mount visited every new context to look for effects; in a cold cache that is a miss per context. The mount now records only the contexts that have effects (it reads that while the context is still in the cache); the rare exception of an effect goes up through the new contexts with a walk over their nodes (a test checks a use_exception component above a new subtree, in both renderers). Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.pxd | 11 +++++- reacton/_fastcore.py | 91 +++++++++++++++++++++++++------------------ reacton/core.py | 57 +++++++++++++++++---------- reacton/core_test.py | 28 +++++++++++++ reacton/utils.py | 1 - 5 files changed, 128 insertions(+), 60 deletions(-) diff --git a/reacton/_fastcore.pxd b/reacton/_fastcore.pxd index 837e65d..5bb1511 100644 --- a/reacton/_fastcore.pxd +++ b/reacton/_fastcore.pxd @@ -72,6 +72,7 @@ cdef object _new_instance(object cls) # (only _fastcore uses these) cdef dict _plain_classes +cdef dict _logger_cache cdef frozenset _SCALAR_TYPES cdef int _plain_class(object cls) except -1 @@ -108,12 +109,15 @@ cdef object _mount_value(_Mount m, object value, object c, list nodes, object dk cpdef object init_context(object c) -@cython.locals(root=object, counter=object) +@cython.locals(root=object) cpdef object init_render_context(object rc, object element, object container, object children_trait, object handle_error, bint fast) +@cython.locals(reasons=object) +cpdef add_rerender_reason(object rc, object reason) + cpdef bint fast_selected() -@cython.locals(cache=object, enabled=object) +@cython.locals(enabled=object) cdef object _info_enabled() @cython.locals(rc=object, widget=object) @@ -125,6 +129,9 @@ cpdef object render_first(object rc, object element, object container) @cython.locals(order=list, raised=bint, context=object, effects=object, parent=object, effect=object, widget=object, el=ElementBase, key=object) cpdef object finish_mount(object rc, object root) +@cython.locals(node=object, parent=object) +cdef object _bubble_exceptions(object c) + @cython.locals(nodes=list, w=_Materialize, root=object) cpdef object materialize(object c) diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index fd324a9..1cce513 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -388,8 +388,8 @@ def component_widget_new(cls, widget, mime_bundle=mime_bundle_default): _MountedContext: Any = None _FragmentWidget: Any = None _logger: Any = None +_logger_cache: Any = None _RenderContextFast: Any = None -_ThreadSafeCounter = utils.ThreadSafeCounter # values that cannot hold elements (the child visitors skip them) _SCALAR_TYPES = frozenset([str, int, float, bool, complex, bytes, type(None)]) # the dicts of a component context that are made from its nodes (see materialize) @@ -421,7 +421,7 @@ def _new_instance(cls): def _register_core(core): - global _core, _ComponentFunction, _ComponentWidget, _ComponentContext, _MountedContext, _FragmentWidget, _logger, _RenderContextFast + global _core, _ComponentFunction, _ComponentWidget, _ComponentContext, _MountedContext, _FragmentWidget, _logger, _RenderContextFast, _logger_cache _core = core _ComponentFunction = core.ComponentFunction _ComponentWidget = core.ComponentWidget @@ -429,6 +429,7 @@ def _register_core(core): _MountedContext = core._MountedContext _FragmentWidget = core.FragmentWidget _logger = core.logger + _logger_cache = getattr(_logger, "_cache", None) _RenderContextFast = core._RenderContextFast _register_hooks(core) @@ -616,7 +617,9 @@ def _mount_component(m, el, parent, parent_nodes, context, key): parent.needs_render = True if widget is not None: context.compact_widget = widget - m.order.append(context) + if context.effects: + # (read now: the context is in the cache; finish_mount only visits these) + m.order.append(context) return widget @@ -945,17 +948,11 @@ def init_render_context(rc, element, container, children_trait, handle_error, fa rc.last_root_widget = None rc._is_rendering = False rc._rerender_needed = False - rc._rerender_needed_reasons = deque(maxlen=2) rc.thread_lock = threading.Lock() rc._closing = False rc.tracebacks = [] rc.handle_error = handle_error rc.reconsolidating = False - # utils.ThreadSafeCounter() without its __init__ frame - counter = _ThreadSafeCounter.__new__(_ThreadSafeCounter) - counter._value = 0 - counter._lock = threading.Lock() - rc._batch_counter = counter rc._walk_all = True rc._shared_widgets = {} rc._shared_elements_next = set() @@ -971,14 +968,11 @@ def init_render_context(rc, element, container, children_trait, handle_error, fa def _info_enabled(): # _logger.isEnabledFor(logging.INFO) without its frame: logging keeps the answer in - # Logger._cache (cleared when a level changes) - if _logger.disabled: - return False - cache = getattr(_logger, "_cache", None) - enabled = cache.get(20) if cache is not None else None + # Logger._cache (the same dict, cleared when a level changes); Logger.disabled is not in it + enabled = _logger_cache.get(20) if _logger_cache is not None else None if enabled is None: return _logger.isEnabledFor(20) - return enabled + return enabled and not _logger.disabled def render_fixed(element, handle_error=True): @@ -995,6 +989,19 @@ def render_fixed(element, handle_error=True): return widget, rc +# (the class default of _RenderContext._rerender_needed_reasons: no reasons yet) +_NO_REASONS: tuple = () + + +def add_rerender_reason(rc, reason): + """rc._rerender_needed_reasons.append(reason); the deque is made by the first reason (making + it costs more than the rest of a render context, and most never need one).""" + reasons = rc._rerender_needed_reasons + if reasons is _NO_REASONS: + reasons = rc._rerender_needed_reasons = deque(maxlen=2) + reasons.append(reason) + + def fast_selected(): """REACTON_FAST=1 in the environment (read for every render: it can change at runtime).""" if not cython.compiled: @@ -1020,7 +1027,7 @@ def render_first(rc, element, container): or rc._closing or not isinstance(element, ElementBase) or element.is_shared - or isinstance(element.component, _ComponentWidget) + or (type(element.component) is not _ComponentFunction and isinstance(element.component, _ComponentWidget)) # (state_set made contexts for the initial state) or root.children_next # (render() logs its phases) @@ -1112,32 +1119,30 @@ def finish_mount(rc, root): """Reconciliation of a mounted subtree (rc.context is the parent of root): run the effects, children first, and hook the root widget into the parent.""" parent_context = rc.context + # (the contexts of the mount that have effects, children first) order = root.mount_order raised = False try: for context in order: effects = context.effects parent = context.parent - if effects: - rc.context = context - for effect in effects: - if effect.next is not None or effect.executed: - rc._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) - rc._set_rerender_needed("Exception ocurred during effect") - rc._mark_dirty(parent) - parent.needs_render = True - raised = True - if raised: - 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) + rc.context = context + for effect in effects: + if effect.next is not None or effect.executed: + rc._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) + rc._set_rerender_needed("Exception ocurred during effect") + rc._mark_dirty(parent) + parent.needs_render = True + raised = True + if raised: + _bubble_exceptions(root) widget = root.compact_widget el = root.invoke_element key = root.key_in_parent @@ -1151,6 +1156,18 @@ def finish_mount(rc, root): rc._mount_roots.pop(root, None) +def _bubble_exceptions(c): + # an effect of the mount raised: the exceptions go up to the top of the mount, children + # first (as in _render_component), through the contexts that do not handle them + for node in c.nodes: + if type(node) is _MountedContext: + _bubble_exceptions(node) + if c.exceptions_self or c.exceptions_children and not c.exception_handler: + parent = c.parent + parent.exceptions_children.extend(c.exceptions_self) + parent.exceptions_children.extend(c.exceptions_children) + + def materialize(c): """Make the dicts of a mounted context from its nodes (the same keys as the two phase walk). @@ -2008,7 +2025,7 @@ def __call__(self, value): ) else: reason = _RerenderReason(reason=f"state changed with key {key}", prev_value=prev_value, next_value=value) - rc._rerender_needed_reasons.append(reason) + add_rerender_reason(rc, reason) rc._rerender_needed = True rc._possible_rerender() diff --git a/reacton/core.py b/reacton/core.py index 66e27a9..58f8bfc 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1488,6 +1488,10 @@ def __call__(self): self.executed = True +# (makes the batch counter of a render context, once) +_batch_counter_lock = threading.Lock() + + class _RenderContext: context: Optional[ComponentContext] = None # the ident of the thread that holds thread_lock (render) @@ -1504,14 +1508,16 @@ class _RenderContext: last_root_widget: widgets.Widget _is_rendering: bool _rerender_needed: bool - # (only the last two reasons are read: the "too many renders" error message) - _rerender_needed_reasons: Deque[RerenderReason] + # (only the last two reasons are read: the "too many renders" error message; made by the + # first one, see _add_rerender_reason) + _rerender_needed_reasons: Deque[RerenderReason] = _fastcore._NO_REASONS # type: ignore[assignment] thread_lock: Any _closing: bool tracebacks: List[TracebackType] handle_error: bool reconsolidating: bool - _batch_counter: utils.ThreadSafeCounter + # (made by the first batch, see __enter__) + _batch_counter: Optional[utils.ThreadSafeCounter] = None # when set, the next render phase walks the whole tree instead of skipping subtrees in # which no state changed (see _render) _walk_all: bool @@ -1532,12 +1538,22 @@ def __init__(self, element: Element, container: widgets.Widget = None, children_ if initial_state: self.state_set(self.context_root, initial_state) + def _add_rerender_reason(self, reason: RerenderReason): + _fastcore.add_rerender_reason(self, reason) + def __enter__(self): - counter = self._batch_counter.increment() + batch_counter = self._batch_counter + if batch_counter is None: + with _batch_counter_lock: + batch_counter = self._batch_counter + if batch_counter is None: + batch_counter = self._batch_counter = utils.ThreadSafeCounter() + counter = batch_counter.increment() if counter == 1: logger.info("entering batch render") def __exit__(self, exc_type, exc_value, traceback): + assert self._batch_counter is not None counter = self._batch_counter.decrement() if counter == 0: logger.info("finishing batch render (%s)", "needs rerender" if self._rerender_needed else "no rerender needed") @@ -1714,7 +1730,7 @@ def set_(value): if DEBUG: trigger_stack = traceback.format_stack() - self._rerender_needed_reasons.append( + self._add_rerender_reason( RerenderReason( reason=f"state changed with key {key}", prev_value=prev_value, @@ -1724,7 +1740,7 @@ def set_(value): ) ) else: - self._rerender_needed_reasons.append(RerenderReason(reason=f"state changed with key {key}", prev_value=prev_value, next_value=value)) + self._add_rerender_reason(RerenderReason(reason=f"state changed with key {key}", prev_value=prev_value, next_value=value)) self._rerender_needed = True self._possible_rerender() @@ -1752,13 +1768,14 @@ def update(self, element: Element): self._walk_all = True if self._is_rendering: self.element = element - self._rerender_needed_reasons.append(RerenderReason(reason="root element changed")) + self._add_rerender_reason(RerenderReason(reason="root element changed")) self._rerender_needed = True else: self.render(element, self.container) def _possible_rerender(self): - if not self._is_rendering and self._batch_counter.current() == 0: + batch_counter = self._batch_counter + if not self._is_rendering and (batch_counter is None or batch_counter.current() == 0): self.render(self.element, self.container) elif logger.isEnabledFor(logging.INFO): logger.info("No render phase triggered, already rendering") @@ -2181,7 +2198,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): self.tracebacks.append(el.traceback) logger.exception("Component %r raised exception %r", el.component, e) context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during render")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during render")) self._rerender_needed = True context.needs_render = True @@ -2220,7 +2237,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): except RuntimeError as e: logger.exception("Exception in hook count check") context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during render (hook count check)")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during render (hook count check)")) self._rerender_needed = True context.needs_render = True # only expose to parent when no error occurs @@ -2242,7 +2259,7 @@ def _render(self, element: Element, default_key: str, parent_key: str): # this happens when an exception was added from an event handler # this means no exception was raised during the render phase # but we still need to rerender, until someone catches the exception - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during render")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during render")) self._rerender_needed = True self.context.needs_render = True @@ -2342,7 +2359,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): except BaseException as e: logger.exception("Effect cleanup %r raised exception %r", effect.callable, e) context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during effect")) self._rerender_needed = True context.needs_render = True effect = child_context.effects[effect_index] = effect.next @@ -2354,7 +2371,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): except BaseException as e: logger.exception("Effect %r raised exception %r", effect.callable, e) context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during effect")) self._rerender_needed = True context.needs_render = True else: @@ -2366,7 +2383,7 @@ def _reconsolidate(self, el: Element, default_key: str, parent_key: str): except BaseException as e: logger.exception("Effect %r raised exception %r", effect.callable, e) context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during effect")) self._rerender_needed = True context.needs_render = True @@ -2430,7 +2447,7 @@ def reconsolidate_children(): widget, orphan_ids = el._create_widget(kwargs) except BaseException as e: context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during reconciliation (creating widget)")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during reconciliation (creating widget)")) self._rerender_needed = True if el.is_shared: self._shared_widgets[el] = widget @@ -2447,7 +2464,7 @@ def reconsolidate_children(): el._update_widget(widget_previous, el_prev, kwargs) except BaseException as e: context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during reconciliation (updating widget)")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during reconciliation (updating widget)")) self._rerender_needed = True if el.is_shared: self._shared_widgets[el] = widget_previous @@ -2464,7 +2481,7 @@ def reconsolidate_children(): widget, orphan_ids = el._create_widget(kwargs) except BaseException as e: context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during reconciliation (updating widget)")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during reconciliation (updating widget)")) self._rerender_needed = True if el.is_shared: self._shared_widgets[el] = widget @@ -2580,7 +2597,7 @@ def _remove_element(self, el: Element, default_key: str, parent_key): except BaseException as e: logger.exception("Effect cleanup %r raised exception %r", effect.callable, e) child_context.exceptions_self.append(e) - self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect")) + self._add_rerender_reason(RerenderReason(reason="Exception ocurred during effect")) self._rerender_needed = True for handler in child_context.event_handlers: try: @@ -2588,7 +2605,7 @@ def _remove_element(self, el: Element, default_key: str, parent_key): 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._add_rerender_reason(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) @@ -2741,7 +2758,7 @@ def __init__(self, element: Element, container: widgets.Widget = None, children_ self.state_set(self.context_root, initial_state) def _set_rerender_needed(self, reason: str): - self._rerender_needed_reasons.append(RerenderReason(reason=reason)) + self._add_rerender_reason(RerenderReason(reason=reason)) self._rerender_needed = True def _mark_dirty(self, context: ComponentContext): diff --git a/reacton/core_test.py b/reacton/core_test.py index c95fba8..642e542 100644 --- a/reacton/core_test.py +++ b/reacton/core_test.py @@ -4989,3 +4989,31 @@ def Catcher(): results[fast] = container.children[0].value rc.close() assert results[True] == results[False] == "caught ValueError('cleanup failed')" + + +def test_exception_in_an_effect_of_a_new_subtree_bubbles_up(): + # an effect of a component deep in a new (mounted) subtree raises: the exception goes up + # through the components that do not handle it, in both renderers + @react.component + def Failing(): + def effect(): + raise ValueError("effect failed") + + react.use_effect(effect, []) + return w.Button(description="failing") + + @react.component + def Middle(): + return w.VBox(children=[w.Label(value="middle"), Failing()]) + + @react.component + def Catcher(): + exception, clear = react.use_exception() + return w.Label(value=f"caught {exception!r}") if exception else w.HBox(children=[Middle()]) + + for fast in [False, True]: + with unittest.mock.patch.dict(os.environ, {"REACTON_FAST": "1" if fast else "0"}): + container = widgets.VBox() + box, rc = react.render(Catcher(), container, handle_error=False) + assert container.children[0].value == "caught ValueError('effect failed')" + rc.close() diff --git a/reacton/utils.py b/reacton/utils.py index 890e78b..c955903 100644 --- a/reacton/utils.py +++ b/reacton/utils.py @@ -210,7 +210,6 @@ def environment() -> str: class ThreadSafeCounter: - # (reacton._fastcore.init_render_context makes one without calling __init__) def __init__(self): self._value = 0 self._lock = threading.Lock() From 0516be3ef57a155d9b3c799d7769ee44733d0ba1 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 11:10:03 +0200 Subject: [PATCH 15/16] Mention the compiled first render in the benchmark README The README explained the mount path, but not that render_fixed and the first render of a new render context now skip the walks of render(): that is most of what changed in the fixed cost of a mount. Co-Authored-By: Claude Opus 5.5 (1M context) --- benchmarks/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index a949b7f..5dff36c 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -78,7 +78,10 @@ renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both: widget that fails to be made) undoes them into the two phase bookkeeping. The implicit container is only built when a component body returns `None`. - **Compiled core (optional).** `reacton/_fastcore.py` holds the element base - classes, the mount, the hooks and the listener/setter objects. It is plain + classes, `render_fixed` and the first render of a new render context + (`render_first`: it mounts the root without the walks of `render()`, whose loop + takes over when more passes are needed), the mount, the hooks and the + listener/setter objects. It is plain Python; `python setup_cython.py build_ext --inplace` compiles it with Cython (pure Python mode, types in `_fastcore.pxd`). `REACTON_CYTHON=0` forces the plain version when a compiled one is present. From 51c6f9cec6cce39aef74e707fda97a0230010f71 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 26 Sep 2026 11:47:39 +0200 Subject: [PATCH 16/16] Declare _RerenderReason before the function that makes it global Python 3.7 raises "annotated name can't be global" when a module level annotation comes after a function that declares the name global; newer Pythons accept it. The installation check of CI runs on 3.7, so the module failed to import there. The declaration now sits with the other names that _register_hooks sets. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/_fastcore.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/reacton/_fastcore.py b/reacton/_fastcore.py index 1cce513..2cfd4ea 100644 --- a/reacton/_fastcore.py +++ b/reacton/_fastcore.py @@ -1547,6 +1547,8 @@ def _remove_mounted(rc, child_context, closing): _FastRC: Any = None # reacton.core._RenderContextFast _Ref: Any = None _Effect: Any = None +# (annotated before _register_hooks declares it global: Python 3.7 rejects the other order) +_RerenderReason: Any = None _logging_debug = 10 # logging.DEBUG enabled for reacton (set at the start of every render, see core.render) _log_debug = False @@ -2030,9 +2032,6 @@ def __call__(self, value): rc._possible_rerender() -_RerenderReason: Any = None - - def _warn_mutated(message): import warnings