pythonnative 0.23.0__py3-none-any.whl → 0.24.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,31 @@ 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
+ - **Fragments** (`type == "__Fragment__"`), expanded inline unless
33
+ keyed, in which case they participate in keyed reconciliation as a
34
+ transparent multi-child wrapper.
35
+ - **Portals** (`type == "Portal"`), native elements whose handler hosts
36
+ their children in a top-level overlay. Portals contribute no child to
37
+ their native parent; their subtree is laid out against the viewport
38
+ like a `Modal`.
26
39
  - **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.
40
+ computed by simulating the native child list, so appends and keyed
41
+ reorders emit the minimal set of `InsertOp`s.
42
+ - **Two effect phases**: layout effects
43
+ ([`use_layout_effect`][pythonnative.use_layout_effect]) flush
44
+ synchronously after mutations and layout; passive effects
45
+ ([`use_effect`][pythonnative.use_effect]) flush afterwards.
31
46
  - **Incremental layout**: a parallel
32
47
  [`LayoutNode`][pythonnative.layout.LayoutNode] tree is cached across
33
48
  passes; clean subtrees keep their cached nodes (enabling the layout
@@ -35,22 +50,36 @@ Supports:
35
50
  sent to the native side.
36
51
  """
37
52
 
53
+ import inspect
38
54
  import itertools
39
55
  import os
40
- from typing import Any, Dict, List, Optional, Tuple
56
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
41
57
 
58
+ from . import diagnostics
42
59
  from .element import Element
43
60
  from .events import extract_events, get_event_registry
44
61
  from .layout import LayoutNode, calculate_layout, extract_layout_style
45
62
  from .mutations import CreateOp, DestroyOp, InsertOp, Mutation, SetFrameOp, UpdateOp
46
63
 
47
64
  # 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.
65
+ # native handler). ``ref`` is one such prop: components pass a
66
+ # [`Ref`][pythonnative.Ref] from ``use_ref()`` and the reconciler
67
+ # populates ``ref.current`` with the underlying native view (and
68
+ # ``ref._pn_tag`` with the view's tag), mirroring React's ``ref``
69
+ # semantics.
52
70
  _RECONCILER_OWNED_PROPS = frozenset({"ref"})
53
71
 
72
+ # Element types that never own a native view: they are transparent
73
+ # wrappers whose children mount directly into the surrounding native
74
+ # parent.
75
+ _TRANSPARENT_TYPES = frozenset({"__Provider__", "__ErrorBoundary__", "__Fragment__"})
76
+
77
+ # Native element types whose subtree is laid out against the viewport
78
+ # in a detached pass instead of participating in the main layout flow.
79
+ _DETACHED_TYPES = frozenset({"Modal", "Portal"})
80
+
81
+ _MISSING = object()
82
+
54
83
  # Tags are globally unique so multiple reconcilers (screens, list rows)
55
84
  # can share one registry without collisions.
56
85
  _tag_counter = itertools.count(1)
@@ -88,28 +117,58 @@ def _shallow_equal_props(old: dict, new: dict) -> bool:
88
117
  return True
89
118
 
90
119
 
91
- def _flatten_children(children: List[Element]) -> List[Element]:
92
- """Expand [`Fragment`][pythonnative.Fragment] elements inline.
93
-
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.
120
+ def _normalize_children(children: Any, owner: str = "") -> List[Element]:
121
+ """Normalize arbitrary render output into a flat list of Elements.
98
122
 
99
- Args:
100
- children: An ordered child list possibly containing Fragments.
123
+ Accepts a single element, ``None``, ``True``/``False`` (both
124
+ skipped, enabling inline conditionals like ``cond and Text(...)``),
125
+ lists/tuples (flattened recursively), and unkeyed Fragments
126
+ (expanded inline so they never touch the native tree). Keyed
127
+ Fragments are preserved so they can participate in keyed
128
+ reconciliation as a unit.
101
129
 
102
- Returns:
103
- A new list with every Fragment recursively expanded in place.
130
+ Non-Element values other than the above are dropped with a
131
+ dev-mode warning.
104
132
  """
105
- if not children:
106
- return list(children)
107
133
  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)
134
+
135
+ def add(item: Any) -> None:
136
+ if item is None or item is True or item is False:
137
+ return
138
+ if isinstance(item, (list, tuple)):
139
+ for sub in item:
140
+ add(sub)
141
+ return
142
+ if isinstance(item, Element):
143
+ if isinstance(item.type, str) and item.type == "__Fragment__" and item.key is None:
144
+ for sub in item.children:
145
+ add(sub)
146
+ return
147
+ out.append(item)
148
+ return
149
+ diagnostics.warn_once(
150
+ f"Ignoring non-Element child {item!r} ({type(item).__name__})"
151
+ + (f" under {owner}" if owner else "")
152
+ + ". Children must be Elements, lists of Elements, or None/False for conditionals.",
153
+ key=f"badchild:{owner}:{type(item).__name__}",
154
+ )
155
+
156
+ add(children)
157
+
158
+ if diagnostics.is_dev() and len(out) > 1:
159
+ seen: Set[Any] = set()
160
+ for el in out:
161
+ if el.key is None:
162
+ continue
163
+ if el.key in seen:
164
+ diagnostics.warn_once(
165
+ f"Duplicate key {el.key!r} among children"
166
+ + (f" of {owner}" if owner else "")
167
+ + ". Keys must be unique among siblings; duplicates break "
168
+ "keyed reconciliation and can cross-wire component state.",
169
+ key=f"dupkey:{owner}:{el.key!r}",
170
+ )
171
+ seen.add(el.key)
113
172
  return out
114
173
 
115
174
 
@@ -123,16 +182,18 @@ class VNode:
123
182
  element: The `Element` last rendered into this slot.
124
183
  tag: Integer identity of the underlying native view. Native
125
184
  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.
185
+ components, providers, boundaries, keyed fragments)
186
+ delegate the tag of their first native root. ``None`` when
187
+ the subtree renders no native view.
129
188
  native_view: The platform-native view object, resolved from the
130
189
  registry after commit. May be `None` for purely virtual
131
190
  wrappers that rendered nothing.
132
- children: Ordered list of child `VNode` instances.
191
+ children: Ordered list of child `VNode` instances. Wrappers may
192
+ own any number of children; each child contributes zero or
193
+ more native roots to the nearest native ancestor.
133
194
  parent: The owning `VNode`, or `None` for the tree root. Used
134
195
  by local (component-scoped) re-renders to bubble a changed
135
- subtree root up to the nearest native container.
196
+ subtree up to the nearest native container.
136
197
  hook_state: The component's
137
198
  [`HookState`][pythonnative.hooks.HookState] when the node
138
199
  wraps a function component, otherwise `None`.
@@ -154,6 +215,7 @@ class VNode:
154
215
  "_last_frame",
155
216
  "_layout_node",
156
217
  "_layout_dirty",
218
+ "_error",
157
219
  )
158
220
 
159
221
  def __init__(self, element: Element, children: List["VNode"], tag: Optional[int] = None) -> None:
@@ -164,7 +226,7 @@ class VNode:
164
226
  self.parent: Optional["VNode"] = None
165
227
  self.hook_state: Any = None
166
228
  self.mounted: bool = True
167
- self._rendered: Optional[Element] = None
229
+ self._rendered: Any = None
168
230
  # Native-safe props (callables stripped) from the last commit;
169
231
  # the baseline for prop diffing.
170
232
  self._clean_props: Dict[str, Any] = {}
@@ -177,11 +239,14 @@ class VNode:
177
239
  # are skipped (frame diffing).
178
240
  self._last_frame: Optional[Tuple[float, float, float, float]] = None
179
241
  # Cached LayoutNode reused across passes while the subtree is
