pythonnative 0.23.0__py3-none-any.whl → 0.25.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.
@@ -18,16 +18,45 @@ Supports:
18
18
  - **Native elements** (`type` is a string like `"Text"`).
19
19
  - **Function components** (`type` is a callable decorated with
20
20
  [`component`][pythonnative.component]). Their hook state is preserved
21
- across renders.
21
+ across renders. Components may return a single element, a list of
22
+ elements, or ``None``; every node in the tree can contribute zero or
23
+ more native views to its native parent (**multi-child rendering**).
22
24
  - **Provider elements** (`type == "__Provider__"`), which push and pop
23
- context values during tree traversal.
25
+ context values during traversal and, when their value changes,
26
+ re-render every descendant that read the context on its last render
27
+ (**reactive context**), even under memoized components that skipped.
24
28
  - **Error boundary elements** (`type == "__ErrorBoundary__"`), which
25
- catch exceptions in child subtrees and render a fallback.
29
+ catch exceptions in child subtrees, invoke ``on_error``, render a
30
+ fallback (optionally receiving a ``reset`` callable), and remount
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.
46
+ - **Fragments** (`type == "__Fragment__"`), expanded inline unless
47
+ keyed, in which case they participate in keyed reconciliation as a
48
+ transparent multi-child wrapper.
49
+ - **Portals** (`type == "Portal"`), native elements whose handler hosts
50
+ their children in a top-level overlay. Portals contribute no child to
51
+ their native parent; their subtree is laid out against the viewport
52
+ like a `Modal`.
26
53
  - **Key-based child reconciliation** with indexed, move-aware inserts
27
- (keyed reorders emit one move per child instead of detach-all /
28
- re-attach-all).
29
- - **Post-render effect flushing**. After each commit, all queued
30
- effects are executed so they see the committed native tree.
54
+ computed by simulating the native child list, so appends and keyed
55
+ reorders emit the minimal set of `InsertOp`s.
56
+ - **Two effect phases**: layout effects
57
+ ([`use_layout_effect`][pythonnative.use_layout_effect]) flush
58
+ synchronously after mutations and layout; passive effects
59
+ ([`use_effect`][pythonnative.use_effect]) flush afterwards.
31
60
  - **Incremental layout**: a parallel
32
61
  [`LayoutNode`][pythonnative.layout.LayoutNode] tree is cached across
33
62
  passes; clean subtrees keep their cached nodes (enabling the layout
@@ -35,22 +64,38 @@ Supports:
35
64
  sent to the native side.
36
65
  """
37
66
 
67
+ import asyncio
68
+ import inspect
38
69
  import itertools
39
70
  import os
40
- from typing import Any, Dict, List, Optional, Tuple
71
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
41
72
 
73
+ from . import diagnostics
42
74
  from .element import Element
43
75
  from .events import extract_events, get_event_registry
44
76
  from .layout import LayoutNode, calculate_layout, extract_layout_style
45
77
  from .mutations import CreateOp, DestroyOp, InsertOp, Mutation, SetFrameOp, UpdateOp
78
+ from .suspense import CoroDriver, Suspend
46
79
 
47
80
  # Props the reconciler consumes itself (i.e., never forwards to the
48
- # native handler). ``ref`` is one such prop: components pass a dict
49
- # from ``use_ref()`` and the reconciler populates ``ref["current"]``
50
- # with the underlying native view (and ``ref["_pn_tag"]`` with the
51
- # view's tag), mirroring React's ``ref`` semantics.
81
+ # native handler). ``ref`` is one such prop: components pass a
82
+ # [`Ref`][pythonnative.Ref] from ``use_ref()`` and the reconciler
83
+ # populates ``ref.current`` with the underlying native view (and
84
+ # ``ref._pn_tag`` with the view's tag), mirroring React's ``ref``
85
+ # semantics.
52
86
  _RECONCILER_OWNED_PROPS = frozenset({"ref"})
53
87
 
88
+ # Element types that never own a native view: they are transparent
89
+ # wrappers whose children mount directly into the surrounding native
90
+ # parent.
91
+ _TRANSPARENT_TYPES = frozenset({"__Provider__", "__ErrorBoundary__", "__Suspense__", "__Fragment__"})
92
+
93
+ # Native element types whose subtree is laid out against the viewport
94
+ # in a detached pass instead of participating in the main layout flow.
95
+ _DETACHED_TYPES = frozenset({"Modal", "Portal"})
96
+
97
+ _MISSING = object()
98
+
54
99
  # Tags are globally unique so multiple reconcilers (screens, list rows)
55
100
  # can share one registry without collisions.
56
101
  _tag_counter = itertools.count(1)
@@ -88,28 +133,58 @@ def _shallow_equal_props(old: dict, new: dict) -> bool:
88
133
  return True
89
134
 
90
135
 
91
- def _flatten_children(children: List[Element]) -> List[Element]:
92
- """Expand [`Fragment`][pythonnative.Fragment] elements inline.
136
+ def _normalize_children(children: Any, owner: str = "") -> List[Element]:
137
+ """Normalize arbitrary render output into a flat list of Elements.
93
138
 
94
- The reconciler treats Fragments as transparent: when one appears in
95
- a child list, its own children become direct siblings of the
96
- Fragment's location in the parent's child list. This keeps the
97
- Fragment element out of the native tree entirely.
139
+ Accepts a single element, ``None``, ``True``/``False`` (both
140
+ skipped, enabling inline conditionals like ``cond and Text(...)``),
141
+ lists/tuples (flattened recursively), and unkeyed Fragments
142
+ (expanded inline so they never touch the native tree). Keyed
143
+ Fragments are preserved so they can participate in keyed
144
+ reconciliation as a unit.
98
145
 
99
- Args:
100
- children: An ordered child list possibly containing Fragments.
101
-
102
- Returns:
103
- A new list with every Fragment recursively expanded in place.
146
+ Non-Element values other than the above are dropped with a
147
+ dev-mode warning.
104
148
  """
105
- if not children:
106
- return list(children)
107
149
  out: List[Element] = []
108
- for el in children:
109
- if isinstance(el.type, str) and el.type == "__Fragment__":
110
- out.extend(_flatten_children(el.children))
111
- else:
112
- out.append(el)
150
+
151
+ def add(item: Any) -> None:
152
+ if item is None or item is True or item is False:
153
+ return
154
+ if isinstance(item, (list, tuple)):
155
+ for sub in item:
156
+ add(sub)
157
+ return
158
+ if isinstance(item, Element):
159
+ if isinstance(item.type, str) and item.type == "__Fragment__" and item.key is None:
160
+ for sub in item.children:
161
+ add(sub)
162
+ return
163
+ out.append(item)
164
+ return
165
+ diagnostics.warn_once(
166
+ f"Ignoring non-Element child {item!r} ({type(item).__name__})"
167
+ + (f" under {owner}" if owner else "")
168
+ + ". Children must be Elements, lists of Elements, or None/False for conditionals.",
169
+ key=f"badchild:{owner}:{type(item).__name__}",
170
+ )
171
+
172
+ add(children)
173
+
174
+ if diagnostics.is_dev() and len(out) > 1:
175
+ seen: Set[Any] = set()
176
+ for el in out:
177
+ if el.key is None:
178
+ continue
179
+ if el.key in seen:
180
+ diagnostics.warn_once(
181
+ f"Duplicate key {el.key!r} among children"
182
+ + (f" of {owner}" if owner else "")
183
+ + ". Keys must be unique among siblings; duplicates break "
184
+ "keyed reconciliation and can cross-wire component state.",
185
+ key=f"dupkey:{owner}:{el.key!r}",
186
+ )
187
+ seen.add(el.key)
113
188
  return out
114
189
 
115
190
 
@@ -123,16 +198,18 @@ class VNode:
123
198
  element: The `Element` last rendered into this slot.
124
199
  tag: Integer identity of the underlying native view. Native
125
200
  elements own a fresh tag; transparent wrappers (function
126
- components, providers, error boundaries) delegate the tag
127
- of their rendered subtree root. ``None`` before the subtree
128
- renders anything.
201
+ components, providers, boundaries, keyed fragments)
202
+ delegate the tag of their first native root. ``None`` when
203
+ the subtree renders no native view.
129
204
  native_view: The platform-native view object, resolved from the
130
205
  registry after commit. May be `None` for purely virtual
131
206
  wrappers that rendered nothing.
132
- children: Ordered list of child `VNode` instances.
207
+ children: Ordered list of child `VNode` instances. Wrappers may
208
+ own any number of children; each child contributes zero or
209
+ more native roots to the nearest native ancestor.
133
210
  parent: The owning `VNode`, or `None` for the tree root. Used
134
211
  by local (component-scoped) re-renders to bubble a changed
135
- subtree root up to the nearest native container.
212
+ subtree up to the nearest native container.
136
213
  hook_state: The component's
137
214
  [`HookState`][pythonnative.hooks.HookState] when the node
138
215
  wraps a function component, otherwise `None`.
@@ -154,6 +231,10 @@ class VNode:
154
231
  "_last_frame",
155
232
  "_layout_node",
156
233
  "_layout_dirty",
234
+ "_error",
235
+ "_suspense_showing_fallback",
236
+ "_suspense_hydration",
237
+ "_suspense_waits",
157
238
  )
158
239
 
159
240
  def __init__(self, element: Element, children: List["VNode"], tag: Optional[int] = None) -> None:
@@ -164,7 +245,7 @@ class VNode:
164
245
  self.parent: Optional["VNode"] = None
165
246
  self.hook_state: Any = None
166
247
  self.mounted: bool = True
167
- self._rendered: Optional[Element] = None
248
+ self._rendered: Any = None
168
249
  # Native-safe props (callables stripped) from the last commit;
169
250
  # the baseline for prop diffing.
170
251
  self._clean_props: Dict[str, Any] = {}
@@ -177,11 +258,21 @@ class VNode:
177
258
  # are skipped (frame diffing).
178
259
  self._last_frame: Optional[Tuple[float, float, float, float]] = None
