reacton 1.10.1__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.
- {reacton-1.10.1 → reacton-1.10.3}/PKG-INFO +2 -2
- reacton-1.10.3/reacton/_version.py +1 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/core.py +110 -30
- {reacton-1.10.1 → reacton-1.10.3}/reacton/core_test.py +219 -0
- reacton-1.10.1/reacton/_version.py +0 -1
- {reacton-1.10.1 → reacton-1.10.3}/.gitignore +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/LICENSE +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/pyproject.toml +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/react_ipywidgets/__init__.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/react_ipywidgets/core.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/__init__.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/bqplot.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/deprecated/__init__.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/deprecated/ipyvuetify.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/find.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/find_test.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/generate.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/generate_test.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/ipycanvas.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/ipyvue.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/ipyvue_test.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/ipyvuetify.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/ipywidgets.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/logging.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/patch.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/patch_display.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/py.typed +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/rx.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/test.vue +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/test_rx.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/utils.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/utils_test.py +0 -0
- {reacton-1.10.1 → reacton-1.10.3}/reacton/work.py +0 -0
|
@@ -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
|
|
|
@@ -1322,29 +1342,33 @@ class _RenderContext:
|
|
|
1322
1342
|
self._remove_element(self.element, default_key="/", parent_key=ROOT_KEY)
|
|
1323
1343
|
logger.info("Removing elements done.")
|
|
1324
1344
|
assert self.context is self.context_root
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1345
|
+
# everything below used to run outside the lock: a render() that was
|
|
1346
|
+
# blocked on the lock could then interleave with this teardown and
|
|
1347
|
+
# find self.context None mid-render (AssertionError in
|
|
1348
|
+
# Element.__exit__, seen in production behind a prompt kernel close)
|
|
1349
|
+
if self.container:
|
|
1350
|
+
self.container.close()
|
|
1351
|
+
if isinstance(self.container, widgets.DOMWidget) and self.container.layout is not None:
|
|
1352
|
+
self.container.layout.close()
|
|
1353
|
+
if self._shared_elements:
|
|
1354
|
+
raise RuntimeError(f"Element not cleaned up: {self._shared_elements}")
|
|
1355
|
+
if self._orphans:
|
|
1356
|
+
orphan_widgets = set([_get_widgets_dict()[k] for k in self._orphans])
|
|
1357
|
+
raise RuntimeError(f"Orphan widgets not cleaned up for widgets: {orphan_widgets}")
|
|
1358
|
+
exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
|
|
1359
|
+
# break the reference cycles through the tree (see _teardown_component_context);
|
|
1360
|
+
# _closing stays True, making stray setters and event handlers no-ops
|
|
1361
|
+
for context in all_contexts:
|
|
1362
|
+
_teardown_component_context(context)
|
|
1363
|
+
self.context = None
|
|
1364
|
+
self.context_root = None # type: ignore
|
|
1365
|
+
# the root element, container and root widget keep the widget tree alive, and
|
|
1366
|
+
# widgets reference their elements, whose kwargs hold user callbacks - which
|
|
1367
|
+
# capture use_state setters and therefore this render context
|
|
1368
|
+
self.element = None # type: ignore
|
|
1369
|
+
self.container = None
|
|
1370
|
+
self.last_root_widget = None
|
|
1371
|
+
self._old_element_ids.clear()
|
|
1348
1372
|
if exceptions:
|
|
1349
1373
|
raise exceptions[0]
|
|
1350
1374
|
|
|
@@ -1528,6 +1552,35 @@ class _RenderContext:
|
|
|
1528
1552
|
else:
|
|
1529
1553
|
logger.info("No render phase triggered, already rendering")
|
|
1530
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
|
+
|
|
1531
1584
|
def render(self, element: Element, container: widgets.Widget = None):
|
|
1532
1585
|
# render + consolidate
|
|
1533
1586
|
widget = None
|
|
@@ -1547,7 +1600,15 @@ class _RenderContext:
|
|
|
1547
1600
|
self._lock_thread = threading.current_thread()
|
|
1548
1601
|
if was_locked:
|
|
1549
1602
|
logger.info("Mutex released, continuing render phase")
|
|
1603
|
+
if self._closing or self.context is None:
|
|
1604
|
+
# close() won the race for the lock (a disconnect can close the
|
|
1605
|
+
# kernel while an update was waiting to render): the tree is
|
|
1606
|
+
# torn down, there is nothing to render into anymore
|
|
1607
|
+
logger.info("Render requested on a closing/closed render context, ignoring")
|
|
1608
|
+
return container
|
|
1550
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
|
|
1551
1612
|
try:
|
|
1552
1613
|
local.rc = self
|
|
1553
1614
|
self.element = element
|
|
@@ -1623,10 +1684,12 @@ class _RenderContext:
|
|
|
1623
1684
|
|
|
1624
1685
|
logger.info("Render reconsolidate...")
|
|
1625
1686
|
self.reconsolidating = True
|
|
1687
|
+
in_render_phase = False
|
|
1626
1688
|
try:
|
|
1627
1689
|
widget = self._reconsolidate(self.element, default_key="/", parent_key=ROOT_KEY)
|
|
1628
1690
|
finally:
|
|
1629
1691
|
self.reconsolidating = False
|
|
1692
|
+
in_render_phase = True
|
|
1630
1693
|
logger.info("Render reconsolidate done")
|
|
1631
1694
|
self.context.root_element = self.context.root_element_next
|
|
1632
1695
|
self.context.root_element_next = None
|
|
@@ -1675,7 +1738,16 @@ class _RenderContext:
|
|
|
1675
1738
|
self._is_rendering = False
|
|
1676
1739
|
self.context = context_prev
|
|
1677
1740
|
logger.info("Done with render phase: %r", render_count)
|
|
1678
|
-
except
|
|
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()
|
|
1679
1751
|
if DEBUG:
|
|
1680
1752
|
# construct a fake traceback (showing how the elements were constructed)
|
|
1681
1753
|
if not self.tracebacks:
|
|
@@ -1815,8 +1887,13 @@ class _RenderContext:
|
|
|
1815
1887
|
needs_render = context.needs_render
|
|
1816
1888
|
if not needs_render:
|
|
1817
1889
|
if el_prev is not None and context_previous is context:
|
|
1818
|
-
|
|
1819
|
-
|
|
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)
|
|
1820
1897
|
if context.exceptions_children:
|
|
1821
1898
|
# we have exceptions, so we need to render
|
|
1822
1899
|
needs_render = True
|
|
@@ -2156,7 +2233,7 @@ class _RenderContext:
|
|
|
2156
2233
|
if orphan_ids:
|
|
2157
2234
|
for orphan_widget in orphan_widgets:
|
|
2158
2235
|
# these are shared widgets
|
|
2159
|
-
if orphan_widget
|
|
2236
|
+
if _is_shared_ipyvue_template(orphan_widget):
|
|
2160
2237
|
orphan_ids -= {orphan_widget.model_id}
|
|
2161
2238
|
if el.is_shared:
|
|
2162
2239
|
widget = self._shared_widgets[el]
|
|
@@ -2475,8 +2552,11 @@ class _RenderContextFast(_RenderContext):
|
|
|
2475
2552
|
|
|
2476
2553
|
needs_render = context.needs_render
|
|
2477
2554
|
if not needs_render and el_prev is not None and context_previous is context:
|
|
2478
|
-
|
|
2479
|
-
|
|
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)
|
|
2480
2560
|
if not needs_render and context.exceptions_children:
|
|
2481
2561
|
# we have exceptions, so we need to render
|
|
2482
2562
|
needs_render = True
|
|
@@ -2740,7 +2820,7 @@ class _RenderContextFast(_RenderContext):
|
|
|
2740
2820
|
orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids])
|
|
2741
2821
|
for orphan_widget in orphan_widgets:
|
|
2742
2822
|
# these are shared between widgets
|
|
2743
|
-
if orphan_widget
|
|
2823
|
+
if _is_shared_ipyvue_template(orphan_widget):
|
|
2744
2824
|
orphan_ids -= {orphan_widget.model_id}
|
|
2745
2825
|
widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
|
|
2746
2826
|
if widget.model_id not in self._orphans:
|
|
@@ -2963,6 +2963,66 @@ def test_close_when_overridden():
|
|
|
2963
2963
|
rc.close()
|
|
2964
2964
|
|
|
2965
2965
|
|
|
2966
|
+
def test_render_after_close_is_noop():
|
|
2967
|
+
# A disconnect can close the kernel while an update is waiting on the render
|
|
2968
|
+
# lock: the late render used to proceed into the torn-down tree and hit
|
|
2969
|
+
# "assert rc.context is not None" in Element.__exit__ (seen in production).
|
|
2970
|
+
# A render on a closed/closing context must be a no-op instead.
|
|
2971
|
+
@react.component
|
|
2972
|
+
def App():
|
|
2973
|
+
value, _set_value = react.use_state(1)
|
|
2974
|
+
return w.Label(value=str(value))
|
|
2975
|
+
|
|
2976
|
+
box, rc = react.render(App(), handle_error=False)
|
|
2977
|
+
rc.close()
|
|
2978
|
+
rc.render(App(), box) # must not raise, must not render
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
def test_close_render_race():
|
|
2982
|
+
# close() in one thread while renders queue behind the lock in another:
|
|
2983
|
+
# neither thread may raise (the close teardown must not interleave with a
|
|
2984
|
+
# render that was blocked on the lock).
|
|
2985
|
+
import threading
|
|
2986
|
+
|
|
2987
|
+
for _ in range(20):
|
|
2988
|
+
|
|
2989
|
+
@react.component
|
|
2990
|
+
def App():
|
|
2991
|
+
value, _set_value = react.use_state(1)
|
|
2992
|
+
return w.Label(value=str(value))
|
|
2993
|
+
|
|
2994
|
+
box, rc = react.render(App(), handle_error=False)
|
|
2995
|
+
el = App()
|
|
2996
|
+
errors: List[BaseException] = []
|
|
2997
|
+
barrier = threading.Barrier(2)
|
|
2998
|
+
|
|
2999
|
+
def do_render(rc=rc, el=el, box=box, errors=errors, barrier=barrier):
|
|
3000
|
+
try:
|
|
3001
|
+
barrier.wait()
|
|
3002
|
+
rc.render(el, box)
|
|
3003
|
+
except BaseException as e: # noqa
|
|
3004
|
+
errors.append(e)
|
|
3005
|
+
|
|
3006
|
+
def do_close(rc=rc, errors=errors, barrier=barrier):
|
|
3007
|
+
try:
|
|
3008
|
+
barrier.wait()
|
|
3009
|
+
rc.close()
|
|
3010
|
+
except BaseException as e: # noqa
|
|
3011
|
+
errors.append(e)
|
|
3012
|
+
|
|
3013
|
+
t1 = threading.Thread(target=do_render)
|
|
3014
|
+
t2 = threading.Thread(target=do_close)
|
|
3015
|
+
t1.start()
|
|
3016
|
+
t2.start()
|
|
3017
|
+
t1.join()
|
|
3018
|
+
t2.join()
|
|
3019
|
+
# whichever won the lock, the loser must not blow up; if render won,
|
|
3020
|
+
# close ran after and the context must still close cleanly
|
|
3021
|
+
if not rc._closing:
|
|
3022
|
+
rc.close()
|
|
3023
|
+
assert not errors, errors
|
|
3024
|
+
|
|
3025
|
+
|
|
2966
3026
|
def test_render_perf_child_only():
|
|
2967
3027
|
render_child = unittest.mock.Mock()
|
|
2968
3028
|
render_main = unittest.mock.Mock()
|
|
@@ -3228,3 +3288,162 @@ def test_key_mutate_protection(Container):
|
|
|
3228
3288
|
set_state(1)
|
|
3229
3289
|
rc.render(w.HTML(value="recover").key("HTML"))
|
|
3230
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.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|