reacton 1.9.0__tar.gz → 1.10.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 (33) hide show
  1. {reacton-1.9.0 → reacton-1.10.0}/PKG-INFO +2 -1
  2. {reacton-1.9.0 → reacton-1.10.0}/pyproject.toml +1 -0
  3. reacton-1.10.0/reacton/_version.py +1 -0
  4. {reacton-1.9.0 → reacton-1.10.0}/reacton/core.py +695 -22
  5. {reacton-1.9.0 → reacton-1.10.0}/reacton/ipyvue.py +20 -1
  6. reacton-1.10.0/reacton/ipyvue_test.py +90 -0
  7. reacton-1.9.0/reacton/_version.py +0 -1
  8. {reacton-1.9.0 → reacton-1.10.0}/.gitignore +0 -0
  9. {reacton-1.9.0 → reacton-1.10.0}/LICENSE +0 -0
  10. {reacton-1.9.0 → reacton-1.10.0}/react_ipywidgets/__init__.py +0 -0
  11. {reacton-1.9.0 → reacton-1.10.0}/react_ipywidgets/core.py +0 -0
  12. {reacton-1.9.0 → reacton-1.10.0}/reacton/__init__.py +0 -0
  13. {reacton-1.9.0 → reacton-1.10.0}/reacton/bqplot.py +0 -0
  14. {reacton-1.9.0 → reacton-1.10.0}/reacton/core_test.py +0 -0
  15. {reacton-1.9.0 → reacton-1.10.0}/reacton/deprecated/__init__.py +0 -0
  16. {reacton-1.9.0 → reacton-1.10.0}/reacton/deprecated/ipyvuetify.py +0 -0
  17. {reacton-1.9.0 → reacton-1.10.0}/reacton/find.py +0 -0
  18. {reacton-1.9.0 → reacton-1.10.0}/reacton/find_test.py +0 -0
  19. {reacton-1.9.0 → reacton-1.10.0}/reacton/generate.py +0 -0
  20. {reacton-1.9.0 → reacton-1.10.0}/reacton/generate_test.py +0 -0
  21. {reacton-1.9.0 → reacton-1.10.0}/reacton/ipycanvas.py +0 -0
  22. {reacton-1.9.0 → reacton-1.10.0}/reacton/ipyvuetify.py +0 -0
  23. {reacton-1.9.0 → reacton-1.10.0}/reacton/ipywidgets.py +0 -0
  24. {reacton-1.9.0 → reacton-1.10.0}/reacton/logging.py +0 -0
  25. {reacton-1.9.0 → reacton-1.10.0}/reacton/patch.py +0 -0
  26. {reacton-1.9.0 → reacton-1.10.0}/reacton/patch_display.py +0 -0
  27. {reacton-1.9.0 → reacton-1.10.0}/reacton/py.typed +0 -0
  28. {reacton-1.9.0 → reacton-1.10.0}/reacton/rx.py +0 -0
  29. {reacton-1.9.0 → reacton-1.10.0}/reacton/test.vue +0 -0
  30. {reacton-1.9.0 → reacton-1.10.0}/reacton/test_rx.py +0 -0
  31. {reacton-1.9.0 → reacton-1.10.0}/reacton/utils.py +0 -0
  32. {reacton-1.9.0 → reacton-1.10.0}/reacton/utils_test.py +0 -0
  33. {reacton-1.9.0 → reacton-1.10.0}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reacton
3
- Version: 1.9.0
3
+ Version: 1.10.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
@@ -61,3 +61,4 @@ Requires-Dist: black; extra == 'generate'
61
61
  Requires-Dist: bqplot; extra == 'generate'
62
62
  Requires-Dist: jinja2; extra == 'generate'
63
63
  Requires-Dist: mypy; extra == 'generate'
64
+ Requires-Dist: ruff; (python_version > '3.6') and extra == 'generate'
@@ -57,6 +57,7 @@ dev = [
57
57
  ]
58
58
 
59
59
  generate = [
60
+ "ruff; python_version > '3.6'",
60
61
  "black",
61
62
  "bqplot",
62
63
  "jinja2",
@@ -0,0 +1 @@
1
+ __version__ = "1.10.0"
@@ -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)
@@ -1155,6 +1221,9 @@ class _RenderContext:
1155
1221
  self.handle_error = handle_error
1156
1222
  self.reconsolidating = False
1157
1223
  self._batch_counter = utils.ThreadSafeCounter()
1224
+ # when set, the next render phase walks the whole tree instead of
1225
+ # skipping subtrees in which no state changed (see _render)
1226
+ self._walk_all = True
1158
1227
  if initial_state:
