reacton 1.10.4__tar.gz → 1.11.0__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 (35) hide show
  1. {reacton-1.10.4 → reacton-1.11.0}/PKG-INFO +1 -1
  2. reacton-1.11.0/reacton/_version.py +1 -0
  3. {reacton-1.10.4 → reacton-1.11.0}/reacton/core.py +182 -97
  4. {reacton-1.10.4 → reacton-1.11.0}/reacton/core_test.py +192 -0
  5. {reacton-1.10.4 → reacton-1.11.0}/reacton/fuzz_test.py +34 -3
  6. reacton-1.11.0/reacton/threads_test.py +562 -0
  7. reacton-1.10.4/reacton/_version.py +0 -1
  8. {reacton-1.10.4 → reacton-1.11.0}/.gitignore +0 -0
  9. {reacton-1.10.4 → reacton-1.11.0}/LICENSE +0 -0
  10. {reacton-1.10.4 → reacton-1.11.0}/pyproject.toml +0 -0
  11. {reacton-1.10.4 → reacton-1.11.0}/react_ipywidgets/__init__.py +0 -0
  12. {reacton-1.10.4 → reacton-1.11.0}/react_ipywidgets/core.py +0 -0
  13. {reacton-1.10.4 → reacton-1.11.0}/reacton/__init__.py +0 -0
  14. {reacton-1.10.4 → reacton-1.11.0}/reacton/bqplot.py +0 -0
  15. {reacton-1.10.4 → reacton-1.11.0}/reacton/deprecated/__init__.py +0 -0
  16. {reacton-1.10.4 → reacton-1.11.0}/reacton/deprecated/ipyvuetify.py +0 -0
  17. {reacton-1.10.4 → reacton-1.11.0}/reacton/find.py +0 -0
  18. {reacton-1.10.4 → reacton-1.11.0}/reacton/find_test.py +0 -0
  19. {reacton-1.10.4 → reacton-1.11.0}/reacton/generate.py +0 -0
  20. {reacton-1.10.4 → reacton-1.11.0}/reacton/generate_test.py +0 -0
  21. {reacton-1.10.4 → reacton-1.11.0}/reacton/ipycanvas.py +0 -0
  22. {reacton-1.10.4 → reacton-1.11.0}/reacton/ipyvue.py +0 -0
  23. {reacton-1.10.4 → reacton-1.11.0}/reacton/ipyvue_test.py +0 -0
  24. {reacton-1.10.4 → reacton-1.11.0}/reacton/ipyvuetify.py +0 -0
  25. {reacton-1.10.4 → reacton-1.11.0}/reacton/ipywidgets.py +0 -0
  26. {reacton-1.10.4 → reacton-1.11.0}/reacton/logging.py +0 -0
  27. {reacton-1.10.4 → reacton-1.11.0}/reacton/patch.py +0 -0
  28. {reacton-1.10.4 → reacton-1.11.0}/reacton/patch_display.py +0 -0
  29. {reacton-1.10.4 → reacton-1.11.0}/reacton/py.typed +0 -0
  30. {reacton-1.10.4 → reacton-1.11.0}/reacton/rx.py +0 -0
  31. {reacton-1.10.4 → reacton-1.11.0}/reacton/test.vue +0 -0
  32. {reacton-1.10.4 → reacton-1.11.0}/reacton/test_rx.py +0 -0
  33. {reacton-1.10.4 → reacton-1.11.0}/reacton/utils.py +0 -0
  34. {reacton-1.10.4 → reacton-1.11.0}/reacton/utils_test.py +0 -0
  35. {reacton-1.10.4 → reacton-1.11.0}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reacton
3
- Version: 1.10.4
3
+ Version: 1.11.0
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.11.0"
@@ -88,30 +88,43 @@ _get_widgets_dict = _widgets_dict_getter()
88
88
  # via the widget constructed hook: diffing the global widgets dict per widget
89
89
  # creation is O(total widgets), and solara replaces that dict with a context
90
90
  # aware mapping we should not depend on.