179
260
  # Cached LayoutNode reused across passes while the subtree is
180
- # clean (see Reconciler._build_layout_tree_cached).
261
+ # clean (see Reconciler._build_layout_list_cached).
181
262
  self._layout_node: Optional[LayoutNode] = None
182
263
  # True when this node's layout-relevant props or child list
183
264
  # changed since the last layout pass.
184
265
  self._layout_dirty: bool = True
266
+ # For ``__ErrorBoundary__`` nodes: the caught exception while
267
+ # the fallback is showing, else ``None``.
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
185
276
 
186
277
 
187
278
  class Reconciler:
@@ -194,8 +285,9 @@ class Reconciler:
194
285
 
195
286
  1. applies the accumulated mutation ops in one batch,
196
287
  2. resolves freshly created native views and populates refs,
197
- 3. flushes pending effects (so they see the committed tree), and
198
- 4. runs the layout pass, emitting only changed frames.
288
+ 3. runs the layout pass, emitting only changed frames,
289
+ 4. flushes layout effects (children-first), then
290
+ 5. flushes passive effects.
199
291
 
200
292
  Args:
201
293
  backend: An object implementing the registry protocol
@@ -220,6 +312,29 @@ class Reconciler:
220
312
  # reference. Drained by
221
313
  # [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty].
222
314
  self._dirty_nodes: Dict[int, VNode] = {}
315
+ # Error-boundary VNodes whose ``reset`` was called; drained
316
+ # alongside dirty components.
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
330
+ # Tags destroyed during the current pass, used when simulating
331
+ # a native parent's child list. Tags are never reused, so stale
332
+ # entries can never alias a live view.
333
+ self._destroyed_tags: Set[int] = set()
334
+ # ``use_back_handler`` registrations, oldest-first. Dispatch
335
+ # walks the list in reverse so deeper / more recently mounted
336
+ # handlers win.
337
+ self._back_handlers: List[Callable[[], bool]] = []
223
338
 
224
339
  # ------------------------------------------------------------------
225
340
  # Public API
@@ -233,12 +348,22 @@ class Reconciler:
233
348
 
234
349
  Returns:
235
350
  The platform-native view that represents the root of the
236
- mounted tree.
351
+ mounted tree (the first native root when the root element
352
+ renders several).
237
353
  """
238
354
  self._log(f"mount: start type={self._type_label(element.type)!r}")
239
355
  self._dirty_nodes.clear()
240
- self._tree = self._create_tree(element)
356
+ self._dirty_boundaries.clear()
357
+ self._dirty_suspense.clear()
358
+ self._destroyed_tags.clear()
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
364
+ self._drain_dirty()
241
365
  self._commit()
366
+ self._warn_on_multiple_roots()
242
367
  return self._tree.native_view
243
368
 
244
369
  def reconcile(self, new_element: Element) -> Any:
@@ -251,13 +376,22 @@ class Reconciler:
251
376
  The (possibly replaced) root native view.
252
377
  """
253
378
  # A full reconcile rebuilds the whole tree from the root, so any
254
- # pending per-component dirty marks are now obsolete.
379
+ # pending per-component dirty marks are now obsolete. Reactive
380
+ # context invalidation may re-add entries during the pass; those
381
+ # are drained before commit.
255
382
  self._dirty_nodes.clear()
256
- if self._tree is None:
257
- self._tree = self._create_tree(new_element)
258
- else:
259
- self._tree = self._reconcile_node(self._tree, new_element)
383
+ self._destroyed_tags.clear()
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
392
+ self._drain_dirty()
260
393
  self._commit()
394
+ self._warn_on_multiple_roots()
261
395
  return self._tree.native_view
262
396
 
263
397
  def root_view(self) -> Any:
@@ -275,6 +409,9 @@ class Reconciler:
275
409
  self._destroy_tree(self._tree)
276
410
  self._tree = None
277
411
  self._dirty_nodes.clear()
412
+ self._dirty_boundaries.clear()
413
+ self._dirty_suspense.clear()
414
+ self._back_handlers.clear()
278
415
  self._flush_ops()
279
416
 
280
417
  def dispatch_command(self, tag: Optional[int], name: str, args: Optional[Dict[str, Any]] = None) -> Any:
@@ -287,12 +424,14 @@ class Reconciler:
287
424
  """Queue ``vnode`` (a function component) for a local re-render.
288
425
 
289
426
  Called by a component's ``use_state`` / ``use_reducer`` setter
290
- when its own state changes. The node is re-rendered on the next
291
- [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
292
- pass, which the screen host schedules. Marking is idempotent and
293
- cheap; the actual render is deferred so several setters (e.g.
294
- inside [`batch_updates`][pythonnative.batch_updates]) coalesce
295
- into a single pass.
427
+ when its own state changes, and by reactive context when a
428
+ Provider's value changes for a consumer that a memoized
429
+ ancestor would otherwise skip. The node is re-rendered on the
430
+ next [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
431
+ pass. Marking is idempotent and cheap; the actual render is
432
+ deferred so several setters (e.g. inside
433
+ [`batch_updates`][pythonnative.batch_updates]) coalesce into a
434
+ single pass.
296
435
  """
297
436
  if vnode is None or vnode.hook_state is None or not vnode.mounted:
298
437
  return
@@ -317,29 +456,11 @@ class Reconciler:
317
456
  """
318
457
  if self._tree is None:
319
458
  return None
320
- if not self._dirty_nodes:
459
+ if not self._dirty_nodes and not self._dirty_boundaries and not self._dirty_suspense:
321
460
  return self._tree.native_view
322
461
 
323
- pending = list(self._dirty_nodes.values())
324
- self._dirty_nodes.clear()
325
- pending.sort(key=self._node_depth)
326
- for vnode in pending:
327
- if not vnode.mounted:
328
- continue
329
- hook_state = vnode.hook_state
330
- if hook_state is None or not hook_state._dirty:
331
- # Already re-rendered as part of a dirty ancestor's pass.
332
- continue
333
- try:
334
- self._update_component(vnode)
335
- except Exception as exc:
336
- # A local re-render starts below any enclosing
337
- # ``ErrorBoundary``, so route the failure to the nearest
338
- # boundary ancestor (re-rendering its subtree through the
339
- # boundary, which mounts the fallback). With no boundary
340
- # the exception propagates, matching a full render.
341
- self._handle_local_render_error(vnode, exc)
342
-
462
+ self._destroyed_tags.clear()
463
+ self._drain_dirty()
343
464
  self._commit()
344
465
  return self._tree.native_view
345
466
 
@@ -365,6 +486,120 @@ class Reconciler:
365
486
  self._run_layout()
366
487
  self._flush_ops()
367
488
 
489
+ # ------------------------------------------------------------------
490
+ # Back handlers (use_back_handler)
491
+ # ------------------------------------------------------------------
492
+
493
+ def register_back_handler(self, handler: Callable[[], bool]) -> Callable[[], None]:
494
+ """Register a back-press handler; returns an unregister callable.
495
+
496
+ Used by [`use_back_handler`][pythonnative.use_back_handler].
497
+ Handlers are dispatched most-recently-registered first.
498
+ """
499
+ self._back_handlers.append(handler)
500
+
501
+ def unregister() -> None:
502
+ try:
503
+ self._back_handlers.remove(handler)
504
+ except ValueError:
505
+ pass
506
+
507
+ return unregister
508
+
509
+ def dispatch_back_press(self) -> bool:
510
+ """Offer the system back action to registered handlers.
511
+
512
+ Returns:
513
+ ``True`` if a handler consumed the event (the platform
514
+ should *not* run its default behavior).
515
+ """
516
+ for handler in reversed(list(self._back_handlers)):
517
+ if handler():
518
+ return True
519
+ return False
520
+
521
+ # ------------------------------------------------------------------
522
+ # Dirty draining (local updates, reactive context, boundary resets)
523
+ # ------------------------------------------------------------------
524
+
525
+ def _drain_dirty(self) -> None:
526
+ """Process dirty components and boundary resets until none remain.
527
+
528
+ Reactive context can mark additional components dirty *during*
529
+ a pass (a re-render changed a Provider value whose consumers sit
530
+ under memoized subtrees), so this loops until quiescent, with a
531
+ cap to break pathological update cycles.
532
+ """
533
+ guard = 0
534
+ while self._dirty_nodes or self._dirty_boundaries or self._dirty_suspense:
535
+ guard += 1
536
+ if guard > 100:
537
+ diagnostics.warn(
538
+ "Update loop did not settle after 100 iterations; a component is "
539
+ "likely setting state unconditionally during render or effects."
540
+ )
541
+ self._dirty_nodes.clear()
542
+ self._dirty_boundaries.clear()
543
+ self._dirty_suspense.clear()
544
+ return
545
+
546
+ boundaries = list(self._dirty_boundaries.values())
547
+ self._dirty_boundaries.clear()
548
+ for boundary in boundaries:
549
+ if not boundary.mounted:
550
+ continue
551
+ providers = self._ancestor_providers(boundary)
552
+ for context, value in providers:
553
+ context._stack.append(value)
554
+ try:
555
+ self._reconcile_error_boundary(boundary, boundary.element)
556
+ finally:
557
+ for context, _value in reversed(providers):
558
+ context._stack.pop()
559
+ self._bubble_structure_change(boundary)
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
+
579
+ pending = list(self._dirty_nodes.values())
580
+ self._dirty_nodes.clear()
581
+ pending.sort(key=self._node_depth)
582
+ for vnode in pending:
583
+ if not vnode.mounted:
584
+ continue
585
+ hook_state = vnode.hook_state
586
+ if hook_state is None or not hook_state._dirty:
587
+ # Already re-rendered as part of a dirty ancestor's pass.
588
+ continue
589
+ try:
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)
596
+ except Exception as exc:
597
+ # A local re-render starts below any enclosing
598
+ # ``ErrorBoundary``, so route the failure to the nearest
599
+ # boundary ancestor (which mounts its fallback). With no
600
+ # boundary the exception propagates, matching a full render.
601
+ self._handle_local_render_error(vnode, exc)
602
+
368
603
  # ------------------------------------------------------------------
369
604
  # Commit driver
370
605
  # ------------------------------------------------------------------
@@ -372,9 +607,13 @@ class Reconciler:
372
607
  def _commit(self) -> None:
373
608
  """Apply the accumulated transaction and run the post-commit phases."""
374
609
  self._flush_ops()
375
- self._flush_effects()
610
+ self._fix_tree_links()
376
611
  self._run_layout()
377
612
  self._flush_ops()
613
+ self._flush_layout_effects()
614
+ self._flush_ops()
615
+ self._flush_passive_effects()
616
+ self._flush_ops()
378
617
 
379
618
  def _flush_ops(self) -> None:
380
619
  """Send pending ops to the backend and resolve created views."""
@@ -393,38 +632,162 @@ class Reconciler:
393
632
  self._attach_ref(vnode.element, vnode.native_view, vnode.tag)
394
633
 
395
634
  # ------------------------------------------------------------------
396
- # Effect flushing
635
+ # Post-commit walks
397
636
  # ------------------------------------------------------------------
398
637
 
399
- def _flush_effects(self) -> None:
400
- """Walk the committed tree and flush pending effects (depth-first).
638
+ def _fix_tree_links(self) -> None:
639
+ """Refresh ``parent`` links and delegated wrapper identity.
401
640
 
402
- This post-commit walk doubles as the single source of truth for
403
- ``VNode.parent`` links and for *delegated* identity: transparent
404
- wrappers (components, providers, boundaries) re-derive their
405
- ``tag`` / ``native_view`` from their subtree root here, so the
406
- rest of the reconciler never has to chase delegation chains by
407
- hand. The cost is folded into a walk the reconciler already runs
408
- after every commit.
641
+ This walk is the single source of truth for *delegated*
642
+ identity: transparent wrappers (components, providers,
643
+ boundaries, keyed fragments) re-derive their ``tag`` /
644
+ ``native_view`` from their first native root, so the rest of
645
+ the reconciler never has to chase delegation chains by hand.
409
646
  """