1159
1228
  self.state_set(self.context_root, initial_state)
1160
1229
 
@@ -1330,6 +1399,7 @@ class _RenderContext:
1330
1399
  context.state_metadata[key] = new_metadata
1331
1400
  # TODO: enable
1332
1401
  context.needs_render = True
1402
+ _mark_needs_render_ancestors(context)
1333
1403
  if self._rerender_needed is False:
1334
1404
  if DEBUG:
1335
1405
  trigger_stack = traceback.format_stack()
@@ -1351,6 +1421,8 @@ class _RenderContext:
1351
1421
  return set_
1352
1422
 
1353
1423
  def force_update(self):
1424
+ # a forced update re-walks the whole tree, no subtree skipping
1425
+ self._walk_all = True
1354
1426
  if not self._is_rendering:
1355
1427
  self.render(self.element, self.container)
1356
1428
 
@@ -1374,6 +1446,7 @@ class _RenderContext:
1374
1446
  self.context.effect_index += 1
1375
1447
 
1376
1448
  def update(self, element: Element):
1449
+ self._walk_all = True
1377
1450
  if self._is_rendering:
1378
1451
  self.element = element
1379
1452
  self._rerender_needed_reasons.append(RerenderReason(reason="root element changed"))
@@ -1430,6 +1503,7 @@ class _RenderContext:
1430
1503
  self._shared_elements_next = set()
1431
1504
  self._render(self.element, "/", parent_key=ROOT_KEY)
1432
1505
  self.first_render = False
1506
+ self._walk_all = False
1433
1507
  except BaseException:
1434
1508
  self._is_rendering = False
1435
1509
  raise
@@ -1464,6 +1538,7 @@ class _RenderContext:
1464
1538
  self.context.exceptions_self = []
1465
1539
 
1466
1540
  self._render(self.element, "/", parent_key=ROOT_KEY)
1541
+ self._walk_all = False
1467
1542
  logger.info("Render done: %r %r", self._rerender_needed, self._rerender_needed_reasons[-1])
1468
1543
  assert self.context is self.context_root
1469
1544
  render_counts += 1
@@ -1488,6 +1563,10 @@ class _RenderContext:
1488
1563
  self.context.root_element = self.context.root_element_next
1489
1564
  self.context.root_element_next = None
1490
1565
 
1566
+ # remove stale elements of the root context itself
1567
+ # (child contexts are swept during their reconciliation)
1568
+ self._remove_stale_root_elements(ROOT_KEY)
1569
+
1491
1570
  if self._shared_elements_next:
1492
1571
  raise RuntimeError(f"Element not reconsolidated: {self._shared_elements_next}")
1493
1572
  logger.debug("Reconsolidate phase resulted in elements:")
@@ -2194,6 +2273,600 @@ class _RenderContext:
2194
2273
  else:
2195
2274
  return value
2196
2275
 
