reacton 1.10.2__tar.gz → 1.10.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {reacton-1.10.2 → reacton-1.10.4}/PKG-INFO +2 -2
  2. reacton-1.10.4/reacton/_version.py +1 -0
  3. {reacton-1.10.2 → reacton-1.10.4}/reacton/core.py +271 -22
  4. {reacton-1.10.2 → reacton-1.10.4}/reacton/core_test.py +958 -1
  5. reacton-1.10.4/reacton/fuzz_test.py +182 -0
  6. {reacton-1.10.2 → reacton-1.10.4}/reacton/ipyvue.py +1 -0
  7. reacton-1.10.2/reacton/_version.py +0 -1
  8. {reacton-1.10.2 → reacton-1.10.4}/.gitignore +0 -0
  9. {reacton-1.10.2 → reacton-1.10.4}/LICENSE +0 -0
  10. {reacton-1.10.2 → reacton-1.10.4}/pyproject.toml +0 -0
  11. {reacton-1.10.2 → reacton-1.10.4}/react_ipywidgets/__init__.py +0 -0
  12. {reacton-1.10.2 → reacton-1.10.4}/react_ipywidgets/core.py +0 -0
  13. {reacton-1.10.2 → reacton-1.10.4}/reacton/__init__.py +0 -0
  14. {reacton-1.10.2 → reacton-1.10.4}/reacton/bqplot.py +0 -0
  15. {reacton-1.10.2 → reacton-1.10.4}/reacton/deprecated/__init__.py +0 -0
  16. {reacton-1.10.2 → reacton-1.10.4}/reacton/deprecated/ipyvuetify.py +0 -0
  17. {reacton-1.10.2 → reacton-1.10.4}/reacton/find.py +0 -0
  18. {reacton-1.10.2 → reacton-1.10.4}/reacton/find_test.py +0 -0
  19. {reacton-1.10.2 → reacton-1.10.4}/reacton/generate.py +0 -0
  20. {reacton-1.10.2 → reacton-1.10.4}/reacton/generate_test.py +0 -0
  21. {reacton-1.10.2 → reacton-1.10.4}/reacton/ipycanvas.py +0 -0
  22. {reacton-1.10.2 → reacton-1.10.4}/reacton/ipyvue_test.py +0 -0
  23. {reacton-1.10.2 → reacton-1.10.4}/reacton/ipyvuetify.py +0 -0
  24. {reacton-1.10.2 → reacton-1.10.4}/reacton/ipywidgets.py +0 -0
  25. {reacton-1.10.2 → reacton-1.10.4}/reacton/logging.py +0 -0
  26. {reacton-1.10.2 → reacton-1.10.4}/reacton/patch.py +0 -0
  27. {reacton-1.10.2 → reacton-1.10.4}/reacton/patch_display.py +0 -0
  28. {reacton-1.10.2 → reacton-1.10.4}/reacton/py.typed +0 -0
  29. {reacton-1.10.2 → reacton-1.10.4}/reacton/rx.py +0 -0
  30. {reacton-1.10.2 → reacton-1.10.4}/reacton/test.vue +0 -0
  31. {reacton-1.10.2 → reacton-1.10.4}/reacton/test_rx.py +0 -0
  32. {reacton-1.10.2 → reacton-1.10.4}/reacton/utils.py +0 -0
  33. {reacton-1.10.2 → reacton-1.10.4}/reacton/utils_test.py +0 -0
  34. {reacton-1.10.2 → reacton-1.10.4}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: reacton
3
- Version: 1.10.2
3
+ Version: 1.10.4
4
4
  Dynamic: Summary
5
5
  Project-URL: Home, https://www.github.com/widgetti/reacton
6
6
  Project-URL: Documentation, https://reacton.solara.dev
@@ -0,0 +1 @@
1
+ __version__ = "1.10.4"
@@ -7,11 +7,13 @@ ReactJS - ipywidgets relation:
7
7
 