410
- if self._tree is not None:
411
- self._tree.parent = None
412
- self._flush_tree_effects(self._tree)
647
+ if self._tree is None:
648
+ return
649
+ self._tree.parent = None
650
+ self._fix_node_links(self._tree)
413
651
 
414
- def _flush_tree_effects(self, node: VNode) -> None:
652
+ def _fix_node_links(self, node: VNode) -> None:
415
653
  for child in node.children:
416
654
  child.parent = node
417
- self._flush_tree_effects(child)
655
+ self._fix_node_links(child)
418
656
  if not self._is_native_node(node):
419
- if node.children:
420
- node.tag = node.children[0].tag
421
- node.native_view = node.children[0].native_view
422
- else:
423
- node.tag = None
424
- node.native_view = None
657
+ self._refresh_identity(node)
658
+
659
+ def _flush_layout_effects(self) -> None:
660
+ """Flush queued layout effects, children before parents."""
661
+ if self._tree is not None:
662
+ self._walk_layout_effects(self._tree)
663
+
664
+ def _walk_layout_effects(self, node: VNode) -> None:
665
+ for child in node.children:
666
+ self._walk_layout_effects(child)
667
+ if node.hook_state is not None:
668
+ node.hook_state.flush_layout_effects()
669
+
670
+ def _flush_passive_effects(self) -> None:
671
+ """Flush queued passive effects, children before parents."""
672
+ if self._tree is not None:
673
+ self._walk_passive_effects(self._tree)
674
+
675
+ def _walk_passive_effects(self, node: VNode) -> None:
676
+ for child in node.children:
677
+ self._walk_passive_effects(child)
425
678
  if node.hook_state is not None:
426
679
  node.hook_state.flush_pending_effects()
427
680
 
681
+ # ------------------------------------------------------------------
682
+ # Native-root helpers (multi-child support)
683
+ # ------------------------------------------------------------------
684
+
685
+ def _native_roots(self, node: VNode) -> List[VNode]:
686
+ """Return the ordered native views ``node`` contributes to its native parent.
687
+
688
+ Native elements contribute themselves, except ``Portal``, whose
689
+ handler self-attaches to a top-level overlay and therefore
690
+ contributes nothing. Transparent wrappers contribute the
691
+ concatenation of their children's roots.
692
+ """
693
+ if self._is_native_node(node):
694
+ if node.element.type == "Portal":
695
+ return []
696
+ return [node]
697
+ roots: List[VNode] = []
698
+ for child in node.children:
699
+ roots.extend(self._native_roots(child))
700
+ return roots
701
+
702
+ def _flattened_child_roots(self, node: VNode) -> List[VNode]:
703
+ """Return the native child list of a native container node."""
704
+ roots: List[VNode] = []
705
+ for child in node.children:
706
+ roots.extend(self._native_roots(child))
707
+ return roots
708
+
709
+ def _refresh_identity(self, node: VNode) -> None:
710
+ """Point a wrapper's ``tag`` / ``native_view`` at its first native root."""
711
+ if self._is_native_node(node):
712
+ return
713
+ for root in self._native_roots(node):
714
+ node.tag = root.tag
715
+ node.native_view = root.native_view
716
+ return
717
+ node.tag = None
718
+ node.native_view = None
719
+
720
+ def _sync_native_children(
721
+ self, parent_tag: int, before_tags: List[Optional[int]], after_roots: List[VNode]
722
+ ) -> bool:
723
+ """Emit the `InsertOp`s that turn the parent's native child list into ``after_roots``.
724
+
725
+ Simulates the native child list: it currently holds the
726
+ surviving members of ``before_tags`` (destroys already emitted
727
+ detach on the native side), and each emitted ensure-insert
728
+ mirrors the handlers' move-aware semantics. Appends and keyed
729
+ reorders therefore emit only the ops they need.
730
+
731
+ Returns:
732
+ Whether the native child list changed at all (used for
733
+ layout invalidation).
734
+ """
735
+ surviving = [t for t in before_tags if t is not None and t not in self._destroyed_tags]
736
+ after_tags = [r.tag for r in after_roots if r.tag is not None]
737
+ if surviving == after_tags:
738
+ return len(surviving) != len(before_tags)
739
+
740
+ sim = list(surviving)
741
+ for i, tag in enumerate(after_tags):
742
+ if i < len(sim) and sim[i] == tag:
743
+ continue
744
+ try:
745
+ j = sim.index(tag)
746
+ except ValueError:
747
+ j = -1
748
+ if j >= 0:
749
+ sim.pop(j)
750
+ sim.insert(i, tag)
751
+ self._ops.append(InsertOp(parent_tag, tag, i))
752
+ return True
753
+
754
+ def _bubble_structure_change(self, vnode: VNode) -> None:
755
+ """Propagate a changed native-root set up to the nearest native container.
756
+
757
+ A local re-render starts below the real native container, so
758
+ when the dirty component's native roots change (view replaced,
759
+ added, or removed), every transparent ancestor re-derives its
760
+ identity and the nearest native ancestor re-ensures its full
761
+ child order (handlers no-op for children already in place).
762
+ """
763
+ node = vnode.parent
764
+ while node is not None:
765
+ if self._is_native_node(node):
766
+ if node.tag is not None:
767
+ roots = self._flattened_child_roots(node)
768
+ for i, root in enumerate(roots):
769
+ if root.tag is not None:
770
+ self._ops.append(InsertOp(node.tag, root.tag, i))
771
+ self._mark_layout_dirty(node)
772
+ return
773
+ self._refresh_identity(node)
774
+ node = node.parent
775
+ # Reached the root with no native container above: the root's
776
+ # identity was already refreshed. The host detects the change by
777
+ # comparing ``root_view()`` after the flush.
778
+
779
+ def _warn_on_multiple_roots(self) -> None:
780
+ if not diagnostics.is_dev() or self._tree is None:
781
+ return
782
+ roots = self._native_roots(self._tree)
783
+ if len(roots) > 1:
784
+ diagnostics.warn_once(
785
+ f"The screen root rendered {len(roots)} native views; only the first "
786
+ "is attached to the window. Wrap your root in a View/Column (Portals "
787
+ "are exempt and may appear anywhere).",
788
+ key=f"multi-root:{id(self)}",
789
+ )
790
+
428
791
  # ------------------------------------------------------------------
429
792
  # Internal helpers
430
793
  # ------------------------------------------------------------------
@@ -438,6 +801,112 @@ class Reconciler:
438
801
  node = node.parent
439
802
  return depth
440
803
 
804
+ def _render_component_body(self, hook_state: Any, element: Element) -> List[Element]:
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
815
+
816
+ component_fn = element.type
817
+ assert callable(component_fn), "component elements always carry a callable type"
818
+ label = self._type_label(element.type)
819
+ hook_state.begin_render(label)
820
+ hook_state._trigger_render = self._screen_re_render
821
+ token = _set_hook_state(hook_state)
822
+ try:
823
+ rendered = component_fn(**element.props)
824
+ if inspect.iscoroutine(rendered):
825
+ rendered = self._drive_async_body(hook_state, label, rendered)
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
836
+ finally:
837
+ _reset_hook_state(token)
838
+ hook_state._dirty = False
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)
909
+
441
910
  def _update_component(self, vnode: "VNode") -> None:
442
911
  """Re-run one function component's body and reconcile its subtree in place.
443
912
 
@@ -449,80 +918,117 @@ class Reconciler:
449
918
  read the context default instead of the provided value). Nested
450
919
  providers *inside* this subtree are pushed/popped normally by the
451
920
  recursive reconcile beneath us.
452
- """
453
- from .hooks import _set_hook_state
454
921
 