180
- # clean (see Reconciler._build_layout_tree_cached).
242
+ # clean (see Reconciler._build_layout_list_cached).
181
243
  self._layout_node: Optional[LayoutNode] = None
182
244
  # True when this node's layout-relevant props or child list
183
245
  # changed since the last layout pass.
184
246
  self._layout_dirty: bool = True
247
+ # For ``__ErrorBoundary__`` nodes: the caught exception while
248
+ # the fallback is showing, else ``None``.
249
+ self._error: Optional[BaseException] = None
185
250
 
186
251
 
187
252
  class Reconciler:
@@ -194,8 +259,9 @@ class Reconciler:
194
259
 
195
260
  1. applies the accumulated mutation ops in one batch,
196
261
  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.
262
+ 3. runs the layout pass, emitting only changed frames,
263
+ 4. flushes layout effects (children-first), then
264
+ 5. flushes passive effects.
199
265
 
200
266
  Args:
201
267
  backend: An object implementing the registry protocol
@@ -220,6 +286,17 @@ class Reconciler:
220
286
  # reference. Drained by
221
287
  # [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty].
222
288
  self._dirty_nodes: Dict[int, VNode] = {}
289
+ # Error-boundary VNodes whose ``reset`` was called; drained
290
+ # alongside dirty components.
291
+ self._dirty_boundaries: Dict[int, VNode] = {}
292
+ # Tags destroyed during the current pass, used when simulating
293
+ # a native parent's child list. Tags are never reused, so stale
294
+ # entries can never alias a live view.
295
+ self._destroyed_tags: Set[int] = set()
296
+ # ``use_back_handler`` registrations, oldest-first. Dispatch
297
+ # walks the list in reverse so deeper / more recently mounted
298
+ # handlers win.
299
+ self._back_handlers: List[Callable[[], bool]] = []
223
300
 
224
301
  # ------------------------------------------------------------------
225
302
  # Public API
@@ -233,12 +310,17 @@ class Reconciler:
233
310
 
234
311
  Returns:
235
312
  The platform-native view that represents the root of the
236
- mounted tree.
313
+ mounted tree (the first native root when the root element
314
+ renders several).
237
315
  """
238
316
  self._log(f"mount: start type={self._type_label(element.type)!r}")
239
317
  self._dirty_nodes.clear()
318
+ self._dirty_boundaries.clear()
319
+ self._destroyed_tags.clear()
240
320
  self._tree = self._create_tree(element)
321
+ self._drain_dirty()
241
322
  self._commit()
323
+ self._warn_on_multiple_roots()
242
324
  return self._tree.native_view
243
325
 
244
326
  def reconcile(self, new_element: Element) -> Any:
@@ -251,13 +333,18 @@ class Reconciler:
251
333
  The (possibly replaced) root native view.
252
334
  """
253
335
  # A full reconcile rebuilds the whole tree from the root, so any
254
- # pending per-component dirty marks are now obsolete.
336
+ # pending per-component dirty marks are now obsolete. Reactive
337
+ # context invalidation may re-add entries during the pass; those
338
+ # are drained before commit.
255
339
  self._dirty_nodes.clear()
340
+ self._destroyed_tags.clear()
256
341
  if self._tree is None:
257
342
  self._tree = self._create_tree(new_element)
258
343
  else:
259
344
  self._tree = self._reconcile_node(self._tree, new_element)
345
+ self._drain_dirty()
260
346
  self._commit()
347
+ self._warn_on_multiple_roots()
261
348
  return self._tree.native_view
262
349
 
263
350
  def root_view(self) -> Any:
@@ -275,6 +362,8 @@ class Reconciler:
275
362
  self._destroy_tree(self._tree)
276
363
  self._tree = None
277
364
  self._dirty_nodes.clear()
365
+ self._dirty_boundaries.clear()
366
+ self._back_handlers.clear()
278
367
  self._flush_ops()
279
368
 
280
369
  def dispatch_command(self, tag: Optional[int], name: str, args: Optional[Dict[str, Any]] = None) -> Any:
@@ -287,12 +376,14 @@ class Reconciler:
287
376
  """Queue ``vnode`` (a function component) for a local re-render.
288
377
 
289
378
  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.
379
+ when its own state changes, and by reactive context when a
380
+ Provider's value changes for a consumer that a memoized
381
+ ancestor would otherwise skip. The node is re-rendered on the
382
+ next [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
383
+ pass. Marking is idempotent and cheap; the actual render is
384
+ deferred so several setters (e.g. inside
385
+ [`batch_updates`][pythonnative.batch_updates]) coalesce into a
386
+ single pass.
296
387
  """
297
388
  if vnode is None or vnode.hook_state is None or not vnode.mounted:
298
389
  return
@@ -317,29 +408,11 @@ class Reconciler:
317
408
  """
318
409
  if self._tree is None:
319
410
  return None
320
- if not self._dirty_nodes:
411
+ if not self._dirty_nodes and not self._dirty_boundaries:
321
412
  return self._tree.native_view
322
413
 
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
-
414
+ self._destroyed_tags.clear()
415
+ self._drain_dirty()
343
416
  self._commit()
344
417
  return self._tree.native_view
345
418
 
@@ -365,6 +438,96 @@ class Reconciler:
365
438
  self._run_layout()
366
439
  self._flush_ops()
367
440
 
441
+ # ------------------------------------------------------------------
442
+ # Back handlers (use_back_handler)
443
+ # ------------------------------------------------------------------
444
+
445
+ def register_back_handler(self, handler: Callable[[], bool]) -> Callable[[], None]:
446
+ """Register a back-press handler; returns an unregister callable.
447
+
448
+ Used by [`use_back_handler`][pythonnative.use_back_handler].
449
+ Handlers are dispatched most-recently-registered first.
450
+ """
451
+ self._back_handlers.append(handler)
452
+
453
+ def unregister() -> None:
454
+ try:
455
+ self._back_handlers.remove(handler)
456
+ except ValueError:
457
+ pass
458
+
459
+ return unregister
460
+
461
+ def dispatch_back_press(self) -> bool:
462
+ """Offer the system back action to registered handlers.
463
+
464
+ Returns:
465
+ ``True`` if a handler consumed the event (the platform
466
+ should *not* run its default behavior).
467
+ """
468
+ for handler in reversed(list(self._back_handlers)):
469
+ if handler():
470
+ return True
471
+ return False
472
+
473
+ # ------------------------------------------------------------------
474
+ # Dirty draining (local updates, reactive context, boundary resets)
475
+ # ------------------------------------------------------------------
476
+
477
+ def _drain_dirty(self) -> None:
478
+ """Process dirty components and boundary resets until none remain.
479
+
480
+ Reactive context can mark additional components dirty *during*
481
+ a pass (a re-render changed a Provider value whose consumers sit
482
+ under memoized subtrees), so this loops until quiescent, with a
483
+ cap to break pathological update cycles.
484
+ """
485
+ guard = 0
486
+ while self._dirty_nodes or self._dirty_boundaries:
487
+ guard += 1
488
+ if guard > 100:
489
+ diagnostics.warn(
490
+ "Update loop did not settle after 100 iterations; a component is "
491
+ "likely setting state unconditionally during render or effects."
492
+ )
493
+ self._dirty_nodes.clear()
494
+ self._dirty_boundaries.clear()
495
+ return
496
+
497
+ boundaries = list(self._dirty_boundaries.values())
498
+ self._dirty_boundaries.clear()
499
+ for boundary in boundaries:
500
+ if not boundary.mounted:
501
+ continue
502
+ providers = self._ancestor_providers(boundary)
503
+ for context, value in providers:
504
+ context._stack.append(value)
505
+ try:
506
+ self._reconcile_error_boundary(boundary, boundary.element)
507
+ finally:
508
+ for context, _value in reversed(providers):
509
+ context._stack.pop()
510
+ self._bubble_structure_change(boundary)
511
+
512
+ pending = list(self._dirty_nodes.values())
513
+ self._dirty_nodes.clear()
514
+ pending.sort(key=self._node_depth)
515
+ for vnode in pending:
516
+ if not vnode.mounted:
517
+ continue
518
+ hook_state = vnode.hook_state
519
+ if hook_state is None or not hook_state._dirty:
520
+ # Already re-rendered as part of a dirty ancestor's pass.
521
+ continue
522
+ try:
523
+ self._update_component(vnode)
524
+ except Exception as exc:
525
+ # A local re-render starts below any enclosing
526
+ # ``ErrorBoundary``, so route the failure to the nearest
527
+ # boundary ancestor (which mounts its fallback). With no
528
+ # boundary the exception propagates, matching a full render.
529
+ self._handle_local_render_error(vnode, exc)
530
+
368
531
  # ------------------------------------------------------------------
369
532
  # Commit driver
370
533
  # ------------------------------------------------------------------
@@ -372,9 +535,13 @@ class Reconciler:
372
535
  def _commit(self) -> None:
373
536
  """Apply the accumulated transaction and run the post-commit phases."""
374
537
  self._flush_ops()
375
- self._flush_effects()
538
+ self._fix_tree_links()
376
539
  self._run_layout()
377
540
  self._flush_ops()
541
+ self._flush_layout_effects()
542
+ self._flush_ops()
543
+ self._flush_passive_effects()
544
+ self._flush_ops()
378
545
 
379
546
  def _flush_ops(self) -> None:
380
547
  """Send pending ops to the backend and resolve created views."""
@@ -393,38 +560,162 @@ class Reconciler:
393
560
  self._attach_ref(vnode.element, vnode.native_view, vnode.tag)
394
561
 
395
562
  # ------------------------------------------------------------------
396
- # Effect flushing
563
+ # Post-commit walks
397
564
  # ------------------------------------------------------------------
398
565
 
399
- def _flush_effects(self) -> None:
400
- """Walk the committed tree and flush pending effects (depth-first).
566
+ def _fix_tree_links(self) -> None:
567
+ """Refresh ``parent`` links and delegated wrapper identity.
401
568
 
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.
569
+ This walk is the single source of truth for *delegated*
570
+ identity: transparent wrappers (components, providers,
571
+ boundaries, keyed fragments) re-derive their ``tag`` /
572
+ ``native_view`` from their first native root, so the rest of
573
+ the reconciler never has to chase delegation chains by hand.
409
574
  """