8
8
  """
9
9
 
10
+ import collections
10
11
  import contextlib
11
12
  import copy
12
13
  import functools
13
14
  import inspect
14
15
  import logging
16
+ import os
15
17
  import sys
16
18
  import threading
17
19
  import traceback
@@ -24,6 +26,7 @@ from typing import (
24
26
  Any,
25
27
  Callable,
26
28
  ContextManager,
29
+ Deque,
27
30
  Dict,
28
31
  Generic,
29
32
  List,
@@ -170,6 +173,26 @@ def close_widget(widget: widgets.Widget):
170
173
  logger.warning("Widget %r does not have a close method, possibly a close trait was added", widget)
171
174
 
172
175
 
176
+ def _is_shared_ipyvue_template(widget: widgets.Widget) -> bool:
177
+ """Is this widget an ipyvue Template that is shared between VueTemplate instances?
178
+
179
+ ipyvue keeps a per-file registry of Template widgets (ipyvue/Template.py), so several
180
+ VueTemplate widgets can point at the same Template. Such a Template outlives the
181
+ VueTemplate that created it, and we must not close it as an orphan.
182
+ ipyvue 3 only puts a Template in that registry when there is a real comm, so an
183
+ unregistered Template belongs to a single VueTemplate and is a normal orphan.
184
+ """
185
+ cls = widget.__class__
186
+ if cls.__name__ != "Template" or cls.__module__ != "ipyvue.Template":
187
+ return False
188
+ module = sys.modules.get(cls.__module__)
189
+ registry = getattr(module, "template_registry", None)
190
+ if registry is None:
191
+ # unknown ipyvue version: keep the old, conservative behaviour
192
+ return True
193
+ return any(template is widget for template in registry.values())
194
+
195
+
173
196
  def _event_handler_exception_wrapper(f):
174
197
  """Wrap an event handler to catch exceptions and put them in a reacton context.
175
198
 
@@ -187,6 +210,10 @@ def _event_handler_exception_wrapper(f):
187
210
  # we add it to exceptions_children, not exception_self
188
211
  # this allows a component to catch the exception of a direct child
189
212
  context.exceptions_children.append(e)
213
+ # force_update walks the whole tree, but when another thread is rendering it
214
+ # only sets a flag that this render may clear: mark the path to this context
215
+ # so the fast renderer does not skip it on the next render
216
+ _mark_needs_render_ancestors(context)
190
217
  rc.force_update()
191
218
 
192
219
  return wrapper
@@ -211,8 +238,12 @@ def same_component(c1, c2):
211
238
 
212
239
  def _mark_needs_render_ancestors(context: "ComponentContext"):
213
240
  """Let the render phase find its way down to a context that needs work, without walking subtrees that do not."""
241
+ # walk up to the root: stopping at the first ancestor that is already marked would
242
+ # assume its ancestors are marked too, which does not hold for a subtree that is
243
+ # being removed (it can carry a flag from before), so the live ancestors above it
244
+ # would stay unmarked and skip e.g. a cleanup exception
214
245
  parent = context.parent
215
- while parent is not None and not parent.needs_render_descendant:
246
+ while parent is not None:
216
247
  parent.needs_render_descendant = True
217
248
  parent = parent.parent
218
249
 
@@ -235,6 +266,34 @@ def _values_identical(a, b):
235
266
  return False
236
267
 
237
268
 
269
+ _missing = object()
270
+
271
+
272
+ def _widget_holds_values(widget: widgets.Widget, kwargs: Dict[str, Any]) -> bool:
273
+ """Does the widget already hold these (resolved) kwargs, so that setting them is a no-op?
274
+
275
+ Only for the same element as last render: its event listeners are the same callbacks,
276
+ and no argument was dropped. Compares by identity, where a list and a tuple with the
277
+ same items count as the same (a Tuple trait stores a list as a tuple).
278
+ """
279
+ for name, value in kwargs.items():
280
+ if name.startswith("on_") and not widget.has_trait(name):
281
+ continue
282
+ if not _value_held(value, getattr(widget, name, _missing)):
283
+ return False
284
+ return True
285
+
286
+
287
+ def _value_held(value, held) -> bool:
288
+ if value is held:
289
+ return True
290
+ if isinstance(value, (list, tuple)):
291
+ return type(held) in (list, tuple) and len(value) == len(held) and all(_value_held(x, y) for x, y in zip(value, held))
292
+ if type(value) is dict:
293
+ return type(held) is dict and len(value) == len(held) and all(k in held and _value_held(v, held[k]) for k, v in value.items())
294
+ return False
295
+
296
+
238
297
  def _with_tracebacks(e, tracebacks):
239
298
  # copy it, and we need with_traceback for unknown reasons not to cause
240
299
  # an infinite loop
@@ -1146,6 +1205,11 @@ class ComponentContext:
1146
1205
  # the render phase skipped this whole subtree (nothing changed), so the
1147
1206
  # reconciliation phase can reuse the previous result without walking