455
- new_el = vnode.element
456
- if not callable(new_el.type):
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).
927
+ """
928
+ element = vnode.element
929
+ if not callable(element.type):
457
930
  return
458
931
  hook_state = vnode.hook_state
459
932
  if hook_state is None:
460
933
  return
461
934
 
935
+ before_tags = [r.tag for r in self._native_roots(vnode)]
936
+
462
937
  providers = self._ancestor_providers(vnode)
463
938
  for context, value in providers:
464
939
  context._stack.append(value)
465
940
  try:
466
- hook_state.reset_index()
467
- hook_state._trigger_render = self._screen_re_render
468
- hook_state._vnode = vnode
469
- hook_state._reconciler = self
470
- _set_hook_state(hook_state)
471
941
  try:
472
- rendered = new_el.type(**new_el.props)
473
- finally:
474
- _set_hook_state(None)
475
- hook_state._dirty = False
476
-
477
- old_tag = vnode.tag
478
- if vnode.children:
479
- child = self._reconcile_node(vnode.children[0], rendered)
480
- else:
481
- child = self._create_tree(rendered)
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
948
+ new_children = self._reconcile_child_list(vnode.children, rendered)
482
949
  finally:
483
950
  for context, _value in reversed(providers):
484
951
  context._stack.pop()
485
952
 
486
- child.parent = vnode
487
- vnode.children = [child]
488
- vnode.tag = child.tag
489
- vnode.native_view = child.native_view
953
+ for child in new_children:
954
+ child.parent = vnode
955
+ vnode.children = new_children
490
956
  vnode._rendered = rendered
957
+ self._refresh_identity(vnode)
958
+ hook_state._vnode = vnode
959
+ hook_state._reconciler = self
491
960
 
492
- if child.tag != old_tag:
493
- self._bubble_root_change(vnode, child)
961
+ after_tags = [r.tag for r in self._native_roots(vnode)]
962
+ if after_tags != before_tags:
963
+ self._bubble_structure_change(vnode)
494
964
 
495
965
  def _handle_local_render_error(self, vnode: "VNode", exc: Exception) -> None:
496
966
  """Route a local re-render failure to the nearest ``ErrorBoundary`` ancestor.
497
967
 
498
- Re-reconciles the boundary against its own element so the throw
499
- is re-triggered *inside*
500
- [`_reconcile_error_boundary`][pythonnative.reconciler.Reconciler._reconcile_error_boundary],
501
- which destroys the failed subtree and mounts the boundary's
502
- fallback. If no boundary encloses ``vnode`` the exception
503
- propagates, exactly as it would during a full render.
968
+ Activates the boundary (destroying the failed subtree and
969
+ mounting the fallback). If no boundary encloses ``vnode`` the
970
+ exception propagates, exactly as it would during a full render;
971
+ the screen host catches it and shows the dev error overlay.
504
972
  """
505
973
  node = vnode.parent
506
974
  while node is not None:
507
975
  if isinstance(node.element.type, str) and node.element.type == "__ErrorBoundary__":
508
- old_tag = node.tag
509
- # Like a local component update, this re-reconcile starts
510
- # mid-tree, so restore the boundary's own ancestor
511
- # provider context first.
512
976
  providers = self._ancestor_providers(node)
513
977
  for context, value in providers:
514
978
  context._stack.append(value)
515
979
  try:
516
- self._reconcile_node(node, node.element)
980
+ self._activate_boundary(node, exc)
517
981
  finally:
518
982
  for context, _value in reversed(providers):
519
983
  context._stack.pop()
520
- if node.tag != old_tag and node.children:
521
- self._bubble_root_change(node, node.children[0])
984
+ self._bubble_structure_change(node)
522
985
  return
523
986
  node = node.parent
524
987
  raise exc
525
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
+
526
1032
  @staticmethod
527
1033
  def _ancestor_providers(vnode: "VNode") -> List[Tuple[Any, Any]]:
528
1034
  """Collect ``(context, value)`` for every ``__Provider__`` above ``vnode``.
@@ -541,42 +1047,10 @@ class Reconciler:
541
1047
  chain.reverse()
542
1048
  return chain
543
1049
 
544
- def _bubble_root_change(self, vnode: "VNode", new_subtree_root: "VNode") -> None:
545
- """Propagate a swapped subtree-root view up to its native parent.
546
-
547
- A local re-render starts below the real native container, so when
548
- the dirty component's root native view is replaced (e.g. its
549
- output changed type), the change must be reflected in (a) every
550
- transparent ancestor that delegated its identity to this subtree
551
- and (b) the nearest native-container ancestor's child list. The
552
- old view's detach is implied by its `DestroyOp` (handlers detach
553
- on destroy); only the indexed insert of the new root is emitted.
554
- """
555
- child = vnode
556
- node = vnode.parent
557
- while node is not None:
558
- if self._is_native_node(node):
559
- try:
560
- idx = node.children.index(child)
561
- except ValueError:
562
- idx = len(node.children) - 1
563
- if node.tag is not None and new_subtree_root.tag is not None:
564
- self._ops.append(InsertOp(node.tag, new_subtree_root.tag, idx))
565
- self._mark_layout_dirty(node)
566
- return
567
- # Transparent ancestor delegates its identity to this subtree.
568
- node.tag = new_subtree_root.tag
569
- node.native_view = new_subtree_root.native_view
570
- child = node
571
- node = node.parent
572
- # Reached the root with no native container above: the root's
573
- # identity was already updated in the loop. The host detects the
574
- # change by comparing ``root_view()`` after the flush.
575
-
576
1050
  @staticmethod
577
1051
  def _is_native_node(node: "VNode") -> bool:
578
1052
  t = node.element.type
579
- return isinstance(t, str) and t not in ("__Provider__", "__ErrorBoundary__", "__Fragment__")
1053
+ return isinstance(t, str) and t not in _TRANSPARENT_TYPES
580
1054
 
581
1055
  @staticmethod
582
1056
  def _log(msg: str) -> None:
@@ -599,59 +1073,71 @@ class Reconciler:
599
1073
  # ------------------------------------------------------------------
600
1074
 
601
1075
  def _create_tree(self, element: Element) -> VNode:
602
- # Provider: push context, create children, pop context
1076
+ # Provider: push context, create children, pop context.
603
1077
  if element.type == "__Provider__":
604
1078
  context = element.props["__context__"]
605
1079
  context._stack.append(element.props["__value__"])
606
1080
  try:
607
- provider_children = _flatten_children(element.children)
608
- child_node = self._create_tree(provider_children[0]) if provider_children else None
1081
+ children = self._create_child_list(_normalize_children(element.children, owner="Provider"))
609
1082
  finally:
610
1083
  context._stack.pop()
611
- children = [child_node] if child_node else []
612
1084
  vnode = VNode(element, children)
613
- vnode.tag = child_node.tag if child_node else None
1085
+ for child in children:
1086
+ child.parent = vnode
1087
+ self._refresh_identity(vnode)
614
1088
  return vnode
615
1089
 
616
- # Error boundary: catch exceptions in the child subtree
1090
+ # Error boundary: catch exceptions in the child subtree.
617
1091
  if element.type == "__ErrorBoundary__":
618
1092
  return self._create_error_boundary(element)
619
1093
 
620
- # Fragment elements should never reach here directly (the parent
621
- # flattens them out of its child list). If we somehow get one as
622
- # a root element, mount its first child.
1094
+ # Suspense boundary: catch suspensions in the child subtree.
1095
+ if element.type == "__Suspense__":
1096
+ return self._create_suspense(element)
1097
+
1098
+ # Keyed fragment (or a fragment reaching here as a root):
1099
+ # a transparent multi-child wrapper.
623
1100
  if element.type == "__Fragment__":
624
- kids = _flatten_children(element.children)
625
- if not kids:
626
- return VNode(element, [])
627
- child_node = self._create_tree(kids[0])
628
- vnode = VNode(element, [child_node])
629
- vnode.tag = child_node.tag
1101
+ children = self._create_child_list(_normalize_children(element.children, owner="Fragment"))
1102
+ vnode = VNode(element, children)
1103
+ for child in children:
1104
+ child.parent = vnode
1105
+ self._refresh_identity(vnode)
630
1106
  return vnode
631
1107
 
632
- # 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).
633
1113
  if callable(element.type):
634
- from .hooks import HookState, _set_hook_state
1114
+ from .hooks import HookState
635
1115
 
636
- hook_state = HookState()
637
- hook_state._trigger_render = self._screen_re_render
638
- _set_hook_state(hook_state)
1116
+ hook_state = self._take_hydrated_hook_state(element)
1117
+ if hook_state is None:
1118
+ hook_state = HookState()
1119
+ rendered = self._render_component_body(hook_state, element)
639
1120
  try:
640
- rendered = element.type(**element.props)
641
- finally:
642
- _set_hook_state(None)
643
- hook_state._dirty = False
644
-
645
- child_node = self._create_tree(rendered)
646
- vnode = VNode(element, [child_node])
647
- vnode.tag = child_node.tag
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
1130
+ vnode = VNode(element, children)
1131
+ for child in children:
1132
+ child.parent = vnode
648
1133
  vnode.hook_state = hook_state
649
1134
  vnode._rendered = rendered
1135
+ self._refresh_identity(vnode)
650
1136
  hook_state._vnode = vnode
651
1137
  hook_state._reconciler = self
652
1138
  return vnode
653
1139
 
654
- # Native element
1140
+ # Native element.
655
1141
  tag = next_tag()
656
1142
  clean_props, events = self._split_props(element.props)
657
1143
  vnode = VNode(element, [], tag=tag)
@@ -661,30 +1147,371 @@ class Reconciler:
661
1147
  self._ops.append(CreateOp(tag, element.type, clean_props))
662
1148
  self._created.append(vnode)
663
1149
 
664
- flat_children = _flatten_children(element.children)
665
- for i, child_el in enumerate(flat_children):
666
- child_node = self._create_tree(child_el)
667
- if child_node.tag is not None:
668
- self._ops.append(InsertOp(tag, child_node.tag, i))
669
- vnode.children.append(child_node)
1150
+ child_els = _normalize_children(element.children, owner=element.type)
1151
+ index = 0
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
670
1172
  return vnode
671
1173
 