410
- if self._tree is not None:
411
- self._tree.parent = None
412
- self._flush_tree_effects(self._tree)
575
+ if self._tree is None:
576
+ return
577
+ self._tree.parent = None
578
+ self._fix_node_links(self._tree)
413
579
 
414
- def _flush_tree_effects(self, node: VNode) -> None:
580
+ def _fix_node_links(self, node: VNode) -> None:
415
581
  for child in node.children:
416
582
  child.parent = node
417
- self._flush_tree_effects(child)
583
+ self._fix_node_links(child)
418
584
  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
585
+ self._refresh_identity(node)
586
+
587
+ def _flush_layout_effects(self) -> None:
588
+ """Flush queued layout effects, children before parents."""
589
+ if self._tree is not None:
590
+ self._walk_layout_effects(self._tree)
591
+
592
+ def _walk_layout_effects(self, node: VNode) -> None:
593
+ for child in node.children:
594
+ self._walk_layout_effects(child)
595
+ if node.hook_state is not None:
596
+ node.hook_state.flush_layout_effects()
597
+
598
+ def _flush_passive_effects(self) -> None:
599
+ """Flush queued passive effects, children before parents."""
600
+ if self._tree is not None:
601
+ self._walk_passive_effects(self._tree)
602
+
603
+ def _walk_passive_effects(self, node: VNode) -> None:
604
+ for child in node.children:
605
+ self._walk_passive_effects(child)
425
606
  if node.hook_state is not None:
426
607
  node.hook_state.flush_pending_effects()
427
608
 
609
+ # ------------------------------------------------------------------
610
+ # Native-root helpers (multi-child support)
611
+ # ------------------------------------------------------------------
612
+
613
+ def _native_roots(self, node: VNode) -> List[VNode]:
614
+ """Return the ordered native views ``node`` contributes to its native parent.
615
+
616
+ Native elements contribute themselves, except ``Portal``, whose
617
+ handler self-attaches to a top-level overlay and therefore
618
+ contributes nothing. Transparent wrappers contribute the
619
+ concatenation of their children's roots.
620
+ """
621
+ if self._is_native_node(node):
622
+ if node.element.type == "Portal":
623
+ return []
624
+ return [node]
625
+ roots: List[VNode] = []
626
+ for child in node.children:
627
+ roots.extend(self._native_roots(child))
628
+ return roots
629
+
630
+ def _flattened_child_roots(self, node: VNode) -> List[VNode]:
631
+ """Return the native child list of a native container node."""
632
+ roots: List[VNode] = []
633
+ for child in node.children:
634
+ roots.extend(self._native_roots(child))
635
+ return roots
636
+
637
+ def _refresh_identity(self, node: VNode) -> None:
638
+ """Point a wrapper's ``tag`` / ``native_view`` at its first native root."""
639
+ if self._is_native_node(node):
640
+ return
641
+ for root in self._native_roots(node):
642
+ node.tag = root.tag
643
+ node.native_view = root.native_view
644
+ return
645
+ node.tag = None
646
+ node.native_view = None
647
+
648
+ def _sync_native_children(
649
+ self, parent_tag: int, before_tags: List[Optional[int]], after_roots: List[VNode]
650
+ ) -> bool:
651
+ """Emit the `InsertOp`s that turn the parent's native child list into ``after_roots``.
652
+
653
+ Simulates the native child list: it currently holds the
654
+ surviving members of ``before_tags`` (destroys already emitted
655
+ detach on the native side), and each emitted ensure-insert
656
+ mirrors the handlers' move-aware semantics. Appends and keyed
657
+ reorders therefore emit only the ops they need.
658
+
659
+ Returns:
660
+ Whether the native child list changed at all (used for
661
+ layout invalidation).
662
+ """
663
+ surviving = [t for t in before_tags if t is not None and t not in self._destroyed_tags]
664
+ after_tags = [r.tag for r in after_roots if r.tag is not None]
665
+ if surviving == after_tags:
666
+ return len(surviving) != len(before_tags)
667
+
668
+ sim = list(surviving)
669
+ for i, tag in enumerate(after_tags):
670
+ if i < len(sim) and sim[i] == tag:
671
+ continue
672
+ try:
673
+ j = sim.index(tag)
674
+ except ValueError:
675
+ j = -1
676
+ if j >= 0:
677
+ sim.pop(j)
678
+ sim.insert(i, tag)
679
+ self._ops.append(InsertOp(parent_tag, tag, i))
680
+ return True
681
+
682
+ def _bubble_structure_change(self, vnode: VNode) -> None:
683
+ """Propagate a changed native-root set up to the nearest native container.
684
+
685
+ A local re-render starts below the real native container, so
686
+ when the dirty component's native roots change (view replaced,
687
+ added, or removed), every transparent ancestor re-derives its
688
+ identity and the nearest native ancestor re-ensures its full
689
+ child order (handlers no-op for children already in place).
690
+ """
691
+ node = vnode.parent
692
+ while node is not None:
693
+ if self._is_native_node(node):
694
+ if node.tag is not None:
695
+ roots = self._flattened_child_roots(node)
696
+ for i, root in enumerate(roots):
697
+ if root.tag is not None:
698
+ self._ops.append(InsertOp(node.tag, root.tag, i))
699
+ self._mark_layout_dirty(node)
700
+ return
701
+ self._refresh_identity(node)
702
+ node = node.parent
703
+ # Reached the root with no native container above: the root's
704
+ # identity was already refreshed. The host detects the change by
705
+ # comparing ``root_view()`` after the flush.
706
+
707
+ def _warn_on_multiple_roots(self) -> None:
708
+ if not diagnostics.is_dev() or self._tree is None:
709
+ return
710
+ roots = self._native_roots(self._tree)
711
+ if len(roots) > 1:
712
+ diagnostics.warn_once(
713
+ f"The screen root rendered {len(roots)} native views; only the first "
714
+ "is attached to the window. Wrap your root in a View/Column (Portals "
715
+ "are exempt and may appear anywhere).",
716
+ key=f"multi-root:{id(self)}",
717
+ )
718
+
428
719
  # ------------------------------------------------------------------
