reacton 1.9.1__tar.gz → 1.10.1__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 (33) hide show
  1. {reacton-1.9.1 → reacton-1.10.1}/PKG-INFO +1 -1
  2. reacton-1.10.1/reacton/_version.py +1 -0
  3. {reacton-1.9.1 → reacton-1.10.1}/reacton/core.py +772 -31
  4. {reacton-1.9.1 → reacton-1.10.1}/reacton/core_test.py +24 -0
  5. {reacton-1.9.1 → reacton-1.10.1}/reacton/ipyvue.py +20 -1
  6. reacton-1.10.1/reacton/ipyvue_test.py +90 -0
  7. reacton-1.9.1/reacton/_version.py +0 -1
  8. {reacton-1.9.1 → reacton-1.10.1}/.gitignore +0 -0
  9. {reacton-1.9.1 → reacton-1.10.1}/LICENSE +0 -0
  10. {reacton-1.9.1 → reacton-1.10.1}/pyproject.toml +0 -0
  11. {reacton-1.9.1 → reacton-1.10.1}/react_ipywidgets/__init__.py +0 -0
  12. {reacton-1.9.1 → reacton-1.10.1}/react_ipywidgets/core.py +0 -0
  13. {reacton-1.9.1 → reacton-1.10.1}/reacton/__init__.py +0 -0
  14. {reacton-1.9.1 → reacton-1.10.1}/reacton/bqplot.py +0 -0
  15. {reacton-1.9.1 → reacton-1.10.1}/reacton/deprecated/__init__.py +0 -0
  16. {reacton-1.9.1 → reacton-1.10.1}/reacton/deprecated/ipyvuetify.py +0 -0
  17. {reacton-1.9.1 → reacton-1.10.1}/reacton/find.py +0 -0
  18. {reacton-1.9.1 → reacton-1.10.1}/reacton/find_test.py +0 -0
  19. {reacton-1.9.1 → reacton-1.10.1}/reacton/generate.py +0 -0
  20. {reacton-1.9.1 → reacton-1.10.1}/reacton/generate_test.py +0 -0
  21. {reacton-1.9.1 → reacton-1.10.1}/reacton/ipycanvas.py +0 -0
  22. {reacton-1.9.1 → reacton-1.10.1}/reacton/ipyvuetify.py +0 -0
  23. {reacton-1.9.1 → reacton-1.10.1}/reacton/ipywidgets.py +0 -0
  24. {reacton-1.9.1 → reacton-1.10.1}/reacton/logging.py +0 -0
  25. {reacton-1.9.1 → reacton-1.10.1}/reacton/patch.py +0 -0
  26. {reacton-1.9.1 → reacton-1.10.1}/reacton/patch_display.py +0 -0
  27. {reacton-1.9.1 → reacton-1.10.1}/reacton/py.typed +0 -0
  28. {reacton-1.9.1 → reacton-1.10.1}/reacton/rx.py +0 -0
  29. {reacton-1.9.1 → reacton-1.10.1}/reacton/test.vue +0 -0
  30. {reacton-1.9.1 → reacton-1.10.1}/reacton/test_rx.py +0 -0
  31. {reacton-1.9.1 → reacton-1.10.1}/reacton/utils.py +0 -0
  32. {reacton-1.9.1 → reacton-1.10.1}/reacton/utils_test.py +0 -0
  33. {reacton-1.9.1 → reacton-1.10.1}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reacton
3
- Version: 1.9.1
3
+ Version: 1.10.1
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.1"
@@ -80,6 +80,37 @@ def _widgets_dict_getter() -> Callable[[], Dict[str, widgets.Widget]]:
80
80
 
81
81
  _get_widgets_dict = _widgets_dict_getter()
82
82
 
83
+
84
+ # Tracking widgets created as a side effect (like Layout and Style) is done
85
+ # via the widget constructed hook: diffing the global widgets dict per widget
86
+ # creation is O(total widgets), and solara replaces that dict with a context
87
+ # aware mapping we should not depend on.
88
+ _construction_recording: Optional[List["widgets.Widget"]] = None
89
+ _chained_construction_callback: Optional[Callable] = None
90
+
91
+
92
+ def _record_constructed_widget(widget: "widgets.Widget"):
93
+ if _construction_recording is not None:
94
+ _construction_recording.append(widget)
95
+ if _chained_construction_callback is not None:
96
+ _chained_construction_callback(widget)
97
+
98
+
99
+ def _start_recording_constructed(recording: List["widgets.Widget"]):
100
+ global _construction_recording, _chained_construction_callback
101
+ current = getattr(widgets.Widget, "_widget_construction_callback", None)
102
+ if current is not _record_constructed_widget:
103
+ # first time, or someone else registered a callback after us: chain it
104
+ _chained_construction_callback = current
105
+ widgets.Widget.on_widget_constructed(_record_constructed_widget)
106
+ _construction_recording = recording
107
+
108
+
109
+ def _stop_recording_constructed():
110
+ global _construction_recording
111
+ _construction_recording = None
112
+
113
+
83
114
  _last_rc = None # used for testing
84
115
  local = threading.local()
85
116
  T = TypeVar("T")
@@ -178,6 +209,32 @@ def same_component(c1, c2):
178
209
  return c1 == c2
179
210
 
180
211
 
212
+ def _mark_needs_render_ancestors(context: "ComponentContext"):
213
+ """Let the render phase find its way down to a context that needs work, without walking subtrees that do not."""
214
+ parent = context.parent
215
+ while parent is not None and not parent.needs_render_descendant:
216
+ parent.needs_render_descendant = True
217
+ parent = parent.parent
218
+
219
+
220
+ def _values_identical(a, b):
221
+ """Are two (possibly nested in containers) values the same objects?
222
+
223
+ Used to detect that reconciliation of an identical element resolved to the
224
+ same widgets, meaning the widget cannot need an update.
225
+ """
226
+ if a is b:
227
+ return True
228
+ type_a = type(a)
229
+ if type_a is not type(b):
230
+ return False
231
+ if type_a is list or type_a is tuple:
232
+ return len(a) == len(b) and all(x is y or _values_identical(x, y) for x, y in zip(a, b))
233
+ if type_a is dict:
234
+ return len(a) == len(b) and all(k in b and (v is b[k] or _values_identical(v, b[k])) for k, v in a.items())
235
+ return False
236
+
237
+
181
238
  def _with_tracebacks(e, tracebacks):
182
239
  # copy it, and we need with_traceback for unknown reasons not to cause
183
240
  # an infinite loop
@@ -383,31 +440,34 @@ class Element(Generic[W]):
383
440
  # we can't use our own kwarg, since that contains elements, not widgets
384
441
  kwargs, listeners = self._split_kwargs(kwargs)
385
442
  assert isinstance(self.component, ComponentWidget)