1174
+ def _create_child_list(self, elements: List[Element]) -> List[VNode]:
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
+ """
1183
+ nodes: List[VNode] = []
1184
+ try:
1185
+ for el in elements:
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
1193
+ except Exception:
1194
+ for node in nodes:
1195
+ self._destroy_tree(node)
1196
+ raise
1197
+ return nodes
1198
+
1199
+ # ------------------------------------------------------------------
1200
+ # Error boundaries
1201
+ # ------------------------------------------------------------------
1202
+
672
1203
  def _create_error_boundary(self, element: Element) -> VNode:
673
- fallback_fn = element.props.get("__fallback__")
674
- eb_children = _flatten_children(element.children)
1204
+ vnode = VNode(element, [])
1205
+ try:
1206
+ children = self._create_child_list(_normalize_children(element.children, owner="ErrorBoundary"))
1207
+ except Exception as exc:
1208
+ self._activate_boundary(vnode, exc)
1209
+ return vnode
1210
+ for child in children:
1211
+ child.parent = vnode
1212
+ vnode.children = children
1213
+ self._refresh_identity(vnode)
1214
+ return vnode
1215
+
1216
+ def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode:
1217
+ old.element = new_el
1218
+
1219
+ if old._error is not None:
1220
+ # Fallback is showing; keep showing it (rebuilt against the
1221
+ # latest fallback prop) until reset() clears the error.
1222
+ fallback_els = self._build_fallback_elements(old, old._error)
1223
+ old.children = self._reconcile_child_list(old.children, fallback_els)
1224
+ for child in old.children:
1225
+ child.parent = old
1226
+ self._refresh_identity(old)
1227
+ return old
1228
+
675
1229
  try:
676
- child_node = self._create_tree(eb_children[0]) if eb_children else None
1230
+ children = self._reconcile_child_list(
1231
+ old.children, _normalize_children(new_el.children, owner="ErrorBoundary")
1232
+ )
1233
+ old.children = children
1234
+ for child in children:
1235
+ child.parent = old
1236
+ self._refresh_identity(old)
677
1237
  except Exception as exc:
678
- if fallback_fn is not None:
679
- fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn
680
- child_node = self._create_tree(fallback_el)
1238
+ self._activate_boundary(old, exc)
1239
+ return old
1240
+
1241
+ def _activate_boundary(self, node: VNode, exc: BaseException) -> None:
1242
+ """Destroy a boundary's failed subtree and mount its fallback.
1243
+
1244
+ Calls the ``on_error`` prop (if any), records the error on the
1245
+ node, and replaces the children with the rendered fallback.
1246
+ Re-raises when the boundary has no fallback, letting an outer
1247
+ boundary (or the screen host) take over.
1248
+ """
1249
+ el = node.element
1250
+ on_error = el.props.get("__on_error__")
1251
+ if callable(on_error):
1252
+ try:
1253
+ on_error(exc)
1254
+ except Exception as cb_exc:
1255
+ diagnostics.warn(f"ErrorBoundary on_error callback raised {cb_exc!r}")
1256
+
1257
+ if el.props.get("__fallback__") is None:
1258
+ raise exc
1259
+
1260
+ for child in node.children:
1261
+ self._destroy_tree(child)
1262
+ node.children = []
1263
+ node._error = exc
1264
+
1265
+ fallback_els = self._build_fallback_elements(node, exc)
1266
+ children = self._create_child_list(fallback_els)
1267
+ for child in children:
1268
+ child.parent = node
1269
+ node.children = children
1270
+ self._refresh_identity(node)
1271
+
1272
+ def _build_fallback_elements(self, node: VNode, exc: BaseException) -> List[Element]:
1273
+ """Render a boundary's fallback prop into a normalized child list."""
1274
+ fallback = node.element.props.get("__fallback__")
1275
+ if fallback is None:
1276
+ return []
1277
+ result: Any = fallback
1278
+ if callable(fallback) and not isinstance(fallback, Element):
1279
+ arity = self._positional_arity(fallback)
1280
+ if arity >= 2:
1281
+ result = fallback(exc, self._make_boundary_reset(node))
1282
+ elif arity == 1:
1283
+ result = fallback(exc)
681
1284
  else:
682
- raise
683
- children = [child_node] if child_node else []
684
- vnode = VNode(element, children)
685
- vnode.tag = child_node.tag if child_node else None
1285
+ result = fallback()
1286
+ return _normalize_children(result, owner="ErrorBoundary.fallback")
1287
+
1288
+ @staticmethod
1289
+ def _positional_arity(fn: Callable) -> int:
1290
+ """Count positional parameters ``fn`` accepts (2+ means unbounded is fine)."""
1291
+ try:
1292
+ sig = inspect.signature(fn)
1293
+ except (TypeError, ValueError):
1294
+ return 1
1295
+ count = 0
1296
+ for p in sig.parameters.values():
1297
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
1298
+ count += 1
1299
+ elif p.kind == inspect.Parameter.VAR_POSITIONAL:
1300
+ return 2
1301
+ return count
1302
+
1303
+ def _make_boundary_reset(self, node: VNode) -> Callable[[], None]:
1304
+ """Return the ``reset`` callable handed to a boundary's fallback."""
1305
+
1306
+ def reset() -> None:
1307
+ if not node.mounted or node._error is None:
1308
+ return
1309
+ node._error = None
1310
+ self._dirty_boundaries[id(node)] = node
1311
+ trigger = self._screen_re_render
1312
+ if trigger is not None:
1313
+ from .hooks import _schedule_trigger
1314
+
1315
+ _schedule_trigger(trigger)
1316
+
1317
+ return reset
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)
686
1326
  return vnode
687
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
+
688
1515
  # ------------------------------------------------------------------
689
1516
  # Reconciliation
690
1517
  # ------------------------------------------------------------------
@@ -695,35 +1522,54 @@ class Reconciler:
695
1522
  self._destroy_tree(old)
696
1523
  return new_node
697
1524
 
698
- # Provider
1525
+ # Provider: detect value changes for reactive context, then
1526
+ # reconcile children under the pushed value.
699
1527
  if new_el.type == "__Provider__":
700
1528
  context = new_el.props["__context__"]
701
- context._stack.append(new_el.props["__value__"])
1529
+ old_context = old.element.props.get("__context__")
1530
+ old_value = old.element.props.get("__value__", _MISSING)
1531
+ new_value = new_el.props["__value__"]
1532
+ if old_context is not context:
1533
+ if old_context is not None:
1534
+ self._mark_context_consumers(old, old_context)
1535
+ self._mark_context_consumers(old, context)
1536
+ elif self._value_changed(old_value, new_value):
1537
+ self._mark_context_consumers(old, context)
1538
+
1539
+ context._stack.append(new_value)
702
1540
  try:
703
- provider_kids = _flatten_children(new_el.children)
704
- if old.children and provider_kids:
705
- child = self._reconcile_node(old.children[0], provider_kids[0])
706
- old.children = [child]
707
- old.tag = child.tag
708
- old.native_view = child.native_view
709
- elif provider_kids:
710
- child = self._create_tree(provider_kids[0])
711
- old.children = [child]
712
- old.tag = child.tag
713
- old.native_view = child.native_view
1541
+ children = self._reconcile_child_list(
1542
+ old.children, _normalize_children(new_el.children, owner="Provider")
1543
+ )
714
1544
  finally:
715
1545
  context._stack.pop()
1546
+ old.children = children
1547
+ for child in children:
1548
+ child.parent = old
716
1549
  old.element = new_el
1550
+ self._refresh_identity(old)
717
1551
  return old
718
1552
 
719
- # Error boundary
1553
+ # Error boundary.
720
1554
  if new_el.type == "__ErrorBoundary__":
721
1555
  return self._reconcile_error_boundary(old, new_el)
722
1556
 
723
- # Function component
724
- if callable(new_el.type):
725
- from .hooks import _set_hook_state
1557
+ # Suspense boundary.
1558
+ if new_el.type == "__Suspense__":
1559
+ return self._reconcile_suspense(old, new_el)
1560
+
1561
+ # Keyed fragment: transparent multi-child wrapper.
1562
+ if new_el.type == "__Fragment__":
1563
+ children = self._reconcile_child_list(old.children, _normalize_children(new_el.children, owner="Fragment"))
1564
+ old.children = children
1565
+ for child in children:
1566
+ child.parent = old
1567
+ old.element = new_el
1568
+ self._refresh_identity(old)
1569
+ return old
726
1570
 
1571
+ # Function component.
1572
+ if callable(new_el.type):
727
1573
  # ``@memo`` skip: if the props haven't changed shallowly and
728
1574
  # the component's own hook state is clean (no setter fired
729
1575
  # while we were rebuilding the parent tree), reuse the
@@ -737,30 +1583,20 @@ class Reconciler:
737
1583
  from .hooks import HookState
738
1584
 
739
1585
  hook_state = HookState()
740
- hook_state.reset_index()
741
- hook_state._trigger_render = self._screen_re_render
742
- _set_hook_state(hook_state)
743
- try:
744
- rendered = new_el.type(**new_el.props)
745
- finally:
746
- _set_hook_state(None)
747
- hook_state._dirty = False
748
-
749
- if old.children:
750
- child = self._reconcile_node(old.children[0], rendered)
751
- else:
752
- child = self._create_tree(rendered)
753
- old.children = [child]
754
- old.tag = child.tag
755
- old.native_view = child.native_view
1586
+ rendered = self._render_component_body(hook_state, new_el)
1587
+ children = self._reconcile_child_list(old.children, rendered)
1588
+ old.children = children
1589
+ for child in children:
1590
+ child.parent = old
756
1591
  old.element = new_el
757
1592
  old.hook_state = hook_state
758
1593
  old._rendered = rendered
1594
+ self._refresh_identity(old)
759
1595
  hook_state._vnode = old
760
1596
  hook_state._reconciler = self
761
1597
  return old
762
1598
 
763
- # Native element
1599
+ # Native element.
764
1600
  new_clean, events = self._split_props(new_el.props)
765
1601
  if old.tag is not None:
766
1602
  self._events.set_events(old.tag, events)