429
720
  # Internal helpers
430
721
  # ------------------------------------------------------------------
@@ -438,6 +729,23 @@ class Reconciler:
438
729
  node = node.parent
439
730
  return depth
440
731
 
732
+ def _render_component_body(self, hook_state: Any, element: Element) -> List[Element]:
733
+ """Run a function component's body and normalize its output."""
734
+ from .hooks import _set_hook_state
735
+
736
+ component_fn = element.type
737
+ assert callable(component_fn), "component elements always carry a callable type"
738
+ hook_state.begin_render(self._type_label(element.type))
739
+ hook_state._trigger_render = self._screen_re_render
740
+ _set_hook_state(hook_state)
741
+ try:
742
+ rendered = component_fn(**element.props)
743
+ hook_state.finish_render()
744
+ finally:
745
+ _set_hook_state(None)
746
+ hook_state._dirty = False
747
+ return _normalize_children(rendered, owner=self._type_label(element.type))
748
+
441
749
  def _update_component(self, vnode: "VNode") -> None:
442
750
  """Re-run one function component's body and reconcile its subtree in place.
443
751
 
@@ -450,75 +758,57 @@ class Reconciler:
450
758
  providers *inside* this subtree are pushed/popped normally by the
451
759
  recursive reconcile beneath us.
452
760
  """
453
- from .hooks import _set_hook_state
454
-
455
- new_el = vnode.element
456
- if not callable(new_el.type):
761
+ element = vnode.element
762
+ if not callable(element.type):
457
763
  return
458
764
  hook_state = vnode.hook_state
459
765
  if hook_state is None:
460
766
  return
461
767
 
768
+ before_tags = [r.tag for r in self._native_roots(vnode)]
769
+
462
770
  providers = self._ancestor_providers(vnode)
463
771
  for context, value in providers:
464
772
  context._stack.append(value)
465
773
  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
- 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)
774
+ rendered = self._render_component_body(hook_state, element)
775
+ new_children = self._reconcile_child_list(vnode.children, rendered)
482
776
  finally:
483
777
  for context, _value in reversed(providers):
484
778
  context._stack.pop()
485
779
 
486
- child.parent = vnode
487
- vnode.children = [child]
488
- vnode.tag = child.tag
489
- vnode.native_view = child.native_view
780
+ for child in new_children:
781
+ child.parent = vnode
782
+ vnode.children = new_children
490
783
  vnode._rendered = rendered
784
+ self._refresh_identity(vnode)
785
+ hook_state._vnode = vnode
786
+ hook_state._reconciler = self
491
787
 
492
- if child.tag != old_tag:
493
- self._bubble_root_change(vnode, child)
788
+ after_tags = [r.tag for r in self._native_roots(vnode)]
789
+ if after_tags != before_tags:
790
+ self._bubble_structure_change(vnode)
494
791
 
495
792
  def _handle_local_render_error(self, vnode: "VNode", exc: Exception) -> None:
496
793
  """Route a local re-render failure to the nearest ``ErrorBoundary`` ancestor.
497
794
 
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.
795
+ Activates the boundary (destroying the failed subtree and
796
+ mounting the fallback). If no boundary encloses ``vnode`` the
797
+ exception propagates, exactly as it would during a full render;
798
+ the screen host catches it and shows the dev error overlay.
504
799
  """
505
800
  node = vnode.parent
506
801
  while node is not None:
507
802
  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
803
  providers = self._ancestor_providers(node)
513
804
  for context, value in providers:
514
805
  context._stack.append(value)
515
806
  try:
516
- self._reconcile_node(node, node.element)
807
+ self._activate_boundary(node, exc)
517
808
  finally:
518
809
  for context, _value in reversed(providers):
519
810
  context._stack.pop()
520
- if node.tag != old_tag and node.children:
521
- self._bubble_root_change(node, node.children[0])
811
+ self._bubble_structure_change(node)
522
812
  return
523
813
  node = node.parent
524
814
  raise exc
@@ -541,42 +831,10 @@ class Reconciler:
541
831
  chain.reverse()
542
832
  return chain
543
833
 
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
834
  @staticmethod
577
835
  def _is_native_node(node: "VNode") -> bool:
578
836
  t = node.element.type
579
- return isinstance(t, str) and t not in ("__Provider__", "__ErrorBoundary__", "__Fragment__")
837
+ return isinstance(t, str) and t not in _TRANSPARENT_TYPES
580
838
 
581
839
  @staticmethod
582
840
  def _log(msg: str) -> None:
@@ -599,59 +857,52 @@ class Reconciler:
599
857
  # ------------------------------------------------------------------
600
858
 
601
859
  def _create_tree(self, element: Element) -> VNode:
602
- # Provider: push context, create children, pop context
860
+ # Provider: push context, create children, pop context.
603
861
  if element.type == "__Provider__":
604
862
  context = element.props["__context__"]
605
863
  context._stack.append(element.props["__value__"])
606
864
  try:
607
- provider_children = _flatten_children(element.children)
608
- child_node = self._create_tree(provider_children[0]) if provider_children else None
865
+ children = self._create_child_list(_normalize_children(element.children, owner="Provider"))
609
866
  finally:
610
867
  context._stack.pop()
611
- children = [child_node] if child_node else []
612
868
  vnode = VNode(element, children)
613
- vnode.tag = child_node.tag if child_node else None
869
+ for child in children:
870
+ child.parent = vnode
871
+ self._refresh_identity(vnode)
614
872
  return vnode
615
873
 
616
- # Error boundary: catch exceptions in the child subtree
874
+ # Error boundary: catch exceptions in the child subtree.
617
875
  if element.type == "__ErrorBoundary__":
618
876
  return self._create_error_boundary(element)
619
877
 
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.
878
+ # Keyed fragment (or a fragment reaching here as a root):
879
+ # a transparent multi-child wrapper.
623
880
  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
881
+ children = self._create_child_list(_normalize_children(element.children, owner="Fragment"))
882
+ vnode = VNode(element, children)
883
+ for child in children:
884
+ child.parent = vnode
885
+ self._refresh_identity(vnode)
630
886
  return vnode
631
887
 
632
- # Function component: call with hook context
888
+ # Function component: call with hook context.
633
889
  if callable(element.type):
634
- from .hooks import HookState, _set_hook_state
890
+ from .hooks import HookState
635
891
 
636
892
  hook_state = HookState()
637
- hook_state._trigger_render = self._screen_re_render
638
- _set_hook_state(hook_state)
639
- 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
893
+ rendered = self._render_component_body(hook_state, element)
894
+ children = self._create_child_list(rendered)
895
+ vnode = VNode(element, children)
896
+ for child in children:
897
+ child.parent = vnode
648
898
  vnode.hook_state = hook_state
649
899
  vnode._rendered = rendered
900
+ self._refresh_identity(vnode)
650
901
  hook_state._vnode = vnode
651
902
  hook_state._reconciler = self
652
903
  return vnode
653
904
 
654
- # Native element
905
+ # Native element.
655
906
  tag = next_tag()
656
907
  clean_props, events = self._split_props(element.props)
657
908
  vnode = VNode(element, [], tag=tag)