386
- # Because we look before and after, we need a lock.
387
- # A different implementation might avoid this.
443
+ # The recording is global state, so we need a lock.
388
444
  with self.create_lock:
389
445
  rc = get_render_context(required=True)
390
- before = set(_get_widgets_dict())
446
+ recorded: List[widgets.Widget] = []
447
+ _start_recording_constructed(recorded)
391
448
  try:
392
- widget = self.component.widget(**kwargs)
393
- hold_trait_notifications = widget.hold_trait_notifications
449
+ try:
450
+ widget = self.component.widget(**kwargs)
451
+ hold_trait_notifications = widget.hold_trait_notifications
394
452
 
395
- @contextlib.contextmanager
396
- def hold_trait_notifications_extra(*args, **kwargs):
397
- with rc, hold_trait_notifications(*args, **kwargs):
398
- yield
453
+ @contextlib.contextmanager
454
+ def hold_trait_notifications_extra(*args, **kwargs):
455
+ with rc, hold_trait_notifications(*args, **kwargs):
456
+ yield
399
457
 
400
- widget.hold_trait_notifications = hold_trait_notifications_extra
458
+ widget.hold_trait_notifications = hold_trait_notifications_extra
401
459
 
402
- if self._meta:
403
- widget._react_meta = dict(self._meta)
404
- except Exception as e:
405
- raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
406
- for name, callback in listeners.items():
407
- if callback is not None:
408
- self._add_widget_event_listener(widget, name, callback)
409
- after = set(_get_widgets_dict())
410
- orphans = (after - before) - {widget.model_id}
460
+ if self._meta:
461
+ widget._react_meta = dict(self._meta)
462
+ except Exception as e:
463
+ raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
464
+ for name, callback in listeners.items():
465
+ if callback is not None:
466
+ self._add_widget_event_listener(widget, name, callback)
467
+ finally:
468
+ _stop_recording_constructed()
469
+ widgets_dict = _get_widgets_dict()
470
+ 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}
411
471
  return widget, orphans
412
472
 
413
473
  def _update_widget(self, widget: widgets.Widget, el_prev: "Element", kwargs):
@@ -632,7 +692,7 @@ class ComponentFunction(Component):
632
692
  for name in normal_kwargs:
633
693
  self.observe(self._rerender, name)
634
694
  el = self._create_el()
635
- self.rc = _RenderContext(el, self)
695
+ self.rc = _render_context_class()(el, self)
636
696
  self.rc.render(el, self)
637
697
 
638
698
  def _create_el(self):
@@ -1080,6 +1140,12 @@ class ComponentContext:
1080
1140
  used_keys: Set[str] = field(default_factory=set)
1081
1141
  # if a child component's state if changed, it needs a rerender
1082
1142
  needs_render: bool = True
1143
+ # some context in this subtree may need a render (set by setters walking up,
1144
+ # cleared when the render phase walks this context)
1145
+ needs_render_descendant: bool = True
1146
+ # the render phase skipped this whole subtree (nothing changed), so the
1147
+ # reconciliation phase can reuse the previous result without walking
1148
+ clean_subtree: bool = False
1083
1149
 
1084
1150
  # elements created in this context go there
1085
1151
  owns: Set[Element] = field(default_factory=set)
@@ -1100,6 +1166,46 @@ class ComponentContext:
1100
1166
  TEffect = TypeVar("TEffect", bound="Effect")
1101
1167
 
1102
1168
 
1169
+ def _teardown_component_context(context: ComponentContext):
1170
+ """Empty a component context so a closed render context can be freed by refcounting.
1171
+
1172
+ Closures created during rendering (use_state setters, event handlers) capture their
1173
+ component context and the render context strongly - they must, a setter can be the
1174
+ only reference keeping its component context alive (cf61378). Since those closures
1175
+ are also stored in the hook state and on widget callback dispatchers, a closed tree
1176
+ is one big reference cycle that refcounting cannot free: it waits for a gen-2
1177
+ garbage collection, which on a server shows up as a memory sawtooth scaling with
1178
+ per-kernel state. Emptying the contexts breaks the cycles: whoever still holds a
1179
+ setter or handler keeps only a small hollow context alive, and set_/force_update
1180
+ are no-ops once the render context is closing.
1181
+ """
1182
+ # replace the containers instead of clearing them: state_get() hands out the live
1183
+ # state dicts (test_state_get closes and re-renders with them), and in general we
1184
+ # only want to drop OUR references, not destroy objects someone else captured
1185
+ context.parent = None
1186
+ context.invoke_element = None
1187
+ context.root_element = None
1188
+ context.root_element_next = None
1189
+ context.elements = {}
1190
+ context.elements_next = {}
1191
+ context.children = {}
1192
+ context.children_next = {}
1193
+ context.widgets = {}
1194
+ context.element_to_widget = {}
1195
+ context.state = {}
1196
+ context.state_metadata = {}
1197
+ context.effects = []
1198
+ context.memo = []
1199
+ context.user_contexts = {}
1200
+ context.user_contexts_prev = {}
1201
+ context.context_listeners = defaultdict(set)
1202
+ context.used_keys = set()
1203
+ context.owns = set()
1204
+ context.exceptions_self = []
1205
+ context.exceptions_children = []
1206
+ context.context_managers = []
1207
+
1208
+
1103
1209
  @dataclass
1104
1210
  class RerenderReason:
1105
1211
  reason: str
@@ -1155,6 +1261,9 @@ class _RenderContext:
1155
1261
  self.handle_error = handle_error
1156
1262
  self.reconsolidating = False
1157
1263
  self._batch_counter = utils.ThreadSafeCounter()
1264
+ # when set, the next render phase walks the whole tree instead of
1265
+ # skipping subtrees in which no state changed (see _render)
1266
+ self._walk_all = True
1158
1267
  if initial_state:
1159
1268
  self.state_set(self.context_root, initial_state)
1160
1269
 
@@ -1198,6 +1307,17 @@ class _RenderContext:
1198
1307
  def close(self):
1199
1308
  with self.thread_lock:
1200
1309
  self._closing = True
1310
+ # snapshot the component contexts before _remove_element detaches them from
1311
+ # their parents: detached contexts would escape the teardown below while the
1312
+ # setter/handler closures in their state still reference them and us
1313
+ all_contexts: List[ComponentContext] = []
1314
+
1315
+ def collect(context: ComponentContext):
1316
+ all_contexts.append(context)
1317
+ for child in list(context.children.values()) + list(context.children_next.values()):
1318
+ collect(child)
1319
+
1320
+ collect(self.context_root)
1201
1321
  logger.info("Removing elements...")
1202
1322
  self._remove_element(self.element, default_key="/", parent_key=ROOT_KEY)
1203
1323
  logger.info("Removing elements done.")
