pythonnative 0.32.0__py3-none-any.whl → 0.34.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. pythonnative/__init__.py +27 -15
  2. pythonnative/animated.py +7 -13
  3. pythonnative/cli/pn.py +1 -2
  4. pythonnative/component.py +255 -0
  5. pythonnative/components/__init__.py +106 -0
  6. pythonnative/components/_base.py +132 -0
  7. pythonnative/components/controls.py +515 -0
  8. pythonnative/components/layout.py +459 -0
  9. pythonnative/components/lists.py +809 -0
  10. pythonnative/components/media.py +204 -0
  11. pythonnative/components/overlays.py +107 -0
  12. pythonnative/components/pressable.py +252 -0
  13. pythonnative/components/structural.py +167 -0
  14. pythonnative/components/text.py +291 -0
  15. pythonnative/diagnostics.py +1 -1
  16. pythonnative/element.py +108 -29
  17. pythonnative/gestures.py +1 -1
  18. pythonnative/hooks.py +366 -671
  19. pythonnative/hosts/__init__.py +85 -0
  20. pythonnative/hosts/android.py +269 -0
  21. pythonnative/hosts/base.py +665 -0
  22. pythonnative/hosts/desktop.py +107 -0
  23. pythonnative/hosts/ios.py +404 -0
  24. pythonnative/hot_reload.py +16 -24
  25. pythonnative/layout.py +372 -66
  26. pythonnative/native_modules/__init__.py +23 -0
  27. pythonnative/native_modules/net_info.py +5 -1
  28. pythonnative/native_views/__init__.py +1 -1
  29. pythonnative/native_views/android.py +50 -6
  30. pythonnative/native_views/base.py +64 -1
  31. pythonnative/native_views/desktop.py +11 -3
  32. pythonnative/native_views/ios.py +72 -12
  33. pythonnative/navigation/__init__.py +102 -0
  34. pythonnative/navigation/container.py +154 -0
  35. pythonnative/navigation/handle.py +573 -0
  36. pythonnative/navigation/hooks.py +94 -0
  37. pythonnative/navigation/host.py +58 -0
  38. pythonnative/navigation/linking.py +200 -0
  39. pythonnative/navigation/navigators.py +637 -0
  40. pythonnative/navigation/screen.py +149 -0
  41. pythonnative/navigation/state.py +248 -0
  42. pythonnative/net.py +4 -0
  43. pythonnative/platform_metrics.py +1 -1
  44. pythonnative/preview.py +21 -19
  45. pythonnative/project/android.py +7 -1
  46. pythonnative/project/doctor.py +1 -1
  47. pythonnative/project/ios.py +1 -0
  48. pythonnative/project/runtime_assets.py +1 -1
  49. pythonnative/reconciler/__init__.py +29 -0
  50. pythonnative/reconciler/boundaries.py +365 -0
  51. pythonnative/reconciler/children.py +88 -0
  52. pythonnative/reconciler/core.py +1153 -0
  53. pythonnative/reconciler/layout_pass.py +359 -0
  54. pythonnative/reconciler/vnode.py +266 -0
  55. pythonnative/scheduler.py +159 -0
  56. pythonnative/sdk/_components.py +2 -4
  57. pythonnative/style.py +95 -13
  58. pythonnative/suspense.py +8 -12
  59. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
  61. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  62. pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
  63. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
  64. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
  66. pythonnative/testing/__init__.py +53 -0
  67. pythonnative/testing/backend.py +276 -0
  68. pythonnative/testing/harness.py +391 -0
  69. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
  70. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
  71. pythonnative/components.py +0 -2856
  72. pythonnative/navigation.py +0 -1031
  73. pythonnative/reconciler.py +0 -2298
  74. pythonnative/screen.py +0 -2068
  75. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
  76. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
  77. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
  78. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1153 @@