1148
1207
  clean_subtree: bool = False
1208
+ # Fast renderer only: stale component effect cleanups are run at the same
1209
+ # point where the default renderer removes stale elements, while widget
1210
+ # closing stays deferred to the fast renderer's stale sweep.
1211
+ fast_stale_effect_keys: Optional[List[str]] = None
1212
+ fast_stale_effects_cleaned: Set[str] = field(default_factory=set)
1149
1213
 
1150
1214
  # elements created in this context go there
1151
1215
  owns: Set[Element] = field(default_factory=set)
@@ -1204,6 +1268,8 @@ def _teardown_component_context(context: ComponentContext):
1204
1268
  context.exceptions_self = []
1205
1269
  context.exceptions_children = []
1206
1270
  context.context_managers = []
1271
+ context.fast_stale_effect_keys = None
1272
+ context.fast_stale_effects_cleaned = set()
1207
1273
 
1208
1274
 
1209
1275
  @dataclass
@@ -1240,6 +1306,30 @@ class Effect:
1240
1306
 
1241
1307
  class _RenderContext:
1242
1308
  context: Optional[ComponentContext] = None
1309
+ # the element that an element of another type replaces, and its context, while
1310
+ # reconciliation removes it (see _remove_replaced)
1311
+ _replaced: Optional[Tuple[ComponentContext, Element]] = None
1312
+
1313
+ def _remove_replaced(self, el_prev: Element, key: str, parent_key: str):
1314
+ """Remove the element at key, which an element of another type replaces."""
1315
+ assert self.context is not None
1316
+ replaced = self._replaced
1317
+ self._replaced = (self.context, el_prev)
1318
+ try:
1319
+ self._remove_element(el_prev, key, parent_key=parent_key)
1320
+ finally:
1321
+ self._replaced = replaced
1322
+
1323
+ def _keep_keyed_child(self, context: ComponentContext, el: Element, key: str) -> bool:
1324
+ # An explicit key is the same in any container of a component, so a child with an
1325
+ # explicit key that the new tree still uses (it moved out of the replaced element, or
1326
+ # stays under the new one) is not removed with the replaced element: reconciliation
1327
+ # updates it where the new tree has it. Shared elements keep the old behavior: their
1328
+ # bookkeeping (_shared_elements, _shared_widgets) needs the removal.
1329
+ replaced = self._replaced
1330
+ return (
1331
+ replaced is not None and context is replaced[0] and el is not replaced[1] and el._key is not None and not el.is_shared and key in context.used_keys
1332
+ )
1243
1333
 
1244
1334
  def __init__(self, element: Element, container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None):
1245
1335
  self.element = element
@@ -1254,7 +1344,11 @@ class _RenderContext:
1254
1344
  self.last_root_widget: widgets.Widget = None
1255
1345
  self._is_rendering = False
1256
1346
  self._rerender_needed = False
1257
- self._rerender_needed_reasons: List[RerenderReason] = []
1347
+ # the reasons are only read for the "too many renders" error message, and a reason
1348
+ # holds the previous and next state value: keeping all of them kept every old state
1349
+ # value alive until close(). REACTON_RERENDER_REASONS keeps more, for debugging.
1350
+ max_reasons = max(1, int(os.environ.get("REACTON_RERENDER_REASONS", "2")))
1351
+ self._rerender_needed_reasons: Deque[RerenderReason] = collections.deque(maxlen=max_reasons)
1258
1352
  self.thread_lock = threading.Lock()
1259
1353
  self._closing = False
1260
1354
  self.tracebacks: List[TracebackType] = []
@@ -1532,6 +1626,35 @@ class _RenderContext:
1532
1626
  else:
1533
1627
  logger.info("No render phase triggered, already rendering")
1534
1628
 
1629
+ def _discard_aborted_pass(self):
1630
+ """Forget everything a render pass staged when it raised before reconciliation.
1631
+
1632
+ The *_next bookkeeping and the chained effects (previous_effect.next) of every
1633
+ context belong to that pass. Mark every context dirty, so the next render()
1634
+ re-executes the components and re-queues their effects from a consistent state.
1635
+ Only for aborts in the render phase: a reconciliation that raised halfway already
1636
+ created widgets, and those need the normal removal path.
1637
+ """
1638
+ # children_next is kept: it also holds contexts pre-created by state_set() (restored
1639
+ # state), and a context created by the aborted pass owns no widgets yet, it is simply
1640
+ # reused or pruned by the next render
1641
+ contexts: List[ComponentContext] = [self.context_root]
1642
+ while contexts:
1643
+ context = contexts.pop()
1644
+ for effect in context.effects:
1645
+ effect.next = None
1646
+ context.root_element_next = None
1647
+ context.elements_next = {}
1648
+ context.exceptions_self = []
1649
+ context.exceptions_children = []
1650
+ context.needs_render = True
1651
+ context.needs_render_descendant = True
1652
+ context.clean_subtree = False
1653
+ contexts.extend(context.children.values())
1654
+ contexts.extend(context.children_next.values())
1655
+ self._shared_elements_next = set()
1656
+ self.context = self.context_root
1657
+
1535
1658
  def render(self, element: Element, container: widgets.Widget = None):