@@ -1212,6 +1332,19 @@ class _RenderContext:
1212
1332
  orphan_widgets = set([_get_widgets_dict()[k] for k in self._orphans])
1213
1333
  raise RuntimeError(f"Orphan widgets not cleaned up for widgets: {orphan_widgets}")
1214
1334
  exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
1335
+ # break the reference cycles through the tree (see _teardown_component_context);
1336
+ # _closing stays True, making stray setters and event handlers no-ops
1337
+ for context in all_contexts:
1338
+ _teardown_component_context(context)
1339
+ self.context = None
1340
+ self.context_root = None # type: ignore
1341
+ # the root element, container and root widget keep the widget tree alive, and
1342
+ # widgets reference their elements, whose kwargs hold user callbacks - which
1343
+ # capture use_state setters and therefore this render context
1344
+ self.element = None # type: ignore
1345
+ self.container = None
1346
+ self.last_root_widget = None
1347
+ self._old_element_ids.clear()
1215
1348
  if exceptions:
1216
1349
  raise exceptions[0]
1217
1350
 
@@ -1277,12 +1410,18 @@ class _RenderContext:
1277
1410
  if DEBUG:
1278
1411
  created_stack = traceback.format_stack()
1279
1412
 
1280
- # if we do not use a weakref, the memory test fails
1281
- # I am not sure exactly why, but this will at least make
1282
- # it easier for Python to garbage collect, since it will
1283
- # avoid circular references
1413
+ # NOTE: set_ captures self and context strongly, and that is a requirement:
1414
+ # a setter may be the ONLY reference keeping its component context alive
1415
+ # (see cf61378, where weak references lost live state). The resulting
1416
+ # reference cycles through the closed tree are broken by close() emptying
1417
+ # the component contexts instead.
1284
1418
 
1285
1419
  def set_(value):
1420
+ if self._closing:
1421
+ # the render context is closed (or closing) and the tree is (being)
1422
+ # torn down: there is nothing to update. This check must come first:
1423
+ # after close, context.state is empty and reading it would fail.
1424
+ return
1286
1425
  if callable(value):
1287
1426
  value = value(context.state[key])
1288
1427
  logger.info("Set state = %r for key %r (previous value was %r) (%r)", value, key, context.state[key], id(self.context))
@@ -1315,11 +1454,6 @@ class _RenderContext:
1315
1454
  equals = eq or utils.equals
1316
1455
  should_update = not equals(context.state[key], value) or should_update
1317
1456
 
1318
- # if a cleanup during close trigger a state update in a different component, we want to ignore that
1319
- # otherwise we get a deadlock
1320
- if self._closing:
1321
- should_update = False
1322
-
1323
1457
  if should_update:
1324
1458
  prev_value = context.state[key]
1325
1459
  context.state[key] = value
@@ -1330,6 +1464,7 @@ class _RenderContext:
1330
1464
  context.state_metadata[key] = new_metadata
1331
1465
  # TODO: enable
1332
1466
  context.needs_render = True
1467
+ _mark_needs_render_ancestors(context)
1333
1468
  if self._rerender_needed is False:
1334
1469
  if DEBUG:
1335
1470
  trigger_stack = traceback.format_stack()
@@ -1351,6 +1486,11 @@ class _RenderContext:
1351
1486
  return set_
1352
1487
 
1353
1488
  def force_update(self):
1489
+ if self._closing:
1490
+ # e.g. an event handler on a closed tree routing an exception to us
1491
+ return
1492
+ # a forced update re-walks the whole tree, no subtree skipping
1493
+ self._walk_all = True
1354
1494
  if not self._is_rendering:
1355
1495
  self.render(self.element, self.container)
1356
1496
 
@@ -1374,6 +1514,7 @@ class _RenderContext:
1374
1514
  self.context.effect_index += 1
1375
1515
 
1376
1516
  def update(self, element: Element):
1517
+ self._walk_all = True
1377
1518
  if self._is_rendering:
1378
1519
  self.element = element
1379
1520
  self._rerender_needed_reasons.append(RerenderReason(reason="root element changed"))
@@ -1430,6 +1571,7 @@ class _RenderContext:
1430
1571
  self._shared_elements_next = set()
1431
1572
  self._render(self.element, "/", parent_key=ROOT_KEY)
1432
1573
  self.first_render = False
1574
+ self._walk_all = False
1433
1575
  except BaseException:
1434
1576
  self._is_rendering = False
1435
1577
  raise
@@ -1464,6 +1606,7 @@ class _RenderContext:
1464
1606
  self.context.exceptions_self = []
1465
1607
 
1466
1608
  self._render(self.element, "/", parent_key=ROOT_KEY)
1609
+ self._walk_all = False
1467
1610
  logger.info("Render done: %r %r", self._rerender_needed, self._rerender_needed_reasons[-1])
1468
1611
  assert self.context is self.context_root
1469
1612
  render_counts += 1
@@ -1488,6 +1631,10 @@ class _RenderContext:
1488
1631
  self.context.root_element = self.context.root_element_next
1489
1632
  self.context.root_element_next = None
1490
1633
 
1634
+ # remove stale elements of the root context itself
1635
+ # (child contexts are swept during their reconciliation)
1636
+ self._remove_stale_root_elements(ROOT_KEY)
1637
+
1491
1638
  if self._shared_elements_next:
1492
1639
  raise RuntimeError(f"Element not reconsolidated: {self._shared_elements_next}")
1493
1640
  logger.debug("Reconsolidate phase resulted in elements:")
@@ -2194,6 +2341,600 @@ class _RenderContext:
2194
2341
  else:
2195
2342
  return value
2196
2343
 
