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.
Files changed (52) hide show
  1. pythonnative/__init__.py +16 -4
  2. pythonnative/animated.py +2 -2
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +59 -0
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/hooks.py +364 -81
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +2 -1
  17. pythonnative/native_modules/notifications.py +132 -9
  18. pythonnative/native_modules/permissions.py +27 -3
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +149 -124
  21. pythonnative/native_views/desktop.py +50 -49
  22. pythonnative/native_views/ios.py +240 -161
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +518 -37
  32. pythonnative/runtime.py +407 -215
  33. pythonnative/screen.py +53 -7
  34. pythonnative/storage.py +2 -2
  35. pythonnative/suspense.py +417 -0
  36. pythonnative/templates/android_template/app/build.gradle +5 -0
  37. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
  38. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
  39. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  40. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  41. pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
  42. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  43. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  44. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  45. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  46. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +2 -1
  47. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +51 -48
  48. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +1 -1
  49. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  50. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
  51. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
  52. {pythonnative-0.24.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
@@ -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
- self._tree = self._create_tree(element)
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
- if self._tree is None:
342
- self._tree = self._create_tree(new_element)
343
- else:
344
- self._tree = self._reconcile_node(self._tree, new_element)
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
- from .hooks import _set_hook_state
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
- hook_state.begin_render(self._type_label(element.type))
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
- _set_hook_state(None)
837
+ _reset_hook_state(token)
746
838
  hook_state._dirty = False
747
- return _normalize_children(rendered, owner=self._type_label(element.type))
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
- rendered = self._render_component_body(hook_state, element)
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 = HookState()
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
- children = self._create_child_list(rendered)
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
- for child_el in child_els:
918
- child_node = self._create_tree(child_el)
919
- child_node.parent = vnode
920
- vnode.children.append(child_node)
921
- for root in self._native_roots(child_node):
922
- if root.tag is not None:
923
- self._ops.append(InsertOp(tag, root.tag, index))
924
- index += 1
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.hook_state.cleanup_all_effects()
1310
- # Break the back-references so the unmounted component's hook
1311
- # state (and the closures it captured) can be freed by plain
1312
- # refcounting, important on iOS, where the cyclic GC is
1313
- # disabled.
1314
- node.hook_state._vnode = None
1315
- node.hook_state._reconciler = None
1316
- node.hook_state._trigger_render = None
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))