91
- _construction_recording: Optional[List["widgets.Widget"]] = None
91
+ # The recording is per thread, so no lock is held while a widget is constructed:
92
+ # construction opens a comm, and in a server that send can wait for a slow client,
93
+ # which with a process wide lock stalled widget creation on every other thread.
94
+ class _ConstructionRecording(threading.local):
95
+ # where the widgets constructed on this thread go, between _start_recording_constructed and
96
+ # _stop_recording_constructed
97
+ constructed: Optional[List["widgets.Widget"]] = None
98
+
99
+
100
+ _construction_recording = _ConstructionRecording()
92
101
  _chained_construction_callback: Optional[Callable] = None
93
102
 
94
103
 
95
104
  def _record_constructed_widget(widget: "widgets.Widget"):
96
- if _construction_recording is not None:
97
- _construction_recording.append(widget)
105
+ constructed = _construction_recording.constructed
106
+ if constructed is not None:
107
+ constructed.append(widget)
98
108
  if _chained_construction_callback is not None:
99
109
  _chained_construction_callback(widget)
100
110
 
101
111
 
102
- def _start_recording_constructed(recording: List["widgets.Widget"]):
103
- global _construction_recording, _chained_construction_callback
112
+ def _start_recording_constructed(recording: List["widgets.Widget"]) -> Optional[List["widgets.Widget"]]:
113
+ """Record the widgets this thread constructs; returns what to pass to _stop_recording_constructed."""
114
+ global _chained_construction_callback
104
115
  current = getattr(widgets.Widget, "_widget_construction_callback", None)
105
116
  if current is not _record_constructed_widget:
106
117
  # first time, or someone else registered a callback after us: chain it
107
118
  _chained_construction_callback = current
108
119
  widgets.Widget.on_widget_constructed(_record_constructed_widget)
109
- _construction_recording = recording
120
+ # a widget whose constructor renders records in its own list; the outer recording continues afterwards
121
+ previous = _construction_recording.constructed
122
+ _construction_recording.constructed = recording
123
+ return previous
110
124
 
111
125
 
112
- def _stop_recording_constructed():
113
- global _construction_recording
114
- _construction_recording = None
126
+ def _stop_recording_constructed(previous: Optional[List["widgets.Widget"]]):
127
+ _construction_recording.constructed = previous
115
128
 
116
129
 
117
130
  _last_rc = None # used for testing
@@ -334,7 +347,6 @@ class Element(Generic[W]):
334
347
  # to make every unique on_value callback to a unique wrapper
335
348
  # so that we can remove the listeners
336
349
  _callback_wrappers: Dict[Tuple[str, str, Callable], Callable] = {}
337
- create_lock: ContextManager = threading.Lock()
338
350
  _shared = False
339
351
 
340
352
  def __init__(self, component, args=None, kwargs=None):
@@ -499,32 +511,30 @@ class Element(Generic[W]):
499
511
  # we can't use our own kwarg, since that contains elements, not widgets
500
512
  kwargs, listeners = self._split_kwargs(kwargs)
501
513
  assert isinstance(self.component, ComponentWidget)
502
- # The recording is global state, so we need a lock.
503
- with self.create_lock:
504
- rc = get_render_context(required=True)
505
- recorded: List[widgets.Widget] = []
506
- _start_recording_constructed(recorded)
514
+ rc = get_render_context(required=True)
515
+ recorded: List[widgets.Widget] = []
516
+ previous_recording = _start_recording_constructed(recorded)
517
+ try:
507
518
  try:
508
- try:
509
- widget = self.component.widget(**kwargs)
510
- hold_trait_notifications = widget.hold_trait_notifications
511
-
512
- @contextlib.contextmanager
513
- def hold_trait_notifications_extra(*args, **kwargs):
514
- with rc, hold_trait_notifications(*args, **kwargs):
515
- yield
516
-
517
- widget.hold_trait_notifications = hold_trait_notifications_extra
518
-
519
- if self._meta:
520
- widget._react_meta = dict(self._meta)
521
- except Exception as e:
522
- raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
523
- for name, callback in listeners.items():
524
- if callback is not None:
525
- self._add_widget_event_listener(widget, name, callback)
526
- finally:
527
- _stop_recording_constructed()
519
+ widget = self.component.widget(**kwargs)
520
+ hold_trait_notifications = widget.hold_trait_notifications
521
+
522
+ @contextlib.contextmanager
523
+ def hold_trait_notifications_extra(*args, **kwargs):
524
+ with rc, hold_trait_notifications(*args, **kwargs):
525
+ yield
526
+
527
+ widget.hold_trait_notifications = hold_trait_notifications_extra
528
+
529
+ if self._meta:
530
+ widget._react_meta = dict(self._meta)
531
+ except Exception as e:
532
+ raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
533
+ for name, callback in listeners.items():
534
+ if callback is not None:
535
+ self._add_widget_event_listener(widget, name, callback)
536
+ finally:
537
+ _stop_recording_constructed(previous_recording)
528
538
  widgets_dict = _get_widgets_dict()
529
539
  orphans = {w.model_id for w in recorded if w is not widget and w.comm is not None and w.model_id in widgets_dict}
530
540
  return widget, orphans
@@ -1306,33 +1316,37 @@ class Effect:
1306
1316
 
1307
1317
  class _RenderContext:
1308
1318
  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