1536
1659
  # render + consolidate
1537
1660
  widget = None
@@ -1558,6 +1681,8 @@ class _RenderContext:
1558
1681
  logger.info("Render requested on a closing/closed render context, ignoring")
1559
1682
  return container
1560
1683
  prev_rc = getattr(local, "rc", None)
1684
+ # an exception that escapes while this is True aborted a render pass (see the except below)
1685
+ in_render_phase = True
1561
1686
  try:
1562
1687
  local.rc = self
1563
1688
  self.element = element
@@ -1603,10 +1728,10 @@ class _RenderContext:
1603
1728
  f += f"Triggered at: {''.join(reason.trigger_stack)}\n"
1604
1729
  return f
1605
1730
 
1606
- self._rerender_needed_reasons[-1]
1607
1731
  msg = f"Too many renders triggered, your render loop does not stop\nLast reason: {format(self._rerender_needed_reasons[-1])}\n"
1608
1732
  if len(self._rerender_needed_reasons) >= 2:
1609
- msg += f"Previous reasons: {format(self._rerender_needed_reasons[-2])}\n"
1733
+ previous = reversed(list(self._rerender_needed_reasons)[:-1])
1734
+ msg += f"Previous reasons: {''.join(format(reason) for reason in previous)}\n"
1610
1735
  raise RuntimeError(msg)
1611
1736
  logger.info("Entering nested render phase: %r", self._rerender_needed_reasons[-1])
1612
1737
  self._rerender_needed = False
@@ -1633,10 +1758,12 @@ class _RenderContext:
1633
1758
 
1634
1759
  logger.info("Render reconsolidate...")
1635
1760
  self.reconsolidating = True
1761
+ in_render_phase = False
1636
1762
  try:
1637
1763
  widget = self._reconsolidate(self.element, default_key="/", parent_key=ROOT_KEY)
1638
1764
  finally:
1639
1765
  self.reconsolidating = False
1766
+ in_render_phase = True
1640
1767
  logger.info("Render reconsolidate done")
1641
1768
  self.context.root_element = self.context.root_element_next
1642
1769
  self.context.root_element_next = None
@@ -1685,7 +1812,16 @@ class _RenderContext:
1685
1812
  self._is_rendering = False
1686
1813
  self.context = context_prev
1687
1814
  logger.info("Done with render phase: %r", render_count)
1688
- except Exception as e:
1815
+ except BaseException as e:
1816
+ # Exceptions raised by components are collected in exceptions_self, so an
1817
+ # exception here comes from the render machinery itself (duplicate key,
1818
+ # hook misuse, ...) or is a cancellation, and aborted a pass halfway. Drop
1819
+ # what that pass staged: otherwise the next render reconciles the last
1820
+ # committed elements, but runs the effects the aborted pass chained, closed
1821
+ # over elements that were never reconciled (get_widget then fails with
1822
+ # "found in a previous render").
1823
+ if in_render_phase:
1824
+ self._discard_aborted_pass()
1689
1825
  if DEBUG:
1690
1826
  # construct a fake traceback (showing how the elements were constructed)
1691
1827
  if not self.tracebacks:
@@ -1698,6 +1834,10 @@ class _RenderContext:
1698
1834
  finally:
1699
1835
  local.rc = prev_rc # type: ignore
1700
1836
  self._is_rendering = False
1837
+ # clear before the lock is released: a stale _lock_thread makes the
1838
+ # recursion guard above fire for a thread that merely rendered last,
1839
+ # while a *different* thread holds the lock (false "Recursive render")
1840
+ self._lock_thread = None
1701
1841
  assert self.context is self.context_root
1702
1842
 
1703
1843
  exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
@@ -1825,8 +1965,13 @@ class _RenderContext:
1825
1965
  needs_render = context.needs_render