@@ -773,50 +1609,52 @@ class Reconciler:
773
1609
  self._mark_layout_dirty(old)
774
1610
  old._clean_props = new_clean
775
1611
 
776
- # Re-attach the ref if the ref dict identity changed (so we
777
- # never leave a stale ref pointing at a destroyed view, and so
778
- # a freshly-supplied ref gets ``current`` populated on update).
1612
+ # Re-attach the ref if the ref identity changed (so we never
1613
+ # leave a stale ref pointing at a destroyed view, and so a
1614
+ # freshly-supplied ref gets ``current`` populated on update).
779
1615
  old_ref = old.element.props.get("ref") if old.element.props else None
780
1616
  new_ref = new_el.props.get("ref") if new_el.props else None
781
1617
  if old_ref is not new_ref:
782
- if isinstance(old_ref, dict):
783
- try:
784
- old_ref["current"] = None
785
- except Exception:
786
- pass
1618
+ self._clear_ref(old_ref)
787
1619
  self._attach_ref(new_el, old.native_view, old.tag)
788
1620
 
789
- self._reconcile_children(old, new_el.children)
1621
+ self._reconcile_native_children(old, new_el.children)
790
1622
  old.element = new_el
791
1623
  return old
792
1624
 
793
- def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode:
794
- fallback_fn = new_el.props.get("__fallback__")
795
- eb_kids = _flatten_children(new_el.children)
1625
+ @staticmethod
1626
+ def _value_changed(old_value: Any, new_value: Any) -> bool:
1627
+ if old_value is _MISSING:
1628
+ return True
1629
+ if old_value is new_value:
1630
+ return False
796
1631
  try:
797
- if old.children and eb_kids:
798
- child = self._reconcile_node(old.children[0], eb_kids[0])
799
- old.children = [child]
800
- old.tag = child.tag
801
- old.native_view = child.native_view
802
- elif eb_kids:
803
- child = self._create_tree(eb_kids[0])
804
- old.children = [child]
805
- old.tag = child.tag
806
- old.native_view = child.native_view
807
- except Exception as exc:
808
- for c in old.children:
809
- self._destroy_tree(c)
810
- if fallback_fn is not None:
811
- fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn
812
- child = self._create_tree(fallback_el)
813
- old.children = [child]
814
- old.tag = child.tag
815
- old.native_view = child.native_view
816
- else:
817
- raise
818
- old.element = new_el
819
- return old
1632
+ return bool(old_value != new_value)
1633
+ except Exception:
1634
+ return True
1635
+
1636
+ def _mark_context_consumers(self, provider_vnode: VNode, context: Any) -> None:
1637
+ """Mark every descendant that read ``context`` for re-render.
1638
+
1639
+ This is what makes context *reactive*: consumers re-render when
1640
+ the Provider value changes even if a memoized ancestor skips.
1641
+ Descent is pruned at nested Providers of the same context, since
1642
+ their subtrees read the inner (unchanged) value.
1643
+ """
1644
+ target = id(context)
1645
+
1646
+ def walk(node: VNode) -> None:
1647
+ for child in node.children:
1648
+ el = child.element
1649
+ if isinstance(el.type, str) and el.type == "__Provider__" and el.props.get("__context__") is context:
1650
+ continue
1651
+ hs = child.hook_state
1652
+ if hs is not None and target in hs.context_deps:
1653
+ hs._dirty = True
1654
+ self._dirty_nodes[id(child)] = child
1655
+ walk(child)
1656
+
1657
+ walk(provider_vnode)
820
1658
 
821
1659
  @staticmethod
822
1660
  def _can_skip_memoized(old: VNode, new_el: Element) -> bool:
@@ -828,7 +1666,8 @@ class Reconciler:
828
1666
  [`memo`][pythonnative.memo].
829
1667
  2. It has been rendered before (``old._rendered`` is populated).
830
1668
  3. None of its internal state setters fired since the last
831
- render (``hook_state._dirty`` is ``False``).
1669
+ render, and no context it reads changed
1670
+ (``hook_state._dirty`` is ``False``).
832
1671
  4. The new props are shallowly equal to the old props.
833
1672
  """
834
1673
  fn = new_el.type
@@ -843,12 +1682,20 @@ class Reconciler:
843
1682
  return False
844
1683
  return _shallow_equal_props(old.element.props, new_el.props)
845
1684
 
846
- def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None:
847
- new_children = _flatten_children(new_children)
848
- old_children = parent.children
849
- is_native = self._is_native_node(parent)
850
- parent_tag = parent.tag if is_native else None
1685
+ def _reconcile_child_list(self, old_children: List[VNode], new_children: List[Element]) -> List[VNode]:
1686
+ """Match, reconcile, create, and destroy one level of children.
1687
+
1688
+ Pure structural pass shared by native containers and
1689
+ transparent wrappers: it emits no `InsertOp`s itself. Native
1690
+ attachment order is derived afterwards by the caller (see
1691
+ [`_reconcile_native_children`][pythonnative.reconciler.Reconciler._reconcile_native_children]
1692
+ and
1693
+ [`_bubble_structure_change`][pythonnative.reconciler.Reconciler._bubble_structure_change]).
851
1694
 
1695
+ On failure, any fully created replacement nodes are destroyed
1696
+ before the exception propagates so an enclosing error boundary
1697
+ can swap in its fallback without leaking native views.
1698
+ """
852
1699
  old_by_key: dict = {}
853
1700
  old_unkeyed: list = []
854
1701
  for child in old_children:
@@ -858,99 +1705,104 @@ class Reconciler:
858
1705
  old_unkeyed.append(child)
859
1706
 
860
1707
  new_child_nodes: List[VNode] = []
1708
+ fresh_nodes: List[VNode] = []
861
1709
  used_keyed: set = set()
862
1710
  unkeyed_iter = iter(old_unkeyed)
863
- # ``(index, vnode)`` pairs that need an indexed insert once the
864
- # stale children have been removed (see op-ordering note below).
865
- pending_inserts: List[Tuple[int, VNode]] = []
866
- structure_changed = False
867
-
868
- for i, new_el in enumerate(new_children):
869
- matched: Optional[VNode] = None
870
-
871
- if new_el.key is not None and new_el.key in old_by_key:
872
- matched = old_by_key[new_el.key]
873
- used_keyed.add(new_el.key)
874
- elif new_el.key is None:
875
- matched = next(unkeyed_iter, None)
876
-
877
- if matched is None:
878
- node = self._create_tree(new_el)
879
- pending_inserts.append((i, node))
880
- structure_changed = True
881
- new_child_nodes.append(node)
882
- elif not self._same_type(matched.element, new_el):
883
- node = self._create_tree(new_el)
884
- self._destroy_tree(matched)
885
- pending_inserts.append((i, node))
886
- structure_changed = True
887
- new_child_nodes.append(node)
888
- else:
889
- old_tag = matched.tag
890
- updated = self._reconcile_node(matched, new_el)
891
- if updated.tag != old_tag:
892
- # The child's subtree root was replaced in place
893
- # (transparent wrapper whose output changed type).
894
- pending_inserts.append((i, updated))
895
- structure_changed = True
896
- new_child_nodes.append(updated)
897
-
898
- # Destroy unused old nodes first: handlers detach on destroy, so
899
- # the native child list contains only kept children (in their old
900
- # relative order) by the time the indexed inserts apply.
1711
+
1712
+ try:
1713
+ for new_el in new_children:
1714
+ matched: Optional[VNode] = None
1715
+
1716
+ if new_el.key is not None and new_el.key in old_by_key:
1717
+ matched = old_by_key[new_el.key]
1718
+ used_keyed.add(new_el.key)
1719
+ elif new_el.key is None:
1720
+ matched = next(unkeyed_iter, None)
1721
+
1722
+ if matched is None:
1723
+ node = self._create_tree(new_el)
1724
+ fresh_nodes.append(node)
1725
+ new_child_nodes.append(node)
1726
+ elif not self._same_type(matched.element, new_el):
1727
+ node = self._create_tree(new_el)
1728
+ self._destroy_tree(matched)
1729
+ fresh_nodes.append(node)
1730
+ new_child_nodes.append(node)
1731
+ else:
1732
+ new_child_nodes.append(self._reconcile_node(matched, new_el))
1733
+ except Exception:
1734
+ for node in fresh_nodes:
1735
+ self._destroy_tree(node)
1736
+ raise
1737
+
901
1738
  for key, node in old_by_key.items():
902
1739
  if key not in used_keyed:
903
1740
  self._destroy_tree(node)
904
- structure_changed = True
905
1741
  for node in unkeyed_iter:
906
1742
  self._destroy_tree(node)
907
- structure_changed = True
908
1743
 
909
- if is_native and parent_tag is not None:
910
- for index, node in pending_inserts:
911
- if node.tag is not None:
912
- self._ops.append(InsertOp(parent_tag, node.tag, index))
913
-
914
- # Keyed reorder: when the kept children changed relative
915
- # order, emit one move-aware insert per child in final
916
- # order. Applying "ensure child at index i" sequentially for
917
- # i = 0..n-1 converges to the target order, and handlers
918
- # no-op when the child is already in place.
919
- if used_keyed:
920
- old_key_order = [c.element.key for c in old_children if c.element.key in used_keyed]
921
- new_key_order = [n.element.key for n in new_child_nodes if n.element.key in used_keyed]
922
- if old_key_order != new_key_order:
923
- structure_changed = True
924
- for i, node in enumerate(new_child_nodes):
925
- if node.tag is not None:
926
- self._ops.append(InsertOp(parent_tag, node.tag, i))
927
-
928
- if structure_changed:
929
- self._mark_layout_dirty(parent)
930
-
931
- parent.children = new_child_nodes
932
-
933
- def _destroy_tree(self, node: VNode) -> None:
1744
+ return new_child_nodes
1745
+
1746
+ def _reconcile_native_children(self, parent: VNode, new_children: List[Element]) -> None:
1747
+ """Reconcile a native container's children and sync its native child list."""
1748
+ before_tags = [r.tag for r in self._flattened_child_roots(parent)]
1749
+ new_els = _normalize_children(new_children, owner=self._type_label(parent.element.type))
1750
+ children = self._reconcile_child_list(parent.children, new_els)
1751
+ parent.children = children
1752
+ for child in children:
1753
+ child.parent = parent
1754
+
1755
+ if parent.tag is not None:
1756
+ changed = self._sync_native_children(parent.tag, before_tags, self._flattened_child_roots(parent))
1757
+ if changed:
1758
+ self._mark_layout_dirty(parent)
1759
+
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
+ """
1772
+ if not node.mounted:
1773
+ return
934
1774
  node.mounted = False
935
- # Drop the node from the pending-render set so a setter that
1775
+ # Drop the node from the pending-render sets so a setter that
936
1776
  # fired moments before unmount can't resurrect a dead subtree.
937
1777
  self._dirty_nodes.pop(id(node), None)
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
938
1783
  if node.hook_state is not None:
939
- node.hook_state.cleanup_all_effects()
940
- # Break the back-references so the unmounted component's hook
941
- # state (and the closures it captured) can be freed by plain
942
- # refcounting, important on iOS, where the cyclic GC is
943
- # disabled.
944
- node.hook_state._vnode = None
945
- node.hook_state._reconciler = None
946
- 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
947
1798
  if node.element is not None:
948
1799
  self._detach_ref(node.element)
949
1800
  for child in node.children:
950
- self._destroy_tree(child)
1801
+ self._destroy_tree(child, salvage=salvage)
951
1802
  if self._is_native_node(node) and node.tag is not None:
952
1803
  self._events.clear(node.tag)
953
1804
  self._ops.append(DestroyOp(node.tag))
1805
+ self._destroyed_tags.add(node.tag)
954
1806
  node.children = []
955
1807
  node.parent = None
956
1808
  node._layout_node = None
@@ -1006,22 +1858,35 @@ class Reconciler:
1006
1858
 
1007
1859
  @staticmethod
1008
1860
  def _attach_ref(element: Element, native_view: Any, tag: Optional[int]) -> None:
1009
- """Populate ``ref["current"]`` (and the internal tag) if a ``ref`` prop exists."""
1010
- ref = element.props.get("ref") if element.props else None
1011
- if isinstance(ref, dict):
1012
- ref["current"] = native_view
1013
- ref["_pn_tag"] = tag
1014
-
1015
- @staticmethod
1016
- def _detach_ref(element: Element) -> None:
1017
- """Clear ``ref["current"]`` so consumers don't hold a stale handle."""
1861
+ """Populate ``ref.current`` (and the internal tag) if a ``ref`` prop exists."""
1018
1862
  ref = element.props.get("ref") if element.props else None