2344
+ def _remove_stale_root_elements(self, parent_key):
2345
+ # The original _reconsolidate already removes stale elements itself,
2346
+ # so for the base (original) render context this is a no-op.
2347
+ pass
2348
+
2349
+
2350
+ class _RenderContextFast(_RenderContext):
2351
+ # Opt-in fast reimplementation of the tree-walking, selectable via the
2352
+ # REACTON_FAST=1 environment variable.
2353
+ #
2354
+ # ------------------------------------------------------------------
2355
+ # The tree walking: one render() call alternates two kinds of phases.
2356
+ #
2357
+ # * render phase (_render): execute component functions depth first
2358
+ # and build the next element tree (elements_next/children_next).
2359
+ # * reconciliation phase (_reconsolidate): walk the new element tree,
2360
+ # create/update/close widgets, run effects, and move the *_next
2361
+ # bookkeeping to current.
2362
+ #
2363
+ # A component subtree in which no state changed, no exception is
2364
+ # pending, and whose element is identical to the previous render is
2365
+ # skipped in both phases: setters mark the path from their context up
2366
+ # to the root (ComponentContext.needs_render_descendant), so the walk
2367
+ # only descends where work can exist. _render marks skipped contexts
2368
+ # (clean_subtree) so _reconsolidate can reuse the previous widget
2369
+ # without walking either.
2370
+ # ------------------------------------------------------------------
2371
+
2372
+ def _set_rerender_needed(self, reason: str):
2373
+ self._rerender_needed_reasons.append(RerenderReason(reason=reason))
2374
+ self._rerender_needed = True
2375
+
2376
+ def _render(self, element: Element, default_key: str, parent_key: str):
2377
+ if not isinstance(element, Element):
2378
+ raise TypeError(f"Expected element, not {element}")
2379
+ # for tracking stale elements when using get_widget
2380
+ self._old_element_ids.add(id(element))
2381
+ context = self.context
2382
+ assert context is not None
2383
+
2384
+ if default_key == "/":
2385
+ # the root element of a component determines which keys are in use,
2386
+ # everything else is stale and gets removed during reconciliation
2387
+ context.used_keys.clear()
2388
+
2389
+ el = element
2390
+ key = el._key
2391
+ if key is None:
2392
+ key = default_key
2393
+ el._key_frozen = True
2394
+
2395
+ if key in context.used_keys:
2396
+ if DEBUG:
2397
+ self.tracebacks.append(el.traceback)
2398
+ raise KeyError(f"Duplicate key {key!r}")
2399
+ context.used_keys.add(key)
2400
+
2401
+ if el.is_shared:
2402
+ # a shared element renders a single widget, process it once per phase
2403
+ if el in self._shared_elements_next:
2404
+ return
2405
+ self._shared_elements_next.add(el)
2406
+
2407
+ el_prev = context.elements_next.get(key)
2408
+ if el_prev is None:
2409
+ el_prev = context.elements.get(key)
2410
+ context.elements_next[key] = el
2411
+ el._render_count += 1 # for testing only
2412
+
2413
+ if isinstance(el.component, ComponentWidget):
2414
+ assert not el.args, "no positional args supported for widgets"
2415
+ if key in context.children_next:
2416
+ # a function component lived at this key before
2417
+ del context.children_next[key]
2418
+ # the element arguments are part of this component's element tree
2419
+ if el.kwargs:
2420
+ self._visit_children(el, key, parent_key, self._render)
2421
+ return
2422
+
2423
+ assert isinstance(el.component, ComponentFunction)
2424
+ if el.is_shared and (el.args or el.kwargs):
2425
+ # arguments of a shared element belong to the context it is rendered in;
2426
+ # for non-shared component elements the component function decides
2427
+ # what ends up in the tree
2428
+ self._visit_children(el, key, parent_key, self._render)
2429
+
2430
+ context_previous = context.children_next.get(key)
2431
+ if context_previous is None:
2432
+ context_previous = context.children.get(key)
2433
+
2434
+ if (
2435
+ not self._walk_all
2436
+ and el is el_prev
2437
+ and not el.is_shared
2438
+ and context_previous is not None
2439
+ and context.children.get(key) is context_previous
2440
+ and not context_previous.needs_render
2441
+ and not context_previous.needs_render_descendant
2442
+ and not context_previous.exceptions_self
2443
+ and not context_previous.exceptions_children
2444
+ and context_previous.root_element is not None
2445
+ and context_previous.root_element_next is None
2446
+ ):
2447
+ # fast path: same element, no state changes or pending exceptions
2448
+ # anywhere in this subtree, and fully reconciled: the previous
2449
+ # result stands, skip the subtree in both phases
2450
+ context_previous.clean_subtree = True
2451
+ context.children_next[key] = context_previous
2452
+ return
2453
+
2454
+ parent_context = context
2455
+ del context
2456
+ if context_previous is None:
2457
+ context = ComponentContext(parent=parent_context, context_managers=[cm(el) for cm in _component_context_manager_classes])
2458
+ el_prev = None # do not compare against an element of a different component
2459
+ elif context_previous.root_element is None and context_previous.root_element_next is None:
2460
+ # pre-created, carrying initial state (state_set), but never rendered
2461
+ context = context_previous
2462
+ context.context_managers = [cm(el) for cm in _component_context_manager_classes]
2463
+ else:
2464
+ assert context_previous.invoke_element is not None
2465
+ if not same_component(context_previous.invoke_element.component, el.component):
2466
+ # a different component took this key, start fresh
2467
+ # (the old context is cleaned up during reconciliation)
2468
+ context = ComponentContext(parent=parent_context, context_managers=[cm(el) for cm in _component_context_manager_classes])
2469
+ el_prev = None
2470
+ else:
2471
+ context = context_previous
2472
+ context.parent = parent_context
2473
+ context.clean_subtree = False
2474
+ context.invoke_element = el
2475
+
2476
+ needs_render = context.needs_render
2477
+ if not needs_render and el_prev is not None and context_previous is context:
2478
+ assert not isinstance(el_prev.component, ComponentWidget)
2479
+ needs_render = el._arguments_changed(el_prev)
2480
+ if not needs_render and context.exceptions_children:
2481
+ # we have exceptions, so we need to render
2482
+ needs_render = True
2483
+ if not needs_render:
2484
+ assert el_prev is not None
2485
+
2486
+ self.container_adders = []
2487
+ self.context = context
2488
+ render_count_check = self.render_count
2489
+ try:
2490
+ context.exceptions_self = []
2491
+ root_element: Optional[Element] = None
2492
+ if needs_render:
2493
+ context.state_index = 0
2494
+ context.effect_index = 0
2495
+ context.memo_index = 0
2496
+ context.user_contexts = {}
2497
+ context.exception_handler = False
2498
+ # we reset it before calling the component function,
2499
+ # which might set it to true again
2500
+ context.needs_render = False
2501
+ try:
2502
+ with contextlib.ExitStack() as stack:
2503
+ for cm in context.context_managers:
2504
+ stack.enter_context(cm)
2505
+ if _default_container is not None:
2506
+ with _default_container() as container:
2507
+ el.component.render_count += 1
2508
+ root_element = el.component.f(*el.args, **el.kwargs)
2509
+ if root_element is None:
2510
+ if len(container.kwargs["children"]) == 1:
2511
+ root_element = container.kwargs["children"][0]
2512
+ else:
2513
+ root_element = container
2514
+ else:
2515
+ el.component.render_count += 1
2516
+ root_element = el.component.f(*el.args, **el.kwargs)
2517
+ assert root_element is not None
2518
+ except BaseException as e:
2519
+ if DEBUG:
2520
+ # we might be interested in the traceback inside the call...
2521
+ if len(self.tracebacks) == 0:
2522
+ assert e.__traceback__ is not None
2523
+ traceback = cast(TracebackType, e.__traceback__)
2524
+ if traceback.tb_next: # is there an error inside the call
2525
+ self.tracebacks.append(traceback.tb_next)
2526
+ self.tracebacks.append(el.traceback)
2527
+ logger.exception("Component %r raised exception %r", el.component, e)
2528
+ context.exceptions_self.append(e)
2529
+ self._set_rerender_needed("Exception ocurred during render")
2530
+ context.needs_render = True
2531
+ if root_element is None and not context.exceptions_self:
2532
+ raise ValueError(f"Component {el.component} returned None")
2533
+ else:
2534
+ root_element = context.root_element_next or context.root_element
2535
+
2536
+ if self.render_count != render_count_check:
2537
+ raise RuntimeError("Recursive render detected, possible a bug in react")
2538
+
2539
+ # the subtree walk below will mark this again when state changes
2540
+ context.needs_render_descendant = False
2541
+ if root_element is not None:
2542
+ self._render(root_element, "/", parent_key=join_key(parent_key, key)) # depth first
2543
+ context.root_element_next = root_element
2544
+ elif el.is_shared:
2545
+ self._shared_elements_next.remove(el)
2546
+
2547
+ try:
2548
+ # when an exception interrupted the component function, fewer hook calls are allowed
2549
+ if not ((context.effect_index == len(context.effects)) or (context.exceptions_self and context.effect_index <= len(context.effects))):
2550
+ raise RuntimeError(
2551
+ f"Previously render had {len(context.effects)} effects, this run {context.effect_index} "
2552
+ f"(in element/component: {el}/{el.component}). "
2553
+ "Are you using conditional hooks?"
2554
+ )
2555
+ if not ((context.memo_index == len(context.memo)) or (context.exceptions_self and context.memo_index <= len(context.memo))):
2556
+ raise RuntimeError(
2557
+ f"Previously render had {len(context.memo)} calls to use_memo, this run {context.memo_index} "
2558
+ f"(in element/component: {el}/{el.component}). "
2559
+ "Are you using conditional hooks?"
2560
+ )
2561
+ except RuntimeError as e:
2562
+ logger.exception("Exception in hook count check")
2563
+ context.exceptions_self.append(e)
2564
+ self._set_rerender_needed("Exception ocurred during render (hook count check)")
2565
+ context.needs_render = True
2566
+
2567
+ # only expose to the parent when we get this far
2568
+ parent_context.children_next[key] = context
2569
+ # drop children/elements from a previous render pass that are no longer used
2570
+ used_keys = context.used_keys
2571
+ for unused in [k for k in context.children_next if k not in used_keys]:
2572
+ del context.children_next[unused]
2573
+ for unused in [k for k in context.elements_next if k not in used_keys]:
2574
+ del context.elements_next[unused]
2575
+ context.user_contexts_prev = context.user_contexts
2576
+ finally:
2577
+ assert context.parent is parent_context
2578
+ self.context = parent_context
2579
+ if context.exceptions_self or context.exceptions_children and not context.exception_handler:
2580
+ # this component does not handle the exceptions, bubble up
2581
+ parent_context.exceptions_children.extend(context.exceptions_self)
2582
+ parent_context.exceptions_children.extend(context.exceptions_children)
2583
+ if context.exceptions_self or context.exceptions_children:
2584
+ # make sure the next render pass walks down to this context
2585
+ # (e.g. so a parent with use_exception gets a chance to handle it)
2586
+ _mark_needs_render_ancestors(context)
2587
+ if parent_context.exceptions_self or parent_context.exceptions_children:
2588
+ if not self._rerender_needed:
2589
+ # this happens when an exception was added from an event handler:
2590
+ # no exception was raised during this render phase, but we still
2591
+ # need to rerender until a component catches the exception
2592
+ self._set_rerender_needed("Exception ocurred during render")
2593
+ parent_context.needs_render = True
2594
+
2595
+ def _reconsolidate(self, el: Element, default_key: str, parent_key: str):
2596
+ key = el._key
2597
+ if key is None:
2598
+ key = default_key
2599
+ assert key is not None
2600
+ context = self.context
2601
+ assert context is not None
2602
+
2603
+ if el.is_shared and el in self._shared_elements and el is not self.element:
2604
+ # shared elements reconcile once, all other uses share the widget
2605
+ return self._shared_widgets[el]
2606
+
2607
+ el_prev = context.elements.get(key)
2608
+ try:
2609
+ if isinstance(el.component, ComponentFunction):
2610
+ child_context_next = context.children_next.get(key)
2611
+ if child_context_next is not None and child_context_next.clean_subtree:
2612
+ # subtree was skipped during the render phase: the previous
2613
+ # reconciliation result stands
2614
+ child_context_next.clean_subtree = False
2615
+ context.children[key] = context.children_next.pop(key)
2616
+ return context.widgets[key]
2617
+
2618
+ if el_prev and isinstance(el_prev.component, ComponentWidget):
2619
+ # a widget element was replaced by a component element at this key
2620
+ self._remove_element(el_prev, default_key=key, parent_key=parent_key)
2621
+ new_parent_key = join_key(parent_key, key)
2622
+ try:
2623
+ if el.is_shared and (el.args or el.kwargs):
2624
+ self._visit_children(el, key, parent_key, self._reconsolidate)
2625
+ assert self.context is context
2626
+
2627
+ child_context_prev = context.children.get(key)
2628
+ child_context = context.children_next[key]
2629
+ if child_context_prev is not None and child_context_prev is not child_context:
2630
+ # the component type changed, remove the old subtree
2631
+ assert el_prev is not None, "prev child is not None, but element is"
2632
+ self._remove_element(el_prev, default_key=key, parent_key=parent_key)
2633
+
2634
+ self.context = child_context
2635
+ assert child_context.root_element_next
2636
+ widget = self._reconsolidate(child_context.root_element_next, "/", new_parent_key)
2637
+ child_context.root_element = child_context.root_element_next
2638
+ child_context.root_element_next = None
2639
+ # merge the meta of the component element onto the root widget,
2640
+ # e.g. SomeComponent().meta(name="a") should appear on the widget
2641
+ if el._meta or getattr(widget, "_react_meta", {}):
2642
+ widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta}
2643
+
2644
+ if el.is_shared:
2645
+ self._shared_widgets[el] = widget
2646
+ else:
2647
+ context.widgets[key] = widget
2648
+
2649
+ # remove elements that are no longer part of this component's tree
2650
+ # NOTE: sorted for reproducibility
2651
+ stale_keys = sorted(set(child_context.elements) - child_context.used_keys)
2652
+ if stale_keys:
2653
+ logger.info("elements to be removed: %r", stale_keys)
2654
+ for stale_key in stale_keys:
2655
+ if stale_key in child_context.elements:
2656
+ self._remove_element(child_context.elements[stale_key], stale_key, new_parent_key)
2657
+
2658
+ self._process_effects(child_context, context)
2659
+
2660
+ if child_context.children_next:
2661
+ # if we had two render phases, we can have old contexts left over
2662
+ child_context.children_next.clear()
2663
+ if child_context.elements_next:
2664
+ # we can still have elements that are not used as a 'widget' in this
2665
+ # context, but are passed down as an element (when shared)
2666
+ unreferenced = []
2667
+ for child_key, child_el in list(child_context.elements_next.items()):
2668
+ if child_el not in self._shared_elements:
2669
+ unreferenced.append(child_el)
2670
+ else:
2671
+ child_context.elements[child_key] = child_context.elements_next.pop(child_key)
2672
+ if unreferenced:
2673
+ raise RuntimeError(f"Unused elements and unreferenced elements {unreferenced}")
2674
+ if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler:
2675
+ # child does not handle exceptions, so bubble up
2676
+ context.exceptions_children.extend(child_context.exceptions_self)
2677
+ context.exceptions_children.extend(child_context.exceptions_children)
2678
+ finally:
2679
+ # restore context
2680
+ self.context = context
2681
+ context.children[key] = context.children_next.pop(key)
2682
+ else:
2683
+ assert isinstance(el.component, ComponentWidget)
2684
+ if el.args:
2685
+ raise TypeError("Widget element only take keyword arguments")
2686
+
2687
+ widget_previous = None
2688
+ if el_prev is not None:
2689
+ if el_prev.is_shared:
2690
+ widget_previous = self._shared_widgets[el_prev]
2691
+ else:
2692
+ widget_previous = context.widgets[key]
2693
+
2694
+ orphan_ids: Set[str] = set()
2695
+ if widget_previous is None:
2696
+ # initial create
2697
+ kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2698
+ if el.is_shared and el in self._shared_widgets:
2699
+ raise RuntimeError(f"Element ({el}) was already in self._shared_widgets")
2700
+ widget = None
2701
+ if not context.exceptions_children:
2702
+ try:
2703
+ widget, orphan_ids = el._create_widget(kwargs)
2704
+ except BaseException as e:
2705
+ context.exceptions_self.append(e)
2706
+ self._set_rerender_needed("Exception ocurred during reconciliation (creating widget)")
2707
+ _mark_needs_render_ancestors(context)
2708
+ self._store_widget(context, el, key, widget)
2709
+ elif el_prev is not None and el_prev.component == el.component:
2710
+ # update the existing widget in place
2711
+ kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2712
+ if not context.exceptions_children:
2713
+ if el is not el_prev or not _values_identical(kwargs, el.kwargs):
2714
+ try:
2715
+ el._update_widget(widget_previous, el_prev, kwargs)
2716
+ except BaseException as e:
2717
+ context.exceptions_self.append(e)
2718
+ self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)")
2719
+ _mark_needs_render_ancestors(context)
2720
+ # else: identical element and all children reconciled to the
2721
+ # same widgets, nothing can have changed
2722
+ self._store_widget(context, el, key, widget_previous)
2723
+ else:
2724
+ assert el_prev is not None, "widget_previous is not None, but el_prev is"
2725
+ # a different widget type at the same key: replace
2726
+ self._remove_element(el_prev, key, parent_key=parent_key)
2727
+ kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2728
+ widget = None
2729
+ if not context.exceptions_children:
2730
+ try:
2731
+ widget, orphan_ids = el._create_widget(kwargs)
2732
+ except BaseException as e:
2733
+ context.exceptions_self.append(e)
2734
+ self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)")
2735
+ _mark_needs_render_ancestors(context)
2736
+ self._store_widget(context, el, key, widget)
2737
+ if orphan_ids:
2738
+ # widgets created as a side effect (like Layout and Style); we track
2739
+ # them by model id (not all widgets are hashable) for cleanup
2740
+ orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids])
2741
+ for orphan_widget in orphan_widgets:
2742
+ # these are shared between widgets
2743
+ if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2744
+ orphan_ids -= {orphan_widget.model_id}
2745
+ widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2746
+ if widget.model_id not in self._orphans:
2747
+ self._orphans[widget.model_id] = set()
2748
+ self._orphans[widget.model_id].update(orphan_ids)
2749
+
2750
+ if el.is_shared:
2751
+ widget = self._shared_widgets[el]
2752
+ else:
2753
+ widget = context.widgets[key]
2754
+ # used in get_widget
2755
+ if el_prev in context.element_to_widget:
2756
+ del context.element_to_widget[el_prev]
2757
+ context.element_to_widget[el] = widget
2758
+ return widget
2759
+ except Exception as e:
2760
+ if DEBUG:
2761
+ # we don't care about the traceback of the root element
2762
+ if self.element is not el:
2763
+ # we might be interested in the traceback inside the call...
2764
+ if len(self.tracebacks) == 0:
2765
+ assert e.__traceback__ is not None
2766
+ traceback = cast(TracebackType, e.__traceback__)
2767
+ if traceback.tb_next: # is there an error inside the call
2768
+ self.tracebacks.append(traceback.tb_next)
2769
+ self.tracebacks.append(el.traceback)
2770
+ raise
2771
+ finally:
2772
+ # this marks the work as 'done'
2773
+ context.elements[key] = context.elements_next.pop(key)
2774
+ if el_prev in self._shared_elements:
2775
+ self._shared_elements.remove(el_prev)
2776
+ if el.is_shared:
2777
+ assert el not in self._shared_elements
2778
+ self._shared_elements.add(el)
2779
+ assert el in self._shared_elements_next
2780
+ self._shared_elements_next.remove(el)
2781
+
2782
+ def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"):
2783
+ # NOTE: effect/cleanup exceptions are recorded on the context of the
2784
+ # component's *parent* (`context`), unlike render exceptions: this is
2785
+ # historical behavior (see notes.md)
2786
+ for effect_index, effect in enumerate(child_context.effects):
2787
+ if effect.next:
2788
+ # if we have a next, the effect itself already executed
2789
+ # TODO: custom equals
2790
+ if effect.next.dependencies is not None and utils.equals(effect.dependencies, effect.next.dependencies):
2791
+ # dependencies are the same, just remove the reference
2792
+ effect.next = None
2793
+ continue
2794
+ # dependencies changed: cleanup and execute next
2795
+ if not effect._cleaned_up:
2796
+ try:
2797
+ effect.cleanup()
2798
+ except BaseException as e:
2799
+ context.exceptions_self.append(e)
2800
+ self._set_rerender_needed("Exception ocurred during effect")
2801
+ _mark_needs_render_ancestors(context)
2802
+ context.needs_render = True
2803
+ effect = child_context.effects[effect_index] = effect.next
2804
+ if child_context.exceptions_self or child_context.exceptions_children:
2805
+ # we had an exception, so we skip the effect (not the cleanup)
2806
+ continue
2807
+ try:
2808
+ effect()
2809
+ except BaseException as e:
2810
+ context.exceptions_self.append(e)
2811
+ self._set_rerender_needed("Exception ocurred during effect")
2812
+ _mark_needs_render_ancestors(context)
2813
+ context.needs_render = True
2814
+
2815
+ def _store_widget(self, context: "ComponentContext", el: Element, key: str, widget: Optional[widgets.Widget]):
2816
+ if el.is_shared:
2817
+ self._shared_widgets[el] = widget
2818
+ else:
2819
+ context.widgets[key] = widget
2820
+
2821
+ def _remove_element(self, el: Element, default_key: str, parent_key):
2822
+ key = el._key
2823
+ if key is None:
2824
+ key = default_key
2825
+ assert key is not None
2826
+ context = self.context
2827
+ assert context is not None
2828
+
2829
+ if el.is_shared:
2830
+ if el not in self._shared_elements:
2831
+ # another use of this element keeps it alive, or it was already removed
2832
+ return
2833
+ self._shared_elements.remove(el)
2834
+
2835
+ if key not in context.elements:
2836
+ # already removed, e.g. as part of the removal of a parent element
2837
+ return
2838
+
2839
+ if isinstance(el.component, ComponentFunction):
2840
+ if el.is_shared:
2841
+ self._visit_children(el, key, parent_key, self._remove_element)
2842
+ child_context = context.children[key]
2843
+ try:
2844
+ self.context = child_context
2845
+ # the element is going away, pending exceptions only matter if cleanup fails
2846
+ child_context.exceptions_self = []
2847
+ child_context.exceptions_children = []
2848
+ for effect in child_context.effects:
2849
+ try:
2850
+ if not effect._cleaned_up:
2851
+ effect.cleanup()
2852
+ except BaseException as e:
2853
+ child_context.exceptions_self.append(e)
2854
+ self._set_rerender_needed("Exception ocurred during effect")
2855
+ _mark_needs_render_ancestors(child_context)
2856
+ assert child_context.root_element is not None
2857
+ self._remove_element(child_context.root_element, "/", parent_key=join_key(parent_key, key))
2858
+ finally:
2859
+ try:
2860
+ assert not child_context.elements, f"left over elements {child_context.elements}"
2861
+ assert not child_context.element_to_widget, f"left over element_to_widget {child_context.element_to_widget}"
2862
+ assert not child_context.widgets, f"left over widgets {child_context.widgets}"
2863
+ assert not child_context.children, f"left over children {child_context.children}"
2864
+ assert not child_context.owns, f"left over owns {child_context.owns}"
2865
+ except Exception as e:
2866
+ child_context.exceptions_self.append(e)
2867
+ # restore context
2868
+ self.context = context
2869
+ if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler:
2870
+ # child does not handle exceptions, so bubble up
2871
+ context.exceptions_children.extend(child_context.exceptions_self)
2872
+ context.exceptions_children.extend(child_context.exceptions_children)
2873
+ del context.children[key]
2874
+ else:
2875
+ self._visit_children(el, key, parent_key, self._remove_element)
2876
+ widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2877
+ if widget is not None:
2878
+ assert widget.comm is not None
2879
+ assert widget.model_id in _get_widgets_dict()
2880
+ for orphan in self._orphans.get(widget.model_id, set()):
2881
+ orphan_widget = _get_widgets_dict().get(orphan)
2882
+ if orphan_widget:
2883
+ close_widget(orphan_widget)
2884
+ self._orphans.pop(widget.model_id, None)
2885
+ el._cleanup_callbacks(widget)
2886
+ el._close_widget(widget)
2887
+ if el.is_shared:
2888
+ del self._shared_widgets[el]
2889
+ else:
2890
+ del context.widgets[key]
2891
+ # elements can be removed multiple times, since they can be added multiple times
2892
+ # (even non-shared elements can)
2893
+ if el in context.element_to_widget:
2894
+ del context.element_to_widget[el]
2895
+ del context.elements[key]
2896
+
2897
+ def _visit_children(self, el: Element, default_key: str, parent_key: str, f: Callable):
2898
+ key = el._key
2899
+ if key is None:
2900
+ key = default_key
2901
+ assert key is not None
2902
+ self._visit_children_values(el.kwargs, key, parent_key, f)
2903
+ self._visit_children_values(el.args, key, parent_key, f)
2904
+
2905
+ def _visit_children_values(self, value: Any, key: str, parent_key: str, f: Callable):
2906
+ if isinstance(value, Element):
2907
+ return f(value, key, parent_key)
2908
+ elif isinstance(value, (list, tuple)):
2909
+ values = []
2910
+ for index, v in enumerate(value):
2911
+ new_value = self._visit_children_values(v, f"{key}{index}/", parent_key, f)
2912
+ if isinstance(new_value, FragmentWidget):
2913
+ values.extend(new_value.children)
2914
+ else:
2915
+ values.append(new_value)
2916
+ if isinstance(value, tuple):
2917
+ return tuple(values)
2918
+ return values
2919
+ elif isinstance(value, dict):
2920
+ return {k: self._visit_children_values(v, f"{key}{k}/", parent_key, f) for k, v in value.items()}
2921
+ else:
2922
+ return value
2923
+
2924
+ def _remove_stale_root_elements(self, parent_key):
2925
+ # remove stale elements of the root context itself
2926
+ # (child contexts are swept during their reconciliation)
2927
+ stale_keys = sorted(set(self.context_root.elements) - self.context_root.used_keys)
2928
+ for stale_key in stale_keys:
2929
+ if stale_key in self.context_root.elements:
2930
+ self._remove_element(self.context_root.elements[stale_key], stale_key, parent_key)
2931
+
2932
+
2933
+ def _render_context_class():
2934
+ import os
2935
+
2936
+ return _RenderContextFast if os.environ.get("REACTON_FAST") == "1" else _RenderContext
2937
+
2197
2938
 
