pythonnative 0.24.0__py3-none-any.whl → 0.26.0__py3-none-any.whl
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.
- pythonnative/__init__.py +16 -4
- pythonnative/animated.py +2 -2
- pythonnative/cli/pn.py +172 -12
- pythonnative/components.py +59 -0
- pythonnative/diagnostics.py +27 -0
- pythonnative/hooks.py +364 -81
- pythonnative/native_modules/app_state.py +2 -1
- pythonnative/native_modules/battery.py +3 -2
- pythonnative/native_modules/biometrics.py +2 -1
- pythonnative/native_modules/camera.py +16 -7
- pythonnative/native_modules/clipboard.py +3 -2
- pythonnative/native_modules/file_system.py +3 -2
- pythonnative/native_modules/haptics.py +7 -6
- pythonnative/native_modules/linking.py +62 -6
- pythonnative/native_modules/location.py +11 -8
- pythonnative/native_modules/net_info.py +2 -1
- pythonnative/native_modules/notifications.py +132 -9
- pythonnative/native_modules/permissions.py +27 -3
- pythonnative/native_modules/share.py +2 -1
- pythonnative/native_views/android.py +149 -124
- pythonnative/native_views/desktop.py +50 -49
- pythonnative/native_views/ios.py +240 -161
- pythonnative/project/android.py +15 -3
- pythonnative/project/builder.py +172 -70
- pythonnative/project/config.py +28 -6
- pythonnative/project/devices.py +291 -0
- pythonnative/project/doctor.py +5 -4
- pythonnative/project/ios.py +51 -77
- pythonnative/project/permissions.py +19 -1
- pythonnative/project/runtime_assets.py +78 -166
- pythonnative/reconciler.py +518 -37
- pythonnative/runtime.py +407 -215
- pythonnative/screen.py +53 -7
- pythonnative/storage.py +2 -2
- pythonnative/suspense.py +417 -0
- pythonnative/templates/android_template/app/build.gradle +5 -0
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
- pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
- pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
- pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
- pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
- pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +2 -1
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +51 -48
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +1 -1
- pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
pythonnative/reconciler.py
CHANGED
|
@@ -29,6 +29,20 @@ Supports:
|
|
|
29
29
|
catch exceptions in child subtrees, invoke ``on_error``, render a
|
|
30
30
|
fallback (optionally receiving a ``reset`` callable), and remount
|
|
31
31
|
their children when ``reset`` is called.
|
|
32
|
+
- **Suspense boundaries** (`type == "__Suspense__"`), which catch
|
|
33
|
+
[`Suspend`][pythonnative.suspense.Suspend] signals from mounting
|
|
34
|
+
descendants (``async def`` component bodies blocking on pending
|
|
35
|
+
awaits, [`Resource.read`][pythonnative.suspense.Resource.read] on unresolved
|
|
36
|
+
data), show their ``fallback`` while pending, and retry the content
|
|
37
|
+
when the awaited work completes. Suspended components' hook states
|
|
38
|
+
are preserved across retries so cached resources are not refetched.
|
|
39
|
+
Updates of already-mounted components that suspend keep their
|
|
40
|
+
previous content on screen and re-render when ready, no fallback
|
|
41
|
+
flash.
|
|
42
|
+
- **Async component bodies**: a component may be an ``async def``.
|
|
43
|
+
The body is driven synchronously as far as possible (awaits on
|
|
44
|
+
resolved futures complete inline); blocking on pending work
|
|
45
|
+
suspends the render.
|
|
32
46
|
- **Fragments** (`type == "__Fragment__"`), expanded inline unless
|
|
33
47
|
keyed, in which case they participate in keyed reconciliation as a
|
|
34
48
|
transparent multi-child wrapper.
|
|
@@ -50,6 +64,7 @@ Supports:
|
|
|
50
64
|
sent to the native side.
|
|
51
65
|
"""
|
|
52
66
|
|
|
67
|
+
import asyncio
|
|
53
68
|
import inspect
|
|
54
69
|
import itertools
|
|
55
70
|
import os
|
|
@@ -60,6 +75,7 @@ from .element import Element
|
|
|
60
75
|
from .events import extract_events, get_event_registry
|
|
61
76
|
from .layout import LayoutNode, calculate_layout, extract_layout_style
|
|
62
77
|
from .mutations import CreateOp, DestroyOp, InsertOp, Mutation, SetFrameOp, UpdateOp
|
|
78
|
+
from .suspense import CoroDriver, Suspend
|
|
63
79
|
|
|
64
80
|
# Props the reconciler consumes itself (i.e., never forwards to the
|
|
65
81
|
# native handler). ``ref`` is one such prop: components pass a
|
|
@@ -72,7 +88,7 @@ _RECONCILER_OWNED_PROPS = frozenset({"ref"})
|
|
|
72
88
|
# Element types that never own a native view: they are transparent
|
|
73
89
|
# wrappers whose children mount directly into the surrounding native
|
|
74
90
|
# parent.
|
|
75
|
-
_TRANSPARENT_TYPES = frozenset({"__Provider__", "__ErrorBoundary__", "__Fragment__"})
|
|
91
|
+
_TRANSPARENT_TYPES = frozenset({"__Provider__", "__ErrorBoundary__", "__Suspense__", "__Fragment__"})
|
|
76
92
|
|
|
77
93
|
# Native element types whose subtree is laid out against the viewport
|
|
78
94
|
# in a detached pass instead of participating in the main layout flow.
|
|
@@ -216,6 +232,9 @@ class VNode:
|
|
|
216
232
|
"_layout_node",
|
|
217
233
|
"_layout_dirty",
|
|
218
234
|
"_error",
|
|
235
|
+
"_suspense_showing_fallback",
|
|
236
|
+
"_suspense_hydration",
|
|
237
|
+
"_suspense_waits",
|
|
219
238
|
)
|
|
220
239
|
|
|
221
240
|
def __init__(self, element: Element, children: List["VNode"], tag: Optional[int] = None) -> None:
|
|
@@ -247,6 +266,13 @@ class VNode:
|
|
|
247
266
|
# For ``__ErrorBoundary__`` nodes: the caught exception while
|
|
248
267
|
# the fallback is showing, else ``None``.
|
|
249
268
|
self._error: Optional[BaseException] = None
|
|
269
|
+
# For ``__Suspense__`` nodes: whether the fallback is showing,
|
|
270
|
+
# the hook states of suspended descendants preserved for the
|
|
271
|
+
# next retry (keyed by ``(component identity, element key)``),
|
|
272
|
+
# and the ids of waitables already wired to trigger a retry.
|
|
273
|
+
self._suspense_showing_fallback: bool = False
|
|
274
|
+
self._suspense_hydration: Optional[Dict[Tuple[int, Any], List[Any]]] = None
|
|
275
|
+
self._suspense_waits: Optional[Set[int]] = None
|
|
250
276
|
|
|
251
277
|
|
|
252
278
|
class Reconciler:
|
|
@@ -289,6 +315,18 @@ class Reconciler:
|
|
|
289
315
|
# Error-boundary VNodes whose ``reset`` was called; drained
|
|
290
316
|
# alongside dirty components.
|
|
291
317
|
self._dirty_boundaries: Dict[int, VNode] = {}
|
|
318
|
+
# Suspense VNodes whose awaited work completed; their content
|
|
319
|
+
# is retried on the next drain.
|
|
320
|
+
self._dirty_suspense: Dict[int, VNode] = {}
|
|
321
|
+
# Hydration map active while a Suspense boundary retries its
|
|
322
|
+
# content: suspended hook states from the previous attempt,
|
|
323
|
+
# reclaimed by matching components so cached resources survive.
|
|
324
|
+
self._hydration: Optional[Dict[Tuple[int, Any], List[Any]]] = None
|
|
325
|
+
# Hook states salvaged while a Suspend unwinds through partially
|
|
326
|
+
# built subtrees (siblings of the suspender that had already
|
|
327
|
+
# rendered). The catching boundary folds these into its
|
|
328
|
+
# hydration map so the retry keeps their caches too.
|
|
329
|
+
self._suspense_salvage: Optional[Dict[Tuple[int, Any], List[Any]]] = None
|
|
292
330
|
# Tags destroyed during the current pass, used when simulating
|
|
293
331
|
# a native parent's child list. Tags are never reused, so stale
|
|
294
332
|
# entries can never alias a live view.
|
|
@@ -316,8 +354,13 @@ class Reconciler:
|
|
|
316
354
|
self._log(f"mount: start type={self._type_label(element.type)!r}")
|
|
317
355
|
self._dirty_nodes.clear()
|
|
318
356
|
self._dirty_boundaries.clear()
|
|
357
|
+
self._dirty_suspense.clear()
|
|
319
358
|
self._destroyed_tags.clear()
|
|
320
|
-
|
|
359
|
+
try:
|
|
360
|
+
self._tree = self._create_tree(element)
|
|
361
|
+
except Suspend as signal:
|
|
362
|
+
self._discard_salvage()
|
|
363
|
+
raise self._missing_suspense_error(signal) from None
|
|
321
364
|
self._drain_dirty()
|
|
322
365
|
self._commit()
|
|
323
366
|
self._warn_on_multiple_roots()
|
|
@@ -338,10 +381,14 @@ class Reconciler:
|
|
|
338
381
|
# are drained before commit.
|
|
339
382
|
self._dirty_nodes.clear()
|
|
340
383
|
self._destroyed_tags.clear()
|
|
341
|
-
|
|
342
|
-
self._tree
|
|
343
|
-
|
|
344
|
-
|
|
384
|
+
try:
|
|
385
|
+
if self._tree is None:
|
|
386
|
+
self._tree = self._create_tree(new_element)
|
|
387
|
+
else:
|
|
388
|
+
self._tree = self._reconcile_node(self._tree, new_element)
|
|
389
|
+
except Suspend as signal:
|
|
390
|
+
self._discard_salvage()
|
|
391
|
+
raise self._missing_suspense_error(signal) from None
|
|
345
392
|
self._drain_dirty()
|
|
346
393
|
self._commit()
|
|
347
394
|
self._warn_on_multiple_roots()
|
|
@@ -363,6 +410,7 @@ class Reconciler:
|
|
|
363
410
|
self._tree = None
|
|
364
411
|
self._dirty_nodes.clear()
|
|
365
412
|
self._dirty_boundaries.clear()
|
|
413
|
+
self._dirty_suspense.clear()
|
|
366
414
|
self._back_handlers.clear()
|
|
367
415
|
self._flush_ops()
|
|
368
416
|
|
|
@@ -408,7 +456,7 @@ class Reconciler:
|
|
|
408
456
|
"""
|
|
409
457
|
if self._tree is None:
|
|
410
458
|
return None
|
|
411
|
-
if not self._dirty_nodes and not self._dirty_boundaries:
|
|
459
|
+
if not self._dirty_nodes and not self._dirty_boundaries and not self._dirty_suspense:
|
|
412
460
|
return self._tree.native_view
|
|
413
461
|
|
|
414
462
|
self._destroyed_tags.clear()
|
|
@@ -483,7 +531,7 @@ class Reconciler:
|
|
|
483
531
|
cap to break pathological update cycles.
|
|
484
532
|
"""
|
|
485
533
|
guard = 0
|
|
486
|
-
while self._dirty_nodes or self._dirty_boundaries:
|
|
534
|
+
while self._dirty_nodes or self._dirty_boundaries or self._dirty_suspense:
|
|
487
535
|
guard += 1
|
|
488
536
|
if guard > 100:
|
|
489
537
|
diagnostics.warn(
|
|
@@ -492,6 +540,7 @@ class Reconciler:
|
|
|
492
540
|
)
|
|
493
541
|
self._dirty_nodes.clear()
|
|
494
542
|
self._dirty_boundaries.clear()
|
|
543
|
+
self._dirty_suspense.clear()
|
|
495
544
|
return
|
|
496
545
|
|
|
497
546
|
boundaries = list(self._dirty_boundaries.values())
|
|
@@ -509,6 +558,24 @@ class Reconciler:
|
|
|
509
558
|
context._stack.pop()
|
|
510
559
|
self._bubble_structure_change(boundary)
|
|
511
560
|
|
|
561
|
+
suspended = list(self._dirty_suspense.values())
|
|
562
|
+
self._dirty_suspense.clear()
|
|
563
|
+
for node in suspended:
|
|
564
|
+
if not node.mounted or not node._suspense_showing_fallback:
|
|
565
|
+
continue
|
|
566
|
+
providers = self._ancestor_providers(node)
|
|
567
|
+
for context, value in providers:
|
|
568
|
+
context._stack.append(value)
|
|
569
|
+
try:
|
|
570
|
+
try:
|
|
571
|
+
self._retry_suspense(node)
|
|
572
|
+
except Exception as exc:
|
|
573
|
+
self._handle_local_render_error(node, exc)
|
|
574
|
+
finally:
|
|
575
|
+
for context, _value in reversed(providers):
|
|
576
|
+
context._stack.pop()
|
|
577
|
+
self._bubble_structure_change(node)
|
|
578
|
+
|
|
512
579
|
pending = list(self._dirty_nodes.values())
|
|
513
580
|
self._dirty_nodes.clear()
|
|
514
581
|
pending.sort(key=self._node_depth)
|
|
@@ -521,6 +588,11 @@ class Reconciler:
|
|
|
521
588
|
continue
|
|
522
589
|
try:
|
|
523
590
|
self._update_component(vnode)
|
|
591
|
+
except Suspend as signal:
|
|
592
|
+
# A descendant mounted during this local update
|
|
593
|
+
# suspended: hand the subtree to the nearest
|
|
594
|
+
# Suspense boundary (fallback + retry).
|
|
595
|
+
self._handle_local_render_suspend(vnode, signal)
|
|
524
596
|
except Exception as exc:
|
|
525
597
|
# A local re-render starts below any enclosing
|
|
526
598
|
# ``ErrorBoundary``, so route the failure to the nearest
|
|
@@ -730,21 +802,110 @@ class Reconciler:
|
|
|
730
802
|
return depth
|
|
731
803
|
|
|
732
804
|
def _render_component_body(self, hook_state: Any, element: Element) -> List[Element]:
|
|
733
|
-
"""Run a function component's body and normalize its output.
|
|
734
|
-
|
|
805
|
+
"""Run a function component's body and normalize its output.
|
|
806
|
+
|
|
807
|
+
``async def`` bodies are driven synchronously as far as they
|
|
808
|
+
can go (see
|
|
809
|
+
[`CoroDriver`][pythonnative.suspense.CoroDriver]); if the body
|
|
810
|
+
blocks on pending work, [`Suspend`][pythonnative.suspense.Suspend]
|
|
811
|
+
propagates to the caller, annotated with the component's hook
|
|
812
|
+
state so a Suspense boundary can preserve it across retries.
|
|
813
|
+
"""
|
|
814
|
+
from .hooks import _reset_hook_state, _set_hook_state
|
|
735
815
|
|
|
736
816
|
component_fn = element.type
|
|
737
817
|
assert callable(component_fn), "component elements always carry a callable type"
|
|
738
|
-
|
|
818
|
+
label = self._type_label(element.type)
|
|
819
|
+
hook_state.begin_render(label)
|
|
739
820
|
hook_state._trigger_render = self._screen_re_render
|
|
740
|
-
_set_hook_state(hook_state)
|
|
821
|
+
token = _set_hook_state(hook_state)
|
|
741
822
|
try:
|
|
742
823
|
rendered = component_fn(**element.props)
|
|
824
|
+
if inspect.iscoroutine(rendered):
|
|
825
|
+
rendered = self._drive_async_body(hook_state, label, rendered)
|
|
743
826
|
hook_state.finish_render()
|
|
827
|
+
except Suspend as signal:
|
|
828
|
+
hook_state.abort_render()
|
|
829
|
+
if signal.hook_state is None:
|
|
830
|
+
signal.hook_state = hook_state
|
|
831
|
+
if signal.key is None:
|
|
832
|
+
signal.key = (id(element.type), element.key)
|
|
833
|
+
if not signal.label:
|
|
834
|
+
signal.label = label
|
|
835
|
+
raise
|
|
744
836
|
finally:
|
|
745
|
-
|
|
837
|
+
_reset_hook_state(token)
|
|
746
838
|
hook_state._dirty = False
|
|
747
|
-
return _normalize_children(rendered, owner=
|
|
839
|
+
return _normalize_children(rendered, owner=label)
|
|
840
|
+
|
|
841
|
+
@staticmethod
|
|
842
|
+
def _drive_async_body(hook_state: Any, label: str, coro: Any) -> Any:
|
|
843
|
+
"""Drive an ``async def`` component body, suspending when it blocks.
|
|
844
|
+
|
|
845
|
+
The driver captures the current :mod:`contextvars` context (in
|
|
846
|
+
which the hook state is installed), so hook calls after an
|
|
847
|
+
``await`` still resolve against this component. A previous
|
|
848
|
+
in-flight body for the same component is cancelled first: only
|
|
849
|
+
the newest render's coroutine may deliver a tree.
|
|
850
|
+
|
|
851
|
+
When the *previous* attempt's coroutine finished while the
|
|
852
|
+
component was suspended (it keeps running in the background),
|
|
853
|
+
the retry consumes that finished result instead of re-running
|
|
854
|
+
the body, so bodies that await one-shot work (a bare
|
|
855
|
+
``asyncio.sleep``, a network call not wrapped in a resource)
|
|
856
|
+
make progress rather than restarting forever.
|
|
857
|
+
"""
|
|
858
|
+
prev = hook_state._async_driver
|
|
859
|
+
if prev is not None:
|
|
860
|
+
if prev.done and not prev.cancelled():
|
|
861
|
+
# Deliver the completed attempt's tree. The body's hook
|
|
862
|
+
# calls already ran to completion inside the driver's
|
|
863
|
+
# captured context, so skip this render's (empty) hook
|
|
864
|
+
# order log rather than flagging a false mismatch.
|
|
865
|
+
coro.close()
|
|
866
|
+
hook_state._async_driver = None
|
|
867
|
+
hook_state._hook_log = None
|
|
868
|
+
error = prev.exception()
|
|
869
|
+
if error is not None:
|
|
870
|
+
raise error
|
|
871
|
+
return prev.result()
|
|
872
|
+
if not prev.done:
|
|
873
|
+
prev.cancel()
|
|
874
|
+
driver = CoroDriver(coro)
|
|
875
|
+
hook_state._async_driver = driver
|
|
876
|
+
driver.start()
|
|
877
|
+
if driver.done:
|
|
878
|
+
hook_state._async_driver = None
|
|
879
|
+
if driver.cancelled():
|
|
880
|
+
raise asyncio.CancelledError()
|
|
881
|
+
error = driver.exception()
|
|
882
|
+
if error is not None:
|
|
883
|
+
raise error
|
|
884
|
+
return driver.result()
|
|
885
|
+
raise Suspend(driver, hook_state=hook_state, label=label)
|
|
886
|
+
|
|
887
|
+
def _register_component_retry(self, vnode: "VNode", signal: Suspend) -> None:
|
|
888
|
+
"""Re-render ``vnode`` when the work it suspended on completes.
|
|
889
|
+
|
|
890
|
+
Used for update-time suspensions: the component keeps its
|
|
891
|
+
previous content on screen and re-runs its body once the
|
|
892
|
+
awaited work is done (no fallback flash, matching transition
|
|
893
|
+
semantics).
|
|
894
|
+
"""
|
|
895
|
+
hook_state = vnode.hook_state
|
|
896
|
+
|
|
897
|
+
def _on_done(_waitable: Any) -> None:
|
|
898
|
+
if not vnode.mounted or vnode.hook_state is not hook_state:
|
|
899
|
+
return
|
|
900
|
+
hook_state._dirty = True
|
|
901
|
+
self.mark_dirty(vnode)
|
|
902
|
+
trigger = self._screen_re_render
|
|
903
|
+
if trigger is not None:
|
|
904
|
+
from .hooks import _schedule_trigger
|
|
905
|
+
|
|
906
|
+
_schedule_trigger(trigger)
|
|
907
|
+
|
|
908
|
+
signal.waitable.add_done_callback(_on_done)
|
|
748
909
|
|
|
749
910
|
def _update_component(self, vnode: "VNode") -> None:
|
|
750
911
|
"""Re-run one function component's body and reconcile its subtree in place.
|
|
@@ -757,6 +918,12 @@ class Reconciler:
|
|
|
757
918
|
read the context default instead of the provided value). Nested
|
|
758
919
|
providers *inside* this subtree are pushed/popped normally by the
|
|
759
920
|
recursive reconcile beneath us.
|
|
921
|
+
|
|
922
|
+
When the body itself suspends, the previous subtree stays on
|
|
923
|
+
screen untouched and the component re-renders once the awaited
|
|
924
|
+
work completes. Suspensions from *descendant mounts* during the
|
|
925
|
+
child reconcile propagate to the caller (which routes them to
|
|
926
|
+
the nearest Suspense boundary).
|
|
760
927
|
"""
|
|
761
928
|
element = vnode.element
|
|
762
929
|
if not callable(element.type):
|
|
@@ -771,7 +938,13 @@ class Reconciler:
|
|
|
771
938
|
for context, value in providers:
|
|
772
939
|
context._stack.append(value)
|
|
773
940
|
try:
|
|
774
|
-
|
|
941
|
+
try:
|
|
942
|
+
rendered = self._render_component_body(hook_state, element)
|
|
943
|
+
except Suspend as signal:
|
|
944
|
+
if signal.hook_state is hook_state:
|
|
945
|
+
self._register_component_retry(vnode, signal)
|
|
946
|
+
return
|
|
947
|
+
raise
|
|
775
948
|
new_children = self._reconcile_child_list(vnode.children, rendered)
|
|
776
949
|
finally:
|
|
777
950
|
for context, _value in reversed(providers):
|
|
@@ -813,6 +986,49 @@ class Reconciler:
|
|
|
813
986
|
node = node.parent
|
|
814
987
|
raise exc
|
|
815
988
|
|
|
989
|
+
def _handle_local_render_suspend(self, vnode: "VNode", signal: Suspend) -> None:
|
|
990
|
+
"""Route a suspension from a local update to the nearest Suspense ancestor.
|
|
991
|
+
|
|
992
|
+
The child reconcile that suspended may have left the boundary's
|
|
993
|
+
content partially updated, so the boundary's whole content is
|
|
994
|
+
torn down and rebuilt through the fallback-and-retry path
|
|
995
|
+
(suspended hook states are preserved for the retry).
|
|
996
|
+
"""
|
|
997
|
+
node = vnode.parent
|
|
998
|
+
while node is not None:
|
|
999
|
+
if isinstance(node.element.type, str) and node.element.type == "__Suspense__":
|
|
1000
|
+
providers = self._ancestor_providers(node)
|
|
1001
|
+
for context, value in providers:
|
|
1002
|
+
context._stack.append(value)
|
|
1003
|
+
try:
|
|
1004
|
+
self._teardown_suspense_content(node)
|
|
1005
|
+
self._suspend_boundary(node, signal)
|
|
1006
|
+
finally:
|
|
1007
|
+
for context, _value in reversed(providers):
|
|
1008
|
+
context._stack.pop()
|
|
1009
|
+
self._bubble_structure_change(node)
|
|
1010
|
+
return
|
|
1011
|
+
node = node.parent
|
|
1012
|
+
self._discard_salvage()
|
|
1013
|
+
raise self._missing_suspense_error(signal) from None
|
|
1014
|
+
|
|
1015
|
+
def _discard_salvage(self) -> None:
|
|
1016
|
+
"""Dispose hook states salvaged during a Suspend that no boundary caught."""
|
|
1017
|
+
salvage = self._suspense_salvage
|
|
1018
|
+
self._suspense_salvage = None
|
|
1019
|
+
if salvage:
|
|
1020
|
+
self._dispose_hydration(salvage)
|
|
1021
|
+
|
|
1022
|
+
@staticmethod
|
|
1023
|
+
def _missing_suspense_error(signal: Suspend) -> RuntimeError:
|
|
1024
|
+
"""Build the error raised when a suspension escapes every boundary."""
|
|
1025
|
+
who = signal.label or "A component"
|
|
1026
|
+
return RuntimeError(
|
|
1027
|
+
f"{who} suspended while rendering, but no Suspense ancestor provides a "
|
|
1028
|
+
"fallback. Wrap the async part of the tree in pn.Suspense(..., "
|
|
1029
|
+
"fallback=...) to declare its loading state."
|
|
1030
|
+
)
|
|
1031
|
+
|
|
816
1032
|
@staticmethod
|
|
817
1033
|
def _ancestor_providers(vnode: "VNode") -> List[Tuple[Any, Any]]:
|
|
818
1034
|
"""Collect ``(context, value)`` for every ``__Provider__`` above ``vnode``.
|
|
@@ -875,6 +1091,10 @@ class Reconciler:
|
|
|
875
1091
|
if element.type == "__ErrorBoundary__":
|
|
876
1092
|
return self._create_error_boundary(element)
|
|
877
1093
|
|
|
1094
|
+
# Suspense boundary: catch suspensions in the child subtree.
|
|
1095
|
+
if element.type == "__Suspense__":
|
|
1096
|
+
return self._create_suspense(element)
|
|
1097
|
+
|
|
878
1098
|
# Keyed fragment (or a fragment reaching here as a root):
|
|
879
1099
|
# a transparent multi-child wrapper.
|
|
880
1100
|
if element.type == "__Fragment__":
|
|
@@ -885,13 +1105,28 @@ class Reconciler:
|
|
|
885
1105
|
self._refresh_identity(vnode)
|
|
886
1106
|
return vnode
|
|
887
1107
|
|
|
888
|
-
# Function component: call with hook context.
|
|
1108
|
+
# Function component: call with hook context. A retrying
|
|
1109
|
+
# Suspense boundary may have preserved this component's hook
|
|
1110
|
+
# state from the attempt that suspended; reclaiming it keeps
|
|
1111
|
+
# cached resources (so the retry renders from cache instead of
|
|
1112
|
+
# refetching).
|
|
889
1113
|
if callable(element.type):
|
|
890
1114
|
from .hooks import HookState
|
|
891
1115
|
|
|
892
|
-
hook_state =
|
|
1116
|
+
hook_state = self._take_hydrated_hook_state(element)
|
|
1117
|
+
if hook_state is None:
|
|
1118
|
+
hook_state = HookState()
|
|
893
1119
|
rendered = self._render_component_body(hook_state, element)
|
|
894
|
-
|
|
1120
|
+
try:
|
|
1121
|
+
children = self._create_child_list(rendered)
|
|
1122
|
+
except Suspend:
|
|
1123
|
+
# The body rendered fine but a descendant suspended:
|
|
1124
|
+
# salvage this component's own state too, so the
|
|
1125
|
+
# boundary's retry re-adopts it.
|
|
1126
|
+
if self._suspense_salvage is None:
|
|
1127
|
+
self._suspense_salvage = {}
|
|
1128
|
+
self._suspense_salvage.setdefault((id(element.type), element.key), []).append(hook_state)
|
|
1129
|
+
raise
|
|
895
1130
|
vnode = VNode(element, children)
|
|
896
1131
|
for child in children:
|
|
897
1132
|
child.parent = vnode
|
|
@@ -914,22 +1149,47 @@ class Reconciler:
|
|
|
914
1149
|
|
|
915
1150
|
child_els = _normalize_children(element.children, owner=element.type)
|
|
916
1151
|
index = 0
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1152
|
+
try:
|
|
1153
|
+
for child_el in child_els:
|
|
1154
|
+
child_node = self._create_tree(child_el)
|
|
1155
|
+
child_node.parent = vnode
|
|
1156
|
+
vnode.children.append(child_node)
|
|
1157
|
+
for root in self._native_roots(child_node):
|
|
1158
|
+
if root.tag is not None:
|
|
1159
|
+
self._ops.append(InsertOp(tag, root.tag, index))
|
|
1160
|
+
index += 1
|
|
1161
|
+
except Suspend:
|
|
1162
|
+
# Don't leak the container (and already-built siblings)
|
|
1163
|
+
# when a child suspends; hook states are salvaged for the
|
|
1164
|
+
# boundary's retry.
|
|
1165
|
+
if self._suspense_salvage is None:
|
|
1166
|
+
self._suspense_salvage = {}
|
|
1167
|
+
self._destroy_tree(vnode, salvage=self._suspense_salvage)
|
|
1168
|
+
raise
|
|
1169
|
+
except Exception:
|
|
1170
|
+
self._destroy_tree(vnode)
|
|
1171
|
+
raise
|
|
925
1172
|
return vnode
|
|
926
1173
|
|
|
927
1174
|
def _create_child_list(self, elements: List[Element]) -> List[VNode]:
|
|
928
|
-
"""Create VNodes for ``elements``, cleaning up on mid-list failure.
|
|
1175
|
+
"""Create VNodes for ``elements``, cleaning up on mid-list failure.
|
|
1176
|
+
|
|
1177
|
+
When the failure is a [`Suspend`][pythonnative.suspense.Suspend],
|
|
1178
|
+
already-built siblings are torn down with their hook states
|
|
1179
|
+
*salvaged* (see `_destroy_tree`), so the Suspense boundary's
|
|
1180
|
+
retry re-mounts them with their caches intact instead of
|
|
1181
|
+
refetching everything the suspender's siblings had loaded.
|
|
1182
|
+
"""
|
|
929
1183
|
nodes: List[VNode] = []
|
|
930
1184
|
try:
|
|
931
1185
|
for el in elements:
|
|
932
1186
|
nodes.append(self._create_tree(el))
|
|
1187
|
+
except Suspend:
|
|
1188
|
+
if self._suspense_salvage is None:
|
|
1189
|
+
self._suspense_salvage = {}
|
|
1190
|
+
for node in nodes:
|
|
1191
|
+
self._destroy_tree(node, salvage=self._suspense_salvage)
|
|
1192
|
+
raise
|
|
933
1193
|
except Exception:
|
|
934
1194
|
for node in nodes:
|
|
935
1195
|
self._destroy_tree(node)
|
|
@@ -1056,6 +1316,202 @@ class Reconciler:
|
|
|
1056
1316
|
|
|
1057
1317
|
return reset
|
|
1058
1318
|
|
|
1319
|
+
# ------------------------------------------------------------------
|
|
1320
|
+
# Suspense boundaries
|
|
1321
|
+
# ------------------------------------------------------------------
|
|
1322
|
+
|
|
1323
|
+
def _create_suspense(self, element: Element) -> VNode:
|
|
1324
|
+
vnode = VNode(element, [])
|
|
1325
|
+
self._attempt_suspense_content(vnode)
|
|
1326
|
+
return vnode
|
|
1327
|
+
|
|
1328
|
+
def _reconcile_suspense(self, old: VNode, new_el: Element) -> VNode:
|
|
1329
|
+
old.element = new_el
|
|
1330
|
+
|
|
1331
|
+
if old._suspense_showing_fallback:
|
|
1332
|
+
# Fallback showing; keep it in sync with the latest fallback
|
|
1333
|
+
# prop. Content retries are driven by waitable completions,
|
|
1334
|
+
# not by parent re-renders.
|
|
1335
|
+
fallback_els = self._suspense_fallback_elements(old)
|
|
1336
|
+
old.children = self._reconcile_child_list(old.children, fallback_els)
|
|
1337
|
+
for child in old.children:
|
|
1338
|
+
child.parent = old
|
|
1339
|
+
self._refresh_identity(old)
|
|
1340
|
+
return old
|
|
1341
|
+
|
|
1342
|
+
try:
|
|
1343
|
+
children = self._reconcile_child_list(old.children, _normalize_children(new_el.children, owner="Suspense"))
|
|
1344
|
+
old.children = children
|
|
1345
|
+
for child in children:
|
|
1346
|
+
child.parent = old
|
|
1347
|
+
self._refresh_identity(old)
|
|
1348
|
+
except Suspend as signal:
|
|
1349
|
+
self._teardown_suspense_content(old)
|
|
1350
|
+
self._suspend_boundary(old, signal)
|
|
1351
|
+
return old
|
|
1352
|
+
|
|
1353
|
+
def _attempt_suspense_content(self, node: VNode) -> None:
|
|
1354
|
+
"""Build a boundary's content from scratch (initial mount or retry).
|
|
1355
|
+
|
|
1356
|
+
Components re-adopt hook states preserved from the previous
|
|
1357
|
+
attempt (the hydration map), so cached resources resolve
|
|
1358
|
+
instead of refetching. On success any still-showing fallback
|
|
1359
|
+
is swapped out for the content; on suspension the fallback
|
|
1360
|
+
mounts (or stays) and a retry is wired to the pending work.
|
|
1361
|
+
"""
|
|
1362
|
+
el = node.element
|
|
1363
|
+
hydration = node._suspense_hydration or {}
|
|
1364
|
+
node._suspense_hydration = None
|
|
1365
|
+
saved_hydration = self._hydration
|
|
1366
|
+
saved_salvage = self._suspense_salvage
|
|
1367
|
+
self._hydration = hydration
|
|
1368
|
+
self._suspense_salvage = None
|
|
1369
|
+
try:
|
|
1370
|
+
children = self._create_child_list(_normalize_children(el.children, owner="Suspense"))
|
|
1371
|
+
except Suspend as signal:
|
|
1372
|
+
# Unclaimed hook states from the previous attempt stay
|
|
1373
|
+
# preserved for the next retry; salvaged sibling states are
|
|
1374
|
+
# folded in by _suspend_boundary.
|
|
1375
|
+
merged = {key: states for key, states in hydration.items() if states}
|
|
1376
|
+
node._suspense_hydration = merged or None
|
|
1377
|
+
self._suspend_boundary(node, signal)
|
|
1378
|
+
return
|
|
1379
|
+
finally:
|
|
1380
|
+
self._hydration = saved_hydration
|
|
1381
|
+
self._suspense_salvage = saved_salvage
|
|
1382
|
+
|
|
1383
|
+
# Success: dispose hook states no component reclaimed, then
|
|
1384
|
+
# swap the fallback (if any) out for the real content.
|
|
1385
|
+
self._dispose_hydration(hydration)
|
|
1386
|
+
for child in node.children:
|
|
1387
|
+
self._destroy_tree(child)
|
|
1388
|
+
for child in children:
|
|
1389
|
+
child.parent = node
|
|
1390
|
+
node.children = children
|
|
1391
|
+
node._suspense_showing_fallback = False
|
|
1392
|
+
node._suspense_waits = None
|
|
1393
|
+
self._refresh_identity(node)
|
|
1394
|
+
|
|
1395
|
+
def _retry_suspense(self, node: VNode) -> None:
|
|
1396
|
+
"""Re-attempt a suspended boundary's content after awaited work finished."""
|
|
1397
|
+
self._attempt_suspense_content(node)
|
|
1398
|
+
|
|
1399
|
+
def _teardown_suspense_content(self, node: VNode) -> None:
|
|
1400
|
+
"""Destroy a boundary's live content, salvaging its hook states.
|
|
1401
|
+
|
|
1402
|
+
Used when an *update* under the boundary suspends: the content
|
|
1403
|
+
components' hook states move into the boundary's hydration map,
|
|
1404
|
+
so when the retry re-mounts them their state, caches, and
|
|
1405
|
+
effect bookkeeping carry over (the fallback round-trip doesn't
|
|
1406
|
+
reset the subtree).
|
|
1407
|
+
"""
|
|
1408
|
+
salvage: Dict[Tuple[int, Any], List[Any]] = {}
|
|
1409
|
+
for child in node.children:
|
|
1410
|
+
self._destroy_tree(child, salvage=salvage)
|
|
1411
|
+
node.children = []
|
|
1412
|
+
node._suspense_showing_fallback = False
|
|
1413
|
+
if salvage:
|
|
1414
|
+
hydration = node._suspense_hydration
|
|
1415
|
+
if hydration is None:
|
|
1416
|
+
hydration = node._suspense_hydration = {}
|
|
1417
|
+
for key, states in salvage.items():
|
|
1418
|
+
hydration.setdefault(key, []).extend(states)
|
|
1419
|
+
|
|
1420
|
+
def _suspend_boundary(self, node: VNode, signal: Suspend) -> None:
|
|
1421
|
+
"""Show a boundary's fallback and schedule a retry for ``signal``.
|
|
1422
|
+
|
|
1423
|
+
A boundary without a fallback is transparent: the suspension
|
|
1424
|
+
propagates to the next Suspense ancestor (mirroring how an
|
|
1425
|
+
ErrorBoundary without a fallback re-raises).
|
|
1426
|
+
"""
|
|
1427
|
+
el = node.element
|
|
1428
|
+
fallback = el.props.get("__fallback__")
|
|
1429
|
+
if fallback is None:
|
|
1430
|
+
raise signal
|
|
1431
|
+
|
|
1432
|
+
# Fold in hook states salvaged while the Suspend unwound
|
|
1433
|
+
# (already-rendered siblings of the suspender), plus the
|
|
1434
|
+
# suspender's own state carried on the signal.
|
|
1435
|
+
salvage = self._suspense_salvage
|
|
1436
|
+
self._suspense_salvage = None
|
|
1437
|
+
if salvage or (signal.hook_state is not None and signal.key is not None):
|
|
1438
|
+
hydration = node._suspense_hydration
|
|
1439
|
+
if hydration is None:
|
|
1440
|
+
hydration = node._suspense_hydration = {}
|
|
1441
|
+
if salvage:
|
|
1442
|
+
for key, states in salvage.items():
|
|
1443
|
+
hydration.setdefault(key, []).extend(states)
|
|
1444
|
+
if signal.hook_state is not None and signal.key is not None:
|
|
1445
|
+
bucket = hydration.setdefault(signal.key, [])
|
|
1446
|
+
if signal.hook_state not in bucket:
|
|
1447
|
+
bucket.append(signal.hook_state)
|
|
1448
|
+
|
|
1449
|
+
if not node._suspense_showing_fallback:
|
|
1450
|
+
children = self._create_child_list(self._suspense_fallback_elements(node))
|
|
1451
|
+
for child in children:
|
|
1452
|
+
child.parent = node
|
|
1453
|
+
node.children = children
|
|
1454
|
+
node._suspense_showing_fallback = True
|
|
1455
|
+
self._refresh_identity(node)
|
|
1456
|
+
|
|
1457
|
+
self._watch_waitable(node, signal.waitable)
|
|
1458
|
+
|
|
1459
|
+
def _suspense_fallback_elements(self, node: VNode) -> List[Element]:
|
|
1460
|
+
"""Render a Suspense boundary's fallback prop into a normalized child list."""
|
|
1461
|
+
fallback = node.element.props.get("__fallback__")
|
|
1462
|
+
if fallback is None:
|
|
1463
|
+
return []
|
|
1464
|
+
result: Any = fallback
|
|
1465
|
+
if callable(fallback) and not isinstance(fallback, Element):
|
|
1466
|
+
result = fallback()
|
|
1467
|
+
return _normalize_children(result, owner="Suspense.fallback")
|
|
1468
|
+
|
|
1469
|
+
def _watch_waitable(self, node: VNode, waitable: Any) -> None:
|
|
1470
|
+
"""Queue a content retry for ``node`` when ``waitable`` completes."""
|
|
1471
|
+
waits = node._suspense_waits
|
|
1472
|
+
if waits is None:
|
|
1473
|
+
waits = node._suspense_waits = set()
|
|
1474
|
+
marker = id(waitable)
|
|
1475
|
+
if marker in waits:
|
|
1476
|
+
return
|
|
1477
|
+
waits.add(marker)
|
|
1478
|
+
|
|
1479
|
+
def _on_done(_w: Any = None) -> None:
|
|
1480
|
+
live_waits = node._suspense_waits
|
|
1481
|
+
if live_waits is not None:
|
|
1482
|
+
live_waits.discard(marker)
|
|
1483
|
+
if not node.mounted or not node._suspense_showing_fallback:
|
|
1484
|
+
return
|
|
1485
|
+
self._dirty_suspense[id(node)] = node
|
|
1486
|
+
trigger = self._screen_re_render
|
|
1487
|
+
if trigger is not None:
|
|
1488
|
+
from .hooks import _schedule_trigger
|
|
1489
|
+
|
|
1490
|
+
_schedule_trigger(trigger)
|
|
1491
|
+
|
|
1492
|
+
waitable.add_done_callback(_on_done)
|
|
1493
|
+
|
|
1494
|
+
def _take_hydrated_hook_state(self, element: Element) -> Any:
|
|
1495
|
+
"""Pop a preserved hook state matching ``element`` from the hydration map."""
|
|
1496
|
+
hydration = self._hydration
|
|
1497
|
+
if not hydration:
|
|
1498
|
+
return None
|
|
1499
|
+
bucket = hydration.get((id(element.type), element.key))
|
|
1500
|
+
if not bucket:
|
|
1501
|
+
return None
|
|
1502
|
+
return bucket.pop(0)
|
|
1503
|
+
|
|
1504
|
+
@staticmethod
|
|
1505
|
+
def _dispose_hydration(hydration: Dict[Tuple[int, Any], List[Any]]) -> None:
|
|
1506
|
+
"""Clean up preserved hook states that no component reclaimed."""
|
|
1507
|
+
for states in hydration.values():
|
|
1508
|
+
for hook_state in states:
|
|
1509
|
+
try:
|
|
1510
|
+
hook_state.cleanup_all_effects()
|
|
1511
|
+
except Exception as exc:
|
|
1512
|
+
diagnostics.warn(f"Error disposing a suspended component's state: {exc!r}")
|
|
1513
|
+
hydration.clear()
|
|
1514
|
+
|
|
1059
1515
|
# ------------------------------------------------------------------
|
|
1060
1516
|
# Reconciliation
|
|
1061
1517
|
# ------------------------------------------------------------------
|
|
@@ -1098,6 +1554,10 @@ class Reconciler:
|
|
|
1098
1554
|
if new_el.type == "__ErrorBoundary__":
|
|
1099
1555
|
return self._reconcile_error_boundary(old, new_el)
|
|
1100
1556
|
|
|
1557
|
+
# Suspense boundary.
|
|
1558
|
+
if new_el.type == "__Suspense__":
|
|
1559
|
+
return self._reconcile_suspense(old, new_el)
|
|
1560
|
+
|
|
1101
1561
|
# Keyed fragment: transparent multi-child wrapper.
|
|
1102
1562
|
if new_el.type == "__Fragment__":
|
|
1103
1563
|
children = self._reconcile_child_list(old.children, _normalize_children(new_el.children, owner="Fragment"))
|
|
@@ -1297,7 +1757,18 @@ class Reconciler:
|
|
|
1297
1757
|
if changed:
|
|
1298
1758
|
self._mark_layout_dirty(parent)
|
|
1299
1759
|
|
|
1300
|
-
def _destroy_tree(self, node: VNode) -> None:
|
|
1760
|
+
def _destroy_tree(self, node: VNode, salvage: Optional[Dict[Tuple[int, Any], List[Any]]] = None) -> None:
|
|
1761
|
+
"""Tear down a subtree, destroying native views and cleaning hook state.
|
|
1762
|
+
|
|
1763
|
+
Args:
|
|
1764
|
+
node: Root of the subtree to destroy.
|
|
1765
|
+
salvage: When given (a Suspense boundary is unwinding),
|
|
1766
|
+
function-component hook states are moved into this map
|
|
1767
|
+
keyed by ``(component identity, element key)`` instead
|
|
1768
|
+
of being cleaned up, so the boundary's retry can
|
|
1769
|
+
re-adopt them (cached resources and effect queues
|
|
1770
|
+
survive the fallback round-trip).
|
|
1771
|
+
"""
|
|
1301
1772
|
if not node.mounted:
|
|
1302
1773
|
return
|
|
1303
1774
|
node.mounted = False
|
|
@@ -1305,19 +1776,29 @@ class Reconciler:
|
|
|
1305
1776
|
# fired moments before unmount can't resurrect a dead subtree.
|
|
1306
1777
|
self._dirty_nodes.pop(id(node), None)
|
|
1307
1778
|
self._dirty_boundaries.pop(id(node), None)
|
|
1779
|
+
self._dirty_suspense.pop(id(node), None)
|
|
1780
|
+
if node._suspense_hydration:
|
|
1781
|
+
self._dispose_hydration(node._suspense_hydration)
|
|
1782
|
+
node._suspense_hydration = None
|
|
1308
1783
|
if node.hook_state is not None:
|
|
1309
|
-
node.
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1784
|
+
if salvage is not None and callable(node.element.type):
|
|
1785
|
+
salvage.setdefault((id(node.element.type), node.element.key), []).append(node.hook_state)
|
|
1786
|
+
node.hook_state._vnode = None
|
|
1787
|
+
node.hook_state._reconciler = None
|
|
1788
|
+
node.hook_state._trigger_render = None
|
|
1789
|
+
else:
|
|
1790
|
+
node.hook_state.cleanup_all_effects()
|
|
1791
|
+
# Break the back-references so the unmounted component's hook
|
|
1792
|
+
# state (and the closures it captured) can be freed by plain
|
|
1793
|
+
# refcounting, important on iOS, where the cyclic GC is
|
|
1794
|
+
# disabled.
|
|
1795
|
+
node.hook_state._vnode = None
|
|
1796
|
+
node.hook_state._reconciler = None
|
|
1797
|
+
node.hook_state._trigger_render = None
|
|
1317
1798
|
if node.element is not None:
|
|
1318
1799
|
self._detach_ref(node.element)
|
|
1319
1800
|
for child in node.children:
|
|
1320
|
-
self._destroy_tree(child)
|
|
1801
|
+
self._destroy_tree(child, salvage=salvage)
|
|
1321
1802
|
if self._is_native_node(node) and node.tag is not None:
|
|
1322
1803
|
self._events.clear(node.tag)
|
|
1323
1804
|
self._ops.append(DestroyOp(node.tag))
|