1826
1966
  if not needs_render:
1827
1967
  if el_prev is not None and context_previous is context:
1828
- assert not isinstance(el_prev.component, ComponentWidget)
1829
- needs_render = el._arguments_changed(el_prev)
1968
+ if isinstance(el_prev.component, ComponentWidget):
1969
+ # an earlier pass of this render() call put a widget element at this
1970
+ # slot (elements_next), while the context is from the last reconciled
1971
+ # render: nothing to compare the arguments against, so render
1972
+ needs_render = True
1973
+ else:
1974
+ needs_render = el._arguments_changed(el_prev)
1830
1975
  if context.exceptions_children:
1831
1976
  # we have exceptions, so we need to render
1832
1977
  needs_render = True
@@ -1973,7 +2118,7 @@ class _RenderContext:
1973
2118
  try:
1974
2119
  if isinstance(el.component, ComponentFunction):
1975
2120
  if el_prev and isinstance(el_prev.component, ComponentWidget):
1976
- self._remove_element(el_prev, default_key=key, parent_key=parent_key)
2121
+ self._remove_replaced(el_prev, key, parent_key=parent_key)
1977
2122
  new_parent_key = join_key(parent_key, key)
1978
2123
  try:
1979
2124
  # TODO: test suite passes when this block if commented out
@@ -2034,6 +2179,7 @@ class _RenderContext:
2034
2179
  try:
2035
2180
  effect.cleanup()
2036
2181
  except BaseException as e:
2182
+ logger.exception("Effect cleanup %r raised exception %r", effect.callable, e)
2037
2183
  context.exceptions_self.append(e)
2038
2184
  self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect"))
2039
2185
  self._rerender_needed = True
@@ -2045,6 +2191,7 @@ class _RenderContext:
2045
2191
  continue
2046
2192
  effect()
2047
2193
  except BaseException as e:
2194
+ logger.exception("Effect %r raised exception %r", effect.callable, e)
2048
2195
  context.exceptions_self.append(e)
2049
2196
  self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect"))
2050
2197
  self._rerender_needed = True
@@ -2056,6 +2203,7 @@ class _RenderContext:
2056
2203
  continue
2057
2204
  effect()
2058
2205
  except BaseException as e:
2206
+ logger.exception("Effect %r raised exception %r", effect.callable, e)
2059
2207
  context.exceptions_self.append(e)
2060
2208
  self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect"))
2061
2209
  self._rerender_needed = True
@@ -2147,7 +2295,7 @@ class _RenderContext:
2147
2295
  else:
2148
2296
  assert el_prev is not None, "widget_previous is not None, but el_prev is"
2149
2297
  logger.debug("Replacing widget: %r → %r %r", el_prev, el, key)
2150
- self._remove_element(el_prev, key, parent_key=parent_key)
2298
+ self._remove_replaced(el_prev, key, parent_key=parent_key)
2151
2299
  kwargs = reconsolidate_children()
2152
2300
  widget = None
2153
2301
  if not context.exceptions_children:
@@ -2166,7 +2314,7 @@ class _RenderContext:
2166
2314
  if orphan_ids:
2167
2315
  for orphan_widget in orphan_widgets:
2168
2316
  # these are shared widgets
2169
- if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2317
+ if _is_shared_ipyvue_template(orphan_widget):
2170
2318
  orphan_ids -= {orphan_widget.model_id}
2171
2319
  if el.is_shared:
2172
2320
  widget = self._shared_widgets[el]
@@ -2243,6 +2391,9 @@ class _RenderContext:
2243
2391
  context = self.context
2244
2392
  logger.debug("Remove: (%s, %s) %r", parent_key, key, el)
2245
2393
 
2394
+ if self._keep_keyed_child(context, el, key):
2395
+ return
2396
+
2246
2397
  if el.is_shared:
2247
2398
  if el not in self._shared_elements:
2248
2399
  return
@@ -2269,6 +2420,7 @@ class _RenderContext:
2269
2420
  if not effect._cleaned_up:
2270
2421
  effect.cleanup()
2271
2422
  except BaseException as e:
2423
+ logger.exception("Effect cleanup %r raised exception %r", effect.callable, e)
2272
2424
  child_context.exceptions_self.append(e)
2273
2425
  self._rerender_needed_reasons.append(RerenderReason(reason="Exception ocurred during effect"))
2274
2426
  self._rerender_needed = True