1019
- if isinstance(ref, dict):
1863
+ if ref is None:
1864
+ return
1865
+ if hasattr(ref, "current"):
1866
+ ref.current = native_view
1020
1867
  try:
1021
- ref["current"] = None
1022
- ref["_pn_tag"] = None
1868
+ ref._pn_tag = tag
1023
1869
  except Exception:
1024
1870
  pass
1871
+ elif diagnostics.is_dev():
1872
+ diagnostics.warn_once(
1873
+ f"Ignoring ref of type {type(ref).__name__}; pass the Ref returned by use_ref().",
1874
+ key=f"badref:{type(ref).__name__}",
1875
+ )
1876
+
1877
+ def _detach_ref(self, element: Element) -> None:
1878
+ """Clear ``ref.current`` so consumers don't hold a stale handle."""
1879
+ self._clear_ref(element.props.get("ref") if element.props else None)
1880
+
1881
+ @staticmethod
1882
+ def _clear_ref(ref: Any) -> None:
1883
+ if ref is None or not hasattr(ref, "current"):
1884
+ return
1885
+ try:
1886
+ ref.current = None
1887
+ ref._pn_tag = None
1888
+ except Exception:
1889
+ pass
1025
1890
 
1026
1891
  @staticmethod
1027
1892
  def _same_type(old_el: Element, new_el: Element) -> bool:
@@ -1051,6 +1916,15 @@ class Reconciler:
1051
1916
  }
1052
1917
  )
1053
1918
 
1919
+ # Childless native leaves that get a measure callback. Extends the
1920
+ # intrinsic set with ``VirtualList``, whose handlers report "fill
1921
+ # the available space" (like a ScrollView clamped to its parent):
1922
+ # without the callback an unstyled list would collapse to 0 points
1923
+ # and the platform virtualizer would never bind a row. Kept out of
1924
+ # ``_INTRINSIC_TYPES`` because its *frame* depends only on
1925
+ # available space, so data-prop changes need no layout pass.
1926
+ _MEASURED_LEAF_TYPES = _INTRINSIC_TYPES | {"VirtualList"}
1927
+
1054
1928
  @classmethod
1055
1929
  def _affects_layout(cls, type_name: str, changed: Dict[str, Any]) -> bool:
1056
1930
  """Whether ``changed`` props can alter the node's layout.
@@ -1073,7 +1947,7 @@ class Reconciler:
1073
1947
  def _run_layout(self) -> None:
1074
1948
  """Build/refresh the layout tree, compute frames, and emit changed ones.
1075
1949
 
1076
- Wraps the user's root VNode in a synthetic outer `LayoutNode`
1950
+ Wraps the user's native roots in a synthetic outer `LayoutNode`
1077
1951
  with the viewport size so the user's root always fills the
1078
1952
  screen by default (matching React Native). Skipped silently
1079
1953
  until the screen host has supplied a viewport size via
@@ -1085,7 +1959,7 @@ class Reconciler:
1085
1959
  flex math (see ``pythonnative.layout``). Only frames that
1086
1960
  differ from the previously applied frame produce `SetFrameOp`s.
1087
1961
 
1088
- The root native view's *frame* is intentionally NOT touched:
1962
+ The first native root's *frame* is intentionally NOT touched:
1089
1963
  its position and size are owned by the screen host (iOS
1090
1964
  ``_sync_root_frame`` places it below the top safe-area
1091
1965
  inset; Android attaches it with ``MATCH_PARENT``). Framing
@@ -1100,55 +1974,64 @@ class Reconciler:
1100
1974
  return
1101
1975
 
1102
1976
  self._layout_pass += 1
1103
- layout_root = self._build_layout_tree_cached(self._tree)
1104
- if layout_root is None:
1105
- return
1106
-
1107
- viewport = LayoutNode(
1108
- style={"width": viewport_w, "height": viewport_h},
1109
- children=[layout_root],
1110
- )
1111
- viewport.dirty = True
1112
- calculate_layout(viewport, viewport_w, viewport_h)
1113
- # Skip set_frame for the root itself; descendants are
1114
- # positioned relative to the root's local origin, which is
1115
- # what they want regardless of where the host placed the
1116
- # root in the screen.
1117
- for child in layout_root.children:
1118
- self._collect_frames(child, 0.0, 0.0)
1119
- # Lay out the children of every visible ``Modal`` as a fresh
1120
- # subtree sized to the viewport. Modals are excluded from the
1121
- # main layout tree (their content lives in a separately
1122
- # presented native container) so without this pass the
1123
- # children's frames never get computed and the modal renders
1124
- # blank.
1125
- self._layout_visible_modals(self._tree, viewport_w, viewport_h)
1977
+ layout_roots = self._build_layout_list_cached(self._tree)
1978
+ if layout_roots:
1979
+ viewport = LayoutNode(
1980
+ style={"width": viewport_w, "height": viewport_h},
1981
+ children=list(layout_roots),
1982
+ )
1983
+ viewport.dirty = True
1984
+ calculate_layout(viewport, viewport_w, viewport_h)
1985
+ # Skip set_frame for the first (host-attached) root itself;
1986
+ # its descendants are positioned relative to the root's
1987
+ # local origin, which is what they want regardless of where
1988
+ # the host placed the root in the screen.
1989
+ for i, root in enumerate(layout_roots):
1990
+ if i == 0:
1991
+ for child in root.children:
1992
+ self._collect_frames(child, 0.0, 0.0)
1993
+ else:
1994
+ self._collect_frames(root, 0.0, 0.0)
1995
+ # Lay out the children of every visible ``Modal`` and every
1996
+ # ``Portal`` as a fresh subtree sized to the viewport. Detached
1997
+ # subtrees are excluded from the main layout tree (their content
1998
+ # lives in a separately presented native container) so without
1999
+ # this pass the children's frames never get computed and the
2000
+ # overlay renders blank.
2001
+ self._layout_detached_subtrees(self._tree, viewport_w, viewport_h)
1126
2002
  self._clear_layout_dirty(self._tree)
1127
2003
 