@@ -661,30 +912,150 @@ class Reconciler:
661
912
  self._ops.append(CreateOp(tag, element.type, clean_props))
662
913
  self._created.append(vnode)
663
914
 
664
- flat_children = _flatten_children(element.children)
665
- for i, child_el in enumerate(flat_children):
915
+ child_els = _normalize_children(element.children, owner=element.type)
916
+ index = 0
917
+ for child_el in child_els:
666
918
  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))
919
+ child_node.parent = vnode
669
920
  vnode.children.append(child_node)
921
+ for root in self._native_roots(child_node):
922
+ if root.tag is not None:
923
+ self._ops.append(InsertOp(tag, root.tag, index))
924
+ index += 1
670
925
  return vnode
671
926
 
927
+ def _create_child_list(self, elements: List[Element]) -> List[VNode]:
928
+ """Create VNodes for ``elements``, cleaning up on mid-list failure."""
929
+ nodes: List[VNode] = []
930
+ try:
931
+ for el in elements:
932
+ nodes.append(self._create_tree(el))
933
+ except Exception:
934
+ for node in nodes:
935
+ self._destroy_tree(node)
936
+ raise
937
+ return nodes
938
+
939
+ # ------------------------------------------------------------------
940
+ # Error boundaries
941
+ # ------------------------------------------------------------------
942
+
672
943
  def _create_error_boundary(self, element: Element) -> VNode:
673
- fallback_fn = element.props.get("__fallback__")
674
- eb_children = _flatten_children(element.children)
944
+ vnode = VNode(element, [])
675
945
  try:
676
- child_node = self._create_tree(eb_children[0]) if eb_children else None
946
+ children = self._create_child_list(_normalize_children(element.children, owner="ErrorBoundary"))
677
947
  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)
681
- 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
948
+ self._activate_boundary(vnode, exc)
949
+ return vnode
950
+ for child in children:
951
+ child.parent = vnode
952
+ vnode.children = children
953
+ self._refresh_identity(vnode)
686
954
  return vnode
687
955
 
956
+ def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode:
957
+ old.element = new_el
958
+
959
+ if old._error is not None:
960
+ # Fallback is showing; keep showing it (rebuilt against the
961
+ # latest fallback prop) until reset() clears the error.
962
+ fallback_els = self._build_fallback_elements(old, old._error)
963
+ old.children = self._reconcile_child_list(old.children, fallback_els)
964
+ for child in old.children:
965
+ child.parent = old
966
+ self._refresh_identity(old)
967
+ return old
968
+
969
+ try:
970
+ children = self._reconcile_child_list(
971
+ old.children, _normalize_children(new_el.children, owner="ErrorBoundary")
972
+ )
973
+ old.children = children
974
+ for child in children:
975
+ child.parent = old
976
+ self._refresh_identity(old)
977
+ except Exception as exc:
978
+ self._activate_boundary(old, exc)
979
+ return old
980
+
981
+ def _activate_boundary(self, node: VNode, exc: BaseException) -> None:
982
+ """Destroy a boundary's failed subtree and mount its fallback.
983
+
984
+ Calls the ``on_error`` prop (if any), records the error on the
985
+ node, and replaces the children with the rendered fallback.
986
+ Re-raises when the boundary has no fallback, letting an outer
987
+ boundary (or the screen host) take over.
988
+ """
989
+ el = node.element
990
+ on_error = el.props.get("__on_error__")
991
+ if callable(on_error):
992
+ try:
993
+ on_error(exc)
994
+ except Exception as cb_exc:
995
+ diagnostics.warn(f"ErrorBoundary on_error callback raised {cb_exc!r}")
996
+
997
+ if el.props.get("__fallback__") is None:
998
+ raise exc
999
+
1000
+ for child in node.children:
1001
+ self._destroy_tree(child)
1002
+ node.children = []
1003
+ node._error = exc
1004
+
1005
+ fallback_els = self._build_fallback_elements(node, exc)
1006
+ children = self._create_child_list(fallback_els)
1007
+ for child in children:
1008
+ child.parent = node
1009
+ node.children = children
1010
+ self._refresh_identity(node)
1011
+
1012
+ def _build_fallback_elements(self, node: VNode, exc: BaseException) -> List[Element]:
1013
+ """Render a boundary's fallback prop into a normalized child list."""
1014
+ fallback = node.element.props.get("__fallback__")
1015
+ if fallback is None:
1016
+ return []
1017
+ result: Any = fallback
1018
+ if callable(fallback) and not isinstance(fallback, Element):
1019
+ arity = self._positional_arity(fallback)
1020
+ if arity >= 2:
1021
+ result = fallback(exc, self._make_boundary_reset(node))
1022
+ elif arity == 1:
1023
+ result = fallback(exc)
1024
+ else:
1025
+ result = fallback()
1026
+ return _normalize_children(result, owner="ErrorBoundary.fallback")
1027
+
1028
+ @staticmethod
1029
+ def _positional_arity(fn: Callable) -> int:
1030
+ """Count positional parameters ``fn`` accepts (2+ means unbounded is fine)."""
1031
+ try:
1032
+ sig = inspect.signature(fn)
1033
+ except (TypeError, ValueError):
1034
+ return 1
1035
+ count = 0
1036
+ for p in sig.parameters.values():
1037
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
1038
+ count += 1
1039
+ elif p.kind == inspect.Parameter.VAR_POSITIONAL:
1040
+ return 2
1041
+ return count
1042
+
1043
+ def _make_boundary_reset(self, node: VNode) -> Callable[[], None]:
1044
+ """Return the ``reset`` callable handed to a boundary's fallback."""
1045
+
1046
+ def reset() -> None:
1047
+ if not node.mounted or node._error is None:
1048
+ return
1049
+ node._error = None
1050
+ self._dirty_boundaries[id(node)] = node
1051
+ trigger = self._screen_re_render
1052
+ if trigger is not None:
1053
+ from .hooks import _schedule_trigger
1054
+
1055
+ _schedule_trigger(trigger)
1056
+
1057
+ return reset
1058
+
688
1059
  # ------------------------------------------------------------------
689
1060
  # Reconciliation
690
1061
  # ------------------------------------------------------------------
@@ -695,35 +1066,50 @@ class Reconciler:
695
1066
  self._destroy_tree(old)
696
1067
  return new_node
697
1068
 
698
- # Provider
1069
+ # Provider: detect value changes for reactive context, then
1070
+ # reconcile children under the pushed value.
699
1071
  if new_el.type == "__Provider__":
700
1072
  context = new_el.props["__context__"]
701
- context._stack.append(new_el.props["__value__"])
1073
+ old_context = old.element.props.get("__context__")
1074
+ old_value = old.element.props.get("__value__", _MISSING)
1075
+ new_value = new_el.props["__value__"]
1076
+ if old_context is not context:
1077
+ if old_context is not None:
1078
+ self._mark_context_consumers(old, old_context)
1079
+ self._mark_context_consumers(old, context)
1080
+ elif self._value_changed(old_value, new_value):
1081
+ self._mark_context_consumers(old, context)
1082
+
1083
+ context._stack.append(new_value)
702
1084
  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
1085
+ children = self._reconcile_child_list(
1086
+ old.children, _normalize_children(new_el.children, owner="Provider")
1087
+ )
714
1088
  finally:
715
1089
  context._stack.pop()
1090
+ old.children = children
1091
+ for child in children:
1092
+ child.parent = old
716
1093
  old.element = new_el
1094
+ self._refresh_identity(old)
717
1095
  return old
718
1096
 
719
- # Error boundary
1097
+ # Error boundary.
720
1098
  if new_el.type == "__ErrorBoundary__":
721
1099
  return self._reconcile_error_boundary(old, new_el)
722
1100
 