@@ -2379,10 +2531,20 @@ class _RenderContextFast(_RenderContext):
2379
2531
  # without walking either.
2380
2532
  # ------------------------------------------------------------------
2381
2533
 
2534
+ # > 0 while the render phase walks the new children of a widget, or the arguments of a
2535
+ # shared element, that replaces an element of another type (see _render_arguments)
2536
+ _replacing = 0
2537
+
2382
2538
  def _set_rerender_needed(self, reason: str):
2383
2539
  self._rerender_needed_reasons.append(RerenderReason(reason=reason))
2384
2540
  self._rerender_needed = True
2385
2541
 
2542
+ def _has_effect_work(self, context: "ComponentContext") -> bool:
2543
+ for effect in context.effects:
2544
+ if not effect.executed or effect.next is not None:
2545
+ return True
2546
+ return False
2547
+
2386
2548
  def _render(self, element: Element, default_key: str, parent_key: str):
2387
2549
  if not isinstance(element, Element):
2388
2550
  raise TypeError(f"Expected element, not {element}")
@@ -2395,6 +2557,8 @@ class _RenderContextFast(_RenderContext):
2395
2557
  # the root element of a component determines which keys are in use,
2396
2558
  # everything else is stale and gets removed during reconciliation
2397
2559
  context.used_keys.clear()
2560
+ context.fast_stale_effect_keys = None
2561
+ context.fast_stale_effects_cleaned.clear()
2398
2562
 
2399
2563
  el = element
2400
2564
  key = el._key
@@ -2427,7 +2591,7 @@ class _RenderContextFast(_RenderContext):
2427
2591
  del context.children_next[key]
2428
2592
  # the element arguments are part of this component's element tree
2429
2593
  if el.kwargs:
2430
- self._visit_children(el, key, parent_key, self._render)
2594
+ self._render_arguments(el, key, parent_key)
2431
2595
  return
2432
2596
 
2433
2597
  assert isinstance(el.component, ComponentFunction)
@@ -2435,7 +2599,7 @@ class _RenderContextFast(_RenderContext):
2435
2599
  # arguments of a shared element belong to the context it is rendered in;
2436
2600
  # for non-shared component elements the component function decides
2437
2601
  # what ends up in the tree
2438
- self._visit_children(el, key, parent_key, self._render)
2602
+ self._render_arguments(el, key, parent_key)
2439
2603
 
2440
2604
  context_previous = context.children_next.get(key)
2441
2605
  if context_previous is None:
@@ -2445,10 +2609,12 @@ class _RenderContextFast(_RenderContext):
2445
2609
  not self._walk_all
2446
2610
  and el is el_prev
2447
2611
  and not el.is_shared
2612
+ and not self._replacing
2448
2613
  and context_previous is not None
2449
2614
  and context.children.get(key) is context_previous
2450
2615
  and not context_previous.needs_render
2451
2616
  and not context_previous.needs_render_descendant
2617
+ and not self._has_effect_work(context_previous)
2452
2618
  and not context_previous.exceptions_self
2453
2619
  and not context_previous.exceptions_children
2454
2620
  and context_previous.root_element is not None
@@ -2485,8 +2651,11 @@ class _RenderContextFast(_RenderContext):
2485
2651
 
2486
2652
  needs_render = context.needs_render
2487
2653
  if not needs_render and el_prev is not None and context_previous is context:
2488
- assert not isinstance(el_prev.component, ComponentWidget)
2489
- needs_render = el._arguments_changed(el_prev)
2654
+ if isinstance(el_prev.component, ComponentWidget):
2655
+ # see the classic renderer: a widget element from an earlier pass of this call
2656
+ needs_render = True
2657
+ else:
2658
+ needs_render = el._arguments_changed(el_prev)
2490
2659
  if not needs_render and context.exceptions_children:
2491
2660
  # we have exceptions, so we need to render
2492
2661
  needs_render = True
@@ -2627,7 +2796,7 @@ class _RenderContextFast(_RenderContext):
2627
2796
 
2628
2797
  if el_prev and isinstance(el_prev.component, ComponentWidget):
2629
2798
  # a widget element was replaced by a component element at this key
2630
- self._remove_element(el_prev, default_key=key, parent_key=parent_key)
2799
+ self._remove_replaced(el_prev, key, parent_key=parent_key)
2631
2800
  new_parent_key = join_key(parent_key, key)
2632
2801
  try:
2633
2802
  if el.is_shared and (el.args or el.kwargs):