1128
- def _layout_visible_modals(
2004
+ def _layout_detached_subtrees(
1129
2005
  self,
1130
2006
  vnode: VNode,
1131
2007
  viewport_w: float,
1132
2008
  viewport_h: float,
1133
2009
  ) -> None:
1134
2010
  element = vnode.element
1135
- if isinstance(element.type, str) and element.type == "Modal":
1136
- if element.props.get("visible") and vnode.children:
1137
- child_layout = self._build_layout_tree(vnode.children[0])
1138
- if child_layout is not None:
1139
- viewport = LayoutNode(
1140
- style={"width": viewport_w, "height": viewport_h},
1141
- children=[child_layout],
1142
- )
1143
- calculate_layout(viewport, viewport_w, viewport_h)
1144
- for c in viewport.children:
1145
- self._collect_frames(c, 0.0, 0.0)
2011
+ if isinstance(element.type, str) and element.type in _DETACHED_TYPES:
2012
+ active = bool(element.props.get("visible")) if element.type == "Modal" else True
2013
+ if not active:
2014
+ return
2015
+ child_layouts: List[LayoutNode] = []
2016
+ for child in vnode.children:
2017
+ child_layouts.extend(self._build_layout_list(child))
2018
+ if child_layouts:
2019
+ viewport = LayoutNode(
2020
+ style={"width": viewport_w, "height": viewport_h},
2021
+ children=child_layouts,
2022
+ )
2023
+ calculate_layout(viewport, viewport_w, viewport_h)
2024
+ for c in viewport.children:
2025
+ self._collect_frames(c, 0.0, 0.0)
2026
+ # Recurse so overlays nested inside this overlay lay out too.
2027
+ for child in vnode.children:
2028
+ self._layout_detached_subtrees(child, viewport_w, viewport_h)
1146
2029
  return
1147
2030
  for child in vnode.children:
1148
- self._layout_visible_modals(child, viewport_w, viewport_h)
2031
+ self._layout_detached_subtrees(child, viewport_w, viewport_h)
1149
2032
 
1150
- def _build_layout_tree_cached(self, vnode: VNode) -> Optional[LayoutNode]:
1151
- """Like `_build_layout_tree` but reuses cached subtrees when clean.
2033
+ def _build_layout_list_cached(self, vnode: VNode) -> List[LayoutNode]:
2034
+ """Like `_build_layout_list` but reuses cached subtrees when clean.
1152
2035
 
1153
2036
  A VNode's cached `LayoutNode` is reused when the node itself is
1154
2037
  layout-clean and every child produced its cached node too (i.e.
@@ -1158,20 +2041,17 @@ class Reconciler:
1158
2041
  forces fresh flex math along the changed path.
1159
2042
  """
1160
2043
  element = vnode.element
1161
- if not isinstance(element.type, str) or element.type in (
1162
- "__Provider__",
1163
- "__ErrorBoundary__",
1164
- "__Fragment__",
1165
- ):
1166
- return self._build_layout_tree_cached(vnode.children[0]) if vnode.children else None
1167
- if element.type == "Modal":
1168
- return None # Off-screen placeholder; not part of the visible flow.
2044
+ if not isinstance(element.type, str) or element.type in _TRANSPARENT_TYPES:
2045
+ out: List[LayoutNode] = []
2046
+ for child in vnode.children:
2047
+ out.extend(self._build_layout_list_cached(child))
2048
+ return out
2049
+ if element.type in _DETACHED_TYPES:
2050
+ return [] # Off-screen placeholder; not part of the visible flow.
1169
2051
 
1170
2052
  child_layouts: List[LayoutNode] = []
1171
2053
  for child_vnode in vnode.children:
1172
- child_layout = self._build_layout_tree_cached(child_vnode)
1173
- if child_layout is not None:
1174
- child_layouts.append(child_layout)
2054
+ child_layouts.extend(self._build_layout_list_cached(child_vnode))
1175
2055
 
1176
2056
  cached = vnode._layout_node
1177
2057
  if cached is not None and not vnode._layout_dirty:
@@ -1179,7 +2059,7 @@ class Reconciler:
1179
2059
  if len(cached_children) == len(child_layouts) and all(
1180
2060
  a is b for a, b in zip(cached_children, child_layouts)
1181
2061
  ):
1182
- return cached
2062
+ return [cached]
1183
2063
 
1184
2064
  layout = LayoutNode(style=extract_layout_style(element.props), user_data=vnode)
1185
2065
  layout.dirty = True
@@ -1206,7 +2086,7 @@ class Reconciler:
1206
2086
  layout.children.append(child_layout)
1207
2087
 
1208
2088
  vnode._layout_node = layout
1209
- return layout
2089
+ return [layout]
1210
2090
 
1211
2091
  @staticmethod
1212
2092
  def _direct_child_layouts(layout: LayoutNode, element: Element) -> List[LayoutNode]:
@@ -1228,24 +2108,25 @@ class Reconciler:
1228
2108
  for child in vnode.children:
1229
2109
  self._clear_layout_dirty(child)
1230
2110
 
1231
- def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]:
1232
- """Build a fresh (uncached) `LayoutNode` tree for ``vnode``.
2111
+ def _build_layout_list(self, vnode: VNode) -> List[LayoutNode]:
2112
+ """Build fresh (uncached) `LayoutNode`s for ``vnode``.
1233
2113
 
1234
- Used for Modal content (laid out against the viewport each
1235
- pass) and by
2114
+ Used for detached content (Modal / Portal, laid out against the
2115
+ viewport each pass) and by
1236
2116
  [`compute_layout_for_test`][pythonnative.reconciler.Reconciler.compute_layout_for_test].
1237
- Function components, providers, and error boundaries are
1238
- transparent: they delegate to their (single) child. Native
1239
- nodes contribute a `LayoutNode` whose ``user_data`` points
1240
- back to the VNode so the layout pass can apply frames.
2117
+ Function components, providers, boundaries, and fragments are
2118
+ transparent: they contribute their children's layout nodes.
2119
+ Native nodes contribute a `LayoutNode` whose ``user_data``
2120
+ points back to the VNode so the layout pass can apply frames.
1241
2121
  """
1242
2122
  element = vnode.element
1243
- if not isinstance(element.type, str):
1244
- return self._build_layout_tree(vnode.children[0]) if vnode.children else None
1245
- if element.type in ("__Provider__", "__ErrorBoundary__", "__Fragment__"):
1246
- return self._build_layout_tree(vnode.children[0]) if vnode.children else None
1247
- if element.type == "Modal":
1248
- return None
2123
+ if not isinstance(element.type, str) or element.type in _TRANSPARENT_TYPES:
2124
+ out: List[LayoutNode] = []
2125
+ for child in vnode.children:
2126
+ out.extend(self._build_layout_list(child))
2127
+ return out
2128
+ if element.type in _DETACHED_TYPES:
2129
+ return []
1249
2130
 
1250
2131
  style = extract_layout_style(element.props)
1251
2132
  layout = LayoutNode(style=style, user_data=vnode)
@@ -1260,18 +2141,16 @@ class Reconciler:
1260
2141
  layout.measure = measure
1261
2142
 
1262
2143
  for child_vnode in vnode.children:
1263
- child_layout = self._build_layout_tree(child_vnode)
1264
- if child_layout is None:
1265
- continue
1266
- if element.type == "ScrollView":
1267
- # ScrollView's child sees an unbounded main-axis viewport so it
1268
- # can size to its full content (the scrollable region).
1269
- axis = element.props.get("scroll_axis", "vertical")
1270
- child_layout = self._wrap_scroll_axis(child_layout, axis="x" if axis == "horizontal" else "y")
1271
- child_layout.dirty = True
1272
- layout.children.append(child_layout)
2144
+ for child_layout in self._build_layout_list(child_vnode):
2145
+ if element.type == "ScrollView":
2146
+ # ScrollView's child sees an unbounded main-axis viewport so it
2147
+ # can size to its full content (the scrollable region).
2148
+ axis = element.props.get("scroll_axis", "vertical")
2149
+ child_layout = self._wrap_scroll_axis(child_layout, axis="x" if axis == "horizontal" else "y")
2150
+ child_layout.dirty = True
2151
+ layout.children.append(child_layout)
1273
2152
 
1274
- return layout
2153
+ return [layout]
1275
2154
 
1276
2155
  @staticmethod
1277
2156
  def _wrap_scroll_axis(child: LayoutNode, axis: str) -> LayoutNode:
@@ -1290,7 +2169,7 @@ class Reconciler:
1290
2169
  def _make_measure_callback(self, vnode: VNode) -> Optional[Any]:
1291
2170
  """Return a measure callback for ``vnode`` if it has an intrinsic size."""
1292
2171
  type_name = vnode.element.type
1293
- if type_name not in self._INTRINSIC_TYPES:
2172
+ if type_name not in self._MEASURED_LEAF_TYPES:
1294
2173
  return None
1295
2174
  if vnode.tag is None:
1296
2175
  return None
@@ -1332,8 +2211,11 @@ class Reconciler:
1332
2211
  # Python code can read measured geometry without a
1333
2212
  # native round-trip (used by FlatList's virtualization).
1334
2213
  ref = vnode.element.props.get("ref") if vnode.element.props else None
1335
- if isinstance(ref, dict):
1336
- ref["_pn_frame"] = frame
2214
+ if ref is not None and hasattr(ref, "current"):
2215
+ try:
2216
+ ref._pn_frame = frame
2217
+ except Exception:
2218
+ pass
1337
2219
  child_offset_x = 0.0
1338
2220
  child_offset_y = 0.0
1339
2221
  else:
@@ -1343,6 +2225,27 @@ class Reconciler:
1343
2225
  for child in layout_node.children:
1344
2226
  self._collect_frames(child, child_offset_x, child_offset_y)
1345
2227
 
2228
+ # ------------------------------------------------------------------
2229
+ # Hot-reload support
2230
+ # ------------------------------------------------------------------
2231
+
2232
+ def reset_hook_signatures(self) -> None:
2233
+ """Forget recorded hook-order signatures across the whole tree.
2234
+
2235
+ Called by the hot-reload machinery after a Fast Refresh swaps in
2236
+ new component bodies, since the old call signatures no longer
2237
+ apply.
2238
+ """
2239
+
2240
+ def walk(node: VNode) -> None:
2241
+ if node.hook_state is not None:
2242
+ node.hook_state.reset_hook_signature()
2243
+ for child in node.children:
2244
+ walk(child)
2245
+
2246
+ if self._tree is not None:
2247
+ walk(self._tree)
2248
+
1346
2249
  # ------------------------------------------------------------------
1347
2250
  # Test / debug accessor
1348
2251
  # ------------------------------------------------------------------
@@ -1356,12 +2259,12 @@ class Reconciler:
1356
2259
  """
1357
2260
  if self._tree is None:
1358
2261
  return None
1359
- layout_root = self._build_layout_tree(self._tree)
1360
- if layout_root is None:
2262
+ layout_roots = self._build_layout_list(self._tree)
2263
+ if not layout_roots:
1361
2264
  return None
1362
2265
  viewport = LayoutNode(
1363
2266
  style={"width": viewport_width, "height": viewport_height},
1364
- children=[layout_root],
2267
+ children=list(layout_roots),
1365
2268
  )
1366
2269
  calculate_layout(viewport, viewport_width, viewport_height)
1367
2270
  return viewport