723
- # Function component
724
- if callable(new_el.type):
725
- from .hooks import _set_hook_state
1101
+ # Keyed fragment: transparent multi-child wrapper.
1102
+ if new_el.type == "__Fragment__":
1103
+ children = self._reconcile_child_list(old.children, _normalize_children(new_el.children, owner="Fragment"))
1104
+ old.children = children
1105
+ for child in children:
1106
+ child.parent = old
1107
+ old.element = new_el
1108
+ self._refresh_identity(old)
1109
+ return old
726
1110
 
1111
+ # Function component.
1112
+ if callable(new_el.type):
727
1113
  # ``@memo`` skip: if the props haven't changed shallowly and
728
1114
  # the component's own hook state is clean (no setter fired
729
1115
  # while we were rebuilding the parent tree), reuse the
@@ -737,30 +1123,20 @@ class Reconciler:
737
1123
  from .hooks import HookState
738
1124
 
739
1125
  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
1126
+ rendered = self._render_component_body(hook_state, new_el)
1127
+ children = self._reconcile_child_list(old.children, rendered)
1128
+ old.children = children
1129
+ for child in children:
1130
+ child.parent = old
756
1131
  old.element = new_el
757
1132
  old.hook_state = hook_state
758
1133
  old._rendered = rendered
1134
+ self._refresh_identity(old)
759
1135
  hook_state._vnode = old
760
1136
  hook_state._reconciler = self
761
1137
  return old
762
1138
 
763
- # Native element
1139
+ # Native element.
764
1140
  new_clean, events = self._split_props(new_el.props)
765
1141
  if old.tag is not None:
766
1142
  self._events.set_events(old.tag, events)
@@ -773,50 +1149,52 @@ class Reconciler:
773
1149
  self._mark_layout_dirty(old)
774
1150
  old._clean_props = new_clean
775
1151
 
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).
1152
+ # Re-attach the ref if the ref identity changed (so we never
1153
+ # leave a stale ref pointing at a destroyed view, and so a
1154
+ # freshly-supplied ref gets ``current`` populated on update).
779
1155
  old_ref = old.element.props.get("ref") if old.element.props else None
780
1156
  new_ref = new_el.props.get("ref") if new_el.props else None
781
1157
  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
1158
+ self._clear_ref(old_ref)
787
1159
  self._attach_ref(new_el, old.native_view, old.tag)
788
1160
 
789
- self._reconcile_children(old, new_el.children)
1161
+ self._reconcile_native_children(old, new_el.children)
790
1162
  old.element = new_el
791
1163
  return old
792
1164
 
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)
1165
+ @staticmethod
1166
+ def _value_changed(old_value: Any, new_value: Any) -> bool:
1167
+ if old_value is _MISSING:
1168
+ return True
1169
+ if old_value is new_value:
1170
+ return False
796
1171
  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
1172
+ return bool(old_value != new_value)
1173
+ except Exception:
1174
+ return True
1175
+
1176
+ def _mark_context_consumers(self, provider_vnode: VNode, context: Any) -> None:
1177
+ """Mark every descendant that read ``context`` for re-render.
1178
+
1179
+ This is what makes context *reactive*: consumers re-render when
1180
+ the Provider value changes even if a memoized ancestor skips.
1181
+ Descent is pruned at nested Providers of the same context, since
1182
+ their subtrees read the inner (unchanged) value.
1183
+ """
1184
+ target = id(context)
1185
+
1186
+ def walk(node: VNode) -> None:
1187
+ for child in node.children:
1188
+ el = child.element
1189
+ if isinstance(el.type, str) and el.type == "__Provider__" and el.props.get("__context__") is context:
1190
+ continue
1191
+ hs = child.hook_state
1192
+ if hs is not None and target in hs.context_deps:
1193
+ hs._dirty = True
1194
+ self._dirty_nodes[id(child)] = child
1195
+ walk(child)
1196
+
1197
+ walk(provider_vnode)
820
1198
 
821
1199
  @staticmethod
822
1200
  def _can_skip_memoized(old: VNode, new_el: Element) -> bool:
@@ -828,7 +1206,8 @@ class Reconciler:
828
1206
  [`memo`][pythonnative.memo].
829
1207
  2. It has been rendered before (``old._rendered`` is populated).
830
1208
  3. None of its internal state setters fired since the last
831
- render (``hook_state._dirty`` is ``False``).
1209
+ render, and no context it reads changed
1210
+ (``hook_state._dirty`` is ``False``).
832
1211
  4. The new props are shallowly equal to the old props.
833
1212
  """
834
1213
  fn = new_el.type
@@ -843,12 +1222,20 @@ class Reconciler:
843
1222
  return False
844
1223
  return _shallow_equal_props(old.element.props, new_el.props)
845
1224
 
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
1225
+ def _reconcile_child_list(self, old_children: List[VNode], new_children: List[Element]) -> List[VNode]:
1226
+ """Match, reconcile, create, and destroy one level of children.
1227
+
1228
+ Pure structural pass shared by native containers and
1229
+ transparent wrappers: it emits no `InsertOp`s itself. Native
1230
+ attachment order is derived afterwards by the caller (see
1231
+ [`_reconcile_native_children`][pythonnative.reconciler.Reconciler._reconcile_native_children]
1232
+ and
1233
+ [`_bubble_structure_change`][pythonnative.reconciler.Reconciler._bubble_structure_change]).
851
1234
 
1235
+ On failure, any fully created replacement nodes are destroyed
1236
+ before the exception propagates so an enclosing error boundary
1237
+ can swap in its fallback without leaking native views.
1238
+ """
852
1239
  old_by_key: dict = {}
853
1240
  old_unkeyed: list = []
854
1241
  for child in old_children:
@@ -858,83 +1245,66 @@ class Reconciler:
858
1245
  old_unkeyed.append(child)
859
1246
 
860
1247
  new_child_nodes: List[VNode] = []
1248
+ fresh_nodes: List[VNode] = []
861
1249
  used_keyed: set = set()
862
1250
  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.
1251
+
1252
+ try:
1253
+ for new_el in new_children:
1254
+ matched: Optional[VNode] = None
1255
+
1256
+ if new_el.key is not None and new_el.key in old_by_key:
1257
+ matched = old_by_key[new_el.key]
1258
+ used_keyed.add(new_el.key)
1259
+ elif new_el.key is None:
1260
+ matched = next(unkeyed_iter, None)
1261
+
1262
+ if matched is None:
1263
+ node = self._create_tree(new_el)
1264
+ fresh_nodes.append(node)
1265
+ new_child_nodes.append(node)
1266
+ elif not self._same_type(matched.element, new_el):
1267
+ node = self._create_tree(new_el)
1268
+ self._destroy_tree(matched)
1269
+ fresh_nodes.append(node)
1270
+ new_child_nodes.append(node)
1271
+ else:
1272
+ new_child_nodes.append(self._reconcile_node(matched, new_el))
1273
+ except Exception:
1274
+ for node in fresh_nodes:
1275
+ self._destroy_tree(node)
1276
+ raise
1277
+
901
1278
  for key, node in old_by_key.items():
902
1279
  if key not in used_keyed:
903
1280
  self._destroy_tree(node)
904
- structure_changed = True
905
1281
  for node in unkeyed_iter:
906
1282
  self._destroy_tree(node)
907
- structure_changed = True
908
1283
 
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
1284
+ return new_child_nodes
1285
+
1286
+ def _reconcile_native_children(self, parent: VNode, new_children: List[Element]) -> None:
1287
+ """Reconcile a native container's children and sync its native child list."""
1288
+ before_tags = [r.tag for r in self._flattened_child_roots(parent)]
1289
+ new_els = _normalize_children(new_children, owner=self._type_label(parent.element.type))
1290
+ children = self._reconcile_child_list(parent.children, new_els)
1291
+ parent.children = children
1292
+ for child in children:
1293
+ child.parent = parent
1294
+
1295
+ if parent.tag is not None:
1296
+ changed = self._sync_native_children(parent.tag, before_tags, self._flattened_child_roots(parent))
1297
+ if changed:
1298
+ self._mark_layout_dirty(parent)
932
1299
 