@@ -2720,20 +2889,22 @@ class _RenderContextFast(_RenderContext):
2720
2889
  # update the existing widget in place
2721
2890
  kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2722
2891
  if not context.exceptions_children:
2723
- if el is not el_prev or not _values_identical(kwargs, el.kwargs):
2892
+ # the same element whose kwargs hold no elements: nothing can have changed.
2893
+ # With elements (a container), the kwargs resolve to widgets, so compare
2894
+ # them with what the widget holds: equal means setting them is a no-op
2895
+ # (and a value changed from the frontend is still set back)
2896
+ if el is not el_prev or not (_values_identical(kwargs, el.kwargs) or _widget_holds_values(widget_previous, kwargs)):
2724
2897
  try:
2725
2898
  el._update_widget(widget_previous, el_prev, kwargs)
2726
2899
  except BaseException as e:
2727
2900
  context.exceptions_self.append(e)
2728
2901
  self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)")
2729
2902
  _mark_needs_render_ancestors(context)
2730
- # else: identical element and all children reconciled to the
2731
- # same widgets, nothing can have changed
2732
2903
  self._store_widget(context, el, key, widget_previous)
2733
2904
  else:
2734
2905
  assert el_prev is not None, "widget_previous is not None, but el_prev is"
2735
2906
  # a different widget type at the same key: replace
2736
- self._remove_element(el_prev, key, parent_key=parent_key)
2907
+ self._remove_replaced(el_prev, key, parent_key=parent_key)
2737
2908
  kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2738
2909
  widget = None
2739
2910
  if not context.exceptions_children:
@@ -2750,7 +2921,7 @@ class _RenderContextFast(_RenderContext):
2750
2921
  orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids])
2751
2922
  for orphan_widget in orphan_widgets:
2752
2923
  # these are shared between widgets
2753
- if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2924
+ if _is_shared_ipyvue_template(orphan_widget):
2754
2925
  orphan_ids -= {orphan_widget.model_id}
2755
2926
  widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2756
2927
  if widget.model_id not in self._orphans:
@@ -2788,6 +2959,7 @@ class _RenderContextFast(_RenderContext):
2788
2959
  self._shared_elements.add(el)
2789
2960
  assert el in self._shared_elements_next
2790
2961
  self._shared_elements_next.remove(el)
2962
+ self._cleanup_stale_effects_for_context(context, parent_key)
2791
2963
 
2792
2964
  def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"):
2793
2965
  # NOTE: effect/cleanup exceptions are recorded on the context of the
@@ -2806,6 +2978,7 @@ class _RenderContextFast(_RenderContext):
2806
2978
  try:
2807
2979
  effect.cleanup()
2808
2980
  except BaseException as e:
2981
+ logger.exception("Effect cleanup %r raised exception %r", effect.callable, e)
2809
2982
  context.exceptions_self.append(e)
2810
2983
  self._set_rerender_needed("Exception ocurred during effect")
2811
2984
  _mark_needs_render_ancestors(context)
@@ -2817,6 +2990,7 @@ class _RenderContextFast(_RenderContext):
2817
2990
  try:
2818
2991
  effect()
2819
2992
  except BaseException as e:
2993
+ logger.exception("Effect %r raised exception %r", effect.callable, e)
2820
2994
  context.exceptions_self.append(e)
2821
2995
  self._set_rerender_needed("Exception ocurred during effect")
2822
2996
  _mark_needs_render_ancestors(context)
@@ -2828,6 +3002,64 @@ class _RenderContextFast(_RenderContext):
2828
3002
  else:
2829
3003
  context.widgets[key] = widget
2830
3004
 
