reacton 1.10.2__tar.gz → 1.10.3__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.10.2 → reacton-1.10.3}/PKG-INFO +2 -2
  2. reacton-1.10.3/reacton/_version.py +1 -0
  3. {reacton-1.10.2 → reacton-1.10.3}/reacton/core.py +77 -7
  4. {reacton-1.10.2 → reacton-1.10.3}/reacton/core_test.py +159 -0
  5. reacton-1.10.2/reacton/_version.py +0 -1
  6. {reacton-1.10.2 → reacton-1.10.3}/.gitignore +0 -0
  7. {reacton-1.10.2 → reacton-1.10.3}/LICENSE +0 -0
  8. {reacton-1.10.2 → reacton-1.10.3}/pyproject.toml +0 -0
  9. {reacton-1.10.2 → reacton-1.10.3}/react_ipywidgets/__init__.py +0 -0
  10. {reacton-1.10.2 → reacton-1.10.3}/react_ipywidgets/core.py +0 -0
  11. {reacton-1.10.2 → reacton-1.10.3}/reacton/__init__.py +0 -0
  12. {reacton-1.10.2 → reacton-1.10.3}/reacton/bqplot.py +0 -0
  13. {reacton-1.10.2 → reacton-1.10.3}/reacton/deprecated/__init__.py +0 -0
  14. {reacton-1.10.2 → reacton-1.10.3}/reacton/deprecated/ipyvuetify.py +0 -0
  15. {reacton-1.10.2 → reacton-1.10.3}/reacton/find.py +0 -0
  16. {reacton-1.10.2 → reacton-1.10.3}/reacton/find_test.py +0 -0
  17. {reacton-1.10.2 → reacton-1.10.3}/reacton/generate.py +0 -0
  18. {reacton-1.10.2 → reacton-1.10.3}/reacton/generate_test.py +0 -0
  19. {reacton-1.10.2 → reacton-1.10.3}/reacton/ipycanvas.py +0 -0
  20. {reacton-1.10.2 → reacton-1.10.3}/reacton/ipyvue.py +0 -0
  21. {reacton-1.10.2 → reacton-1.10.3}/reacton/ipyvue_test.py +0 -0
  22. {reacton-1.10.2 → reacton-1.10.3}/reacton/ipyvuetify.py +0 -0
  23. {reacton-1.10.2 → reacton-1.10.3}/reacton/ipywidgets.py +0 -0
  24. {reacton-1.10.2 → reacton-1.10.3}/reacton/logging.py +0 -0
  25. {reacton-1.10.2 → reacton-1.10.3}/reacton/patch.py +0 -0
  26. {reacton-1.10.2 → reacton-1.10.3}/reacton/patch_display.py +0 -0
  27. {reacton-1.10.2 → reacton-1.10.3}/reacton/py.typed +0 -0
  28. {reacton-1.10.2 → reacton-1.10.3}/reacton/rx.py +0 -0
  29. {reacton-1.10.2 → reacton-1.10.3}/reacton/test.vue +0 -0
  30. {reacton-1.10.2 → reacton-1.10.3}/reacton/test_rx.py +0 -0
  31. {reacton-1.10.2 → reacton-1.10.3}/reacton/utils.py +0 -0
  32. {reacton-1.10.2 → reacton-1.10.3}/reacton/utils_test.py +0 -0
  33. {reacton-1.10.2 → reacton-1.10.3}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: reacton
3
- Version: 1.10.2
3
+ Version: 1.10.3
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.3"
@@ -170,6 +170,26 @@ def close_widget(widget: widgets.Widget):
170
170
  logger.warning("Widget %r does not have a close method, possibly a close trait was added", widget)
171
171
 
172
172
 
173
+ def _is_shared_ipyvue_template(widget: widgets.Widget) -> bool:
174
+ """Is this widget an ipyvue Template that is shared between VueTemplate instances?
175
+
176
+ ipyvue keeps a per-file registry of Template widgets (ipyvue/Template.py), so several
177
+ VueTemplate widgets can point at the same Template. Such a Template outlives the
178
+ VueTemplate that created it, and we must not close it as an orphan.
179
+ ipyvue 3 only puts a Template in that registry when there is a real comm, so an
180
+ unregistered Template belongs to a single VueTemplate and is a normal orphan.
181
+ """
182
+ cls = widget.__class__
183
+ if cls.__name__ != "Template" or cls.__module__ != "ipyvue.Template":
184
+ return False
185
+ module = sys.modules.get(cls.__module__)
186
+ registry = getattr(module, "template_registry", None)
187
+ if registry is None:
188
+ # unknown ipyvue version: keep the old, conservative behaviour
189
+ return True
190
+ return any(template is widget for template in registry.values())
191
+
192
+
173
193
  def _event_handler_exception_wrapper(f):