933
1300
  def _destroy_tree(self, node: VNode) -> None:
1301
+ if not node.mounted:
1302
+ return
934
1303
  node.mounted = False
935
- # Drop the node from the pending-render set so a setter that
1304
+ # Drop the node from the pending-render sets so a setter that
936
1305
  # fired moments before unmount can't resurrect a dead subtree.
937
1306
  self._dirty_nodes.pop(id(node), None)
1307
+ self._dirty_boundaries.pop(id(node), None)
938
1308
  if node.hook_state is not None:
939
1309
  node.hook_state.cleanup_all_effects()
940
1310
  # Break the back-references so the unmounted component's hook
@@ -951,6 +1321,7 @@ class Reconciler:
951
1321
  if self._is_native_node(node) and node.tag is not None:
952
1322
  self._events.clear(node.tag)
953
1323
  self._ops.append(DestroyOp(node.tag))
1324
+ self._destroyed_tags.add(node.tag)
954
1325
  node.children = []
955
1326
  node.parent = None
956
1327
  node._layout_node = None
@@ -1006,22 +1377,35 @@ class Reconciler:
1006
1377
 
1007
1378
  @staticmethod
1008
1379
  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."""
1380
+ """Populate ``ref.current`` (and the internal tag) if a ``ref`` prop exists."""
1010
1381
  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."""
1018
- ref = element.props.get("ref") if element.props else None
1019
- if isinstance(ref, dict):
1382
+ if ref is None:
1383
+ return
1384
+ if hasattr(ref, "current"):
1385
+ ref.current = native_view
1020
1386
  try:
1021
- ref["current"] = None
1022
- ref["_pn_tag"] = None
1387
+ ref._pn_tag = tag
1023
1388
  except Exception:
1024
1389
  pass
1390
+ elif diagnostics.is_dev():
1391
+ diagnostics.warn_once(
1392
+ f"Ignoring ref of type {type(ref).__name__}; pass the Ref returned by use_ref().",
1393
+ key=f"badref:{type(ref).__name__}",
1394
+ )
1395
+
1396
+ def _detach_ref(self, element: Element) -> None:
1397
+ """Clear ``ref.current`` so consumers don't hold a stale handle."""
1398
+ self._clear_ref(element.props.get("ref") if element.props else None)
1399
+
1400
+ @staticmethod
1401
+ def _clear_ref(ref: Any) -> None:
1402
+ if ref is None or not hasattr(ref, "current"):
1403
+ return
1404
+ try:
1405
+ ref.current = None
1406
+ ref._pn_tag = None
1407
+ except Exception:
1408
+ pass
1025
1409
 
1026
1410
  @staticmethod
1027
1411
  def _same_type(old_el: Element, new_el: Element) -> bool:
@@ -1051,6 +1435,15 @@ class Reconciler:
1051
1435
  }
1052
1436
  )
1053
1437
 
1438
+ # Childless native leaves that get a measure callback. Extends the
1439
+ # intrinsic set with ``VirtualList``, whose handlers report "fill
1440
+ # the available space" (like a ScrollView clamped to its parent):
1441
+ # without the callback an unstyled list would collapse to 0 points
1442
+ # and the platform virtualizer would never bind a row. Kept out of
1443
+ # ``_INTRINSIC_TYPES`` because its *frame* depends only on
1444
+ # available space, so data-prop changes need no layout pass.
1445
+ _MEASURED_LEAF_TYPES = _INTRINSIC_TYPES | {"VirtualList"}
1446
+
1054
1447
  @classmethod
1055
1448
  def _affects_layout(cls, type_name: str, changed: Dict[str, Any]) -> bool:
1056
1449
  """Whether ``changed`` props can alter the node's layout.
@@ -1073,7 +1466,7 @@ class Reconciler:
1073
1466
  def _run_layout(self) -> None:
1074
1467
  """Build/refresh the layout tree, compute frames, and emit changed ones.
1075
1468
 
1076
- Wraps the user's root VNode in a synthetic outer `LayoutNode`
1469
+ Wraps the user's native roots in a synthetic outer `LayoutNode`
1077
1470
  with the viewport size so the user's root always fills the
1078
1471
  screen by default (matching React Native). Skipped silently
1079
1472
  until the screen host has supplied a viewport size via
@@ -1085,7 +1478,7 @@ class Reconciler:
1085
1478
  flex math (see ``pythonnative.layout``). Only frames that
1086
1479
  differ from the previously applied frame produce `SetFrameOp`s.
1087
1480
 
1088
- The root native view's *frame* is intentionally NOT touched:
1481
+ The first native root's *frame* is intentionally NOT touched:
1089
1482
  its position and size are owned by the screen host (iOS
1090
1483
  ``_sync_root_frame`` places it below the top safe-area
1091
1484
  inset; Android attaches it with ``MATCH_PARENT``). Framing
@@ -1100,55 +1493,64 @@ class Reconciler:
1100
1493
  return
1101
1494
 
1102
1495
  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)
1496
+ layout_roots = self._build_layout_list_cached(self._tree)
1497
+ if layout_roots:
1498
+ viewport = LayoutNode(
1499
+ style={"width": viewport_w, "height": viewport_h},
1500
+ children=list(layout_roots),
1501
+ )
1502
+ viewport.dirty = True
1503
+ calculate_layout(viewport, viewport_w, viewport_h)
1504
+ # Skip set_frame for the first (host-attached) root itself;
1505
+ # its descendants are positioned relative to the root's
1506
+ # local origin, which is what they want regardless of where
1507
+ # the host placed the root in the screen.
1508
+ for i, root in enumerate(layout_roots):
1509
+ if i == 0:
1510
+ for child in root.children:
1511
+ self._collect_frames(child, 0.0, 0.0)
1512
+ else:
1513
+ self._collect_frames(root, 0.0, 0.0)
1514
+ # Lay out the children of every visible ``Modal`` and every
1515
+ # ``Portal`` as a fresh subtree sized to the viewport. Detached
1516
+ # subtrees are excluded from the main layout tree (their content
1517
+ # lives in a separately presented native container) so without
1518
+ # this pass the children's frames never get computed and the
1519
+ # overlay renders blank.
1520
+ self._layout_detached_subtrees(self._tree, viewport_w, viewport_h)
1126
1521
  self._clear_layout_dirty(self._tree)
1127
1522
 
1128
- def _layout_visible_modals(
1523
+ def _layout_detached_subtrees(
1129
1524
  self,
1130
1525
  vnode: VNode,
1131
1526
  viewport_w: float,
1132
1527
  viewport_h: float,
1133
1528
  ) -> None:
1134
1529
  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)
1530
+ if isinstance(element.type, str) and element.type in _DETACHED_TYPES:
1531
+ active = bool(element.props.get("visible")) if element.type == "Modal" else True
1532
+ if not active:
1533
+ return
1534
+ child_layouts: List[LayoutNode] = []
1535
+ for child in vnode.children:
1536
+ child_layouts.extend(self._build_layout_list(child))
1537
+ if child_layouts:
1538
+ viewport = LayoutNode(
1539
+ style={"width": viewport_w, "height": viewport_h},
1540
+ children=child_layouts,
1541
+ )
1542
+ calculate_layout(viewport, viewport_w, viewport_h)
1543
+ for c in viewport.children:
1544
+ self._collect_frames(c, 0.0, 0.0)
1545
+ # Recurse so overlays nested inside this overlay lay out too.
1546
+ for child in vnode.children:
1547
+ self._layout_detached_subtrees(child, viewport_w, viewport_h)
1146
1548
  return