3005
+ def _cleanup_stale_effects_for_context(self, context: "ComponentContext", parent_key: str):
3006
+ if context.fast_stale_effect_keys is None:
3007
+ # reversed, so we can pop from the end and still go in sorted order
3008
+ context.fast_stale_effect_keys = sorted(set(context.elements) - context.used_keys, reverse=True)
3009
+ context_prev = self.context
3010
+ try:
3011
+ while context.fast_stale_effect_keys:
3012
+ stale_key = context.fast_stale_effect_keys.pop()
3013
+ if stale_key not in context.elements or stale_key in context.fast_stale_effects_cleaned:
3014
+ continue
3015
+ self.context = context
3016
+ self._cleanup_stale_effects(context.elements[stale_key], stale_key, parent_key)
3017
+ finally:
3018
+ self.context = context_prev
3019
+
3020
+ def _cleanup_stale_effects(self, el: Element, default_key: str, parent_key: str):
3021
+ key = el._key
3022
+ if key is None:
3023
+ key = default_key
3024
+ assert key is not None
3025
+ context = self.context
3026
+ assert context is not None
3027
+ if key in context.fast_stale_effects_cleaned:
3028
+ return
3029
+ if el.is_shared and (el in self._shared_elements_next or el not in self._shared_elements):
3030
+ return
3031
+ context.fast_stale_effects_cleaned.add(key)
3032
+
3033
+ if isinstance(el.component, ComponentFunction):
3034
+ if el.is_shared:
3035
+ self._visit_children(el, key, parent_key, self._cleanup_stale_effects)
3036
+ child_context = context.children.get(key)
3037
+ if child_context is None:
3038
+ return
3039
+ try:
3040
+ self.context = child_context
3041
+ child_context.exceptions_self = []
3042
+ child_context.exceptions_children = []
3043
+ for effect in child_context.effects:
3044
+ try:
3045
+ if not effect._cleaned_up:
3046
+ effect.cleanup()
3047
+ except BaseException as e:
3048
+ effect._cleaned_up = True
3049
+ logger.exception("Effect cleanup %r raised exception %r", effect.callable, e)
3050
+ child_context.exceptions_self.append(e)
3051
+ self._set_rerender_needed("Exception ocurred during effect")
3052
+ _mark_needs_render_ancestors(child_context)
3053
+ assert child_context.root_element is not None
3054
+ self._cleanup_stale_effects(child_context.root_element, "/", parent_key=join_key(parent_key, key))
3055
+ finally:
3056
+ self.context = context
3057
+ if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler:
3058
+ context.exceptions_children.extend(child_context.exceptions_self)
3059
+ context.exceptions_children.extend(child_context.exceptions_children)
3060
+ else:
3061
+ self._visit_children(el, key, parent_key, self._cleanup_stale_effects)
3062
+
2831
3063
  def _remove_element(self, el: Element, default_key: str, parent_key):
2832
3064
  key = el._key
2833
3065
  if key is None:
@@ -2836,6 +3068,9 @@ class _RenderContextFast(_RenderContext):
2836
3068
  context = self.context
2837
3069
  assert context is not None
2838
3070
 
3071
+ if self._keep_keyed_child(context, el, key):
3072
+ return
3073
+
2839
3074
  if el.is_shared:
2840
3075
  if el not in self._shared_elements:
2841
3076
  # another use of this element keeps it alive, or it was already removed
@@ -2860,6 +3095,7 @@ class _RenderContextFast(_RenderContext):
2860
3095
  if not effect._cleaned_up:
2861
3096
  effect.cleanup()
2862
3097
  except BaseException as e:
3098
+ logger.exception("Effect cleanup %r raised exception %r", effect.callable, e)
2863
3099
  child_context.exceptions_self.append(e)
2864
3100
  self._set_rerender_needed("Exception ocurred during effect")
2865
3101
  _mark_needs_render_ancestors(child_context)
@@ -2931,6 +3167,21 @@ class _RenderContextFast(_RenderContext):
2931
3167
  else:
2932
3168
  return value
2933
3169
 
3170
+ def _render_arguments(self, el: Element, key: str, parent_key: str):
3171
+ """Render the elements in the arguments of a widget element or a shared element."""
3172
+ assert self.context is not None
3173
+ el_reconciled = self.context.elements.get(key)
3174
+ replaced = el_reconciled is not None and el_reconciled.component != el.component
3175
+ if replaced:
3176
+ # a different component at this key: reconciliation removes the old element and
3177
+ # everything below it, so nothing below the new one may be skipped as clean
3178
+ self._replacing += 1
3179
+ try:
3180
+ self._visit_children(el, key, parent_key, self._render)
3181
+ finally:
3182
+ if replaced:
3183
+ self._replacing -= 1
3184
+
2934
3185
  def _remove_stale_root_elements(self, parent_key):
2935
3186
  # remove stale elements of the root context itself
2936
3187
  # (child contexts are swept during their reconciliation)
@@ -2941,8 +3192,6 @@ class _RenderContextFast(_RenderContext):
2941
3192
 
2942
3193
 
2943
3194
  def _render_context_class():
2944
- import os
2945
-
2946
3195
  return _RenderContextFast if os.environ.get("REACTON_FAST") == "1" else _RenderContext
2947
3196
 
2948
3197