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