2198
2939
  @overload
2199
2940
  def render(
@@ -2209,14 +2950,14 @@ def render(
2209
2950
 
2210
2951
  def render(element: Element[T], container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None):
2211
2952
  container = container or widgets.VBox()
2212
- _rc = _RenderContext(element, container, children_trait=children_trait, handle_error=handle_error, initial_state=initial_state)
2953
+ _rc = _render_context_class()(element, container, children_trait=children_trait, handle_error=handle_error, initial_state=initial_state)
2213
2954
  _rc.render(element, _rc.container)
2214
2955
  local.last_rc = weakref.ref(_rc)
2215
2956
  return container, _rc
2216
2957
 
2217
2958
 
2218
2959
  def render_fixed(element: Element[T], handle_error: bool = True) -> Tuple[T, _RenderContext]:
2219
- _rc = _RenderContext(element, handle_error=handle_error)
2960
+ _rc = _render_context_class()(element, handle_error=handle_error)
2220
2961
  widget = _rc.render(element)
2221
2962
  local.last_rc = weakref.ref(_rc)
2222
2963
  return widget, _rc
@@ -3162,6 +3162,30 @@ def test_memory_leak(component):
3162
3162
  assert weak_rc() is None
3163
3163
 
3164
3164
 
3165
+ @reacton.component
3166
+ def UseStateComponent():
3167
+ value, set_value = reacton.use_state(0)
3168
+ return w.Button(description=str(value), on_click=lambda: set_value(value + 1))
3169
+
3170
+
3171
+ @pytest.mark.parametrize("component", [w.Button, ButtonComponentFunction, UseMemoComponent, UseStateComponent])
3172
+ def test_no_reference_cycles_after_close(component):
3173
+ # Stricter than test_memory_leak: the closed render tree must be freed by plain
3174
+ # reference counting, without a garbage collection. If this fails, a closure (a
3175
+ # use_state setter or an event handler) holds the render context strongly and the
3176
+ # tree became one big reference cycle that only a gen-2 gc can free - on a server
3177
+ # that shows up as a memory sawtooth that scales with per-kernel state.
3178
+ box, rc = react.render(component(), handle_error=False)
3179
+ rc.close()
3180
+ weak_rc = weakref.ref(rc)
3181
+ gc.disable()
3182
+ try:
3183
+ del rc
3184
+ assert weak_rc() is None
3185
+ finally:
3186
+ gc.enable()
3187
+
3188
+
3165
3189
  def test_fragment():
3166
3190
  @reacton.component
3167
3191
  def Children():
@@ -3,7 +3,7 @@ from typing import Any, Callable, cast
3
3
  import ipyvue
4
4
 
5
5
  import reacton as react
6
- from reacton.core import get_render_context
6
+ from reacton.core import ComponentWidget, get_render_context
7
7
 
8
8
 
9
9
  def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[Any], Any]):