1319
+ # the element that reconciliation removes because the new tree replaced it by an element
1320
+ # of another type, or no longer has it, and its context (see _remove_outgoing)
1321
+ _outgoing: Optional[Tuple[ComponentContext, Element]] = None
1312
1322
 
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."""
1323
+ def _remove_outgoing(self, el: Element, key: str, parent_key: str):
1324
+ """Remove the element at key, which the new tree replaced or no longer has."""
1315
1325
  assert self.context is not None
1316
- replaced = self._replaced
1317
- self._replaced = (self.context, el_prev)
1326
+ outgoing = self._outgoing
1327
+ self._outgoing = (self.context, el)
1318
1328
  try:
1319
- self._remove_element(el_prev, key, parent_key=parent_key)
1329
+ self._remove_element(el, key, parent_key=parent_key)
1320
1330
  finally:
1321
- self._replaced = replaced
1331
+ self._outgoing = outgoing
1322
1332
 
1323
1333
  def _keep_keyed_child(self, context: ComponentContext, el: Element, key: str) -> bool:
1324
1334
  # 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
1335
+ # explicit key that the new tree still uses (it moved out of the outgoing element, for
1336
+ # instance a wrapper that went away, or stays under the element that replaces it) is
1337
+ # not removed with the outgoing element: reconciliation updates it where the new tree
1338
+ # has it. Shared elements keep the old behavior: their bookkeeping (_shared_elements,
1339
+ # _shared_widgets) needs the removal.
1340
+ outgoing = self._outgoing
1330
1341
  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
1342
+ outgoing is not None and context is outgoing[0] and el is not outgoing[1] and el._key is not None and not el.is_shared and key in context.used_keys
1332
1343
  )
1333
1344
 
1334
1345
  def __init__(self, element: Element, container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None):
1335
1346
  self.element = element
1347
+ # the last requested root element: the thread that holds the render lock copies it to
1348
+ # self.element at the start of each pass (see render)
1349
+ self._element_next = element
1336
1350
  self.container = container
1337
1351
  self.children_next_trait = children_trait
1338
1352
  self.first_render = True
@@ -1344,6 +1358,9 @@ class _RenderContext:
1344
1358
  self.last_root_widget: widgets.Widget = None
1345
1359
  self._is_rendering = False
1346
1360
  self._rerender_needed = False
1361
+ self._state_set_by_other_thread = False
1362
+ # used for tests only: called when a render loop is done, right after its last look at _rerender_needed
1363
+ self._on_render_loop_done: Optional[Callable[[], None]] = None
1347
1364
  # the reasons are only read for the "too many renders" error message, and a reason
1348
1365
  # holds the previous and next state value: keeping all of them kept every old state
1349
1366
  # value alive until close(). REACTON_RERENDER_REASONS keeps more, for debugging.
@@ -1399,15 +1416,27 @@ class _RenderContext:
1399
1416
  _find = find # for backward compatibility
1400
1417
 
1401
1418
  def close(self):
1419
+ # close() waits for the render lock, so it cannot run in its own render: it would wait for itself
1420
+ if self._lock_thread == threading.current_thread():
1421
+ raise RuntimeError("close() called during a render of this render context, current thread: %r" % threading.current_thread())
1422
+ if self._closing:
1423
+ # closed or closing: a second close(), or an effect cleanup that calls close() during close()
1424
+ return
1402
1425
  with self.thread_lock:
1426
+ if self._closing:
1427
+ return # another thread closed us while we waited for the lock
1403
1428
  self._closing = True
1404
1429
  # snapshot the component contexts before _remove_element detaches them from
1405
1430
  # their parents: detached contexts would escape the teardown below while the
1406
1431
  # setter/handler closures in their state still reference them and us
1407
- all_contexts: List[ComponentContext] = []
1432
+ # by id: after a failed render, a context can be in both children and children_next, and
1433
+ # a visit per path to it is 2**depth visits
1434
+ all_contexts: Dict[int, ComponentContext] = {}
1408
1435
 
1409
1436
  def collect(context: ComponentContext):
1410
- all_contexts.append(context)
1437
+ if id(context) in all_contexts:
1438
+ return
1439
+ all_contexts[id(context)] = context
1411
1440
  for child in list(context.children.values()) + list(context.children_next.values()):
1412
1441
  collect(child)
1413
1442
 
@@ -1432,7 +1461,7 @@ class _RenderContext:
1432
1461
  exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
1433
1462
  # break the reference cycles through the tree (see _teardown_component_context);
1434
1463
  # _closing stays True, making stray setters and event handlers no-ops
1435
- for context in all_contexts:
1464
+ for context in all_contexts.values():
1436
1465
  _teardown_component_context(context)
1437
1466
  self.context = None
1438
1467
  self.context_root = None # type: ignore
@@ -1440,6 +1469,7 @@ class _RenderContext:
1440
1469
  # widgets reference their elements, whose kwargs hold user callbacks - which
1441
1470
  # capture use_state setters and therefore this render context
1442
1471
  self.element = None # type: ignore
1472
+ self._element_next = None # type: ignore
1443
1473
  self.container = None
1444
1474
  self.last_root_widget = None
1445
1475
  self._old_element_ids.clear()
@@ -1563,6 +1593,9 @@ class _RenderContext:
1563
1593
  # TODO: enable
1564
1594
  context.needs_render = True
1565
1595
  _mark_needs_render_ancestors(context)
1596
+ if self._lock_thread is not threading.current_thread():
1597
+ # not part of the render loop of the thread that renders (see render)
1598
+ self._state_set_by_other_thread = True
1566
1599
  if self._rerender_needed is False:
1567
1600
  if DEBUG:
1568
1601
  trigger_stack = traceback.format_stack()
@@ -1589,8 +1622,13 @@ class _RenderContext:
1589
1622
  return
1590
1623
  # a forced update re-walks the whole tree, no subtree skipping
1591
1624
  self._walk_all = True
1625
+ # mark before we look at _is_rendering, like a setter: a render that is ending then sees the
1626
+ # mark after it released the lock (see render), or we see that no render runs
1627
+ if self._rerender_needed is False:
1628
+ self._rerender_needed_reasons.append(RerenderReason(reason="forced update"))
1629
+ self._rerender_needed = True
1592
1630
  if not self._is_rendering:
1593
- self.render(self.element, self.container)
1631
+ self.render()
1594
1632
 
1595
1633
  def use_effect(self, effect: EffectCallable, dependencies=None):
1596
1634
  assert self.context is not None
@@ -1612,17 +1650,13 @@ class _RenderContext:
1612
1650
  self.context.effect_index += 1
1613
1651
 
1614
1652
  def update(self, element: Element):
1615
- self._walk_all = True
1616
- if self._is_rendering:
1617
- self.element = element
1618
- self._rerender_needed_reasons.append(RerenderReason(reason="root element changed"))
1619
- self._rerender_needed = True
1620
- else:
1621
- self.render(element, self.container)
1653
+ # the element first: force_update marks, and the render takes _element_next after it saw the mark
1654
+ self._element_next = element
1655
+ self.force_update()
1622
1656
 
1623
1657
  def _possible_rerender(self):
1624
1658
  if not self._is_rendering and self._batch_counter.current() == 0:
1625
- self.render(self.element, self.container)
1659
+ self.render()
1626
1660
  else:
1627
1661
  logger.info("No render phase triggered, already rendering")
1628
1662
 
@@ -1639,8 +1673,12 @@ class _RenderContext:
1639
1673
  # state), and a context created by the aborted pass owns no widgets yet, it is simply
1640
1674
  # reused or pruned by the next render
1641
1675
  contexts: List[ComponentContext] = [self.context_root]
1676
+ seen: Set[int] = set() # a context can be in both children and children_next (see close)
1642
1677
  while contexts:
1643
1678
  context = contexts.pop()
1679
+ if id(context) in seen:
1680
+ continue
1681
+ seen.add(id(context))
1644
1682
  for effect in context.effects:
1645
1683
  effect.next = None
1646
1684
  context.root_element_next = None
@@ -1655,41 +1693,60 @@ class _RenderContext:
1655
1693
  self._shared_elements_next = set()
1656
1694
  self.context = self.context_root
1657
1695
 
1658
- def render(self, element: Element, container: widgets.Widget = None):
1696
+ def render(self, element: Optional[Element] = None, container: widgets.Widget = None):
1659
1697
  # render + consolidate
1698
+ # We never wait for the render lock: the caller may hold a lock that the render needs (a
1699
+ # deadlock). So we first mark the request, then only try the lock. When another thread holds
1700
+ # it, that thread renders the request: it takes _element_next at the start of each pass, and
1701
+ # looks at _rerender_needed again after it released the lock (below).
1702
+ widget, rendered = self._render_once(element, container)
1703
+ # A request from another thread (a setter, update(), render()) sets _rerender_needed, and leaves
1704
+ # the render to us while we render or hold the render lock. We released the lock, and look again
1705
+ # now: either we see the request here, or that thread got the lock and renders it itself. A loop,
1706
+ # not a recursion: this can repeat many times in a row while another thread changes state.
1707
+ while rendered and self._rerender_needed and not self._is_rendering and self._batch_counter.current() == 0:
1708
+ widget, rendered = self._render_once(None, container)
1709
+ return widget
1710
+
1711
+ def _render_once(self, element: Optional[Element], container: widgets.Widget) -> Tuple[Any, bool]:
1712
+ # returns the root widget, and whether we rendered (False: another thread holds the render
1713
+ # lock and renders our request, or we are closed, or we rendered an error message)
1714
+ if self._lock_thread == threading.current_thread():
1715
+ raise RuntimeError("Recursive render detected (avoided deadlock), current thread: %r" % threading.current_thread())
1660
1716
  widget = None
1661
1717
  if container is None:
1662
1718
  container = self.container
1663
- was_locked = False
1664
- if self.thread_lock.locked():
1665
- if self._lock_thread == threading.current_thread():
1666
- raise RuntimeError("Recursive render detected (avoided deadlock), current thread: %r" % threading.current_thread())
1667
- logger.info(
1668
- "Render phase still in progress, waiting for mutex to release (locked obtained by %r, we are in thread %r)",
1669
- self._lock_thread,
1670
- threading.current_thread(),
1671
- )
1672
- was_locked = True
1673
- with self.thread_lock:
1719
+ if element is not None:
1720
+ self._element_next = element
1721
+ if self._rerender_needed is False:
1722
+ self._rerender_needed_reasons.append(RerenderReason(reason="render requested"))
1723
+ self._rerender_needed = True
1724
+ locked = False
1725
+ try:
1726
+ # acquire inside the try, in one statement with the assignment: an exception raised
1727
+ # on the next line (an interrupt, a cancel from a trace function) still releases the lock
1728
+ locked = self.thread_lock.acquire(blocking=False)
1729
+ if not locked:
1730
+ logger.info("Render phase in progress in thread %r, leaving the render to it", self._lock_thread)
1731
+ return container, False
1674
1732
  self._lock_thread = threading.current_thread()
1675
- if was_locked:
1676
- logger.info("Mutex released, continuing render phase")
1677
1733
  if self._closing or self.context is None:
1678
1734
  # close() won the race for the lock (a disconnect can close the
1679
1735
  # kernel while an update was waiting to render): the tree is
1680
1736
  # torn down, there is nothing to render into anymore
1681
1737
  logger.info("Render requested on a closing/closed render context, ignoring")
1682
- return container
1738
+ return container, False
1683
1739
  prev_rc = getattr(local, "rc", None)
1684
1740
  # an exception that escapes while this is True aborted a render pass (see the except below)
1685
1741
  in_render_phase = True
1686
1742
  try:
1687
1743
  local.rc = self
1688
- self.element = element
1689
- del element
1690
1744
  main_render_phase = not self._is_rendering
1691
1745
  render_count = self.render_count # make a copy
1746
+ # clear before taking the element: a request that comes in between is seen by the loop
1692
1747
  self._rerender_needed = False
1748
+ self._state_set_by_other_thread = False
1749
+ self.element = self._element_next
1693
1750
  logger.info("Render phase: %r %r of %r", self.render_count, "main" if main_render_phase else "(nested)", self.element)
1694
1751
  self.render_count += 1
1695
1752
  self._is_rendering = True
@@ -1714,11 +1771,18 @@ class _RenderContext:
1714
1771
  if main_render_phase:
1715
1772
  stable = False
1716
1773
  render_counts = 0
1774
+ render_limit = 50
1717
1775
  while not stable and not self.context_root.exceptions_children:
1718
1776
  # we started the rendering loop (main_render_phase is True), so we keep going
1719
1777
  # but if an exception bubbled up, we should stop
1720
1778
  while self._rerender_needed and not self.context_root.exceptions_children:
1721
- if render_counts > 50:
1779
+ if self._state_set_by_other_thread:
1780
+ # another thread changed state during a pass (a progress update for
1781
+ # instance): that is not a render loop of our own, so allow more passes.
1782
+ # But not without end: our caller would not return, and close() waits.
1783
+ self._state_set_by_other_thread = False
1784
+ render_limit = 100
1785
+ if render_counts > render_limit:
1722
1786
 
1723
1787
  def format(reason: RerenderReason):
1724
1788
  f = f"Reason: {reason.reason}\nValue changed from {reason.prev_value} to {reason.next_value}\n"
@@ -1728,13 +1792,17 @@ class _RenderContext:
1728
1792
  f += f"Triggered at: {''.join(reason.trigger_stack)}\n"
1729
1793
  return f
1730
1794
 
1731
- msg = f"Too many renders triggered, your render loop does not stop\nLast reason: {format(self._rerender_needed_reasons[-1])}\n"
1795
+ msg = "Too many renders triggered, your render loop does not stop\n"
1796
+ if render_limit > 50:
1797
+ msg += "Another thread changes state during every render pass: add a pause between its changes\n"
1798
+ msg += f"Last reason: {format(self._rerender_needed_reasons[-1])}\n"
1732
1799
  if len(self._rerender_needed_reasons) >= 2:
1733
1800
  previous = reversed(list(self._rerender_needed_reasons)[:-1])
1734
1801
  msg += f"Previous reasons: {''.join(format(reason) for reason in previous)}\n"
1735
1802
  raise RuntimeError(msg)
1736
1803
  logger.info("Entering nested render phase: %r", self._rerender_needed_reasons[-1])
1737
1804
  self._rerender_needed = False
1805
+ self.element = self._element_next
1738
1806
  self._shared_elements_next = set()
1739
1807
  self.context.exception_handler = False
1740
1808
  self.context.exceptions_children = []
@@ -1808,6 +1876,8 @@ class _RenderContext:
1808
1876
  stable = False
1809
1877
  else:
1810
1878
  stable = True
1879
+ if self._on_render_loop_done is not None:
1880
+ self._on_render_loop_done()
1811
1881
 
1812
1882
  self._is_rendering = False
1813
1883
  self.context = context_prev
@@ -1834,13 +1904,18 @@ class _RenderContext:
1834
1904
  finally:
1835
1905
  local.rc = prev_rc # type: ignore
1836
1906
  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
1841
1907
  assert self.context is self.context_root
1908
+ # read the errors of this render while we hold the lock: once it is released, close()
1909
+ # on another thread can tear the tree down (self.context is None then)
1910
+ exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
1911
+ finally:
1912
+ if locked:
1913
+ # clear before the lock is released, on every way out (also the return for a closed
1914
+ # render context): a stale _lock_thread makes the checks for a render or close() in
1915
+ # its own render fire for a thread that merely rendered last
1916
+ self._lock_thread = None
1917
+ self.thread_lock.release()
1842
1918
 
1843
- exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
1844
1919
  if exceptions:
1845
1920
  exc = exceptions[0]
1846
1921
  if DEBUG:
@@ -1857,10 +1932,10 @@ class _RenderContext:
1857
1932
  value = html.escape(error)
1858
1933
  from . import ipywidgets as w
1859
1934
 
1860
- return self.render(w.HTML(value="<pre>" + value + "</pre>", layout=w.Layout(overflow="auto")), self.container)
1935
+ return self.render(w.HTML(value="<pre>" + value + "</pre>", layout=w.Layout(overflow="auto")), self.container), False
1861
1936
  else:
1862
1937
  raise exc
1863
- return widget
1938
+ return widget, True
1864
1939
 
1865
1940
  def _render(self, element: Element, default_key: str, parent_key: str):
1866
1941
  if not isinstance(element, Element):
@@ -2118,7 +2193,7 @@ class _RenderContext:
2118
2193
  try:
2119
2194
  if isinstance(el.component, ComponentFunction):
2120
2195
  if el_prev and isinstance(el_prev.component, ComponentWidget):
2121
- self._remove_replaced(el_prev, key, parent_key=parent_key)
2196
+ self._remove_outgoing(el_prev, key, parent_key=parent_key)
2122
2197
  new_parent_key = join_key(parent_key, key)
2123
2198
  try:
2124
2199
  # TODO: test suite passes when this block if commented out
@@ -2164,7 +2239,7 @@ class _RenderContext:
2164
2239
  if removed:
2165
2240
  for key_remove in removed:
2166
2241
  el_remove = elements[key_remove]
2167
- self._remove_element(el_remove, key_remove, parent_key)
2242
+ self._remove_outgoing(el_remove, key_remove, parent_key)
2168
2243
  for effect_index, effect in enumerate(child_context.effects):
2169
2244
  if effect.next:
2170
2245
  # if we have a next, it means that effect itself is executed
@@ -2295,7 +2370,7 @@ class _RenderContext:
2295
2370
  else:
2296
2371
  assert el_prev is not None, "widget_previous is not None, but el_prev is"
2297
2372
  logger.debug("Replacing widget: %r → %r %r", el_prev, el, key)
2298
- self._remove_replaced(el_prev, key, parent_key=parent_key)
2373
+ self._remove_outgoing(el_prev, key, parent_key=parent_key)
2299
2374
  kwargs = reconsolidate_children()
2300
2375
  widget = None
2301
2376
  if not context.exceptions_children:
@@ -2372,7 +2447,7 @@ class _RenderContext:
2372
2447
  if extra:
2373
2448
  for key in list(extra):
2374
2449
  if key in self.context.elements:
2375
- self._remove_element(self.context.elements[key], key, parent_key=parent_key)
2450
+ self._remove_outgoing(self.context.elements[key], key, parent_key=parent_key)
2376
2451
 
2377
2452
  # keeping this for debugging
2378
2453
  # logger.debug("Current:")
@@ -2796,7 +2871,7 @@ class _RenderContextFast(_RenderContext):
2796
2871
 
2797
2872
  if el_prev and isinstance(el_prev.component, ComponentWidget):
2798
2873
  # a widget element was replaced by a component element at this key
2799
- self._remove_replaced(el_prev, key, parent_key=parent_key)
2874
+ self._remove_outgoing(el_prev, key, parent_key=parent_key)
2800
2875
  new_parent_key = join_key(parent_key, key)
2801
2876
  try:
2802
2877
  if el.is_shared and (el.args or el.kwargs):
@@ -2832,7 +2907,7 @@ class _RenderContextFast(_RenderContext):
2832
2907
  logger.info("elements to be removed: %r", stale_keys)
2833
2908
  for stale_key in stale_keys:
2834
2909
  if stale_key in child_context.elements:
2835
- self._remove_element(child_context.elements[stale_key], stale_key, new_parent_key)
2910
+ self._remove_outgoing(child_context.elements[stale_key], stale_key, new_parent_key)
2836
2911
 
2837
2912
  self._process_effects(child_context, context)
2838
2913
 
@@ -2904,7 +2979,7 @@ class _RenderContextFast(_RenderContext):
2904
2979
  else:
2905
2980
  assert el_prev is not None, "widget_previous is not None, but el_prev is"
2906
2981
  # a different widget type at the same key: replace
2907
- self._remove_replaced(el_prev, key, parent_key=parent_key)
2982
+ self._remove_outgoing(el_prev, key, parent_key=parent_key)
2908
2983
  kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2909
2984
  widget = None
2910
2985
  if not context.exceptions_children:
@@ -3013,7 +3088,15 @@ class _RenderContextFast(_RenderContext):
3013
3088
  if stale_key not in context.elements or stale_key in context.fast_stale_effects_cleaned:
3014
3089
  continue
3015
3090
  self.context = context
3016
- self._cleanup_stale_effects(context.elements[stale_key], stale_key, parent_key)
3091
+ el = context.elements[stale_key]
3092
+ # keyed children that the new tree still uses stay, with their effects
3093
+ # (see _keep_keyed_child)
3094
+ outgoing = self._outgoing
3095
+ self._outgoing = (context, el)
3096
+ try:
3097
+ self._cleanup_stale_effects(el, stale_key, parent_key)
3098
+ finally:
3099
+ self._outgoing = outgoing
3017
3100
  finally:
3018
3101
  self.context = context_prev
3019
3102
 
@@ -3026,6 +3109,8 @@ class _RenderContextFast(_RenderContext):
3026
3109
  assert context is not None
3027
3110
  if key in context.fast_stale_effects_cleaned:
3028
3111
  return
3112
+ if self._keep_keyed_child(context, el, key):
3113
+ return
3029
3114
  if el.is_shared and (el in self._shared_elements_next or el not in self._shared_elements):
3030
3115
  return
3031
3116
  context.fast_stale_effects_cleaned.add(key)
@@ -3188,7 +3273,7 @@ class _RenderContextFast(_RenderContext):
3188
3273
  stale_keys = sorted(set(self.context_root.elements) - self.context_root.used_keys)
3189
3274
  for stale_key in stale_keys:
3190
3275
  if stale_key in self.context_root.elements:
3191
- self._remove_element(self.context_root.elements[stale_key], stale_key, parent_key)
3276
+ self._remove_outgoing(self.context_root.elements[stale_key], stale_key, parent_key)
3192
3277
 
3193
3278
 
3194
3279
  def _render_context_class():