2276
+ def _remove_stale_root_elements(self, parent_key):
2277
+ # The original _reconsolidate already removes stale elements itself,
2278
+ # so for the base (original) render context this is a no-op.
2279
+ pass
2280
+
2281
+
2282
+ class _RenderContextFast(_RenderContext):
2283
+ # Opt-in fast reimplementation of the tree-walking, selectable via the
2284
+ # REACTON_FAST=1 environment variable.
2285
+ #
2286
+ # ------------------------------------------------------------------
2287
+ # The tree walking: one render() call alternates two kinds of phases.
2288
+ #
2289
+ # * render phase (_render): execute component functions depth first
2290
+ # and build the next element tree (elements_next/children_next).
2291
+ # * reconciliation phase (_reconsolidate): walk the new element tree,
2292
+ # create/update/close widgets, run effects, and move the *_next
2293
+ # bookkeeping to current.
2294
+ #
2295
+ # A component subtree in which no state changed, no exception is
2296
+ # pending, and whose element is identical to the previous render is
2297
+ # skipped in both phases: setters mark the path from their context up
2298
+ # to the root (ComponentContext.needs_render_descendant), so the walk
2299
+ # only descends where work can exist. _render marks skipped contexts
2300
+ # (clean_subtree) so _reconsolidate can reuse the previous widget
2301
+ # without walking either.
2302
+ # ------------------------------------------------------------------
2303
+
2304
+ def _set_rerender_needed(self, reason: str):
2305
+ self._rerender_needed_reasons.append(RerenderReason(reason=reason))
2306
+ self._rerender_needed = True
2307
+
2308
+ def _render(self, element: Element, default_key: str, parent_key: str):
2309
+ if not isinstance(element, Element):
2310
+ raise TypeError(f"Expected element, not {element}")
2311
+ # for tracking stale elements when using get_widget
2312
+ self._old_element_ids.add(id(element))
2313
+ context = self.context
2314
+ assert context is not None
2315
+
2316
+ if default_key == "/":
2317
+ # the root element of a component determines which keys are in use,
2318
+ # everything else is stale and gets removed during reconciliation
2319
+ context.used_keys.clear()
2320
+
2321
+ el = element
2322
+ key = el._key
2323
+ if key is None:
2324
+ key = default_key
2325
+ el._key_frozen = True
2326
+
2327
+ if key in context.used_keys:
2328
+ if DEBUG:
2329
+ self.tracebacks.append(el.traceback)
2330
+ raise KeyError(f"Duplicate key {key!r}")
2331
+ context.used_keys.add(key)
2332
+
2333
+ if el.is_shared:
2334
+ # a shared element renders a single widget, process it once per phase
2335
+ if el in self._shared_elements_next:
2336
+ return
2337
+ self._shared_elements_next.add(el)
2338
+
2339
+ el_prev = context.elements_next.get(key)
2340
+ if el_prev is None:
2341
+ el_prev = context.elements.get(key)
2342
+ context.elements_next[key] = el
2343
+ el._render_count += 1 # for testing only
2344
+
2345
+ if isinstance(el.component, ComponentWidget):
2346
+ assert not el.args, "no positional args supported for widgets"
2347
+ if key in context.children_next:
2348
+ # a function component lived at this key before
2349
+ del context.children_next[key]
2350
+ # the element arguments are part of this component's element tree
2351
+ if el.kwargs:
2352
+ self._visit_children(el, key, parent_key, self._render)
2353
+ return
2354
+
2355
+ assert isinstance(el.component, ComponentFunction)
2356
+ if el.is_shared and (el.args or el.kwargs):
2357
+ # arguments of a shared element belong to the context it is rendered in;
2358
+ # for non-shared component elements the component function decides
2359
+ # what ends up in the tree
2360
+ self._visit_children(el, key, parent_key, self._render)
2361
+
2362
+ context_previous = context.children_next.get(key)
2363
+ if context_previous is None:
2364
+ context_previous = context.children.get(key)
2365
+
2366
+ if (
2367
+ not self._walk_all
2368
+ and el is el_prev
2369
+ and not el.is_shared
2370
+ and context_previous is not None
2371
+ and context.children.get(key) is context_previous
2372
+ and not context_previous.needs_render
2373
+ and not context_previous.needs_render_descendant
2374
+ and not context_previous.exceptions_self
2375
+ and not context_previous.exceptions_children
2376
+ and context_previous.root_element is not None
2377
+ and context_previous.root_element_next is None
2378
+ ):
2379
+ # fast path: same element, no state changes or pending exceptions
2380
+ # anywhere in this subtree, and fully reconciled: the previous
2381
+ # result stands, skip the subtree in both phases
2382
+ context_previous.clean_subtree = True
2383
+ context.children_next[key] = context_previous
2384
+ return
2385
+
2386
+ parent_context = context
2387
+ del context
2388
+ if context_previous is None:
2389
+ context = ComponentContext(parent=parent_context, context_managers=[cm(el) for cm in _component_context_manager_classes])
2390
+ el_prev = None # do not compare against an element of a different component
2391
+ elif context_previous.root_element is None and context_previous.root_element_next is None:
2392
+ # pre-created, carrying initial state (state_set), but never rendered
2393
+ context = context_previous
2394
+ context.context_managers = [cm(el) for cm in _component_context_manager_classes]
2395
+ else:
2396
+ assert context_previous.invoke_element is not None
2397
+ if not same_component(context_previous.invoke_element.component, el.component):
2398
+ # a different component took this key, start fresh
2399
+ # (the old context is cleaned up during reconciliation)
2400
+ context = ComponentContext(parent=parent_context, context_managers=[cm(el) for cm in _component_context_manager_classes])
2401
+ el_prev = None
2402
+ else:
2403
+ context = context_previous
2404
+ context.parent = parent_context
2405
+ context.clean_subtree = False
2406
+ context.invoke_element = el
2407
+
2408
+ needs_render = context.needs_render
2409
+ if not needs_render and el_prev is not None and context_previous is context:
2410
+ assert not isinstance(el_prev.component, ComponentWidget)
2411
+ needs_render = el._arguments_changed(el_prev)
2412
+ if not needs_render and context.exceptions_children:
2413
+ # we have exceptions, so we need to render
2414
+ needs_render = True
2415
+ if not needs_render:
2416
+ assert el_prev is not None
2417
+
2418
+ self.container_adders = []
2419
+ self.context = context
2420
+ render_count_check = self.render_count
2421
+ try:
2422
+ context.exceptions_self = []
2423
+ root_element: Optional[Element] = None
2424
+ if needs_render:
2425
+ context.state_index = 0
2426
+ context.effect_index = 0
2427
+ context.memo_index = 0
2428
+ context.user_contexts = {}
2429
+ context.exception_handler = False
2430
+ # we reset it before calling the component function,
2431
+ # which might set it to true again
2432
+ context.needs_render = False
2433
+ try:
2434
+ with contextlib.ExitStack() as stack:
2435
+ for cm in context.context_managers:
2436
+ stack.enter_context(cm)
2437
+ if _default_container is not None:
2438
+ with _default_container() as container:
2439
+ el.component.render_count += 1
2440
+ root_element = el.component.f(*el.args, **el.kwargs)
2441
+ if root_element is None:
2442
+ if len(container.kwargs["children"]) == 1:
2443
+ root_element = container.kwargs["children"][0]
2444
+ else:
2445
+ root_element = container
2446
+ else:
2447
+ el.component.render_count += 1
2448
+ root_element = el.component.f(*el.args, **el.kwargs)
2449
+ assert root_element is not None
2450
+ except BaseException as e:
2451
+ if DEBUG:
2452
+ # we might be interested in the traceback inside the call...
2453
+ if len(self.tracebacks) == 0:
2454
+ assert e.__traceback__ is not None
2455
+ traceback = cast(TracebackType, e.__traceback__)
2456
+ if traceback.tb_next: # is there an error inside the call
2457
+ self.tracebacks.append(traceback.tb_next)
2458
+ self.tracebacks.append(el.traceback)
2459
+ logger.exception("Component %r raised exception %r", el.component, e)
2460
+ context.exceptions_self.append(e)
2461
+ self._set_rerender_needed("Exception ocurred during render")
2462
+ context.needs_render = True
2463
+ if root_element is None and not context.exceptions_self:
2464
+ raise ValueError(f"Component {el.component} returned None")
2465
+ else:
2466
+ root_element = context.root_element_next or context.root_element
2467
+
2468
+ if self.render_count != render_count_check:
2469
+ raise RuntimeError("Recursive render detected, possible a bug in react")
2470
+
2471
+ # the subtree walk below will mark this again when state changes
2472
+ context.needs_render_descendant = False
2473
+ if root_element is not None:
2474
+ self._render(root_element, "/", parent_key=join_key(parent_key, key)) # depth first
2475
+ context.root_element_next = root_element
2476
+ elif el.is_shared:
2477
+ self._shared_elements_next.remove(el)
2478
+
2479
+ try:
2480
+ # when an exception interrupted the component function, fewer hook calls are allowed
2481
+ if not ((context.effect_index == len(context.effects)) or (context.exceptions_self and context.effect_index <= len(context.effects))):
2482
+ raise RuntimeError(
2483
+ f"Previously render had {len(context.effects)} effects, this run {context.effect_index} "
2484
+ f"(in element/component: {el}/{el.component}). "
2485
+ "Are you using conditional hooks?"
2486
+ )
2487
+ if not ((context.memo_index == len(context.memo)) or (context.exceptions_self and context.memo_index <= len(context.memo))):
2488
+ raise RuntimeError(
2489
+ f"Previously render had {len(context.memo)} calls to use_memo, this run {context.memo_index} "
2490
+ f"(in element/component: {el}/{el.component}). "
2491
+ "Are you using conditional hooks?"
2492
+ )
2493
+ except RuntimeError as e:
2494
+ logger.exception("Exception in hook count check")
2495
+ context.exceptions_self.append(e)
2496
+ self._set_rerender_needed("Exception ocurred during render (hook count check)")
2497
+ context.needs_render = True
2498
+
2499
+ # only expose to the parent when we get this far
2500
+ parent_context.children_next[key] = context
2501
+ # drop children/elements from a previous render pass that are no longer used
2502
+ used_keys = context.used_keys
2503
+ for unused in [k for k in context.children_next if k not in used_keys]:
2504
+ del context.children_next[unused]
2505
+ for unused in [k for k in context.elements_next if k not in used_keys]:
2506
+ del context.elements_next[unused]
2507
+ context.user_contexts_prev = context.user_contexts
2508
+ finally:
2509
+ assert context.parent is parent_context
2510
+ self.context = parent_context
2511
+ if context.exceptions_self or context.exceptions_children and not context.exception_handler:
2512
+ # this component does not handle the exceptions, bubble up
2513
+ parent_context.exceptions_children.extend(context.exceptions_self)
2514
+ parent_context.exceptions_children.extend(context.exceptions_children)
2515
+ if context.exceptions_self or context.exceptions_children:
2516
+ # make sure the next render pass walks down to this context
2517
+ # (e.g. so a parent with use_exception gets a chance to handle it)
2518
+ _mark_needs_render_ancestors(context)
2519
+ if parent_context.exceptions_self or parent_context.exceptions_children:
2520
+ if not self._rerender_needed:
2521
+ # this happens when an exception was added from an event handler:
2522
+ # no exception was raised during this render phase, but we still
2523
+ # need to rerender until a component catches the exception
2524
+ self._set_rerender_needed("Exception ocurred during render")
2525
+ parent_context.needs_render = True
2526
+
2527
+ def _reconsolidate(self, el: Element, default_key: str, parent_key: str):
2528
+ key = el._key
2529
+ if key is None:
2530
+ key = default_key
2531
+ assert key is not None
2532
+ context = self.context
2533
+ assert context is not None
2534
+
2535
+ if el.is_shared and el in self._shared_elements and el is not self.element:
2536
+ # shared elements reconcile once, all other uses share the widget
2537
+ return self._shared_widgets[el]
2538
+
2539
+ el_prev = context.elements.get(key)
2540
+ try:
2541
+ if isinstance(el.component, ComponentFunction):
2542
+ child_context_next = context.children_next.get(key)
2543
+ if child_context_next is not None and child_context_next.clean_subtree:
2544
+ # subtree was skipped during the render phase: the previous
2545
+ # reconciliation result stands
2546
+ child_context_next.clean_subtree = False
2547
+ context.children[key] = context.children_next.pop(key)
2548
+ return context.widgets[key]
2549
+
2550
+ if el_prev and isinstance(el_prev.component, ComponentWidget):
2551
+ # a widget element was replaced by a component element at this key
2552
+ self._remove_element(el_prev, default_key=key, parent_key=parent_key)
2553
+ new_parent_key = join_key(parent_key, key)
2554
+ try:
2555
+ if el.is_shared and (el.args or el.kwargs):
2556
+ self._visit_children(el, key, parent_key, self._reconsolidate)
2557
+ assert self.context is context
2558
+
2559
+ child_context_prev = context.children.get(key)
2560
+ child_context = context.children_next[key]
2561
+ if child_context_prev is not None and child_context_prev is not child_context:
2562
+ # the component type changed, remove the old subtree
2563
+ assert el_prev is not None, "prev child is not None, but element is"
2564
+ self._remove_element(el_prev, default_key=key, parent_key=parent_key)
2565
+
2566
+ self.context = child_context
2567
+ assert child_context.root_element_next
2568
+ widget = self._reconsolidate(child_context.root_element_next, "/", new_parent_key)
2569
+ child_context.root_element = child_context.root_element_next
2570
+ child_context.root_element_next = None
2571
+ # merge the meta of the component element onto the root widget,
2572
+ # e.g. SomeComponent().meta(name="a") should appear on the widget
2573
+ if el._meta or getattr(widget, "_react_meta", {}):
2574
+ widget._react_meta = {**getattr(widget, "_react_meta", {}), **el._meta}
2575
+
2576
+ if el.is_shared:
2577
+ self._shared_widgets[el] = widget
2578
+ else:
2579
+ context.widgets[key] = widget
2580
+
2581
+ # remove elements that are no longer part of this component's tree
2582
+ # NOTE: sorted for reproducibility
2583
+ stale_keys = sorted(set(child_context.elements) - child_context.used_keys)
2584
+ if stale_keys:
2585
+ logger.info("elements to be removed: %r", stale_keys)
2586
+ for stale_key in stale_keys:
2587
+ if stale_key in child_context.elements:
2588
+ self._remove_element(child_context.elements[stale_key], stale_key, new_parent_key)
2589
+
2590
+ self._process_effects(child_context, context)
2591
+
2592
+ if child_context.children_next:
2593
+ # if we had two render phases, we can have old contexts left over
2594
+ child_context.children_next.clear()
2595
+ if child_context.elements_next:
2596
+ # we can still have elements that are not used as a 'widget' in this
2597
+ # context, but are passed down as an element (when shared)
2598
+ unreferenced = []
2599
+ for child_key, child_el in list(child_context.elements_next.items()):
2600
+ if child_el not in self._shared_elements:
2601
+ unreferenced.append(child_el)
2602
+ else:
2603
+ child_context.elements[child_key] = child_context.elements_next.pop(child_key)
2604
+ if unreferenced:
2605
+ raise RuntimeError(f"Unused elements and unreferenced elements {unreferenced}")
2606
+ if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler:
2607
+ # child does not handle exceptions, so bubble up
2608
+ context.exceptions_children.extend(child_context.exceptions_self)
2609
+ context.exceptions_children.extend(child_context.exceptions_children)
2610
+ finally:
2611
+ # restore context
2612
+ self.context = context
2613
+ context.children[key] = context.children_next.pop(key)
2614
+ else:
2615
+ assert isinstance(el.component, ComponentWidget)
2616
+ if el.args:
2617
+ raise TypeError("Widget element only take keyword arguments")
2618
+
2619
+ widget_previous = None
2620
+ if el_prev is not None:
2621
+ if el_prev.is_shared:
2622
+ widget_previous = self._shared_widgets[el_prev]
2623
+ else:
2624
+ widget_previous = context.widgets[key]
2625
+
2626
+ orphan_ids: Set[str] = set()
2627
+ if widget_previous is None:
2628
+ # initial create
2629
+ kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2630
+ if el.is_shared and el in self._shared_widgets:
2631
+ raise RuntimeError(f"Element ({el}) was already in self._shared_widgets")
2632
+ widget = None
2633
+ if not context.exceptions_children:
2634
+ try:
2635
+ widget, orphan_ids = el._create_widget(kwargs)
2636
+ except BaseException as e:
2637
+ context.exceptions_self.append(e)
2638
+ self._set_rerender_needed("Exception ocurred during reconciliation (creating widget)")
2639
+ _mark_needs_render_ancestors(context)
2640
+ self._store_widget(context, el, key, widget)
2641
+ elif el_prev is not None and el_prev.component == el.component:
2642
+ # update the existing widget in place
2643
+ kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2644
+ if not context.exceptions_children:
2645
+ if el is not el_prev or not _values_identical(kwargs, el.kwargs):
2646
+ try:
2647
+ el._update_widget(widget_previous, el_prev, kwargs)
2648
+ except BaseException as e:
2649
+ context.exceptions_self.append(e)
2650
+ self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)")
2651
+ _mark_needs_render_ancestors(context)
2652
+ # else: identical element and all children reconciled to the
2653
+ # same widgets, nothing can have changed
2654
+ self._store_widget(context, el, key, widget_previous)
2655
+ else:
2656
+ assert el_prev is not None, "widget_previous is not None, but el_prev is"
2657
+ # a different widget type at the same key: replace
2658
+ self._remove_element(el_prev, key, parent_key=parent_key)
2659
+ kwargs = self._visit_children_values(el.kwargs, key, parent_key, self._reconsolidate)
2660
+ widget = None
2661
+ if not context.exceptions_children:
2662
+ try:
2663
+ widget, orphan_ids = el._create_widget(kwargs)
2664
+ except BaseException as e:
2665
+ context.exceptions_self.append(e)
2666
+ self._set_rerender_needed("Exception ocurred during reconciliation (updating widget)")
2667
+ _mark_needs_render_ancestors(context)
2668
+ self._store_widget(context, el, key, widget)
2669
+ if orphan_ids:
2670
+ # widgets created as a side effect (like Layout and Style); we track
2671
+ # them by model id (not all widgets are hashable) for cleanup
2672
+ orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids])
2673
+ for orphan_widget in orphan_widgets:
2674
+ # these are shared between widgets
2675
+ if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2676
+ orphan_ids -= {orphan_widget.model_id}
2677
+ widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2678
+ if widget.model_id not in self._orphans:
2679
+ self._orphans[widget.model_id] = set()
2680
+ self._orphans[widget.model_id].update(orphan_ids)
2681
+
2682
+ if el.is_shared:
2683
+ widget = self._shared_widgets[el]
2684
+ else:
2685
+ widget = context.widgets[key]
2686
+ # used in get_widget
2687
+ if el_prev in context.element_to_widget:
2688
+ del context.element_to_widget[el_prev]
2689
+ context.element_to_widget[el] = widget
2690
+ return widget
2691
+ except Exception as e:
2692
+ if DEBUG:
2693
+ # we don't care about the traceback of the root element
2694
+ if self.element is not el:
2695
+ # we might be interested in the traceback inside the call...
2696
+ if len(self.tracebacks) == 0:
2697
+ assert e.__traceback__ is not None
2698
+ traceback = cast(TracebackType, e.__traceback__)
2699
+ if traceback.tb_next: # is there an error inside the call
2700
+ self.tracebacks.append(traceback.tb_next)
2701
+ self.tracebacks.append(el.traceback)
2702
+ raise
2703
+ finally:
2704
+ # this marks the work as 'done'
2705
+ context.elements[key] = context.elements_next.pop(key)
2706
+ if el_prev in self._shared_elements:
2707
+ self._shared_elements.remove(el_prev)
2708
+ if el.is_shared:
2709
+ assert el not in self._shared_elements
2710
+ self._shared_elements.add(el)
2711
+ assert el in self._shared_elements_next
2712
+ self._shared_elements_next.remove(el)
2713
+
2714
+ def _process_effects(self, child_context: "ComponentContext", context: "ComponentContext"):
2715
+ # NOTE: effect/cleanup exceptions are recorded on the context of the
2716
+ # component's *parent* (`context`), unlike render exceptions: this is
2717
+ # historical behavior (see notes.md)
2718
+ for effect_index, effect in enumerate(child_context.effects):
2719
+ if effect.next:
2720
+ # if we have a next, the effect itself already executed
2721
+ # TODO: custom equals
2722
+ if effect.next.dependencies is not None and utils.equals(effect.dependencies, effect.next.dependencies):
2723
+ # dependencies are the same, just remove the reference
2724
+ effect.next = None
2725
+ continue
2726
+ # dependencies changed: cleanup and execute next
2727
+ if not effect._cleaned_up:
2728
+ try:
2729
+ effect.cleanup()
2730
+ except BaseException as e:
2731
+ context.exceptions_self.append(e)
2732
+ self._set_rerender_needed("Exception ocurred during effect")
2733
+ _mark_needs_render_ancestors(context)
2734
+ context.needs_render = True
2735
+ effect = child_context.effects[effect_index] = effect.next
2736
+ if child_context.exceptions_self or child_context.exceptions_children:
2737
+ # we had an exception, so we skip the effect (not the cleanup)
2738
+ continue
2739
+ try:
2740
+ effect()
2741
+ except BaseException as e:
2742
+ context.exceptions_self.append(e)
2743
+ self._set_rerender_needed("Exception ocurred during effect")
2744
+ _mark_needs_render_ancestors(context)
2745
+ context.needs_render = True
2746
+
2747
+ def _store_widget(self, context: "ComponentContext", el: Element, key: str, widget: Optional[widgets.Widget]):
2748
+ if el.is_shared:
2749
+ self._shared_widgets[el] = widget
2750
+ else:
2751
+ context.widgets[key] = widget
2752
+
2753
+ def _remove_element(self, el: Element, default_key: str, parent_key):
2754
+ key = el._key
2755
+ if key is None:
2756
+ key = default_key
2757
+ assert key is not None
2758
+ context = self.context
2759
+ assert context is not None
2760
+
2761
+ if el.is_shared:
2762
+ if el not in self._shared_elements:
2763
+ # another use of this element keeps it alive, or it was already removed
2764
+ return
2765
+ self._shared_elements.remove(el)
2766
+
2767
+ if key not in context.elements:
2768
+ # already removed, e.g. as part of the removal of a parent element
2769
+ return
2770
+
2771
+ if isinstance(el.component, ComponentFunction):
2772
+ if el.is_shared:
2773
+ self._visit_children(el, key, parent_key, self._remove_element)
2774
+ child_context = context.children[key]
2775
+ try:
2776
+ self.context = child_context
2777
+ # the element is going away, pending exceptions only matter if cleanup fails
2778
+ child_context.exceptions_self = []
2779
+ child_context.exceptions_children = []
2780
+ for effect in child_context.effects:
2781
+ try:
2782
+ if not effect._cleaned_up:
2783
+ effect.cleanup()
2784
+ except BaseException as e:
2785
+ child_context.exceptions_self.append(e)
2786
+ self._set_rerender_needed("Exception ocurred during effect")
2787
+ _mark_needs_render_ancestors(child_context)
2788
+ assert child_context.root_element is not None
2789
+ self._remove_element(child_context.root_element, "/", parent_key=join_key(parent_key, key))
2790
+ finally:
2791
+ try:
2792
+ assert not child_context.elements, f"left over elements {child_context.elements}"
2793
+ assert not child_context.element_to_widget, f"left over element_to_widget {child_context.element_to_widget}"
2794
+ assert not child_context.widgets, f"left over widgets {child_context.widgets}"
2795
+ assert not child_context.children, f"left over children {child_context.children}"
2796
+ assert not child_context.owns, f"left over owns {child_context.owns}"
2797
+ except Exception as e:
2798
+ child_context.exceptions_self.append(e)
2799
+ # restore context
2800
+ self.context = context
2801
+ if child_context.exceptions_self or child_context.exceptions_children and not child_context.exception_handler:
2802
+ # child does not handle exceptions, so bubble up
2803
+ context.exceptions_children.extend(child_context.exceptions_self)
2804
+ context.exceptions_children.extend(child_context.exceptions_children)
2805
+ del context.children[key]
2806
+ else:
2807
+ self._visit_children(el, key, parent_key, self._remove_element)
2808
+ widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2809
+ if widget is not None:
2810
+ assert widget.comm is not None
2811
+ assert widget.model_id in _get_widgets_dict()
2812
+ for orphan in self._orphans.get(widget.model_id, set()):
2813
+ orphan_widget = _get_widgets_dict().get(orphan)
2814
+ if orphan_widget:
2815
+ close_widget(orphan_widget)
2816
+ self._orphans.pop(widget.model_id, None)
2817
+ el._cleanup_callbacks(widget)
2818
+ el._close_widget(widget)
2819
+ if el.is_shared:
2820
+ del self._shared_widgets[el]
2821
+ else:
2822
+ del context.widgets[key]
2823
+ # elements can be removed multiple times, since they can be added multiple times
2824
+ # (even non-shared elements can)
2825
+ if el in context.element_to_widget:
2826
+ del context.element_to_widget[el]
2827
+ del context.elements[key]
2828
+
2829
+ def _visit_children(self, el: Element, default_key: str, parent_key: str, f: Callable):
2830
+ key = el._key
2831
+ if key is None:
2832
+ key = default_key
2833
+ assert key is not None
2834
+ self._visit_children_values(el.kwargs, key, parent_key, f)
2835
+ self._visit_children_values(el.args, key, parent_key, f)
2836
+
2837
+ def _visit_children_values(self, value: Any, key: str, parent_key: str, f: Callable):
2838
+ if isinstance(value, Element):
2839
+ return f(value, key, parent_key)
2840
+ elif isinstance(value, (list, tuple)):
2841
+ values = []
2842
+ for index, v in enumerate(value):
2843
+ new_value = self._visit_children_values(v, f"{key}{index}/", parent_key, f)
2844
+ if isinstance(new_value, FragmentWidget):
2845
+ values.extend(new_value.children)
2846
+ else:
2847
+ values.append(new_value)
2848
+ if isinstance(value, tuple):
2849
+ return tuple(values)
2850
+ return values
2851
+ elif isinstance(value, dict):
2852
+ return {k: self._visit_children_values(v, f"{key}{k}/", parent_key, f) for k, v in value.items()}
2853
+ else:
2854
+ return value
2855
+
2856
+ def _remove_stale_root_elements(self, parent_key):
2857
+ # remove stale elements of the root context itself
2858
+ # (child contexts are swept during their reconciliation)
2859
+ stale_keys = sorted(set(self.context_root.elements) - self.context_root.used_keys)
2860
+ for stale_key in stale_keys:
2861
+ if stale_key in self.context_root.elements:
2862
+ self._remove_element(self.context_root.elements[stale_key], stale_key, parent_key)
2863
+
2864
+
2865
+ def _render_context_class():
2866
+ import os
2867
+
2868
+ return _RenderContextFast if os.environ.get("REACTON_FAST") == "1" else _RenderContext
2869
+
2197
2870
 
2198
2871
  @overload
2199
2872
  def render(
@@ -2209,14 +2882,14 @@ def render(
2209
2882
 
2210
2883
  def render(element: Element[T], container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None):
2211
2884
  container = container or widgets.VBox()
2212
- _rc = _RenderContext(element, container, children_trait=children_trait, handle_error=handle_error, initial_state=initial_state)
2885
+ _rc = _render_context_class()(element, container, children_trait=children_trait, handle_error=handle_error, initial_state=initial_state)
2213
2886
  _rc.render(element, _rc.container)
2214
2887
  local.last_rc = weakref.ref(_rc)
2215
2888
  return container, _rc
2216
2889
 
2217
2890
 
2218
2891
  def render_fixed(element: Element[T], handle_error: bool = True) -> Tuple[T, _RenderContext]:
2219
- _rc = _RenderContext(element, handle_error=handle_error)
2892
+ _rc = _render_context_class()(element, handle_error=handle_error)
2220
2893
  widget = _rc.render(element)
2221
2894
  local.last_rc = weakref.ref(_rc)
2222
2895
  return widget, _rc
@@ -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.0"
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