1
+ """The [`Reconciler`][pythonnative.reconciler.core.Reconciler]: element trees in, native mutations out.
2
+
3
+ One reconciler owns one mounted tree (a screen, a list row, a test
4
+ render). Each pass runs the same phases in order:
5
+
6
+ 1. **Render**: component bodies run for the subtrees that need it,
7
+ producing [`Element`][pythonnative.Element] descriptions. Hook state
8
+ is installed for the duration of each body.
9
+ 2. **Diff and stage**: elements are compared with the mounted
10
+ [`VNode`][pythonnative.reconciler.VNode] in their slot. Creates,
11
+ prop updates, attach/detach, and destroys are *staged* into an op
12
+ list instead of being applied immediately.
13
+ 3. **Commit**: the staged ops go to the backend in one batch, refs are
14
+ populated, the layout pass runs (once a viewport is known), then
15
+ effects flush: ``use_layout_effect`` first, ``use_effect`` after.
16
+
17
+ A render requested while a pass is in flight (an effect setting
18
+ state, a boundary reset) is queued and drained before the call
19
+ returns, bounded so runaway update loops surface as an error rather
20
+ than a hang. Hosts never observe a half-committed tree.
21
+
22
+ The class is assembled from mixins to keep each concern readable:
23
+ this module holds the render/diff/commit pipeline,
24
+ ``boundaries`` handles
25
+ ``ErrorBoundary`` and ``Suspense``, and
26
+ ``layout_pass`` runs flexbox.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import inspect
33
+ import os
34
+ from contextlib import contextmanager
35
+ from functools import partial
36
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
37
+
38
+ from .. import diagnostics
39
+ from ..component import Component
40
+ from ..element import ERROR_BOUNDARY, FRAGMENT, SUSPENSE, Element
41
+ from ..events import extract_events, get_event_registry
42
+ from ..hooks import Context, HookState, install_hook_state, restore_hook_state
43
+ from ..mutations import CreateOp, DestroyOp, InsertOp, Mutation, UpdateOp
44
+ from ..scheduler import TransitionQueue, schedule_trigger
45
+ from ..suspense import CoroDriver, Suspend
46
+ from .boundaries import BoundaryMixin, HydrationMap
47
+ from .children import plan_child_moves
48
+ from .layout_pass import LayoutMixin, affects_layout
49
+ from .vnode import VNode, next_tag, normalize_children, shallow_equal_props
50
+
51
+ __all__ = ["Reconciler"]
52
+
53
+ # Props the reconciler consumes itself and never forwards to the
54
+ # native handler. ``ref`` is populated with the native view after
55
+ # commit, mirroring React's ``ref`` semantics.
56
+ _RECONCILER_OWNED_PROPS = frozenset({"ref"})
57
+
58
+ # Hard ceiling on renders drained inside one host call, so a component
59
+ # that sets state unconditionally in an effect surfaces as an error
60
+ # instead of hanging the UI thread.
61
+ _MAX_RENDERS_PER_PASS = 25
62
+
63
+ _MISSING = object()
64
+
65
+
66
+ class Reconciler(BoundaryMixin, LayoutMixin):
67
+ """Owns one mounted tree and translates element diffs into native mutations.
68
+
69
+ Args:
70
+ backend: An object implementing the registry protocol
71
+ (``apply_mutations``, ``resolve_view``, ``measure_intrinsic``,
72
+ ``command``). PythonNative ships Android, iOS, and desktop
73
+ registries; tests use
74
+ [`FakeBackend`][pythonnative.testing.FakeBackend].
75
+
76
+ Attributes:
77
+ backend: The backend passed at construction.
78
+ on_render_requested: Optional callback hosts set to be told a
79
+ re-render is wanted (to hop onto the UI thread or guard
80
+ with a red box). When ``None`` the reconciler re-renders
81
+ inline. Whatever the callback does, it should end up
82
+ calling [`flush_dirty`][pythonnative.reconciler.core.Reconciler.flush_dirty].
83
+ on_back_registered: Optional callback fired when the first
84
+ [`use_back_handler`][pythonnative.use_back_handler]
85
+ registers, so hosts can enable hardware back interception.
86
+ transitions: Deferred-render queue behind
87
+ [`use_transition`][pythonnative.use_transition]; owned per
88
+ reconciler so screens never delay each other.
89
+ """
90
+
91
+ def __init__(self, backend: Any) -> None:
92
+ self.backend = backend
93
+ self.root: Optional[VNode] = None
94
+ self.on_render_requested: Optional[Callable[[], None]] = None
95
+ self.on_back_registered: Optional[Callable[[], None]] = None
96
+ self.transitions = TransitionQueue()
97
+
98
+ self._events = get_event_registry()
99
+ self._ops: List[Mutation] = []
100
+ self._created: List[VNode] = []
101
+ self._rendering = False
102
+ self._render_queued = False
103
+ # Component nodes whose own state changed since the last flush,
104
+ # keyed by ``id`` to dedupe while keeping a strong reference.
105
+ self._dirty_nodes: Dict[int, VNode] = {}
106
+ # Error boundaries whose ``reset`` was called.
107
+ self._dirty_boundaries: Dict[int, VNode] = {}
108
+ # Suspense nodes whose awaited work completed.
109
+ self._dirty_suspense: Dict[int, VNode] = {}
110
+ # Hydration map active while a Suspense boundary retries its
111
+ # content, and hook states salvaged while a Suspend unwinds.
112
+ self._hydration: Optional[HydrationMap] = None
113
+ self._suspense_salvage: Optional[HydrationMap] = None
114
+ # Tags destroyed during the current pass; tags are never
115
+ # reused, so stale entries can never alias a live view.
116
+ self._destroyed_tags: Set[int] = set()
117
+ # ``use_back_handler`` registrations, oldest first.
118
+ self._back_handlers: List[Callable[[], bool]] = []
119
+ self._init_layout_state()
120
+
121
+ # ------------------------------------------------------------------
122
+ # Public API
123
+ # ------------------------------------------------------------------
124
+
125
+ @property
126
+ def root_tag(self) -> Optional[int]:
127
+ """Tag of the root native view, or ``None`` when nothing is mounted."""
128
+ return self.root.tag if self.root is not None else None
129
+
130
+ def root_view(self) -> Any:
131
+ """The root native view object, or ``None`` before mount."""
132
+ return self.root.native_view if self.root is not None else None
133
+
134
+ def mount(self, element: Element) -> Any:
135
+ """Build native views for ``element`` and return the root native view.
136
+
137
+ Any previously mounted tree is unmounted first.
138
+ """
139
+ if self.root is not None:
140
+ self.unmount()
141
+ self._log(f"mount: {element!r}")
142
+ with self._pass():
143
+ self._destroyed_tags.clear()
144
+ try:
145
+ self.root = self._create_tree(element)
146
+ except Suspend as signal:
147
+ self._discard_salvage()
148
+ raise self._missing_suspense_error(signal) from None
149
+ self._drain_dirty()
150
+ self._commit()
151
+ self._warn_on_multiple_roots()
152
+ return self.root_view()
153
+
154
+ def reconcile(self, element: Element) -> Any:
155
+ """Diff ``element`` against the mounted tree and patch native views.
156
+
157
+ Pending state updates are drained as part of the pass, so hosts
158
+ only call this when the root element itself changed (new props
159
+ from outside the tree).
160
+ """
161
+ if self.root is None:
162
+ return self.mount(element)
163
+ with self._pass():
164
+ # A full pass covers every dirty component; reactive context
165
+ # may re-add entries during it, which the drain picks up.
166
+ self._dirty_nodes.clear()
167
+ self._destroyed_tags.clear()
168
+ try:
169
+ self.root = self._reconcile_node(self.root, element)
170
+ except Suspend as signal:
171
+ self._discard_salvage()
172
+ raise self._missing_suspense_error(signal) from None
173
+ self._drain_dirty()
174
+ self._commit()
175
+ self._warn_on_multiple_roots()
176
+ return self.root_view()
177
+
178
+ def flush_dirty(self) -> Any:
179
+ """Re-render only the components whose state changed, then commit.
180
+
181
+ This is the hot path for state-driven updates: each dirty
182
+ component re-runs its own body and reconciles its subtree in
183
+ place; the batch commits as one native transaction. Returns the
184
+ (possibly replaced) root native view.
185
+ """
186
+ if self.root is None:
187
+ return None
188
+ if self._rendering:
189
+ self._render_queued = True
190
+ return self.root_view()
191
+ if not self._has_dirty_work():
192
+ return self.root_view()
193
+ with self._pass():
194
+ self._destroyed_tags.clear()
195
+ self._drain_dirty()
196
+ self._commit()
197
+ return self.root_view()
198
+
199
+ def unmount(self) -> None:
200
+ """Tear down the mounted tree, running effect cleanups and destroying native views."""
201
+ root = self.root
202
+ if root is None:
203
+ return
204
+ self._destroy_tree(root)
205
+ self.root = None
206
+ self._dirty_nodes.clear()
207
+ self._dirty_boundaries.clear()
208
+ self._dirty_suspense.clear()
209
+ self._back_handlers.clear()
210
+ self.transitions.clear()
211
+ self._flush_ops()
212
+
213
+ def dispatch_command(self, tag: Optional[int], name: str, args: Optional[Dict[str, Any]] = None) -> Any:
214
+ """Run an imperative command against the view registered under ``tag``."""
215
+ if tag is None:
216
+ return None
217
+ return self.backend.command(tag, name, args or {})
218
+
219
+ def dispatch_back_press(self) -> bool:
220
+ """Offer the system back action to registered handlers, newest first.
221
+
222
+ Returns ``True`` if a handler consumed the event (the platform
223
+ should not run its default behavior).
224
+ """
225
+ for handler in reversed(list(self._back_handlers)):
226
+ if handler():
227
+ return True
228
+ return False
229
+
230
+ def reset_hook_signatures(self) -> None:
231
+ """Forget recorded hook-order signatures across the whole tree (Fast Refresh)."""
232
+
233
+ def walk(node: VNode) -> None:
234
+ if node.hook_state is not None:
235
+ node.hook_state.reset_hook_signature()
236
+ for child in node.children:
237
+ walk(child)
238
+
239
+ if self.root is not None:
240
+ walk(self.root)
241
+
242
+ def walk(self) -> Iterator[VNode]:
243
+ """Yield every mounted node in depth-first, document order."""
244
+ stack = [self.root] if self.root is not None else []
245
+ while stack:
246
+ node = stack.pop()
247
+ yield node
248
+ stack.extend(reversed(node.children))
249
+
250
+ # ------------------------------------------------------------------
251
+ # RenderOwner protocol (called by hooks)
252
+ # ------------------------------------------------------------------
253
+
254
+ def mark_dirty(self, vnode: VNode) -> None:
255
+ """Queue ``vnode`` (a component) for a local re-render on the next flush."""
256
+ if vnode is None or vnode.hook_state is None or not vnode.mounted:
257
+ return
258
+ self._dirty_nodes[id(vnode)] = vnode
259
+
260
+ def request_render(self) -> None:
261
+ """Ask for a flush: via the host callback when set, inline otherwise."""
262
+ if self._rendering:
263
+ self._render_queued = True
264
+ return
265
+ if self.on_render_requested is not None:
266
+ self.on_render_requested()
267
+ else:
268
+ self.flush_dirty()
269
+
270
+ def register_back_handler(self, handler: Callable[[], bool]) -> Callable[[], None]:
271
+ """Register a back-press handler; returns an unregister callable."""
272
+ self._back_handlers.append(handler)
273
+ if len(self._back_handlers) == 1 and self.on_back_registered is not None:
274
+ self.on_back_registered()
275
+
276
+ def unregister() -> None:
277
+ try:
278
+ self._back_handlers.remove(handler)
279
+ except ValueError:
280
+ pass
281
+
282
+ return unregister
283
+
284
+ # ------------------------------------------------------------------
285
+ # Pass bookkeeping
286
+ # ------------------------------------------------------------------
287
+
288
+ @contextmanager
289
+ def _pass(self) -> Iterator[None]:
290
+ """Mark a pass in flight and drain renders requested during it."""
291
+ if self._rendering:
292
+ self._render_queued = True
293
+ yield
294
+ return
295
+ self._rendering = True
296
+ try:
297
+ yield
298
+ passes = 0
299
+ while self._render_queued and self.root is not None:
300
+ self._render_queued = False
301
+ if not self._has_dirty_work():
302
+ continue
303
+ passes += 1
304
+ if passes > _MAX_RENDERS_PER_PASS:
305
+ raise RuntimeError(
306
+ "Too many re-renders: a component keeps requesting updates while committing "
307
+ "(for example an effect that sets state unconditionally)."
308
+ )
309
+ self._destroyed_tags.clear()
310
+ self._drain_dirty()
311
+ self._commit()
312
+ finally:
313
+ self._rendering = False
314
+ self._render_queued = False
315
+
316
+ def _has_dirty_work(self) -> bool:
317
+ return bool(self._dirty_nodes or self._dirty_boundaries or self._dirty_suspense)
318
+
319
+ def _drain_dirty(self) -> None:
320
+ """Process dirty components and boundary retries until none remain."""
321
+ guard = 0
322
+ while self._has_dirty_work():
323
+ guard += 1
324
+ if guard > 100:
325
+ diagnostics.warn(
326
+ "Update loop did not settle after 100 iterations; a component is "
327
+ "likely setting state unconditionally during render or effects."
328
+ )
329
+ self._dirty_nodes.clear()
330
+ self._dirty_boundaries.clear()
331
+ self._dirty_suspense.clear()
332
+ return
333
+
334
+ boundaries = list(self._dirty_boundaries.values())
335
+ self._dirty_boundaries.clear()
336
+ for boundary in boundaries:
337
+ if boundary.mounted:
338
+ with self._providers_above(boundary):
339
+ self._local_update(
340
+ boundary, partial(self._reconcile_error_boundary, boundary, boundary.element)
341
+ )
342
+
343
+ suspended = list(self._dirty_suspense.values())
344
+ self._dirty_suspense.clear()
345
+ for node in suspended:
346
+ if node.mounted and node.suspense_showing_fallback:
347
+ with self._providers_above(node):
348
+ try:
349
+ self._local_update(node, partial(self._attempt_suspense_content, node))
350
+ except Exception as exc:
351
+ self._route_error(node, exc)
352
+
353
+ pending = sorted(self._dirty_nodes.values(), key=VNode.depth)
354
+ self._dirty_nodes.clear()
355
+ for vnode in pending:
356
+ hs = vnode.hook_state
357
+ if not vnode.mounted or hs is None or not hs._dirty:
358
+ continue # already covered by a dirty ancestor's pass
359
+ try:
360
+ self._update_component(vnode)
361
+ except Suspend as signal:
362
+ self._route_suspend(vnode, signal)
363
+ except Exception as exc:
364
+ self._route_error(vnode, exc)
365
+
366
+ def _commit(self) -> None:
367
+ """Apply the staged transaction and run the post-commit phases."""
368
+ self._flush_ops()
369
+ self._fix_tree_links()
370
+ self._run_layout()
371
+ self._flush_ops()
372
+ self._dispatch_layout_events()
373
+ self._flush_layout_effects()
374
+ self._flush_ops()
375
+ self._flush_passive_effects()
376
+ self._flush_ops()
377
+
378
+ def _flush_ops(self) -> None:
379
+ """Send pending ops to the backend and resolve created views."""
380
+ ops = self._ops
381
+ created = self._created
382
+ if ops:
383
+ self._ops = []
384
+ self._created = []
385
+ self.backend.apply_mutations(ops)
386
+ elif created:
387
+ self._created = []
388
+ for node in created:
389
+ if not node.mounted or node.tag is None:
390
+ continue
391
+ node.native_view = self.backend.resolve_view(node.tag)
392
+ self._attach_ref(node.element, node.native_view, node.tag)
393
+
394
+ def _fix_tree_links(self) -> None:
395
+ """Refresh ``parent`` links and delegated wrapper identity across the tree."""
396
+ if self.root is None:
397
+ return
398
+ self.root.parent = None
399
+ self._fix_node_links(self.root)
400
+
401
+ def _fix_node_links(self, node: VNode) -> None:
402
+ for child in node.children:
403
+ child.parent = node
404
+ self._fix_node_links(child)
405
+ if not node.is_native:
406
+ self._refresh_identity(node)
407
+
408
+ def _flush_layout_effects(self) -> None:
409
+ if self.root is not None:
410
+ self._walk_effects(self.root, layout=True)
411
+
412
+ def _flush_passive_effects(self) -> None:
413
+ if self.root is not None:
414
+ self._walk_effects(self.root, layout=False)
415
+
416
+ def _walk_effects(self, node: VNode, layout: bool) -> None:
417
+ for child in node.children:
418
+ self._walk_effects(child, layout)
419
+ hs = node.hook_state
420
+ if hs is not None:
421
+ if layout:
422
+ hs.flush_layout_effects()
423
+ else:
424
+ hs.flush_pending_effects()
425
+
426
+ # ------------------------------------------------------------------
427
+ # Tree creation
428
+ # ------------------------------------------------------------------
429
+
430
+ def _create_tree(self, element: Element) -> VNode:
431
+ type_obj = element.type
432
+ if isinstance(type_obj, str):
433
+ return self._create_native(element)
434
+ if isinstance(type_obj, Component):
435
+ return self._create_component(element)
436
+ if isinstance(type_obj, Context):
437
+ return self._create_provider(element)
438
+ if type_obj is ERROR_BOUNDARY:
439
+ return self._create_error_boundary(element)
440
+ if type_obj is SUSPENSE:
441
+ return self._create_suspense(element)
442
+ if type_obj is FRAGMENT:
443
+ return self._create_wrapper(element, "Fragment")
444
+ raise TypeError(
445
+ f"Unsupported element type {type_obj!r}. Element types are native view names, "
446
+ "@component functions, contexts (providers), or the structural types."
447
+ )
448
+
449
+ def _create_native(self, element: Element) -> VNode:
450
+ tag = next_tag()
451
+ clean_props, events = self._split_props(element.props)
452
+ node = VNode(element, [], tag=tag)
453
+ node.clean_props = clean_props
454
+ if events:
455
+ self._events.set_events(tag, events)
456
+ self._ops.append(CreateOp(tag, element.type, clean_props))
457
+ self._created.append(node)
458
+
459
+ index = 0
460
+ try:
461
+ for child_el in normalize_children(element.children, owner=element.type):
462
+ child = self._create_tree(child_el)
463
+ child.parent = node
464
+ node.children.append(child)
465
+ for root in self._native_roots(child):
466
+ if root.tag is not None:
467
+ self._ops.append(InsertOp(tag, root.tag, index))
468
+ index += 1
469
+ except Suspend:
470
+ # Don't leak the container (and already-built siblings) when
471
+ # a child suspends; hook states are salvaged for the retry.
472
+ if self._suspense_salvage is None:
473
+ self._suspense_salvage = {}
474
+ self._destroy_tree(node, salvage=self._suspense_salvage)
475
+ raise
476
+ except Exception:
477
+ self._destroy_tree(node)
478
+ raise
479
+ return node
480
+
481
+ def _create_component(self, element: Element) -> VNode:
482
+ # A retrying Suspense boundary may have preserved this
483
+ # component's hook state from the attempt that suspended;
484
+ # reclaiming it keeps cached resources warm.
485
+ hook_state = self._take_hydrated_hook_state(element) or HookState()
486
+ rendered = self._render_component_body(hook_state, element)
487
+ try:
488
+ children = self._create_child_list(rendered)
489
+ except Suspend:
490
+ # The body rendered fine but a descendant suspended: salvage
491
+ # this component's own state too, so the retry re-adopts it.
492
+ if self._suspense_salvage is None:
493
+ self._suspense_salvage = {}
494
+ self._suspense_salvage.setdefault((id(element.type), element.key), []).append(hook_state)
495
+ raise
496
+ node = VNode(element, children)
497
+ for child in children:
498
+ child.parent = node
499
+ node.hook_state = hook_state
500
+ node.rendered = rendered
501
+ self._refresh_identity(node)
502
+ hook_state.vnode = node
503
+ hook_state.owner = self
504
+ return node
505
+
506
+ def _create_provider(self, element: Element) -> VNode:
507
+ context: Context = element.type
508
+ context._push(element.props.get("value"))
509
+ try:
510
+ children = self._create_child_list(
511
+ normalize_children(element.children, owner=self._provider_label(context))
512
+ )
513
+ finally:
514
+ context._pop()
515
+ node = VNode(element, children)
516
+ for child in children:
517
+ child.parent = node
518
+ self._refresh_identity(node)
519
+ return node
520
+
521
+ def _create_wrapper(self, element: Element, owner: str) -> VNode:
522
+ children = self._create_child_list(normalize_children(element.children, owner=owner))
523
+ node = VNode(element, children)
524
+ for child in children:
525
+ child.parent = node
526
+ self._refresh_identity(node)
527
+ return node
528
+
529
+ def _create_child_list(self, elements: List[Element]) -> List[VNode]:
530
+ """Create nodes for ``elements``, cleaning up on mid-list failure.
531
+
532
+ When the failure is a ``Suspend``, already-built siblings are
533
+ torn down with their hook states *salvaged*, so the boundary's
534
+ retry re-mounts them with their caches intact.
535
+ """
536
+ nodes: List[VNode] = []
537
+ try:
538
+ for el in elements:
539
+ nodes.append(self._create_tree(el))
540
+ except Suspend:
541
+ if self._suspense_salvage is None:
542
+ self._suspense_salvage = {}
543
+ for node in nodes:
544
+ self._destroy_tree(node, salvage=self._suspense_salvage)
545
+ raise
546
+ except Exception:
547
+ for node in nodes:
548
+ self._destroy_tree(node)
549
+ raise
550
+ return nodes
551
+
552
+ # ------------------------------------------------------------------
553
+ # Component bodies
554
+ # ------------------------------------------------------------------
555
+
556
+ def _render_component_body(self, hook_state: HookState, element: Element) -> List[Element]:
557
+ """Run a component's function with hook state installed and normalize its output.
558
+
559
+ ``async def`` bodies are driven synchronously as far as they can
560
+ go (see [`CoroDriver`][pythonnative.suspense.CoroDriver]); if
561
+ the body blocks on pending work, ``Suspend`` propagates,
562
+ annotated with the component's hook state so a Suspense
563
+ boundary can preserve it across retries.
564
+ """
565
+ component: Component = element.type
566
+ label = component.display_name
567
+ hook_state.begin_render(label)
568
+ hook_state.owner = self
569
+ token = install_hook_state(hook_state)
570
+ try:
571
+ rendered = component.render(element)
572
+ if inspect.iscoroutine(rendered):
573
+ rendered = self._drive_async_body(hook_state, label, rendered)
574
+ hook_state.finish_render()
575
+ except Suspend as signal:
576
+ hook_state.abort_render()
577
+ if signal.hook_state is None:
578
+ signal.hook_state = hook_state
579
+ if signal.key is None:
580
+ signal.key = (id(element.type), element.key)
581
+ if not signal.label:
582
+ signal.label = label
583
+ raise
584
+ finally:
585
+ restore_hook_state(token)
586
+ hook_state._dirty = False
587
+ return normalize_children(rendered, owner=label)
588
+
589
+ @staticmethod
590
+ def _drive_async_body(hook_state: HookState, label: str, coro: Any) -> Any:
591
+ """Drive an ``async def`` component body, suspending when it blocks.
592
+
593
+ A previous in-flight body for the same component is cancelled
594
+ first: only the newest render's coroutine may deliver a tree.
595
+ When the previous attempt finished while the component was
596
+ suspended, its result is consumed instead of re-running the
597
+ body, so bodies awaiting one-shot work make progress.
598
+ """
599
+ prev = hook_state._async_driver
600
+ if prev is not None:
601
+ if prev.done and not prev.cancelled():
602
+ coro.close()
603
+ hook_state._async_driver = None
604
+ hook_state._hook_log = None
605
+ error = prev.exception()
606
+ if error is not None:
607
+ raise error
608
+ return prev.result()
609
+ if not prev.done:
610
+ prev.cancel()
611
+ driver = CoroDriver(coro)
612
+ hook_state._async_driver = driver
613
+ driver.start()
614
+ if driver.done:
615
+ hook_state._async_driver = None
616
+ if driver.cancelled():
617
+ raise asyncio.CancelledError()
618
+ error = driver.exception()
619
+ if error is not None:
620
+ raise error
621
+ return driver.result()
622
+ raise Suspend(driver, hook_state=hook_state, label=label)
623
+
624
+ def _register_component_retry(self, vnode: VNode, signal: Suspend) -> None:
625
+ """Re-render ``vnode`` once the work it suspended on completes.
626
+
627
+ Update-time suspensions keep the previous content on screen (no
628
+ fallback flash) and re-run the body when the awaited work is
629
+ done, matching transition semantics.
630
+ """
631
+ hook_state = vnode.hook_state
632
+
633
+ def _on_done(_waitable: Any) -> None:
634
+ if not vnode.mounted or vnode.hook_state is not hook_state or hook_state is None:
635
+ return
636
+ hook_state._dirty = True
637
+ self.mark_dirty(vnode)
638
+ schedule_trigger(self.request_render)
639
+
640
+ signal.waitable.add_done_callback(_on_done)
641
+
642
+ def _update_component(self, vnode: VNode) -> None:
643
+ """Re-run one component's body and reconcile its subtree in place.
644
+
645
+ A local update starts in the middle of the tree, so the provider
646
+ chain above the component is re-established before the body
647
+ runs. When the body itself suspends, the previous subtree stays
648
+ on screen and the component re-renders once the awaited work
649
+ completes; suspensions from descendant mounts propagate to the
650
+ caller, which routes them to the nearest Suspense boundary.
651
+ """
652
+ hook_state = vnode.hook_state
653
+ if hook_state is None or not vnode.is_component:
654
+ return
655
+
656
+ def work() -> None:
657
+ try:
658
+ rendered = self._render_component_body(hook_state, vnode.element)
659
+ except Suspend as signal:
660
+ if signal.hook_state is hook_state:
661
+ self._register_component_retry(vnode, signal)
662
+ return
663
+ raise
664
+ children = self._reconcile_child_list(vnode.children, rendered)
665
+ for child in children:
666
+ child.parent = vnode
667
+ vnode.children = children
668
+ vnode.rendered = rendered
669
+ self._refresh_identity(vnode)
670
+ hook_state.vnode = vnode
671
+ hook_state.owner = self
672
+
673
+ with self._providers_above(vnode):
674
+ self._local_update(vnode, work)
675
+
676
+ def _local_update(self, node: VNode, work: Callable[[], Any]) -> None:
677
+ """Run ``work`` on ``node`` and repair the surrounding native structure.
678
+
679
+ Captures the nearest native container's child tags before the
680
+ work, then refreshes transparent ancestors' delegated identity
681
+ and re-syncs that container's children afterwards, so only the
682
+ moves the update actually caused reach the native side.
683
+ """
684
+ container = self._nearest_native_ancestor(node)
685
+ before = self._flattened_child_tags(container) if container is not None else []
686
+ work()
687
+ current = node.parent
688
+ while current is not None and current is not container:
689
+ self._refresh_identity(current)
690
+ current = current.parent
691
+ if container is not None and container.tag is not None:
692
+ if self._sync_native_children(container.tag, before, self._flattened_child_roots(container)):
693
+ self._mark_layout_dirty(container)
694
+
695
+ def _route_error(self, vnode: VNode, exc: BaseException) -> None:
696
+ """Route a local render failure to the nearest ``ErrorBoundary`` ancestor.
697
+
698
+ Without an enclosing boundary the exception propagates, exactly
699
+ as it would during a full render.
700
+ """
701
+ node = vnode.parent
702
+ while node is not None:
703
+ if node.is_error_boundary:
704
+ with self._providers_above(node):
705
+ self._local_update(node, partial(self._activate_boundary, node, exc))
706
+ return
707
+ node = node.parent
708
+ raise exc
709
+
710
+ def _route_suspend(self, vnode: VNode, signal: Suspend) -> None:
711
+ """Route a suspension from a local update to the nearest Suspense ancestor.
712
+
713
+ The child reconcile that suspended may have left the boundary's
714
+ content partially updated, so the whole content is torn down and
715
+ rebuilt through the fallback-and-retry path (hook states are
716
+ preserved for the retry).
717
+ """
718
+ node = vnode.parent
719
+ while node is not None:
720
+ if node.is_suspense:
721
+ with self._providers_above(node):
722
+
723
+ def work(n: VNode = node) -> None:
724
+ self._teardown_suspense_content(n)
725
+ self._suspend_boundary(n, signal)
726
+
727
+ self._local_update(node, work)
728
+ return
729
+ node = node.parent
730
+ self._discard_salvage()
731
+ raise self._missing_suspense_error(signal) from None
732
+
733
+ @contextmanager
734
+ def _providers_above(self, vnode: VNode) -> Iterator[None]:
735
+ """Push the provided values of every provider above ``vnode``, outermost first."""
736
+ chain: List[Tuple[Context, Any]] = []
737
+ node = vnode.parent
738
+ while node is not None:
739
+ if node.is_provider:
740
+ chain.append((node.element.type, node.element.props.get("value")))
741
+ node = node.parent
742
+ chain.reverse()
743
+ for context, value in chain:
744
+ context._push(value)
745
+ try:
746
+ yield
747
+ finally:
748
+ for context, _value in reversed(chain):
749
+ context._pop()
750
+
751
+ # ------------------------------------------------------------------
752
+ # Reconciliation
753
+ # ------------------------------------------------------------------
754
+
755
+ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode:
756
+ if not self._same_type(old.element, new_el):
757
+ new_node = self._create_tree(new_el)
758
+ self._destroy_tree(old)
759
+ return new_node
760
+ if old.is_native:
761
+ return self._reconcile_native(old, new_el)
762
+ if old.is_component:
763
+ return self._reconcile_component(old, new_el)
764
+ if old.is_provider:
765
+ return self._reconcile_provider(old, new_el)
766
+ if old.is_error_boundary:
767
+ return self._reconcile_error_boundary(old, new_el)
768
+ if old.is_suspense:
769
+ return self._reconcile_suspense(old, new_el)
770
+ return self._reconcile_wrapper(old, new_el, "Fragment")
771
+
772
+ def _reconcile_native(self, old: VNode, new_el: Element) -> VNode:
773
+ new_clean, events = self._split_props(new_el.props)
774
+ if old.tag is not None:
775
+ self._events.set_events(old.tag, events)
776
+ changed = self._diff_props(old.clean_props, new_clean)
777
+ if changed:
778
+ if old.tag is not None:
779
+ self._ops.append(UpdateOp(old.tag, changed))
780
+ old.measure_cache = None
781
+ if affects_layout(old.element.type, changed):
782
+ self._mark_layout_dirty(old)
783
+ old.clean_props = new_clean
784
+
785
+ old_ref = old.element.props.get("ref")
786
+ new_ref = new_el.props.get("ref")
787
+ if old_ref is not new_ref:
788
+ self._clear_ref(old_ref)
789
+ self._attach_ref(new_el, old.native_view, old.tag)
790
+
791
+ before = self._flattened_child_tags(old)
792
+ children = self._reconcile_child_list(old.children, normalize_children(new_el.children, owner=new_el.type))
793
+ old.children = children
794
+ for child in children:
795
+ child.parent = old
796
+ old.element = new_el
797
+ if old.tag is not None and self._sync_native_children(old.tag, before, self._flattened_child_roots(old)):
798
+ self._mark_layout_dirty(old)
799
+ return old
800
+
801
+ def _reconcile_component(self, old: VNode, new_el: Element) -> VNode:
802
+ if self._can_skip_memoized(old, new_el):
803
+ old.element = new_el
804
+ return old
805
+ hook_state = old.hook_state or HookState()
806
+ rendered = self._render_component_body(hook_state, new_el)
807
+ children = self._reconcile_child_list(old.children, rendered)
808
+ old.children = children
809
+ for child in children:
810
+ child.parent = old
811
+ old.element = new_el
812
+ old.hook_state = hook_state
813
+ old.rendered = rendered
814
+ self._refresh_identity(old)
815
+ hook_state.vnode = old
816
+ hook_state.owner = self
817
+ return old
818
+
819
+ def _reconcile_provider(self, old: VNode, new_el: Element) -> VNode:
820
+ context: Context = new_el.type
821
+ old_value = old.element.props.get("value", _MISSING)
822
+ new_value = new_el.props.get("value")
823
+ if self._value_changed(old_value, new_value):
824
+ self._mark_context_consumers(old, context)
825
+ context._push(new_value)
826
+ try:
827
+ children = self._reconcile_child_list(
828
+ old.children, normalize_children(new_el.children, owner=self._provider_label(context))
829
+ )
830
+ finally:
831
+ context._pop()
832
+ old.children = children
833
+ for child in children:
834
+ child.parent = old
835
+ old.element = new_el
836
+ self._refresh_identity(old)
837
+ return old
838
+
839
+ def _reconcile_wrapper(self, old: VNode, new_el: Element, owner: str) -> VNode:
840
+ children = self._reconcile_child_list(old.children, normalize_children(new_el.children, owner=owner))
841
+ old.children = children
842
+ for child in children:
843
+ child.parent = old
844
+ old.element = new_el
845
+ self._refresh_identity(old)
846
+ return old
847
+
848
+ def _mark_context_consumers(self, provider: VNode, context: Context) -> None:
849
+ """Mark every descendant that read ``context`` for re-render.
850
+
851
+ Descent is pruned at nested providers of the same context, since
852
+ their subtrees read the inner (unchanged) value.
853
+ """
854
+ target = id(context)
855
+
856
+ def walk(node: VNode) -> None:
857
+ for child in node.children:
858
+ if child.is_provider and child.element.type is context:
859
+ continue
860
+ hs = child.hook_state
861
+ if hs is not None and target in hs.context_deps:
862
+ hs._dirty = True
863
+ self._dirty_nodes[id(child)] = child
864
+ walk(child)
865
+
866
+ walk(provider)
867
+
868
+ @staticmethod
869
+ def _can_skip_memoized(old: VNode, new_el: Element) -> bool:
870
+ """Whether a memoized component can reuse its last render.
871
+
872
+ Requires the component to be memoized, to have rendered before,
873
+ to have no pending state change (``hook_state._dirty``), and
874
+ props that compare equal under its comparator (shallow equality
875
+ by default), children included.
876
+ """
877
+ component: Component = new_el.type
878
+ if not component.memoized or old.rendered is None or old.hook_state is None or old.hook_state._dirty:
879
+ return False
880
+ compare = component.props_equal or shallow_equal_props
881
+ try:
882
+ return bool(compare(old.element.props, new_el.props)) and old.element.children == new_el.children
883
+ except Exception:
884
+ return False
885
+
886
+ def _reconcile_child_list(self, old_children: List[VNode], new_children: List[Element]) -> List[VNode]:
887
+ """Match, reconcile, create, and destroy one level of children.
888
+
889
+ Keyed children match by key; unkeyed children match by position
890
+ among the unkeyed siblings. Type changes replace the node. This
891
+ pass is purely structural: native attachment order is derived
892
+ afterwards by the nearest native container via
893
+ [`_sync_native_children`][pythonnative.reconciler.core.Reconciler._sync_native_children].
894
+
895
+ On failure, any freshly created replacement nodes are destroyed
896
+ before the exception propagates so an enclosing boundary can
897
+ swap in its fallback without leaking native views.
898
+ """
899
+ old_by_key: Dict[Any, VNode] = {}
900
+ old_unkeyed: List[VNode] = []
901
+ for child in old_children:
902
+ if child.element.key is not None:
903
+ old_by_key[child.element.key] = child
904
+ else:
905
+ old_unkeyed.append(child)
906
+
907
+ result: List[VNode] = []
908
+ fresh: List[VNode] = []
909
+ used_keys: Set[Any] = set()
910
+ unkeyed_iter = iter(old_unkeyed)
911
+ try:
912
+ for new_el in new_children:
913
+ matched: Optional[VNode] = None
914
+ if new_el.key is not None:
915
+ matched = old_by_key.get(new_el.key)
916
+ if matched is not None:
917
+ used_keys.add(new_el.key)
918
+ else:
919
+ matched = next(unkeyed_iter, None)
920
+
921
+ if matched is None:
922
+ node = self._create_tree(new_el)
923
+ fresh.append(node)
924
+ result.append(node)
925
+ elif not self._same_type(matched.element, new_el):
926
+ node = self._create_tree(new_el)
927
+ self._destroy_tree(matched)
928
+ fresh.append(node)
929
+ result.append(node)
930
+ else:
931
+ result.append(self._reconcile_node(matched, new_el))
932
+ except Exception:
933
+ for node in fresh:
934
+ self._destroy_tree(node)
935
+ raise
936
+
937
+ for key, node in old_by_key.items():
938
+ if key not in used_keys:
939
+ self._destroy_tree(node)
940
+ for node in unkeyed_iter:
941
+ self._destroy_tree(node)
942
+ return result
943
+
944
+ # ------------------------------------------------------------------
945
+ # Destruction
946
+ # ------------------------------------------------------------------
947
+
948
+ def _destroy_tree(self, node: VNode, salvage: Optional[HydrationMap] = None) -> None:
949
+ """Tear down a subtree, destroying native views and cleaning hook state.
950
+
951
+ Args:
952
+ node: Root of the subtree to destroy.
953
+ salvage: When given (a Suspense boundary is unwinding),
954
+ component hook states move into this map keyed by
955
+ ``(component identity, element key)`` instead of being
956
+ cleaned up, so the boundary's retry can re-adopt them.
957
+ """
958
+ if not node.mounted:
959
+ return
960
+ node.mounted = False
961
+ self._dirty_nodes.pop(id(node), None)
962
+ self._dirty_boundaries.pop(id(node), None)
963
+ self._dirty_suspense.pop(id(node), None)
964
+ if node.suspense_hydration:
965
+ self._dispose_hydration(node.suspense_hydration)
966
+ node.suspense_hydration = None
967
+ hs = node.hook_state
968
+ if hs is not None:
969
+ if salvage is not None and node.is_component:
970
+ salvage.setdefault((id(node.element.type), node.element.key), []).append(hs)
971
+ else:
972
+ hs.cleanup_all_effects()
973
+ hs.detach()
974
+ self._clear_ref(node.element.props.get("ref"))
975
+ for child in node.children:
976
+ self._destroy_tree(child, salvage=salvage)
977
+ if node.is_native and node.tag is not None:
978
+ self._events.clear(node.tag)
979
+ self._ops.append(DestroyOp(node.tag))
980
+ self._destroyed_tags.add(node.tag)
981
+ node.children = []
982
+ node.parent = None
983
+ node.layout_node = None
984
+
985
+ # ------------------------------------------------------------------
986
+ # Native structure helpers
987
+ # ------------------------------------------------------------------
988
+
989
+ def _native_roots(self, node: VNode) -> List[VNode]:
990
+ """The native views ``node`` contributes to its native parent, in order.
991
+
992
+ Native elements contribute themselves, except ``Portal``, whose
993
+ handler self-attaches to a top-level overlay. Transparent
994
+ wrappers contribute the concatenation of their children's roots.
995
+ """
996
+ if node.is_native:
997
+ return [] if node.element.type == "Portal" else [node]
998
+ roots: List[VNode] = []
999
+ for child in node.children:
1000
+ roots.extend(self._native_roots(child))
1001
+ return roots
1002
+
1003
+ def _flattened_child_roots(self, node: VNode) -> List[VNode]:
1004
+ roots: List[VNode] = []
1005
+ for child in node.children:
1006
+ roots.extend(self._native_roots(child))
1007
+ return roots
1008
+
1009
+ def _flattened_child_tags(self, node: VNode) -> List[int]:
1010
+ return [root.tag for root in self._flattened_child_roots(node) if root.tag is not None]
1011
+
1012
+ @staticmethod
1013
+ def _nearest_native_ancestor(node: VNode) -> Optional[VNode]:
1014
+ current = node.parent
1015
+ while current is not None:
1016
+ if current.is_native:
1017
+ return current
1018
+ current = current.parent
1019
+ return None
1020
+
1021
+ def _refresh_identity(self, node: VNode) -> None:
1022
+ """Point a wrapper's ``tag`` / ``native_view`` at its first native root."""
1023
+ if node.is_native:
1024
+ return
1025
+ for root in self._native_roots(node):
1026
+ node.tag = root.tag
1027
+ node.native_view = root.native_view
1028
+ return
1029
+ node.tag = None
1030
+ node.native_view = None
1031
+
1032
+ def _sync_native_children(self, parent_tag: int, before_tags: List[int], after_roots: List[VNode]) -> bool:
1033
+ """Emit the inserts that turn the parent's native child list into ``after_roots``.
1034
+
1035
+ Children destroyed this pass have already had their views torn
1036
+ down, so they're dropped from the "before" picture; the
1037
+ remaining difference is expressed as the minimal move-aware
1038
+ insert sequence (longest-increasing-subsequence based, so a
1039
+ keyed reorder of *n* children costs at most *n* minus the
1040
+ length of the already-ordered run).
1041
+
1042
+ Returns whether the native child list changed at all.
1043
+ """
1044
+ surviving = [t for t in before_tags if t not in self._destroyed_tags]
1045
+ after_tags = [r.tag for r in after_roots if r.tag is not None]
1046
+ if surviving == after_tags:
1047
+ return len(surviving) != len(before_tags)
1048
+ for tag, index in plan_child_moves(surviving, after_tags):
1049
+ self._ops.append(InsertOp(parent_tag, tag, index))
1050
+ return True
1051
+
1052
+ def _warn_on_multiple_roots(self) -> None:
1053
+ if not diagnostics.is_dev() or self.root is None:
1054
+ return
1055
+ roots = self._native_roots(self.root)
1056
+ if len(roots) > 1:
1057
+ diagnostics.warn_once(
1058
+ f"The screen root rendered {len(roots)} native views; only the first "
1059
+ "is attached to the window. Wrap your root in a View/Column (Portals "
1060
+ "are exempt and may appear anywhere).",
1061
+ key=f"multi-root:{id(self)}",
1062
+ )
1063
+
1064
+ # ------------------------------------------------------------------
1065
+ # Props and refs
1066
+ # ------------------------------------------------------------------
1067
+
1068
+ @staticmethod
1069
+ def _split_props(props: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Callable[..., Any]]]:
1070
+ """Strip reconciler-owned keys, then split event callables from native props."""
1071
+ if not props:
1072
+ return {}, {}
1073
+ stripped = {key: value for key, value in props.items() if key not in _RECONCILER_OWNED_PROPS}
1074
+ return extract_events(stripped)
1075
+
1076
+ @staticmethod
1077
+ def _diff_props(old: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]:
1078
+ """Return only the props that changed between two clean prop dicts (removed props map to ``None``)."""
1079
+ changed: Dict[str, Any] = {}
1080
+ for key, new_val in new.items():
1081
+ if key not in old:
1082
+ changed[key] = new_val
1083
+ continue
1084
+ old_val = old[key]
1085
+ try:
1086
+ if callable(new_val) or callable(old_val):
1087
+ if old_val is not new_val:
1088
+ changed[key] = new_val
1089
+ elif old_val != new_val:
1090
+ changed[key] = new_val
1091
+ except Exception:
1092
+ changed[key] = new_val
1093
+ for key in old:
1094
+ if key not in new:
1095
+ changed[key] = None
1096
+ return changed
1097
+
1098
+ @staticmethod
1099
+ def _value_changed(old_value: Any, new_value: Any) -> bool:
1100
+ if old_value is _MISSING:
1101
+ return True
1102
+ if old_value is new_value:
1103
+ return False
1104
+ try:
1105
+ return bool(old_value != new_value)
1106
+ except Exception:
1107
+ return True
1108
+
1109
+ @staticmethod
1110
+ def _attach_ref(element: Element, native_view: Any, tag: Optional[int]) -> None:
1111
+ ref = element.props.get("ref")
1112
+ if ref is None:
1113
+ return
1114
+ if hasattr(ref, "current"):
1115
+ ref.current = native_view
1116
+ try:
1117
+ ref._pn_tag = tag
1118
+ except Exception:
1119
+ pass
1120
+ elif diagnostics.is_dev():
1121
+ diagnostics.warn_once(
1122
+ f"Ignoring ref of type {type(ref).__name__}; pass the Ref returned by use_ref().",
1123
+ key=f"badref:{type(ref).__name__}",
1124
+ )
1125
+
1126
+ @staticmethod
1127
+ def _clear_ref(ref: Any) -> None:
1128
+ if ref is None or not hasattr(ref, "current"):
1129
+ return
1130
+ try:
1131
+ ref.current = None
1132
+ ref._pn_tag = None
1133
+ except Exception:
1134
+ pass
1135
+
1136
+ @staticmethod
1137
+ def _same_type(old_el: Element, new_el: Element) -> bool:
1138
+ if isinstance(old_el.type, str):
1139
+ return old_el.type == new_el.type
1140
+ return old_el.type is new_el.type
1141
+
1142
+ @staticmethod
1143
+ def _provider_label(context: Context) -> str:
1144
+ return f"{context.name}.Provider" if context.name else "Provider"
1145
+
1146
+ @staticmethod
1147
+ def _log(msg: str) -> None:
1148
+ if os.environ.get("PYTHONNATIVE_DEBUG", "").lower() not in {"1", "true", "yes", "on"}:
1149
+ return
1150
+ try:
1151
+ print(f"[PN] reconciler: {msg}", flush=True)
1152
+ except Exception:
1153
+ pass