@@ -11,6 +11,20 @@ def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[A
11
11
  callback_ref = react.use_ref(callback)
12
12
  callback_ref.current = callback
13
13
 
14
+ # Put the event name in the widget constructor arguments: the synced _events
15
+ # trait then goes along with the comm open message. The later on_event call
16
+ # (in the effect below) only updates _events when the event set differs, so
17
+ # this saves one update message per widget per event. When the element is
18
+ # reused from a previous render (memoized) and the widget already exists,
19
+ # on_event falls back to syncing _events itself.
20
+ if isinstance(el.component, ComponentWidget) and issubclass(el.component.widget, ipyvue.VueWidget):
21
+ events = el.kwargs.get("_events")
22
+ if events is None:
23
+ el.kwargs["_events"] = [event_and_modifiers]
24
+ elif event_and_modifiers not in events:
25
+ # do not mutate the list, it could be shared with a previous element
26
+ el.kwargs["_events"] = [*events, event_and_modifiers]
27
+
14
28
  def add_event_handler():
15
29
  vue_widget = cast(ipyvue.VueWidget, react.core.get_widget(el))
16
30
  # we are basically copying the logic from reacton.core._event_handler_exception_wrapper
@@ -32,6 +46,11 @@ def use_event(el: react.core.Element, event_and_modifiers, callback: Callable[[A
32
46
  vue_widget.on_event(event_and_modifiers, handler)
33
47
 
34
48
  def cleanup():
49
+ if rc._closing:
50
+ # the whole tree is going away: removing the handler would sync
51
+ # the _events trait to the frontend (one message per widget)
52
+ # right before the comm is closed anyway
53
+ return
35
54
  vue_widget.on_event(event_and_modifiers, handler, remove=True)
36
55
 
37
56
  return cleanup
@@ -0,0 +1,90 @@
1
+ import unittest.mock
2
+
3
+ import ipyvuetify
4
+ import ipyvue
5
+
6
+ import reacton as react
7
+
8
+ from . import ipyvuetify as v
9
+ from .ipyvue import use_event
10
+
11
+
12
+ def test_use_event_no_sync_messages():
13
+ """The _events trait should be set at construction and never re-assigned.
14
+
15
+ Re-assigning it (by on_event during the effect, or on_event(remove=True)
16
+ during close) sends one widget update message per widget per event.
17
+ """
18
+ on_click = unittest.mock.Mock()
19
+
20
+ @react.component
21
+ def Test():
22
+ btn = v.Btn(children=["click me"])
23
+ use_event(btn, "click", on_click)
24
+ return btn
25
+
26
+ box, rc = react.render(Test(), handle_error=False)
27
+ btn = rc.find(ipyvuetify.Btn).widget
28
+
29
+ events_changes = unittest.mock.Mock()
30
+ btn.observe(events_changes, "_events")
31
+
32
+ # the event name went along with the constructor arguments
33
+ assert btn._events == ["click"]
34
+ # and the handler works
35
+ btn.fire_event("click", {})
36
+ on_click.assert_called_once()
37
+
38
+ rc.force_update()
39
+ events_changes.assert_not_called()
40
+
41
+ rc.close()
42
+ events_changes.assert_not_called()
43
+
44
+
45
+ def test_use_event_removed_on_rerender():
46
+ """When the widget persists but the event changes, _events must sync."""
47
+ on_event = unittest.mock.Mock()
48
+ set_event_name = None
49
+
50
+ @react.component
51
+ def Test():
52
+ nonlocal set_event_name
53
+ event_name, set_event_name = react.use_state("click")
54
+ btn = v.Btn(children=["click me"])
55
+ use_event(btn, event_name, on_event)
56
+ return btn
57
+
58
+ box, rc = react.render(Test(), handle_error=False)
59
+ btn = rc.find(ipyvuetify.Btn).widget
60
+ assert btn._events == ["click"]
61
+ assert set_event_name is not None
62
+ set_event_name("dblclick")
63
+ assert btn._events == ["dblclick"]
64
+ btn.fire_event("dblclick", {})
65
+ on_event.assert_called_once()
66
+ rc.close()
67
+
68
+
69
+ def test_use_event_component_element():
70
+ """use_event on a component element (not a widget element) keeps working."""
71
+ on_click = unittest.mock.Mock()
72
+
73
+ @react.component
74
+ def Button():
75
+ return v.Btn(children=["click me"])
76
+
77
+ @react.component
78
+ def Test():
79
+ btn = Button()
80
+ use_event(btn, "click", on_click)
81
+ return btn
82
+
83
+ box, rc = react.render(Test(), handle_error=False)
84
+ btn = rc.find(ipyvuetify.Btn).widget
85
+ assert isinstance(btn, ipyvue.VueWidget)
86
+ # falls back to syncing _events from the effect
87
+ assert btn._events == ["click"]
88
+ btn.fire_event("click", {})
89
+ on_click.assert_called_once()
90
+ rc.close()
@@ -1 +0,0 @@
1
- __version__ = "1.9.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes