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,359 @@
1
+ """The layout phase of a reconciler pass.
2
+
3
+ After native mutations commit, the reconciler builds a
4
+ [`LayoutNode`][pythonnative.layout.LayoutNode] tree mirroring the
5
+ native nodes of the mounted tree, runs the flexbox engine against the
6
+ viewport, and emits a ``SetFrameOp`` for every frame that changed.
7
+
8
+ Two details keep this cheap on every state update:
9
+
10
+ - **Incremental rebuild.** Each native ``VNode`` caches its
11
+ ``LayoutNode``; subtrees whose props and child list are unchanged
12
+ reuse the cached node with ``dirty=False`` so the engine can serve
13
+ their sizes from its measurement memo.
14
+ - **Frame diffing.** Frames equal to the last applied frame produce no
15
+ op at all.
16
+
17
+ Detached subtrees (``Modal``, ``Portal``) are excluded from the main
18
+ flow and laid out against the viewport as independent trees, since
19
+ their content lives in a separately presented native container.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
25
+
26
+ from .. import diagnostics
27
+ from ..element import Element
28
+ from ..layout import LAYOUT_STYLE_KEYS, LayoutNode, calculate_layout, extract_layout_style
29
+ from ..mutations import Mutation, SetFrameOp
30
+ from .vnode import DETACHED_TYPES, VNode
31
+
32
+ if TYPE_CHECKING:
33
+ pass
34
+
35
+ Frame = Tuple[float, float, float, float]
36
+
37
+ # Native leaves whose size derives from their content; any prop change
38
+ # invalidates their measurement.
39
+ INTRINSIC_TYPES = frozenset(
40
+ {
41
+ "Text",
42
+ "Button",
43
+ "Image",
44
+ "TextInput",
45
+ "Switch",
46
+ "Slider",
47
+ "ProgressBar",
48
+ "ActivityIndicator",
49
+ "TabBar",
50
+ "Picker",
51
+ "Checkbox",
52
+ "SegmentedControl",
53
+ "DatePicker",
54
+ }
55
+ )
56
+
57
+ # Childless native leaves that get a measure callback. Extends the
58
+ # intrinsic set with ``VirtualList``, whose handlers report "fill the
59
+ # available space" (like a ScrollView clamped to its parent): without
60
+ # the callback an unstyled list would collapse to 0 points and the
61
+ # platform virtualizer would never bind a row.
62
+ MEASURED_LEAF_TYPES = INTRINSIC_TYPES | {"VirtualList"}
63
+
64
+
65
+ def affects_layout(type_name: str, changed: Dict[str, Any]) -> bool:
66
+ """Whether ``changed`` props can alter a native node's layout."""
67
+ if type_name in INTRINSIC_TYPES:
68
+ return True
69
+ return any(key in LAYOUT_STYLE_KEYS for key in changed)
70
+
71
+
72
+ class LayoutMixin:
73
+ """Layout-pass behavior mixed into the reconciler."""
74
+
75
+ # Provided by the concrete reconciler.
76
+ backend: Any
77
+ root: Optional[VNode]
78
+ _ops: List[Mutation]
79
+
80
+ def _init_layout_state(self) -> None:
81
+ self._viewport_size: Tuple[float, float] = (0.0, 0.0)
82
+ self._layout_pass_count = 0
83
+ # ``(tag, frame)`` pairs whose ``on_layout`` should fire after
84
+ # the layout pass commits (frames are only queued on change).
85
+ self._pending_layout_events: List[Tuple[int, Frame]] = []
86
+
87
+ # ------------------------------------------------------------------
88
+ # Public surface
89
+ # ------------------------------------------------------------------
90
+
91
+ @property
92
+ def viewport_size(self) -> Tuple[float, float]:
93
+ """The last viewport size supplied by the host, ``(0, 0)`` before layout is possible."""
94
+ return self._viewport_size
95
+
96
+ def set_viewport_size(self, width: float, height: float) -> None:
97
+ """Update the viewport size and re-run layout if it changed.
98
+
99
+ Called by the screen host whenever the platform reports a new
100
+ container size. The first call after mount triggers the
101
+ initial layout pass; identical sizes are no-ops.
102
+ """
103
+ if width <= 0 or height <= 0:
104
+ return
105
+ if self._viewport_size == (width, height):
106
+ return
107
+ self._viewport_size = (width, height)
108
+ if self.root is not None:
109
+ self._run_layout()
110
+ self._flush_ops()
111
+ self._dispatch_layout_events()
112
+
113
+ def compute_layout_for_test(self, viewport_width: float, viewport_height: float) -> Optional[LayoutNode]:
114
+ """Build and compute a layout tree without touching the backend.
115
+
116
+ Returns the synthetic viewport ``LayoutNode`` with all
117
+ descendants positioned, or ``None`` if nothing is mounted.
118
+ """
119
+ if self.root is None:
120
+ return None
121
+ roots = self._build_layout_list(self.root)
122
+ if not roots:
123
+ return None
124
+ viewport = LayoutNode(style={"width": viewport_width, "height": viewport_height}, children=list(roots))
125
+ calculate_layout(viewport, viewport_width, viewport_height)
126
+ return viewport
127
+
128
+ # ------------------------------------------------------------------
129
+ # Hooks used by the core
130
+ # ------------------------------------------------------------------
131
+
132
+ def _flush_ops(self) -> None: ... # pragma: no cover
133
+
134
+ @staticmethod
135
+ def _mark_layout_dirty(node: VNode) -> None:
136
+ node.layout_dirty = True
137
+ node.layout_node = None
138
+
139
+ # ------------------------------------------------------------------
140
+ # The pass
141
+ # ------------------------------------------------------------------
142
+
143
+ def _run_layout(self) -> None:
144
+ """Build/refresh the layout tree, compute frames, and emit changed ones.
145
+
146
+ Wraps the user's native roots in a synthetic outer ``LayoutNode``
147
+ sized to the viewport so the root fills the screen by default.
148
+ Skipped until the host has supplied a viewport size.
149
+
150
+ The first native root's own frame is intentionally left alone:
151
+ its position and size are owned by the screen host (iOS places
152
+ it below the safe-area inset; Android attaches it with
153
+ ``MATCH_PARENT``).
154
+ """
155
+ root = self.root
156
+ if root is None:
157
+ return
158
+ viewport_w, viewport_h = self._viewport_size
159
+ if viewport_w <= 0 or viewport_h <= 0:
160
+ return
161
+
162
+ self._layout_pass_count += 1
163
+ layout_roots = self._build_layout_list_cached(root)
164
+ if layout_roots:
165
+ viewport = LayoutNode(style={"width": viewport_w, "height": viewport_h}, children=list(layout_roots))
166
+ viewport.dirty = True
167
+ calculate_layout(viewport, viewport_w, viewport_h)
168
+ for i, layout_root in enumerate(layout_roots):
169
+ if i == 0:
170
+ for child in layout_root.children:
171
+ self._collect_frames(child, 0.0, 0.0)
172
+ else:
173
+ self._collect_frames(layout_root, 0.0, 0.0)
174
+ self._layout_detached_subtrees(root, viewport_w, viewport_h)
175
+ self._clear_layout_dirty(root)
176
+
177
+ def _layout_detached_subtrees(self, node: VNode, viewport_w: float, viewport_h: float) -> None:
178
+ element = node.element
179
+ if isinstance(element.type, str) and element.type in DETACHED_TYPES:
180
+ active = bool(element.props.get("visible")) if element.type == "Modal" else True
181
+ if not active:
182
+ return
183
+ child_layouts: List[LayoutNode] = []
184
+ for child in node.children:
185
+ child_layouts.extend(self._build_layout_list(child))
186
+ if child_layouts:
187
+ viewport = LayoutNode(style={"width": viewport_w, "height": viewport_h}, children=child_layouts)
188
+ calculate_layout(viewport, viewport_w, viewport_h)
189
+ for c in viewport.children:
190
+ self._collect_frames(c, 0.0, 0.0)
191
+ for child in node.children:
192
+ self._layout_detached_subtrees(child, viewport_w, viewport_h)
193
+ return
194
+ for child in node.children:
195
+ self._layout_detached_subtrees(child, viewport_w, viewport_h)
196
+
197
+ def _build_layout_list_cached(self, node: VNode) -> List[LayoutNode]:
198
+ """Like ``_build_layout_list`` but reuses cached subtrees when clean."""
199
+ element = node.element
200
+ if not node.is_native:
201
+ out: List[LayoutNode] = []
202
+ for child in node.children:
203
+ out.extend(self._build_layout_list_cached(child))
204
+ return out
205
+ if element.type in DETACHED_TYPES:
206
+ return []
207
+
208
+ child_layouts: List[LayoutNode] = []
209
+ for child in node.children:
210
+ child_layouts.extend(self._build_layout_list_cached(child))
211
+
212
+ cached = node.layout_node
213
+ if cached is not None and not node.layout_dirty:
214
+ cached_children = self._direct_child_layouts(cached, element)
215
+ if len(cached_children) == len(child_layouts) and all(
216
+ a is b for a, b in zip(cached_children, child_layouts)
217
+ ):
218
+ return [cached]
219
+
220
+ layout = self._new_layout_node(node)
221
+ for child_layout in child_layouts:
222
+ if element.type == "ScrollView":
223
+ child_layout = self._wrap_scroll_axis(child_layout, self._scroll_axis(element))
224
+ child_layout.dirty = True
225
+ layout.children.append(child_layout)
226
+ node.layout_node = layout
227
+ return [layout]
228
+
229
+ def _build_layout_list(self, node: VNode) -> List[LayoutNode]:
230
+ """Build fresh (uncached) ``LayoutNode`` for ``node`` and its subtree."""
231
+ element = node.element
232
+ if not node.is_native:
233
+ out: List[LayoutNode] = []
234
+ for child in node.children:
235
+ out.extend(self._build_layout_list(child))
236
+ return out
237
+ if element.type in DETACHED_TYPES:
238
+ return []
239
+ layout = self._new_layout_node(node)
240
+ for child in node.children:
241
+ for child_layout in self._build_layout_list(child):
242
+ if element.type == "ScrollView":
243
+ child_layout = self._wrap_scroll_axis(child_layout, self._scroll_axis(element))
244
+ child_layout.dirty = True
245
+ layout.children.append(child_layout)
246
+ return [layout]
247
+
248
+ def _new_layout_node(self, node: VNode) -> LayoutNode:
249
+ element = node.element
250
+ layout = LayoutNode(style=extract_layout_style(element.props), user_data=node)
251
+ layout.dirty = True
252
+ if element.type == "ScrollView":
253
+ # Mark the scroll axis so the engine clamps the container's
254
+ # main-axis size to its parent's available space; otherwise
255
+ # the container grows to fit its content and nothing scrolls.
256
+ layout._pn_scroll_axis = self._scroll_axis(element)
257
+ if not node.children:
258
+ measure = self._make_measure_callback(node)
259
+ if measure is not None:
260
+ layout.measure = measure
261
+ return layout
262
+
263
+ @staticmethod
264
+ def _scroll_axis(element: Element) -> str:
265
+ return "x" if element.props.get("scroll_axis", "vertical") == "horizontal" else "y"
266
+
267
+ @staticmethod
268
+ def _direct_child_layouts(layout: LayoutNode, element: Element) -> List[LayoutNode]:
269
+ """Return the cached child layout nodes, unwrapping ScrollView wrappers."""
270
+ if element.type == "ScrollView":
271
+ out: List[LayoutNode] = []
272
+ for wrapper in layout.children:
273
+ out.extend(wrapper.children)
274
+ return out
275
+ return list(layout.children)
276
+
277
+ def _clear_layout_dirty(self, node: VNode) -> None:
278
+ node.layout_dirty = False
279
+ layout = node.layout_node
280
+ if layout is not None:
281
+ layout.dirty = False
282
+ for layout_child in layout.children:
283
+ layout_child.dirty = False
284
+ for child in node.children:
285
+ self._clear_layout_dirty(child)
286
+
287
+ @staticmethod
288
+ def _wrap_scroll_axis(child: LayoutNode, axis: str) -> LayoutNode:
289
+ """Wrap ``child`` so the layout engine treats one axis as unbounded."""
290
+ wrapper = LayoutNode(style={"flex_direction": "column" if axis == "y" else "row"}, user_data=None)
291
+ wrapper.children.append(child)
292
+ return wrapper
293
+
294
+ def _make_measure_callback(self, node: VNode) -> Optional[Callable[[float, float], Tuple[float, float]]]:
295
+ """Return a measure callback for ``node`` if it has an intrinsic size."""
296
+ if node.element.type not in MEASURED_LEAF_TYPES or node.tag is None:
297
+ return None
298
+ backend = self.backend
299
+ tag = node.tag
300
+
301
+ def measure(max_w: float, max_h: float) -> Tuple[float, float]:
302
+ cache = node.measure_cache
303
+ if cache is not None and cache[0] == max_w and cache[1] == max_h:
304
+ return (cache[2], cache[3])
305
+ try:
306
+ w, h = backend.measure_intrinsic(tag, max_w, max_h)
307
+ result = (float(w), float(h))
308
+ node.measure_cache = (max_w, max_h, result[0], result[1])
309
+ return result
310
+ except Exception:
311
+ return (0.0, 0.0)
312
+
313
+ return measure
314
+
315
+ def _collect_frames(self, layout_node: LayoutNode, parent_x: float, parent_y: float) -> None:
316
+ """Walk a positioned layout tree and emit ``SetFrameOp`` for changed frames.
317
+
318
+ Coordinates accumulate through transparent wrapper nodes (the
319
+ ScrollView axis wrapper) so the native view receives its
320
+ position relative to its true native parent.
321
+ """
322
+ node: Optional[VNode] = layout_node.user_data
323
+ if node is not None and node.tag is not None:
324
+ frame: Frame = (
325
+ layout_node.x + parent_x,
326
+ layout_node.y + parent_y,
327
+ layout_node.width,
328
+ layout_node.height,
329
+ )
330
+ if node.last_frame != frame:
331
+ node.last_frame = frame
332
+ self._ops.append(SetFrameOp(node.tag, *frame))
333
+ ref = node.element.props.get("ref")
334
+ if ref is not None and hasattr(ref, "current"):
335
+ try:
336
+ ref._pn_frame = frame
337
+ except Exception:
338
+ pass
339
+ if "on_layout" in node.element.props:
340
+ self._pending_layout_events.append((node.tag, frame))
341
+ child_x = child_y = 0.0
342
+ else:
343
+ child_x = layout_node.x + parent_x
344
+ child_y = layout_node.y + parent_y
345
+ for child in layout_node.children:
346
+ self._collect_frames(child, child_x, child_y)
347
+
348
+ def _dispatch_layout_events(self) -> None:
349
+ """Fire queued ``on_layout`` callbacks after frames were applied."""
350
+ if not self._pending_layout_events:
351
+ return
352
+ from ..events import dispatch_event
353
+
354
+ pending, self._pending_layout_events = self._pending_layout_events, []
355
+ for tag, frame in pending:
356
+ try:
357
+ dispatch_event(tag, "on_layout", {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]})
358
+ except Exception as exc:
359
+ diagnostics.report_error(exc, phase="event")
@@ -0,0 +1,266 @@
1
+ """Mounted-tree nodes and the pure helpers the reconciler shares.
2
+
3
+ [`VNode`][pythonnative.reconciler.VNode] pairs an
4
+ [`Element`][pythonnative.Element] with the identity of the native view
5
+ it produced (its **tag**) plus the bookkeeping the reconciler needs
6
+ across passes (layout cache, boundary state, hook state).
7
+
8
+ Everything in this module is side-effect free with respect to the
9
+ native layer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import itertools
15
+ from typing import Any, Dict, List, Optional, Set, Tuple
16
+
17
+ from .. import diagnostics
18
+ from ..component import Component
19
+ from ..element import ERROR_BOUNDARY, FRAGMENT, SUSPENSE, Element, type_label
20
+ from ..hooks import Context, HookState
21
+ from ..layout import LayoutNode
22
+
23
+ __all__ = [
24
+ "DETACHED_TYPES",
25
+ "VNode",
26
+ "next_tag",
27
+ "normalize_children",
28
+ "shallow_equal_props",
29
+ ]
30
+
31
+ # Native element types whose subtree is laid out against the viewport
32
+ # in a detached pass instead of participating in the main layout flow.
33
+ # Their handlers host the children in a separately presented native
34
+ # container (a modal, an overlay, a pushed screen).
35
+ DETACHED_TYPES = frozenset({"Modal", "Portal"})
36
+
37
+ # Tags are globally unique so multiple reconcilers (screens, list rows)
38
+ # can share one registry without collisions.
39
+ _tag_counter = itertools.count(1)
40
+
41
+
42
+ def next_tag() -> int:
43
+ """Allocate a fresh, process-unique view tag."""
44
+ return next(_tag_counter)
45
+
46
+
47
+ def shallow_equal_props(old: dict, new: dict) -> bool:
48
+ """Return whether two prop dicts are equal under shallow comparison.
49
+
50
+ Used by [`memo`][pythonnative.memo] to skip re-rendering when none
51
+ of a component's props changed identity. Callables only count as
52
+ equal if they're the *same object*; fresh closures always invalidate
53
+ the memo (matching React's behavior; pair with
54
+ [`use_callback`][pythonnative.use_callback] when stability matters).
55
+ """
56
+ if old is new:
57
+ return True
58
+ if old.keys() != new.keys():
59
+ return False
60
+ for key, ov in old.items():
61
+ nv = new[key]
62
+ if ov is nv:
63
+ continue
64
+ if callable(ov) or callable(nv):
65
+ return False
66
+ try:
67
+ if ov != nv:
68
+ return False
69
+ except Exception:
70
+ return False
71
+ return True
72
+
73
+
74
+ def normalize_children(children: Any, owner: str = "") -> List[Element]:
75
+ """Normalize arbitrary render output into a flat list of Elements.
76
+
77
+ Accepts a single element, ``None``, ``True``/``False`` (both
78
+ skipped, enabling inline conditionals like ``cond and Text(...)``),
79
+ lists/tuples/generators (flattened recursively), and unkeyed
80
+ Fragments (expanded inline so they never touch the native tree).
81
+ Keyed Fragments are preserved so they can participate in keyed
82
+ reconciliation as a unit.
83
+
84
+ Non-Element values other than the above are dropped with a
85
+ dev-mode warning.
86
+ """
87
+ out: List[Element] = []
88
+
89
+ def add(item: Any) -> None:
90
+ if item is None or item is True or item is False:
91
+ return
92
+ if isinstance(item, Element):
93
+ if item.type is FRAGMENT and item.key is None:
94
+ for sub in item.children:
95
+ add(sub)
96
+ return
97
+ out.append(item)
98
+ return
99
+ if isinstance(item, (str, bytes)):
100
+ diagnostics.warn_once(
101
+ f"Ignoring bare string child {item!r}"
102
+ + (f" under {owner}" if owner else "")
103
+ + ". Wrap text in pn.Text(...).",
104
+ key=f"strchild:{owner}",
105
+ )
106
+ return
107
+ try:
108
+ iterator = iter(item)
109
+ except TypeError:
110
+ diagnostics.warn_once(
111
+ f"Ignoring non-Element child {item!r} ({type(item).__name__})"
112
+ + (f" under {owner}" if owner else "")
113
+ + ". Children must be Elements, iterables of Elements, or None/False for conditionals.",
114
+ key=f"badchild:{owner}:{type(item).__name__}",
115
+ )
116
+ return
117
+ for sub in iterator:
118
+ add(sub)
119
+
120
+ add(children)
121
+
122
+ if diagnostics.is_dev() and len(out) > 1:
123
+ seen: Set[Any] = set()
124
+ for el in out:
125
+ if el.key is None:
126
+ continue
127
+ if el.key in seen:
128
+ diagnostics.warn_once(
129
+ f"Duplicate key {el.key!r} among children"
130
+ + (f" of {owner}" if owner else "")
131
+ + ". Keys must be unique among siblings; duplicates break "
132
+ "keyed reconciliation and can cross-wire component state.",
133
+ key=f"dupkey:{owner}:{el.key!r}",
134
+ )
135
+ seen.add(el.key)
136
+ return out
137
+
138
+
139
+ class VNode:
140
+ """A mounted [`Element`][pythonnative.Element] plus its native identity.
141
+
142
+ The reconciler walks parallel trees of ``VNode`` and incoming
143
+ ``Element`` to compute the minimal set of native mutations.
144
+
145
+ Attributes:
146
+ element: The ``Element`` last rendered into this slot.
147
+ tag: Integer identity of the underlying native view. Native
148
+ elements own a fresh tag; transparent wrappers (components,
149
+ providers, boundaries, keyed fragments) delegate the tag of
150
+ their first native root. ``None`` when the subtree renders
151
+ no native view.
152
+ native_view: The platform-native view object, resolved from the
153
+ backend after commit. ``None`` for wrappers that rendered
154
+ nothing.
155
+ children: Ordered list of child ``VNode`` instances.
156
+ parent: The owning ``VNode``, or ``None`` for the tree root.
157
+ hook_state: The component's
158
+ [`HookState`][pythonnative.hooks.HookState] when the node
159
+ wraps a component, otherwise ``None``.
160
+ mounted: ``False`` once the node has been destroyed, so stale
161
+ entries in the reconciler's dirty set are skipped.
162
+ """
163
+
164
+ __slots__ = (
165
+ "element",
166
+ "tag",
167
+ "native_view",
168
+ "children",
169
+ "parent",
170
+ "hook_state",
171
+ "mounted",
172
+ "rendered",
173
+ "clean_props",
174
+ "measure_cache",
175
+ "last_frame",
176
+ "layout_node",
177
+ "layout_dirty",
178
+ "error",
179
+ "suspense_showing_fallback",
180
+ "suspense_hydration",
181
+ "suspense_waits",
182
+ )
183
+
184
+ def __init__(self, element: Element, children: Optional[List["VNode"]] = None, tag: Optional[int] = None) -> None:
185
+ self.element = element
186
+ self.tag = tag
187
+ self.native_view: Any = None
188
+ self.children: List[VNode] = children if children is not None else []
189
+ self.parent: Optional[VNode] = None
190
+ self.hook_state: Optional[HookState] = None
191
+ self.mounted: bool = True
192
+ # The normalized element list a component body last produced.
193
+ self.rendered: Optional[List[Element]] = None
194
+ # Native-safe props (events and reconciler-owned keys stripped)
195
+ # from the last commit; the baseline for prop diffing.
196
+ self.clean_props: Dict[str, Any] = {}
197
+ # Cache for the leaf intrinsic-size measure callback:
198
+ # ``(max_w, max_h, width, height)``. Invalidated whenever the
199
+ # node's props change, so unchanged leaves skip native
200
+ # ``measure_intrinsic`` calls entirely.
201
+ self.measure_cache: Optional[Tuple[float, float, float, float]] = None
202
+ # Last frame sent to the native side; unchanged frames are
203
+ # skipped (frame diffing).
204
+ self.last_frame: Optional[Tuple[float, float, float, float]] = None
205
+ # Cached LayoutNode reused across passes while the subtree is
206
+ # clean (see the layout mixin).
207
+ self.layout_node: Optional[LayoutNode] = None
208
+ # True when this node's layout-relevant props or child list
209
+ # changed since the last layout pass.
210
+ self.layout_dirty: bool = True
211
+ # For error-boundary nodes: the caught exception while the
212
+ # fallback is showing, else ``None``.
213
+ self.error: Optional[BaseException] = None
214
+ # For Suspense nodes: whether the fallback is showing, the hook
215
+ # states of suspended descendants preserved for the next retry
216
+ # (keyed by ``(component identity, element key)``), and the ids
217
+ # of waitables already wired to trigger a retry.
218
+ self.suspense_showing_fallback: bool = False
219
+ self.suspense_hydration: Optional[Dict[Tuple[int, Any], List[HookState]]] = None
220
+ self.suspense_waits: Optional[Set[int]] = None
221
+
222
+ # ------------------------------------------------------------------
223
+ # Classification helpers
224
+ # ------------------------------------------------------------------
225
+
226
+ @property
227
+ def is_native(self) -> bool:
228
+ """Whether this node owns a native view."""
229
+ return isinstance(self.element.type, str)
230
+
231
+ @property
232
+ def is_component(self) -> bool:
233
+ """Whether this node renders a user component (a ``@component`` function)."""
234
+ return isinstance(self.element.type, Component)
235
+
236
+ @property
237
+ def is_provider(self) -> bool:
238
+ """Whether this node is a context provider."""
239
+ return isinstance(self.element.type, Context)
240
+
241
+ @property
242
+ def is_error_boundary(self) -> bool:
243
+ """Whether this node is an ``ErrorBoundary``."""
244
+ return self.element.type is ERROR_BOUNDARY
245
+
246
+ @property
247
+ def is_suspense(self) -> bool:
248
+ """Whether this node is a ``Suspense`` boundary."""
249
+ return self.element.type is SUSPENSE
250
+
251
+ @property
252
+ def label(self) -> str:
253
+ """Human-readable name of the element type (for diagnostics and tree dumps)."""
254
+ return type_label(self.element.type)
255
+
256
+ def depth(self) -> int:
257
+ """Return the number of ancestors above this node (the root has depth 0)."""
258
+ depth = 0
259
+ node = self.parent
260
+ while node is not None:
261
+ depth += 1
262
+ node = node.parent
263
+ return depth
264
+
265
+ def __repr__(self) -> str:
266
+ return f"VNode({self.label!r}, tag={self.tag}, children={len(self.children)})"