174
194
  """Wrap an event handler to catch exceptions and put them in a reacton context.
175
195
 
@@ -1532,6 +1552,35 @@ class _RenderContext:
1532
1552
  else:
1533
1553
  logger.info("No render phase triggered, already rendering")
1534
1554
 
1555
+ def _discard_aborted_pass(self):
1556
+ """Forget everything a render pass staged when it raised before reconciliation.
1557
+
1558
+ The *_next bookkeeping and the chained effects (previous_effect.next) of every
1559
+ context belong to that pass. Mark every context dirty, so the next render()
1560
+ re-executes the components and re-queues their effects from a consistent state.
1561
+ Only for aborts in the render phase: a reconciliation that raised halfway already
1562
+ created widgets, and those need the normal removal path.
1563
+ """
1564
+ # children_next is kept: it also holds contexts pre-created by state_set() (restored
1565
+ # state), and a context created by the aborted pass owns no widgets yet, it is simply
1566
+ # reused or pruned by the next render
1567
+ contexts: List[ComponentContext] = [self.context_root]
1568
+ while contexts:
1569
+ context = contexts.pop()
1570
+ for effect in context.effects:
1571
+ effect.next = None
1572
+ context.root_element_next = None
1573
+ context.elements_next = {}
1574
+ context.exceptions_self = []
1575
+ context.exceptions_children = []
1576
+ context.needs_render = True
1577
+ context.needs_render_descendant = True
1578
+ context.clean_subtree = False
1579
+ contexts.extend(context.children.values())
1580
+ contexts.extend(context.children_next.values())
1581
+ self._shared_elements_next = set()
1582
+ self.context = self.context_root
1583
+
1535
1584
  def render(self, element: Element, container: widgets.Widget = None):
1536
1585
  # render + consolidate
1537
1586
  widget = None
@@ -1558,6 +1607,8 @@ class _RenderContext:
1558
1607
  logger.info("Render requested on a closing/closed render context, ignoring")
1559
1608
  return container
1560
1609
  prev_rc = getattr(local, "rc", None)
1610
+ # an exception that escapes while this is True aborted a render pass (see the except below)
1611
+ in_render_phase = True
1561
1612
  try:
1562
1613
  local.rc = self
1563
1614
  self.element = element
@@ -1633,10 +1684,12 @@ class _RenderContext:
1633
1684
 
1634
1685
  logger.info("Render reconsolidate...")
1635
1686
  self.reconsolidating = True
1687
+ in_render_phase = False
1636
1688
  try:
1637
1689
  widget = self._reconsolidate(self.element, default_key="/", parent_key=ROOT_KEY)
1638
1690
  finally:
1639
1691
  self.reconsolidating = False
1692
+ in_render_phase = True
1640
1693
  logger.info("Render reconsolidate done")
1641
1694
  self.context.root_element = self.context.root_element_next
1642
1695
  self.context.root_element_next = None
@@ -1685,7 +1738,16 @@ class _RenderContext:
1685
1738
  self._is_rendering = False
1686
1739
  self.context = context_prev
1687
1740
  logger.info("Done with render phase: %r", render_count)
1688
- except Exception as e:
1741
+ except BaseException as e:
1742
+ # Exceptions raised by components are collected in exceptions_self, so an
1743
+ # exception here comes from the render machinery itself (duplicate key,
1744
+ # hook misuse, ...) or is a cancellation, and aborted a pass halfway. Drop
1745
+ # what that pass staged: otherwise the next render reconciles the last
1746
+ # committed elements, but runs the effects the aborted pass chained, closed
1747
+ # over elements that were never reconciled (get_widget then fails with
1748
+ # "found in a previous render").
1749
+ if in_render_phase:
1750
+ self._discard_aborted_pass()
1689
1751
  if DEBUG:
1690
1752
  # construct a fake traceback (showing how the elements were constructed)
1691
1753
  if not self.tracebacks:
@@ -1825,8 +1887,13 @@ class _RenderContext:
1825
1887
  needs_render = context.needs_render
1826
1888
  if not needs_render:
1827
1889
  if el_prev is not None and context_previous is context:
1828
- assert not isinstance(el_prev.component, ComponentWidget)
1829
- needs_render = el._arguments_changed(el_prev)
1890
+ if isinstance(el_prev.component, ComponentWidget):
1891
+ # an earlier pass of this render() call put a widget element at this
1892
+ # slot (elements_next), while the context is from the last reconciled
1893
+ # render: nothing to compare the arguments against, so render
1894
+ needs_render = True
1895
+ else:
1896
+ needs_render = el._arguments_changed(el_prev)
1830
1897
  if context.exceptions_children:
1831
1898
  # we have exceptions, so we need to render
1832
1899
  needs_render = True
@@ -2166,7 +2233,7 @@ class _RenderContext:
2166
2233
  if orphan_ids:
2167
2234
  for orphan_widget in orphan_widgets:
2168
2235
  # these are shared widgets
2169
- if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2236
+ if _is_shared_ipyvue_template(orphan_widget):
2170
2237
  orphan_ids -= {orphan_widget.model_id}
2171
2238
  if el.is_shared:
2172
2239
  widget = self._shared_widgets[el]
@@ -2485,8 +2552,11 @@ class _RenderContextFast(_RenderContext):
2485
2552
 
2486
2553
  needs_render = context.needs_render
2487
2554
  if not needs_render and el_prev is not None and context_previous is context:
2488
- assert not isinstance(el_prev.component, ComponentWidget)
2489
- needs_render = el._arguments_changed(el_prev)
2555
+ if isinstance(el_prev.component, ComponentWidget):
2556
+ # see the classic renderer: a widget element from an earlier pass of this call
2557
+ needs_render = True
2558
+ else:
2559
+ needs_render = el._arguments_changed(el_prev)
2490
2560
  if not needs_render and context.exceptions_children:
2491
2561
  # we have exceptions, so we need to render
2492
2562
  needs_render = True
@@ -2750,7 +2820,7 @@ class _RenderContextFast(_RenderContext):
2750
2820
  orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids])
2751
2821
  for orphan_widget in orphan_widgets:
2752
2822
  # these are shared between widgets
2753
- if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2823
+ if _is_shared_ipyvue_template(orphan_widget):
2754
2824
  orphan_ids -= {orphan_widget.model_id}
2755
2825
  widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2756
2826
  if widget.model_id not in self._orphans:
@@ -3288,3 +3288,162 @@ def test_key_mutate_protection(Container):
3288
3288
  set_state(1)
3289
3289
  rc.render(w.HTML(value="recover").key("HTML"))
3290
3290
  rc.close()
3291
+
3292
+
3293
+ def test_render_nested_pass_widget_to_component_flip():
3294
+ # One render() call, two passes: the first pass puts a widget element at a
3295
+ # slot, a state write during that pass opens a nested pass, and the nested
3296
+ # pass puts back the function component that held the slot in the last
3297
+ # committed render. _render then compares the component element against
3298
+ # the widget element left in elements_next by the first pass and trips
3299
+ # `assert not isinstance(el_prev.component, ComponentWidget)`.
3300
+ set_mode = cast(Callable[[str], None], None)
3301
+
3302
+ set_clicks = cast(Callable[[int], None], None)
3303
+
3304
+ @react.component
3305
+ def Child():
3306
+ nonlocal set_clicks
3307
+ clicks, set_clicks = react.use_state(0)
3308
+ return w.Button(description=f"child {clicks}")
3309
+
3310
+ @react.component
3311
+ def Parent():
3312
+ nonlocal set_mode
3313
+ mode, set_mode = react.use_state("component")
3314
+ if mode == "widget":
3315
+ # a set during render opens a nested pass in the same render() call
3316
+ set_mode("component")
3317
+ return w.Label(description="loading")
3318
+ return Child()
3319
+
3320
+ box, rc = react.render(Parent(), handle_error=False)
3321
+ set_clicks(3)
3322
+ assert box.children[0].description == "child 3"
3323
+ set_mode("widget")
3324
+ assert isinstance(box.children[0], widgets.Button)
3325
+ # nothing was committed in between, so Child was never unmounted and keeps its state
3326
+ assert box.children[0].description == "child 3"
3327
+ rc.close()
3328
+
3329
+
3330
+ def test_render_nested_pass_flip_strands_get_widget_effect():
3331
+ # The production chain behind the flip above (Grotto PENG-1257, through
3332
+ # solara's RoutingProvider): the AssertionError escapes render(), and a
3333
+ # parent that re-executed in the aborted nested pass already chained a new
3334
+ # effect closure (previous_effect.next) over its new root element, while
3335
+ # its root_element_next was never set. The next render that does not
3336
+ # re-execute the parent runs that closure, and get_widget on the never
3337
+ # reconciled root raises "was found to be in a previous render".
3338
+ set_mode = cast(Callable[[str], None], None)
3339
+ set_counter = cast(Callable[[int], None], None)
3340
+ set_other = cast(Callable[[int], None], None)
3341
+
3342
+ @react.component
3343
+ def Child():
3344
+ return w.Button(description="child")
3345
+
3346
+ @react.component
3347
+ def Flipper():
3348
+ nonlocal set_mode
3349
+ mode, set_mode = react.use_state("component")
3350
+ if mode == "widget":
3351
+ # a set during render opens a nested pass, and the parent's state
3352
+ # changes too, so the parent re-executes in that nested pass
3353
+ # (in production: a router.push from a subscriber)
3354
+ set_counter(1)
3355
+ set_mode("component")
3356
+ return w.Label(description="loading")
3357
+ return Child()
3358
+
3359
+ @react.component
3360
+ def Other():
3361
+ nonlocal set_other
3362
+ value, set_other = react.use_state(0)
3363
+ return w.Label(description=f"other {value}")
3364
+
3365
+ @react.component
3366
+ def Parent():
3367
+ nonlocal set_counter
3368
+ _counter, set_counter = react.use_state(0)
3369
+
3370
+ def effect():
3371
+ react.get_widget(root)
3372
+
3373
+ react.use_effect(effect)
3374
+ root = w.VBox(children=[Flipper(), Other()])
3375
+ return root
3376
+
3377
+ box, rc = react.render(Parent(), handle_error=False)
3378
+ root = box.children[0]
3379
+ assert root.children[1].description == "other 0"
3380
+ try:
3381
+ set_mode("widget")
3382
+ except AssertionError:
3383
+ # the first stage, covered by test_render_nested_pass_widget_to_component_flip
3384
+ pass
3385
+ # an unrelated state change must not run a stale effect closure
3386
+ set_other(1)
3387
+ assert root.children[1].description == "other 1"
3388
+ rc.close()
3389
+
3390
+
3391
+ @pytest.mark.parametrize("abort_in", ["first_pass", "nested_pass"])
3392
+ def test_render_escaped_exception_strands_chained_effect(abort_in):
3393
+ # An exception raised by reacton's own bookkeeping inside _render (here a
3394
+ # duplicate key) escapes render() and leaves the tree half updated: a
3395
+ # component that re-executed in the aborted pass already chained a new
3396
+ # effect closure (previous_effect.next) over the elements of that pass, but
3397
+ # its root_element_next was never set. The next render that does not
3398
+ # re-execute that component runs the chained effect against elements that
3399
+ # were never reconciled; with get_widget that is the
3400
+ # "was found to be in a previous render" KeyError.
3401
+ # (Seen in production through solara's RoutingProvider, which calls
3402
+ # get_widget(main) from an effect without dependencies.)
3403
+ set_dup = cast(Callable[[bool], None], None)
3404
+ set_other = cast(Callable[[int], None], None)
3405
+ set_counter = cast(Callable[[int], None], None)
3406
+
3407
+ @react.component
3408
+ def Other():
3409
+ nonlocal set_other
3410
+ value, set_other = react.use_state(0)
3411
+ return w.Label(description=f"other {value}")
3412
+
3413
+ @react.component
3414
+ def Dup(dup: bool, counter: int):
3415
+ aborted = react.use_ref(False)
3416
+ if dup and counter == 0 and abort_in == "nested_pass":
3417
+ # make the parent re-execute in a nested pass of this render() call
3418
+ set_counter(1)
3419
+ abort_now = counter == (1 if abort_in == "nested_pass" else 0)
3420
+ if dup and abort_now and not aborted.current:
3421
+ # abort this pass with an exception from reacton's bookkeeping
3422
+ aborted.current = True
3423
+ return w.VBox(children=[w.Label(description="a").key("dup"), w.Label(description="b").key("dup")])
3424
+ return w.Label(description="no dup")
3425
+
3426
+ @react.component
3427
+ def Parent():
3428
+ nonlocal set_dup, set_counter
3429
+ dup, set_dup = react.use_state(False)
3430
+ counter, set_counter = react.use_state(0)
3431
+
3432
+ def effect():
3433
+ react.get_widget(root)
3434
+
3435
+ react.use_effect(effect)
3436
+ root = w.VBox(children=[Other(), Dup(dup, counter)])
3437
+ return root
3438
+
3439
+ box, rc = react.render(Parent(), handle_error=False)
3440
+ root = box.children[0]
3441
+ assert root.children[0].description == "other 0"
3442
+ # pass 1: Dup bumps the counter, nested pass: Parent re-executes and chains
3443
+ # a new effect closure over its new root, then Dup aborts the pass
3444
+ with pytest.raises(KeyError, match="Duplicate key"):
3445
+ set_dup(True)
3446
+ # an unrelated state change must not run a stale effect closure
3447
+ set_other(1)
3448
+ assert root.children[0].description == "other 1"
3449
+ rc.close()
@@ -1 +0,0 @@
1
- __version__ = "1.10.2"
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
File without changes