1147
1549
  for child in vnode.children:
1148
- self._layout_visible_modals(child, viewport_w, viewport_h)
1550
+ self._layout_detached_subtrees(child, viewport_w, viewport_h)
1149
1551
 
1150
- def _build_layout_tree_cached(self, vnode: VNode) -> Optional[LayoutNode]:
1151
- """Like `_build_layout_tree` but reuses cached subtrees when clean.
1552
+ def _build_layout_list_cached(self, vnode: VNode) -> List[LayoutNode]:
1553
+ """Like `_build_layout_list` but reuses cached subtrees when clean.
1152
1554
 
1153
1555
  A VNode's cached `LayoutNode` is reused when the node itself is
1154
1556
  layout-clean and every child produced its cached node too (i.e.
@@ -1158,20 +1560,17 @@ class Reconciler:
1158
1560
  forces fresh flex math along the changed path.
1159
1561
  """
1160
1562
  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.
1563
+ if not isinstance(element.type, str) or element.type in _TRANSPARENT_TYPES:
1564
+ out: List[LayoutNode] = []
1565
+ for child in vnode.children:
1566
+ out.extend(self._build_layout_list_cached(child))
1567
+ return out
1568
+ if element.type in _DETACHED_TYPES:
1569
+ return [] # Off-screen placeholder; not part of the visible flow.
1169
1570
 
1170
1571
  child_layouts: List[LayoutNode] = []
1171
1572
  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)
1573
+ child_layouts.extend(self._build_layout_list_cached(child_vnode))
1175
1574
 
1176
1575
  cached = vnode._layout_node
1177
1576
  if cached is not None and not vnode._layout_dirty:
@@ -1179,7 +1578,7 @@ class Reconciler:
1179
1578
  if len(cached_children) == len(child_layouts) and all(
1180
1579
  a is b for a, b in zip(cached_children, child_layouts)
1181
1580
  ):
1182
- return cached
1581
+ return [cached]
1183
1582
 
1184
1583
  layout = LayoutNode(style=extract_layout_style(element.props), user_data=vnode)
1185
1584
  layout.dirty = True
@@ -1206,7 +1605,7 @@ class Reconciler:
1206
1605
  layout.children.append(child_layout)
1207
1606
 
1208
1607
  vnode._layout_node = layout
1209
- return layout
1608
+ return [layout]
1210
1609
 
1211
1610
  @staticmethod
1212
1611
  def _direct_child_layouts(layout: LayoutNode, element: Element) -> List[LayoutNode]:
@@ -1228,24 +1627,25 @@ class Reconciler:
1228
1627
  for child in vnode.children:
1229
1628
  self._clear_layout_dirty(child)
1230
1629
 
1231
- def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]:
1232
- """Build a fresh (uncached) `LayoutNode` tree for ``vnode``.
1630
+ def _build_layout_list(self, vnode: VNode) -> List[LayoutNode]:
1631
+ """Build fresh (uncached) `LayoutNode`s for ``vnode``.
1233
1632
 
1234
- Used for Modal content (laid out against the viewport each
1235
- pass) and by
1633
+ Used for detached content (Modal / Portal, laid out against the
1634
+ viewport each pass) and by
1236
1635
  [`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.
1636
+ Function components, providers, boundaries, and fragments are
1637
+ transparent: they contribute their children's layout nodes.
1638
+ Native nodes contribute a `LayoutNode` whose ``user_data``
1639
+ points back to the VNode so the layout pass can apply frames.
1241
1640
  """
1242
1641
  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
1642
+ if not isinstance(element.type, str) or element.type in _TRANSPARENT_TYPES:
1643
+ out: List[LayoutNode] = []
1644
+ for child in vnode.children:
1645
+ out.extend(self._build_layout_list(child))
1646
+ return out
1647
+ if element.type in _DETACHED_TYPES:
1648
+ return []
1249
1649
 
1250
1650
  style = extract_layout_style(element.props)
1251
1651
  layout = LayoutNode(style=style, user_data=vnode)
@@ -1260,18 +1660,16 @@ class Reconciler:
1260
1660
  layout.measure = measure
1261
1661
 
1262
1662
  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)
1663
+ for child_layout in self._build_layout_list(child_vnode):
1664
+ if element.type == "ScrollView":
1665
+ # ScrollView's child sees an unbounded main-axis viewport so it
1666
+ # can size to its full content (the scrollable region).
1667
+ axis = element.props.get("scroll_axis", "vertical")
1668
+ child_layout = self._wrap_scroll_axis(child_layout, axis="x" if axis == "horizontal" else "y")
1669
+ child_layout.dirty = True
1670
+ layout.children.append(child_layout)
1273
1671
 
1274
- return layout
1672
+ return [layout]
1275
1673
 
1276
1674
  @staticmethod
1277
1675
  def _wrap_scroll_axis(child: LayoutNode, axis: str) -> LayoutNode:
@@ -1290,7 +1688,7 @@ class Reconciler:
1290
1688
  def _make_measure_callback(self, vnode: VNode) -> Optional[Any]:
1291
1689
  """Return a measure callback for ``vnode`` if it has an intrinsic size."""
1292
1690
  type_name = vnode.element.type
1293
- if type_name not in self._INTRINSIC_TYPES:
1691
+ if type_name not in self._MEASURED_LEAF_TYPES:
1294
1692
  return None
1295
1693
  if vnode.tag is None:
1296
1694
  return None
@@ -1332,8 +1730,11 @@ class Reconciler:
1332
1730
  # Python code can read measured geometry without a
1333
1731
  # native round-trip (used by FlatList's virtualization).
1334
1732
  ref = vnode.element.props.get("ref") if vnode.element.props else None
1335
- if isinstance(ref, dict):
1336
- ref["_pn_frame"] = frame
1733
+ if ref is not None and hasattr(ref, "current"):
1734
+ try:
1735
+ ref._pn_frame = frame
1736
+ except Exception:
1737
+ pass
1337
1738
  child_offset_x = 0.0
1338
1739
  child_offset_y = 0.0
1339
1740
  else:
@@ -1343,6 +1744,27 @@ class Reconciler:
1343
1744
  for child in layout_node.children:
1344
1745
  self._collect_frames(child, child_offset_x, child_offset_y)
1345
1746
 
1747
+ # ------------------------------------------------------------------
1748
+ # Hot-reload support
1749
+ # ------------------------------------------------------------------
1750
+
1751
+ def reset_hook_signatures(self) -> None:
1752
+ """Forget recorded hook-order signatures across the whole tree.
1753
+
1754
+ Called by the hot-reload machinery after a Fast Refresh swaps in
1755
+ new component bodies, since the old call signatures no longer
1756
+ apply.
1757
+ """
1758
+
1759
+ def walk(node: VNode) -> None:
1760
+ if node.hook_state is not None:
1761
+ node.hook_state.reset_hook_signature()
1762
+ for child in node.children:
1763
+ walk(child)
1764
+
1765
+ if self._tree is not None:
1766
+ walk(self._tree)
1767
+
1346
1768
  # ------------------------------------------------------------------
1347
1769
  # Test / debug accessor
1348
1770
  # ------------------------------------------------------------------
@@ -1356,12 +1778,12 @@ class Reconciler:
1356
1778
  """
1357
1779
  if self._tree is None:
1358
1780
  return None
1359
- layout_root = self._build_layout_tree(self._tree)
1360
- if layout_root is None:
1781
+ layout_roots = self._build_layout_list(self._tree)
1782
+ if not layout_roots:
1361
1783
  return None
1362
1784
  viewport = LayoutNode(
1363
1785
  style={"width": viewport_width, "height": viewport_height},
1364
- children=[layout_root],
1786
+ children=list(layout_roots),
1365
1787
  )
1366
1788
  calculate_layout(viewport, viewport_width, viewport_height)
1367
1789
  return viewport