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.
- pythonnative/__init__.py +29 -50
- pythonnative/animated.py +1 -1
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +250 -513
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +413 -49
- pythonnative/hot_reload.py +9 -1
- pythonnative/native_views/android.py +78 -1
- pythonnative/native_views/desktop.py +38 -1
- pythonnative/native_views/ios.py +241 -7
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +43 -7
- pythonnative/reconciler.py +863 -441
- pythonnative/screen.py +324 -27
- pythonnative/storage.py +3 -3
- pythonnative/style.py +83 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
- {pythonnative-0.23.0.dist-info → pythonnative-0.24.0.dist-info}/RECORD +25 -24
- {pythonnative-0.23.0.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.24.0.dist-info}/top_level.txt +0 -0
pythonnative/reconciler.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
- **
|
|
30
|
-
|
|
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
|
|
49
|
-
# from ``use_ref()`` and the reconciler
|
|
50
|
-
# with the underlying native view (and
|
|
51
|
-
# view's tag), mirroring React's ``ref``
|
|
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
|
|
92
|
-
"""
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|
|
127
|
-
of their
|
|
128
|
-
renders
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
198
|
-
4.
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
324
|
-
self.
|
|
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.
|
|
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
|
-
#
|
|
563
|
+
# Post-commit walks
|
|
397
564
|
# ------------------------------------------------------------------
|
|
398
565
|
|
|
399
|
-
def
|
|
400
|
-
"""
|
|
566
|
+
def _fix_tree_links(self) -> None:
|
|
567
|
+
"""Refresh ``parent`` links and delegated wrapper identity.
|
|
401
568
|
|
|
402
|
-
This
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
``
|
|
406
|
-
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
575
|
+
if self._tree is None:
|
|
576
|
+
return
|
|
577
|
+
self._tree.parent = None
|
|
578
|
+
self._fix_node_links(self._tree)
|
|
413
579
|
|
|
414
|
-
def
|
|
580
|
+
def _fix_node_links(self, node: VNode) -> None:
|
|
415
581
|
for child in node.children:
|
|
416
582
|
child.parent = node
|
|
417
|
-
self.
|
|
583
|
+
self._fix_node_links(child)
|
|
418
584
|
if not self._is_native_node(node):
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
vnode.
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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.
|
|
807
|
+
self._activate_boundary(node, exc)
|
|
517
808
|
finally:
|
|
518
809
|
for context, _value in reversed(providers):
|
|
519
810
|
context._stack.pop()
|
|
520
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
621
|
-
#
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
vnode
|
|
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
|
|
890
|
+
from .hooks import HookState
|
|
635
891
|
|
|
636
892
|
hook_state = HookState()
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
eb_children = _flatten_children(element.children)
|
|
944
|
+
vnode = VNode(element, [])
|
|
675
945
|
try:
|
|
676
|
-
|
|
946
|
+
children = self._create_child_list(_normalize_children(element.children, owner="ErrorBoundary"))
|
|
677
947
|
except Exception as exc:
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
#
|
|
724
|
-
if
|
|
725
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
777
|
-
#
|
|
778
|
-
#
|
|
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
|
-
|
|
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.
|
|
1161
|
+
self._reconcile_native_children(old, new_el.children)
|
|
790
1162
|
old.element = new_el
|
|
791
1163
|
return old
|
|
792
1164
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
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
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
|
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
|
|
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
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1104
|
-
if
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
#
|
|
1122
|
-
#
|
|
1123
|
-
#
|
|
1124
|
-
#
|
|
1125
|
-
|
|
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
|
|
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
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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.
|
|
1550
|
+
self._layout_detached_subtrees(child, viewport_w, viewport_h)
|
|
1149
1551
|
|
|
1150
|
-
def
|
|
1151
|
-
"""Like `
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
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
|
|
1232
|
-
"""Build
|
|
1630
|
+
def _build_layout_list(self, vnode: VNode) -> List[LayoutNode]:
|
|
1631
|
+
"""Build fresh (uncached) `LayoutNode`s for ``vnode``.
|
|
1233
1632
|
|
|
1234
|
-
Used for
|
|
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
|
|
1238
|
-
transparent: they
|
|
1239
|
-
nodes contribute a `LayoutNode` whose ``user_data``
|
|
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
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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.
|
|
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
|
|
1336
|
-
|
|
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
|
-
|
|
1360
|
-
if
|
|
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=
|
|
1786
|
+
children=list(layout_roots),
|
|
1365
1787
|
)
|
|
1366
1788
|
calculate_layout(viewport, viewport_width, viewport_height)
|
|
1367
1789
|
return viewport
|