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
pythonnative/hooks.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """Hook primitives for function components.
2
2
 
3
- Provides React-like hooks for managing state, effects, memoization,
4
- context, and navigation within function components decorated with
5
- [`component`][pythonnative.component]. Hooks must be called at the top
3
+ Provides React-like hooks for managing state, effects, memoization, and
4
+ context within components decorated with
5
+ [`component`][pythonnative.component.component]. Hooks must be called at the top
6
6
  level of a component (not inside conditionals or loops) so they map to
7
7
  the same slot across renders. In dev mode the framework verifies this
8
8
  and raises [`HookOrderError`][pythonnative.diagnostics.HookOrderError]
@@ -25,12 +25,17 @@ than a plain global, so ``async def`` component bodies keep their hook
25
25
  identity across ``await`` boundaries even when several coroutine
26
26
  renders interleave on the event loop.
27
27
 
28
+ Hooks talk to the reconciler through the small
29
+ [`RenderOwner`][pythonnative.hooks.RenderOwner] protocol (mark a
30
+ component dirty, request a render, defer a transition, register a back
31
+ handler). That is the whole contract between the two modules.
32
+
28
33
  Example:
29
34
  ```python
30
35
  import pythonnative as pn
31
36
 
32
37
  @pn.component
33
- def Counter(initial=0):
38
+ def Counter(initial: int = 0):
34
39
  count, set_count = pn.use_state(initial)
35
40
  return pn.Column(
36
41
  pn.Text(f"Count: {count}"),
@@ -39,36 +44,67 @@ Example:
39
44
  ```
40
45
  """
41
46
 
47
+ from __future__ import annotations
48
+
42
49
  import asyncio
43
50
  import inspect
44
- from contextlib import contextmanager
45
51
  from contextvars import ContextVar, Token
46
52
  from dataclasses import dataclass, field, replace
47
- from typing import Any, Awaitable, Callable, Dict, Generator, Generic, List, Optional, Tuple, TypeVar
53
+ from typing import (
54
+ Any,
55
+ Awaitable,
56
+ Callable,
57
+ Dict,
58
+ Generic,
59
+ List,
60
+ Optional,
61
+ Protocol,
62
+ Tuple,
63
+ TypeVar,
64
+ Union,
65
+ overload,
66
+ )
48
67
 
49
68
  from . import diagnostics
50
- from .element import Element
69
+ from .element import Element, Node
70
+ from .scheduler import TransitionQueue, in_transition, run_in_transition, schedule_trigger
51
71
  from .suspense import CoroDriver, Resource
52
72
 
53
73
  T = TypeVar("T")
54
74
 
75
+ StateSetter = Callable[[Union[T, Callable[[T], T]]], None]
76
+ """Setter returned by [`use_state`][pythonnative.use_state]: accepts a value or ``current -> new``."""
77
+
55
78
  _SENTINEL = object()
56
79
 
57
80
  # The component whose body is currently executing. A ContextVar (not a
58
81
  # global or thread-local) so coroutine component bodies resume with the
59
82
  # right hook state after every ``await``, no matter how renders
60
83
  # interleave on the loop.
61
- _hook_context: "ContextVar[Optional[HookState]]" = ContextVar("pn_hook_state", default=None)
84
+ _hook_context: ContextVar[Optional["HookState"]] = ContextVar("pn_hook_state", default=None)
85
+
86
+
87
+ # ======================================================================
88
+ # Reconciler contract
89
+ # ======================================================================
90
+
62
91
 
63
- # Depth of nested ``batch_updates`` blocks plus the trigger deferred by
64
- # the outermost block, per execution context.
65
- _batch_depth: "ContextVar[int]" = ContextVar("pn_batch_depth", default=0)
66
- _batch_pending: "ContextVar[Optional[List[Callable[[], None]]]]" = ContextVar("pn_batch_pending", default=None)
92
+ class RenderOwner(Protocol):
93
+ """What a hook needs from the object that renders its component.
94
+
95
+ The reconciler implements this; tests may substitute a stub.
96
+ """
67
97
 
68
- # Whether state updates in the current execution context were marked as
69
- # transitions (see ``use_transition``). Transition updates defer their
70
- # re-render to a later loop turn so urgent updates stay responsive.
71
- _transition_var: "ContextVar[bool]" = ContextVar("pn_transition", default=False)
98
+ transitions: TransitionQueue
99
+
100
+ def mark_dirty(self, vnode: Any) -> None:
101
+ """Queue ``vnode``'s component for a local re-render."""
102
+
103
+ def request_render(self) -> None:
104
+ """Ask the host to flush dirty components (may be deferred)."""
105
+
106
+ def register_back_handler(self, handler: Callable[[], bool]) -> Callable[[], None]:
107
+ """Register a system back-press handler; returns an unregister callable."""
72
108
 
73
109
 
74
110
  # ======================================================================
@@ -120,7 +156,7 @@ class Ref(Generic[T]):
120
156
  class HookState:
121
157
  """Per-instance storage for one component's hooks.
122
158
 
123
- Each `@component` instance owns one `HookState`. Hooks are matched
159
+ Each component instance owns one ``HookState``. Hooks are matched
124
160
  to slots by call order, so they must always be called in the same
125
161
  order across renders. Effects scheduled during render are deferred
126
162
  (layout effects into ``_pending_layout_effects``, passive effects
@@ -128,12 +164,15 @@ class HookState:
128
164
  phases after native mutations commit.
129
165
 
130
166
  Attributes:
131
- states: One entry per `use_state` / `use_reducer` call.
132
- effects: One `(deps, cleanup)` tuple per `use_effect` call.
133
- layout_effects: One `(deps, cleanup)` tuple per
134
- `use_layout_effect` call.
135
- memos: One `(deps, value)` tuple per `use_memo` / `use_callback`.
136
- refs: One [`Ref`][pythonnative.Ref] per `use_ref` call.
167
+ states: One entry per ``use_state`` / ``use_reducer`` call.
168
+ effects: One ``(deps, cleanup)`` tuple per ``use_effect`` call.
169
+ layout_effects: One ``(deps, cleanup)`` tuple per
170
+ ``use_layout_effect`` call.
171
+ memos: One ``(deps, value)`` tuple per ``use_memo`` / ``use_callback``.
172
+ refs: One [`Ref`][pythonnative.Ref] per ``use_ref`` call.
173
+ owner: The [`RenderOwner`][pythonnative.hooks.RenderOwner]
174
+ (reconciler) this component is mounted in, or ``None``.
175
+ vnode: The reconciler's node for this component, or ``None``.
137
176
  """
138
177
 
139
178
  __slots__ = (
@@ -150,14 +189,13 @@ class HookState:
150
189
  "ref_index",
151
190
  "resource_index",
152
191
  "context_deps",
153
- "_trigger_render",
192
+ "owner",
193
+ "vnode",
154
194
  "_pending_effects",
155
195
  "_pending_layout_effects",
156
196
  "_pending_effects_mark",
157
197
  "_pending_layout_effects_mark",
158
198
  "_dirty",
159
- "_vnode",
160
- "_reconciler",
161
199
  "_hook_log",
162
200
  "_hook_signature",
163
201
  "_component_name",
@@ -183,22 +221,15 @@ class HookState:
183
221
  # Provider's value changes so consumers re-render even when a
184
222
  # memoized ancestor skipped (reactive context).
185
223
  self.context_deps: Dict[int, Any] = {}
186
- self._trigger_render: Optional[Callable[[], None]] = None
224
+ self.owner: Optional[RenderOwner] = None
225
+ self.vnode: Any = None
187
226
  self._pending_effects: List[Tuple[int, Callable, Any]] = []
188
227
  self._pending_layout_effects: List[Tuple[int, Callable, Any]] = []
189
228
  # Cleared by the reconciler after each successful render.
190
229
  # ``use_state`` / ``use_reducer`` setters flip it to ``True``
191
- # whenever they actually mutate state, so [`memo`][pythonnative.memo]
192
- # knows that a memoized component still needs to re-render even
193
- # when its props didn't change.
230
+ # whenever they actually mutate state, so a memoized component
231
+ # still re-renders even when its props didn't change.
194
232
  self._dirty: bool = False
195
- # Back-references wired by the reconciler so a state setter can
196
- # mark *its own* component subtree dirty for a local re-render
197
- # (instead of forcing a whole-app re-render from the root). Both
198
- # stay ``None`` until the component is mounted, and are cleared
199
- # again when it unmounts.
200
- self._vnode: Any = None
201
- self._reconciler: Any = None
202
233
  # Dev-mode hook-order guard: the sequence of hook kinds called
203
234
  # during the in-flight render, and the signature captured from
204
235
  # the first successful render.
@@ -213,13 +244,12 @@ class HookState:
213
244
  self._pending_effects_mark: int = 0
214
245
  self._pending_layout_effects_mark: int = 0
215
246
 
216
- def begin_render(self, component_name: str = "") -> None:
217
- """Prepare for a render pass: reset cursors and the dev-mode hook log.
247
+ # ------------------------------------------------------------------
248
+ # Render lifecycle
249
+ # ------------------------------------------------------------------
218
250
 
219
- Called by the reconciler before each invocation of the
220
- component body so the next render reads slots in the same
221
- order they were written.
222
- """
251
+ def begin_render(self, component_name: str = "") -> None:
252
+ """Prepare for a render pass: reset cursors and the dev-mode hook log."""
223
253
  self.state_index = 0
224
254
  self.effect_index = 0
225
255
  self.layout_effect_index = 0
@@ -246,9 +276,6 @@ class HookState:
246
276
  def finish_render(self) -> None:
247
277
  """Finalize a successful render: lock in / verify the hook signature.
248
278
 
249
- Only called when the component body returned without raising,
250
- so a failed render never corrupts the signature.
251
-
252
279
  Raises:
253
280
  HookOrderError: In dev mode, when this render called fewer
254
281
  hooks than the previous one.
@@ -297,14 +324,13 @@ class HookState:
297
324
  )
298
325
 
299
326
  def reset_hook_signature(self) -> None:
300
- """Forget the recorded hook signature (used by Fast Refresh).
301
-
302
- After a hot reload swaps in a new component body, the old
303
- signature no longer applies; the next render records a fresh
304
- one.
305
- """
327
+ """Forget the recorded hook signature (used by Fast Refresh)."""
306
328
  self._hook_signature = None
307
329
 
330
+ # ------------------------------------------------------------------
331
+ # Effects
332
+ # ------------------------------------------------------------------
333
+
308
334
  def flush_layout_effects(self) -> None:
309
335
  """Run layout effects queued during render (commit phase, pre-paint)."""
310
336
  pending = self._pending_layout_effects
@@ -312,11 +338,7 @@ class HookState:
312
338
  self._pending_layout_effects_mark = 0
313
339
  for idx, effect_fn, deps in pending:
314
340
  _, prev_cleanup = self.layout_effects[idx]
315
- if callable(prev_cleanup):
316
- try:
317
- prev_cleanup()
318
- except Exception:
319
- pass
341
+ _run_cleanup(prev_cleanup)
320
342
  cleanup = _activate_effect(effect_fn)
321
343
  self.layout_effects[idx] = (list(deps) if deps is not None else None, cleanup)
322
344
 
@@ -335,11 +357,7 @@ class HookState:
335
357
  self._pending_effects_mark = 0
336
358
  for idx, effect_fn, deps in pending:
337
359
  _, prev_cleanup = self.effects[idx]
338
- if callable(prev_cleanup):
339
- try:
340
- prev_cleanup()
341
- except Exception:
342
- pass
360
+ _run_cleanup(prev_cleanup)
343
361
  cleanup = _activate_effect(effect_fn)
344
362
  self.effects[idx] = (list(deps) if deps is not None else None, cleanup)
345
363
 
@@ -352,18 +370,10 @@ class HookState:
352
370
  component instance is unmounted by the reconciler.
353
371
  """
354
372
  for i, (_deps, cleanup) in enumerate(self.layout_effects):
355
- if callable(cleanup):
356
- try:
357
- cleanup()
358
- except Exception:
359
- pass
373
+ _run_cleanup(cleanup)
360
374
  self.layout_effects[i] = (_SENTINEL, None)
361
375
  for i, (_deps, cleanup) in enumerate(self.effects):
362
- if callable(cleanup):
363
- try:
364
- cleanup()
365
- except Exception:
366
- pass
376
+ _run_cleanup(cleanup)
367
377
  self.effects[i] = (_SENTINEL, None)
368
378
  self._pending_effects = []
369
379
  self._pending_layout_effects = []
@@ -380,27 +390,44 @@ class HookState:
380
390
  if driver is not None:
381
391
  driver.cancel()
382
392
 
393
+ def detach(self) -> None:
394
+ """Break the back-references to the reconciler (on unmount).
395
+
396
+ Lets the unmounted component's hook state (and the closures it
397
+ captured) be freed by plain refcounting, which matters on iOS
398
+ where the cyclic GC is disabled.
399
+ """
400
+ self.owner = None
401
+ self.vnode = None
402
+
383
403
 
384
404
  # ======================================================================
385
- # Context helpers
405
+ # Context helpers (framework-internal)
386
406
  # ======================================================================
387
407
 
388
408
 
389
- def _get_hook_state() -> Optional[HookState]:
390
- """Return the active `HookState`, or `None` if no render is in flight."""
409
+ def current_hook_state() -> Optional[HookState]:
410
+ """Return the active ``HookState``, or ``None`` if no render is in flight."""
391
411
  return _hook_context.get()
392
412
 
393
413
 
394
- def _set_hook_state(state: Optional[HookState]) -> "Token[Optional[HookState]]":
395
- """Install `state` as the active `HookState`; returns the reset token."""
414
+ def install_hook_state(state: Optional[HookState]) -> Token[Optional[HookState]]:
415
+ """Install ``state`` as the active ``HookState``; returns the reset token."""
396
416
  return _hook_context.set(state)
397
417
 
398
418
 
399
- def _reset_hook_state(token: "Token[Optional[HookState]]") -> None:
400
- """Restore the hook state that was active before `_set_hook_state`."""
419
+ def restore_hook_state(token: Token[Optional[HookState]]) -> None:
420
+ """Restore the hook state that was active before ``install_hook_state``."""
401
421
  _hook_context.reset(token)
402
422
 
403
423
 
424
+ def _require_hook_state(hook_name: str) -> HookState:
425
+ ctx = _hook_context.get()
426
+ if ctx is None:
427
+ raise RuntimeError(f"{hook_name} must be called inside a @component function")
428
+ return ctx
429
+
430
+
404
431
  def _deps_changed(prev: Any, current: Any) -> bool:
405
432
  """Return whether the dependency arrays differ enough to re-run an effect."""
406
433
  if prev is _SENTINEL:
@@ -412,6 +439,17 @@ def _deps_changed(prev: Any, current: Any) -> bool:
412
439
  return any(p is not c and p != c for p, c in zip(prev, current))
413
440
 
414
441
 
442
+ def _run_cleanup(cleanup: Any) -> None:
443
+ if callable(cleanup):
444
+ try:
445
+ cleanup()
446
+ except Exception as exc:
447
+ # Never let a failing cleanup abort an unmount; surface it
448
+ # through the RedBox in dev mode, a warning otherwise.
449
+ if not diagnostics.report_error(exc, phase="effect cleanup"):
450
+ diagnostics.warn(f"Effect cleanup raised {exc!r}")
451
+
452
+
415
453
  def _activate_effect(effect_fn: Callable) -> Any:
416
454
  """Invoke an effect callback, running coroutine effects as tasks.
417
455
 
@@ -462,143 +500,47 @@ def _activate_effect(effect_fn: Callable) -> Any:
462
500
  return _cleanup
463
501
 
464
502
 
465
- # ======================================================================
466
- # Batching and transition scheduling
467
- # ======================================================================
468
-
469
-
470
- def _schedule_trigger(trigger: Callable[[], None]) -> None:
471
- """Run ``trigger`` immediately, or defer it inside a `batch_updates` block."""
472
- pending = _batch_pending.get()
473
- if _batch_depth.get() > 0 and pending is not None:
474
- if trigger not in pending:
475
- pending.append(trigger)
476
- else:
477
- trigger()
478
-
479
-
480
- # Transition scheduling state. Deferred triggers (and the callbacks
481
- # that flip ``is_pending`` back off) run together on a later loop turn.
482
- _deferred_triggers: List[Callable[[], None]] = []
483
- _post_transition_callbacks: List[Callable[[], None]] = []
484
- _transition_flush_scheduled = False
485
-
486
-
487
- def _flush_transitions() -> None:
488
- """Run every deferred transition render, then the completion callbacks."""
489
- global _transition_flush_scheduled
490
- _transition_flush_scheduled = False
491
- triggers = list(_deferred_triggers)
492
- _deferred_triggers.clear()
493
- callbacks = list(_post_transition_callbacks)
494
- _post_transition_callbacks.clear()
495
- for trigger in triggers:
496
- try:
497
- trigger()
498
- except Exception as exc:
499
- if not diagnostics.report_error(exc, phase="transition"):
500
- raise
501
- for callback in callbacks:
502
- try:
503
- callback()
504
- except Exception as exc:
505
- if not diagnostics.report_error(exc, phase="transition"):
506
- raise
507
-
508
-
509
- def _schedule_transition_flush() -> None:
510
- """Ensure a transition flush is queued on the framework loop."""
511
- global _transition_flush_scheduled
512
- if _transition_flush_scheduled:
513
- return
514
- _transition_flush_scheduled = True
515
- from .runtime import get_loop
516
-
517
- get_loop().call_soon(_flush_transitions)
518
-
519
-
520
- def _defer_transition_trigger(trigger: Callable[[], None]) -> None:
521
- if trigger not in _deferred_triggers:
522
- _deferred_triggers.append(trigger)
523
- _schedule_transition_flush()
524
-
525
-
526
- def _run_in_transition(fn: Callable[[], None]) -> None:
527
- """Run ``fn`` with its state updates marked as transitions."""
528
- token = _transition_var.set(True)
529
- try:
530
- fn()
531
- finally:
532
- _transition_var.reset(token)
533
-
534
-
535
- def _notify_state_changed(ctx: "HookState") -> None:
503
+ def _notify_state_changed(ctx: HookState) -> None:
536
504
  """Mark ``ctx``'s component dirty and schedule a render after a state change.
537
505
 
538
- Enqueuing the owning ``VNode`` in the reconciler's dirty set is what
539
- makes the subsequent render *local*: the screen host's trigger calls
540
- ``flush_dirty``, which re-renders only the components marked here
541
- rather than the whole app. The dirty mark is eager (so several
542
- setters coalesce), while the render trigger respects
543
- [`batch_updates`][pythonnative.batch_updates] and defers to a later
544
- loop turn inside a transition (see
506
+ Enqueuing the owning node in the reconciler's dirty set is what
507
+ makes the subsequent render *local*: the host's trigger flushes
508
+ only the components marked here rather than the whole app. The
509
+ dirty mark is eager (so several setters coalesce), while the render
510
+ request respects [`batch_updates`][pythonnative.scheduler.batch_updates] and
511
+ defers to a later loop turn inside a transition (see
545
512
  [`use_transition`][pythonnative.use_transition]).
546
513
  """
547
514
  ctx._dirty = True
548
- reconciler = ctx._reconciler
549
- vnode = ctx._vnode
550
- if reconciler is not None and vnode is not None:
551
- reconciler.mark_dirty(vnode)
552
- if ctx._trigger_render:
553
- if _transition_var.get():
554
- _defer_transition_trigger(ctx._trigger_render)
555
- else:
556
- _schedule_trigger(ctx._trigger_render)
515
+ owner = ctx.owner
516
+ if owner is None:
517
+ return
518
+ if ctx.vnode is not None:
519
+ owner.mark_dirty(ctx.vnode)
520
+ if in_transition():
521
+ owner.transitions.defer(owner.request_render)
522
+ else:
523
+ schedule_trigger(owner.request_render)
557
524
 
558
525
 
559
- @contextmanager
560
- def batch_updates() -> Generator[None, None, None]:
561
- """Coalesce multiple state updates into a single re-render.
526
+ # ======================================================================
527
+ # State hooks
528
+ # ======================================================================
562
529
 
563
- State setters called inside the `with` block defer their
564
- re-render trigger until the block exits, so any number of
565
- `set_*` calls produce at most one render pass.
566
530
 
567
- Yields:
568
- None. The block executes normally; deferred renders fire on
569
- exit.
531
+ @overload
532
+ def use_state() -> Tuple[Optional[Any], StateSetter[Any]]: ...
570
533
 
571
- Example:
572
- ```python
573
- import pythonnative as pn
574
534
 
575
- with pn.batch_updates():
576
- set_count(1)
577
- set_name("hello")
578
- ```
579
- """
580
- depth = _batch_depth.get()
581
- depth_token = _batch_depth.set(depth + 1)
582
- pending_token = None
583
- if depth == 0:
584
- pending_token = _batch_pending.set([])
585
- try:
586
- yield
587
- finally:
588
- _batch_depth.reset(depth_token)
589
- if pending_token is not None:
590
- triggers = _batch_pending.get() or []
591
- _batch_pending.reset(pending_token)
592
- for trigger in triggers:
593
- trigger()
535
+ @overload
536
+ def use_state(initial: Callable[[], T]) -> Tuple[T, StateSetter[T]]: ...
594
537
 
595
538
 
596
- # ======================================================================
597
- # Public hooks
598
- # ======================================================================
539
+ @overload
540
+ def use_state(initial: T) -> Tuple[T, StateSetter[T]]: ...
599
541
 
600
542
 
601
- def use_state(initial: Any = None) -> Tuple[Any, Callable]:
543
+ def use_state(initial: Any = None) -> Tuple[Any, StateSetter[Any]]:
602
544
  """Return ``(value, setter)`` for component-local state.
603
545
 
604
546
  State persists across re-renders of the same component instance.
@@ -614,7 +556,7 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
614
556
  state and ``setter`` updates it (and triggers a re-render).
615
557
 
616
558
  Raises:
617
- RuntimeError: If called outside a `@component` function.
559
+ RuntimeError: If called outside a ``@component`` function.
618
560
 
619
561
  Example:
620
562
  ```python
@@ -629,9 +571,7 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
629
571
  )
630
572
  ```
631
573
  """
632
- ctx = _get_hook_state()
633
- if ctx is None:
634
- raise RuntimeError("use_state must be called inside a @component function")
574
+ ctx = _require_hook_state("use_state")
635
575
  ctx.record_hook("use_state")
636
576
 
637
577
  idx = ctx.state_index
@@ -653,7 +593,9 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
653
593
  return current, setter
654
594
 
655
595
 
656
- def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple[Any, Callable]:
596
+ def use_reducer(
597
+ reducer: Callable[[T, Any], T], initial_state: Union[T, Callable[[], T]]
598
+ ) -> Tuple[T, Callable[[Any], None]]:
657
599
  """Return ``(state, dispatch)`` for reducer-based state management.
658
600
 
659
601
  A reducer is a pure function that takes the current state and an
@@ -664,41 +606,19 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
664
606
 
665
607
  Args:
666
608
  reducer: ``reducer(current_state, action) -> new_state``.
667
- The component re-renders only when `reducer` returns a
609
+ The component re-renders only when ``reducer`` returns a
668
610
  value different from the current state.
669
611
  initial_state: Initial state value, or a callable invoked once
670
612
  on the first render.
671
613
 
672
614
  Returns:
673
- A 2-tuple ``(state, dispatch)`` where `dispatch` runs the
615
+ A 2-tuple ``(state, dispatch)`` where ``dispatch`` runs the
674
616
  reducer with the supplied action.
675
617
 
676
618
  Raises:
677
- RuntimeError: If called outside a `@component` function.
678
-
679
- Example:
680
- ```python
681
- import pythonnative as pn
682
-
683
- def reducer(state, action):
684
- if action == "increment":
685
- return state + 1
686
- if action == "reset":
687
- return 0
688
- return state
689
-
690
- @pn.component
691
- def Counter():
692
- count, dispatch = pn.use_reducer(reducer, 0)
693
- return pn.Row(
694
- pn.Button("+", on_press=lambda: dispatch("increment")),
695
- pn.Button("Reset", on_press=lambda: dispatch("reset")),
696
- )
697
- ```
619
+ RuntimeError: If called outside a ``@component`` function.
698
620
  """
699
- ctx = _get_hook_state()
700
- if ctx is None:
701
- raise RuntimeError("use_reducer must be called inside a @component function")
621
+ ctx = _require_hook_state("use_reducer")
702
622
  ctx.record_hook("use_reducer")
703
623
 
704
624
  idx = ctx.state_index
@@ -719,7 +639,12 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
719
639
  return current, dispatch
720
640
 
721
641
 
722
- def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
642
+ # ======================================================================
643
+ # Effect hooks
644
+ # ======================================================================
645
+
646
+
647
+ def use_effect(effect: Callable[[], Any], deps: Optional[list] = None) -> None:
723
648
  """Schedule a side effect to run after the native commit.
724
649
 
725
650
  Effects are queued during the render pass and flushed once the
@@ -727,17 +652,17 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
727
652
  means effect callbacks can safely measure layout or interact with
728
653
  committed native views.
729
654
 
730
- The `deps` argument controls when the effect re-runs:
655
+ The ``deps`` argument controls when the effect re-runs:
731
656
 
732
- - `None`: every render.
733
- - `[]`: mount only.
734
- - `[a, b]`: when `a` or `b` change (compared by identity, then `==`).
657
+ - ``None``: every render.
658
+ - ``[]``: mount only.
659
+ - ``[a, b]``: when ``a`` or ``b`` change (compared by identity, then ``==``).
735
660
 
736
- A synchronous `effect` may return a cleanup callable; the previous
661
+ A synchronous ``effect`` may return a cleanup callable; the previous
737
662
  cleanup runs before the next effect (and on unmount).
738
663
 
739
- An **async** `effect` (an ``async def``) runs as a task on the
740
- framework loop. When `deps` change or the component unmounts, the
664
+ An **async** ``effect`` (an ``async def``) runs as a task on the
665
+ framework loop. When ``deps`` change or the component unmounts, the
741
666
  in-flight task is cancelled (:class:`asyncio.CancelledError` is
742
667
  raised at its current ``await``), giving async effects structured
743
668
  cancellation for free. If the coroutine finishes and returns a
@@ -747,14 +672,15 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
747
672
  effect: A zero-arg callable invoked after commit: either a
748
673
  synchronous function (optionally returning a cleanup
749
674
  callable) or an ``async def``.
750
- deps: Dependency list, or `None` to run on every render.
675
+ deps: Dependency list, or ``None`` to run on every render.
751
676
 
752
677
  Raises:
753
- RuntimeError: If called outside a `@component` function.
678
+ RuntimeError: If called outside a ``@component`` function.
754
679
 
755
680
  Example:
756
681
  ```python
757
682
  import asyncio
683
+ import time
758
684
 
759
685
  import pythonnative as pn
760
686
 
@@ -771,9 +697,7 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
771
697
  return pn.Text(now)
772
698
  ```
773
699
  """
774
- ctx = _get_hook_state()
775
- if ctx is None:
776
- raise RuntimeError("use_effect must be called inside a @component function")
700
+ ctx = _require_hook_state("use_effect")
777
701
  ctx.record_hook("use_effect")
778
702
 
779
703
  idx = ctx.effect_index
@@ -789,7 +713,7 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
789
713
  ctx._pending_effects.append((idx, effect, deps))
790
714
 
791
715
 
792
- def use_layout_effect(effect: Callable, deps: Optional[list] = None) -> None:
716
+ def use_layout_effect(effect: Callable[[], Any], deps: Optional[list] = None) -> None:
793
717
  """Schedule a side effect that runs synchronously inside the commit.
794
718
 
795
719
  Like [`use_effect`][pythonnative.use_effect], but the callback
@@ -799,36 +723,18 @@ def use_layout_effect(effect: Callable, deps: Optional[list] = None) -> None:
799
723
  imperative view command before the user sees the new frame, for
800
724
  example scrolling a list into position on mount.
801
725
 
802
- Prefer `use_effect` for everything else; layout effects block the
726
+ Prefer ``use_effect`` for everything else; layout effects block the
803
727
  commit, so heavy work here delays the frame.
804
728
 
805
729
  Args:
806
730
  effect: A zero-arg callable invoked during commit. Optionally
807
731
  returns a cleanup callable.
808
- deps: Dependency list, or `None` to run on every render.
732
+ deps: Dependency list, or ``None`` to run on every render.
809
733
 
810
734
  Raises:
811
- RuntimeError: If called outside a `@component` function.
812
-
813
- Example:
814
- ```python
815
- import pythonnative as pn
816
-
817
- @pn.component
818
- def AutoScrollList(items):
819
- list_ref = pn.use_ref()
820
-
821
- def scroll_to_bottom():
822
- if list_ref.current is not None:
823
- list_ref.current.scroll_to_end(animated=False)
824
-
825
- pn.use_layout_effect(scroll_to_bottom, [len(items)])
826
- return pn.FlatList(items, render_item=Row, ref=list_ref)
827
- ```
735
+ RuntimeError: If called outside a ``@component`` function.
828
736
  """
829
- ctx = _get_hook_state()
830
- if ctx is None:
831
- raise RuntimeError("use_layout_effect must be called inside a @component function")
737
+ ctx = _require_hook_state("use_layout_effect")
832
738
  ctx.record_hook("use_layout_effect")
833
739
 
834
740
  idx = ctx.layout_effect_index
@@ -844,8 +750,13 @@ def use_layout_effect(effect: Callable, deps: Optional[list] = None) -> None:
844
750
  ctx._pending_layout_effects.append((idx, effect, deps))
845
751
 
846
752
 
753
+ # ======================================================================
754
+ # Memoization hooks
755
+ # ======================================================================
756
+
757
+
847
758
  def use_memo(factory: Callable[[], T], deps: list) -> T:
848
- """Return a memoized value that is recomputed only when `deps` change.
759
+ """Return a memoized value that is recomputed only when ``deps`` change.
849
760
 
850
761
  Use this for expensive computations whose inputs change rarely. For
851
762
  cheap computations, plain inline code is faster (memoization itself
@@ -860,11 +771,9 @@ def use_memo(factory: Callable[[], T], deps: list) -> T:
860
771
  The cached or freshly computed value.
861
772
 
862
773
  Raises:
863
- RuntimeError: If called outside a `@component` function.
774
+ RuntimeError: If called outside a ``@component`` function.
864
775
  """
865
- ctx = _get_hook_state()
866
- if ctx is None:
867
- raise RuntimeError("use_memo must be called inside a @component function")
776
+ ctx = _require_hook_state("use_memo")
868
777
  ctx.record_hook("use_memo")
869
778
 
870
779
  idx = ctx.memo_index
@@ -884,19 +793,22 @@ def use_memo(factory: Callable[[], T], deps: list) -> T:
884
793
  return value
885
794
 
886
795
 
887
- def use_callback(callback: Callable, deps: list) -> Callable:
796
+ F = TypeVar("F", bound=Callable[..., Any])
797
+
798
+
799
+ def use_callback(callback: F, deps: list) -> F:
888
800
  """Return a stable reference to ``callback``, refreshed when ``deps`` change.
889
801
 
890
- Equivalent to `use_memo(lambda: callback, deps)`. Useful when passing
891
- a function as a prop to a memoized child component, so the child
892
- doesn't see a fresh function identity on every render.
802
+ Equivalent to ``use_memo(lambda: callback, deps)``. Useful when
803
+ passing a function as a prop to a memoized child component, so the
804
+ child doesn't see a fresh function identity on every render.
893
805
 
894
806
  Args:
895
807
  callback: The callable to memoize.
896
808
  deps: Dependency list controlling when the reference refreshes.
897
809
 
898
810
  Returns:
899
- A callable with stable identity across renders (until `deps` change).
811
+ A callable with stable identity across renders (until ``deps`` change).
900
812
  """
901
813
  return use_memo(lambda: callback, deps)
902
814
 
@@ -908,10 +820,9 @@ def use_ref(initial: Optional[T] = None) -> Ref[T]:
908
820
  triggering them: timers, last-seen values, native handles, and so on.
909
821
 
910
822
  ``ref.current`` is also populated by the reconciler with the
911
- underlying native view (`UIView` on iOS, `android.view.View` on
912
- Android, a Tk widget on desktop) when the ref is passed via the
913
- ``ref=`` prop on a built-in element, and cleared to ``None`` when
914
- that element unmounts. Composite components such as
823
+ underlying native view when the ref is passed via the ``ref=`` prop
824
+ on a built-in element, and cleared to ``None`` when that element
825
+ unmounts. Composite components such as
915
826
  [`FlatList`][pythonnative.FlatList] publish a typed controller
916
827
  object instead (see
917
828
  [`use_imperative_handle`][pythonnative.use_imperative_handle]).
@@ -924,11 +835,9 @@ def use_ref(initial: Optional[T] = None) -> Ref[T]:
924
835
  *not* trigger re-renders.
925
836
 
926
837
  Raises:
927
- RuntimeError: If called outside a `@component` function.
838
+ RuntimeError: If called outside a ``@component`` function.
928
839
  """
929
- ctx = _get_hook_state()
930
- if ctx is None:
931
- raise RuntimeError("use_ref must be called inside a @component function")
840
+ ctx = _require_hook_state("use_ref")
932
841
  ctx.record_hook("use_ref")
933
842
 
934
843
  idx = ctx.ref_index
@@ -943,7 +852,7 @@ def use_ref(initial: Optional[T] = None) -> Ref[T]:
943
852
 
944
853
 
945
854
  def use_imperative_handle(
946
- ref: Optional[Ref],
855
+ ref: Optional[Ref[Any]],
947
856
  factory: Callable[[], Any],
948
857
  deps: Optional[list] = None,
949
858
  ) -> None:
@@ -962,18 +871,15 @@ def use_imperative_handle(
962
871
  request a handle), in which case this is a no-op.
963
872
  factory: Zero-arg callable returning the handle object.
964
873
  deps: Dependency list controlling when the handle is rebuilt.
965
- Defaults to ``[]`` semantics only if you pass ``[]``;
966
874
  ``None`` rebuilds on every render, matching effects.
967
875
 
968
876
  Raises:
969
- RuntimeError: If called outside a `@component` function.
877
+ RuntimeError: If called outside a ``@component`` function.
970
878
 
971
879
  Example:
972
880
  ```python
973
- import pythonnative as pn
974
-
975
881
  @pn.component
976
- def VideoPlayer(source, ref=None):
882
+ def VideoPlayer(source: str, ref: pn.Ref | None = None):
977
883
  pn.use_imperative_handle(ref, lambda: PlayerController(...), [source])
978
884
  return pn.View(...)
979
885
  ```
@@ -1028,20 +934,15 @@ def use_resource(fetcher: Callable[[], Any], deps: Optional[list] = None) -> Res
1028
934
 
1029
935
  Example:
1030
936
  ```python
1031
- import pythonnative as pn
1032
-
1033
-
1034
937
  @pn.component
1035
- async def UserCard(user_id):
938
+ async def UserCard(user_id: str):
1036
939
  user = await pn.use_resource(lambda: api.get_user(user_id), [user_id])
1037
940
  return pn.Text(user["name"])
1038
941
  ```
1039
942
  """
1040
943
  from .suspense import start_resource
1041
944
 
1042
- ctx = _get_hook_state()
1043
- if ctx is None:
1044
- raise RuntimeError("use_resource must be called inside a @component function")
945
+ ctx = _require_hook_state("use_resource")
1045
946
  ctx.record_hook("use_resource")
1046
947
 
1047
948
  idx = ctx.resource_index
@@ -1082,9 +983,6 @@ def use_transition() -> Tuple[bool, Callable[[Callable[[], None]], None]]:
1082
983
 
1083
984
  Example:
1084
985
  ```python
1085
- import pythonnative as pn
1086
-
1087
-
1088
986
  @pn.component
1089
987
  def Search():
1090
988
  query, set_query = pn.use_state("")
@@ -1101,23 +999,24 @@ def use_transition() -> Tuple[bool, Callable[[Callable[[], None]], None]]:
1101
999
  )
1102
1000
  ```
1103
1001
  """
1104
- ctx = _get_hook_state()
1105
- if ctx is None:
1106
- raise RuntimeError("use_transition must be called inside a @component function")
1002
+ ctx = _require_hook_state("use_transition")
1107
1003
 
1108
1004
  is_pending, set_pending = use_state(False)
1109
1005
 
1110
1006
  def start_transition(fn: Callable[[], None]) -> None:
1007
+ owner = ctx.owner
1008
+ if owner is None:
1009
+ fn()
1010
+ return
1111
1011
  set_pending(True)
1112
- _run_in_transition(fn)
1113
- _post_transition_callbacks.append(lambda: set_pending(False))
1114
- _schedule_transition_flush()
1012
+ run_in_transition(fn)
1013
+ owner.transitions.on_complete(lambda: set_pending(False))
1115
1014
 
1116
1015
  start = use_callback(start_transition, [])
1117
1016
  return is_pending, start
1118
1017
 
1119
1018
 
1120
- def use_deferred_value(value: Any) -> Any:
1019
+ def use_deferred_value(value: T) -> T:
1121
1020
  """Return a copy of ``value`` that lags behind during fast updates.
1122
1021
 
1123
1022
  The returned value updates in a deferred (transition-priority)
@@ -1136,14 +1035,12 @@ def use_deferred_value(value: Any) -> Any:
1136
1035
  Raises:
1137
1036
  RuntimeError: If called outside a ``@component`` function.
1138
1037
  """
1139
- ctx = _get_hook_state()
1140
- if ctx is None:
1141
- raise RuntimeError("use_deferred_value must be called inside a @component function")
1038
+ _require_hook_state("use_deferred_value")
1142
1039
 
1143
1040
  deferred, set_deferred = use_state(value)
1144
1041
 
1145
1042
  def _adopt() -> None:
1146
- _run_in_transition(lambda: set_deferred(value))
1043
+ run_in_transition(lambda: set_deferred(value))
1147
1044
 
1148
1045
  use_effect(_adopt, [value])
1149
1046
  return deferred
@@ -1197,14 +1094,11 @@ def use_query(
1197
1094
 
1198
1095
  Example:
1199
1096
  ```python
1200
- import pythonnative as pn
1201
-
1202
-
1203
1097
  @pn.component
1204
- def UserCard(user_id):
1098
+ def UserCard(user_id: str):
1205
1099
  q = pn.use_query(lambda: api.get_user(user_id), [user_id])
1206
1100
  if q.loading:
1207
- return pn.Text("Loading…")
1101
+ return pn.Text("Loading...")
1208
1102
  if q.error:
1209
1103
  return pn.Text(f"Error: {q.error}")
1210
1104
  return pn.Text(q.data["name"])
@@ -1231,7 +1125,8 @@ def use_query(
1231
1125
  except asyncio.CancelledError:
1232
1126
  raise
1233
1127
  except BaseException as exc: # pragma: no cover - surfaced to user
1234
- set_state(lambda s, e=exc: replace(s, loading=False, error=e))
1128
+ failure = exc
1129
+ set_state(lambda s: replace(s, loading=False, error=failure))
1235
1130
 
1236
1131
  future = run_async(_runner())
1237
1132
 
@@ -1288,7 +1183,7 @@ class MutationCall(Generic[T]):
1288
1183
 
1289
1184
  __slots__ = ("_future",)
1290
1185
 
1291
- def __init__(self, future: "Any") -> None:
1186
+ def __init__(self, future: Any) -> None:
1292
1187
  self._future = future
1293
1188
 
1294
1189
  def __await__(self) -> Any:
@@ -1326,17 +1221,14 @@ def use_mutation(
1326
1221
 
1327
1222
  Example:
1328
1223
  ```python
1329
- import pythonnative as pn
1330
-
1331
-
1332
1224
  @pn.component
1333
1225
  def NewPostForm():
1334
1226
  state, save = pn.use_mutation(api.create_post)
1335
1227
 
1336
1228
  return pn.Column(
1337
1229
  pn.Button("Save", on_press=lambda: save(post)),
1338
- pn.Text("Saving…") if state.loading else pn.Text(""),
1339
- pn.Text(str(state.error)) if state.error else pn.Text(""),
1230
+ state.loading and pn.Text("Saving..."),
1231
+ state.error and pn.Text(str(state.error)),
1340
1232
  )
1341
1233
  ```
1342
1234
  """
@@ -1356,7 +1248,8 @@ def use_mutation(
1356
1248
  set_state(lambda s: replace(s, loading=False))
1357
1249
  raise
1358
1250
  except BaseException as exc:
1359
- set_state(lambda s, e=exc: replace(s, loading=False, error=e))
1251
+ failure = exc
1252
+ set_state(lambda s: replace(s, loading=False, error=failure))
1360
1253
  raise
1361
1254
 
1362
1255
  future = run_async(_runner())
@@ -1366,10 +1259,41 @@ def use_mutation(
1366
1259
 
1367
1260
 
1368
1261
  # ======================================================================
1369
- # Platform-metric hooks (window dimensions, safe-area insets, keyboard)
1262
+ # External subscriptions
1370
1263
  # ======================================================================
1371
1264
 
1372
1265
 
1266
+ def use_subscription(subscribe: Callable[[Callable[[], None]], Callable[[], None]], get_snapshot: Callable[[], T]) -> T:
1267
+ """Subscribe to an external store and re-render when its snapshot changes.
1268
+
1269
+ The Pythonic counterpart of React's ``useSyncExternalStore``: the
1270
+ platform-metric hooks below are built on it, and it's the right
1271
+ primitive for app-level stores that live outside the component
1272
+ tree.
1273
+
1274
+ Args:
1275
+ subscribe: ``subscribe(on_change) -> unsubscribe``. Called once
1276
+ on mount; ``on_change`` must be invoked whenever the store
1277
+ changes.
1278
+ get_snapshot: Zero-arg callable returning the current value.
1279
+ Re-read on every render.
1280
+
1281
+ Returns:
1282
+ The current snapshot.
1283
+
1284
+ Raises:
1285
+ RuntimeError: If called outside a ``@component`` function.
1286
+ """
1287
+ _require_hook_state("use_subscription")
1288
+ _, set_tick = use_state(0)
1289
+
1290
+ def _subscribe() -> Callable[[], None]:
1291
+ return subscribe(lambda: set_tick(lambda n: n + 1))
1292
+
1293
+ use_effect(_subscribe, [])
1294
+ return get_snapshot()
1295
+
1296
+
1373
1297
  def use_window_dimensions() -> Dict[str, float]:
1374
1298
  """Return the current viewport size and re-render when it changes.
1375
1299
 
@@ -1383,32 +1307,11 @@ def use_window_dimensions() -> Dict[str, float]:
1383
1307
  screen host has run its first layout pass.
1384
1308
 
1385
1309
  Raises:
1386
- RuntimeError: If called outside a `@component` function.
1387
-
1388
- Example:
1389
- ```python
1390
- import pythonnative as pn
1391
-
1392
- @pn.component
1393
- def MyView():
1394
- dims = pn.use_window_dimensions()
1395
- return pn.Text(f"{dims['width']:.0f} x {dims['height']:.0f}")
1396
- ```
1310
+ RuntimeError: If called outside a ``@component`` function.
1397
1311
  """
1398
1312
  from . import platform_metrics
1399
1313
 
1400
- ctx = _get_hook_state()
1401
- if ctx is None:
1402
- raise RuntimeError("use_window_dimensions must be called inside a @component function")
1403
-
1404
- _, set_tick = use_state(0)
1405
-
1406
- def subscribe() -> Callable[[], None]:
1407
- return platform_metrics.subscribe(lambda: set_tick(lambda n: n + 1))
1408
-
1409
- use_effect(subscribe, [])
1410
-
1411
- dims = platform_metrics.get_window_dimensions()
1314
+ dims = use_subscription(platform_metrics.subscribe, platform_metrics.get_window_dimensions)
1412
1315
  return {"width": dims.width, "height": dims.height}
1413
1316
 
1414
1317
 
@@ -1422,28 +1325,12 @@ def use_safe_area_insets() -> Dict[str, float]:
1422
1325
  floats in layout units (pt on iOS, dp on Android).
1423
1326
 
1424
1327
  Raises:
1425
- RuntimeError: If called outside a `@component` function.
1328
+ RuntimeError: If called outside a ``@component`` function.
1426
1329
  """
1427
1330
  from . import platform_metrics
1428
1331
 
1429
- ctx = _get_hook_state()
1430
- if ctx is None:
1431
- raise RuntimeError("use_safe_area_insets must be called inside a @component function")
1432
-
1433
- _, set_tick = use_state(0)
1434
-
1435
- def subscribe() -> Callable[[], None]:
1436
- return platform_metrics.subscribe(lambda: set_tick(lambda n: n + 1))
1437
-
1438
- use_effect(subscribe, [])
1439
-
1440
- insets = platform_metrics.get_safe_area_insets()
1441
- return {
1442
- "top": insets.top,
1443
- "bottom": insets.bottom,
1444
- "left": insets.left,
1445
- "right": insets.right,
1446
- }
1332
+ insets = use_subscription(platform_metrics.subscribe, platform_metrics.get_safe_area_insets)
1333
+ return {"top": insets.top, "bottom": insets.bottom, "left": insets.left, "right": insets.right}
1447
1334
 
1448
1335
 
1449
1336
  def use_keyboard_height() -> float:
@@ -1455,22 +1342,11 @@ def use_keyboard_height() -> float:
1455
1342
  of reading this directly.
1456
1343
 
1457
1344
  Raises:
1458
- RuntimeError: If called outside a `@component` function.
1345
+ RuntimeError: If called outside a ``@component`` function.
1459
1346
  """
1460
1347
  from . import platform_metrics
1461
1348
 
1462
- ctx = _get_hook_state()
1463
- if ctx is None:
1464
- raise RuntimeError("use_keyboard_height must be called inside a @component function")
1465
-
1466
- _, set_tick = use_state(0)
1467
-
1468
- def subscribe() -> Callable[[], None]:
1469
- return platform_metrics.subscribe(lambda: set_tick(lambda n: n + 1))
1470
-
1471
- use_effect(subscribe, [])
1472
-
1473
- return platform_metrics.get_keyboard_height()
1349
+ return use_subscription(platform_metrics.subscribe, platform_metrics.get_keyboard_height)
1474
1350
 
1475
1351
 
1476
1352
  def use_color_scheme() -> str:
@@ -1485,33 +1361,11 @@ def use_color_scheme() -> str:
1485
1361
  ``"light"`` or ``"dark"``.
1486
1362
 
1487
1363
  Raises:
1488
- RuntimeError: If called outside a `@component` function.
1489
-
1490
- Example:
1491
- ```python
1492
- import pythonnative as pn
1493
-
1494
- @pn.component
1495
- def Banner():
1496
- scheme = pn.use_color_scheme()
1497
- bg = "#000000" if scheme == "dark" else "#FFFFFF"
1498
- return pn.View(style=pn.style(background_color=bg))
1499
- ```
1364
+ RuntimeError: If called outside a ``@component`` function.
1500
1365
  """
1501
1366
  from . import appearance
1502
1367
 
1503
- ctx = _get_hook_state()
1504
- if ctx is None:
1505
- raise RuntimeError("use_color_scheme must be called inside a @component function")
1506
-
1507
- _, set_tick = use_state(0)
1508
-
1509
- def subscribe() -> Callable[[], None]:
1510
- return appearance.subscribe(lambda: set_tick(lambda n: n + 1))
1511
-
1512
- use_effect(subscribe, [])
1513
-
1514
- return appearance.get_color_scheme()
1368
+ return use_subscription(appearance.subscribe, appearance.get_color_scheme)
1515
1369
 
1516
1370
 
1517
1371
  # ======================================================================
@@ -1519,249 +1373,121 @@ def use_color_scheme() -> str:
1519
1373
  # ======================================================================
1520
1374
 
1521
1375
 
1522
- class Context:
1523
- """Container for a value shared across a subtree.
1376
+ class Context(Generic[T]):
1377
+ """A value shared with a subtree, created by [`create_context`][pythonnative.create_context].
1524
1378
 
1525
- Created by [`create_context`][pythonnative.create_context]; consumed
1526
- via [`use_context`][pythonnative.use_context]. Use
1527
- [`Provider`][pythonnative.Provider] to set the value for a subtree.
1379
+ Provide a value with [`Provider`][pythonnative.hooks.Context.Provider]
1380
+ and read it with [`use_context`][pythonnative.use_context]. A
1381
+ ``Context`` is itself an element type: ``ctx.Provider(value, ...)``
1382
+ returns an element whose ``type`` is ``ctx``.
1528
1383
 
1529
1384
  Context is *reactive*: when a Provider's value changes, every
1530
1385
  component that read the context on its last render re-renders,
1531
1386
  even if a memoized ancestor skipped its own re-render.
1532
1387
 
1533
1388
  Attributes:
1534
- default: The value returned when no `Provider` ancestor exists.
1389
+ default: The value returned when no Provider ancestor exists.
1390
+ name: Optional label for diagnostics.
1535
1391
  """
1536
1392
 
1537
- def __init__(self, default: Any = None) -> None:
1393
+ __slots__ = ("default", "name", "_stack")
1394
+
1395
+ def __init__(self, default: T, name: Optional[str] = None) -> None:
1538
1396
  self.default = default
1539
- self._stack: List[Any] = []
1397
+ self.name = name
1398
+ self._stack: List[T] = []
1540
1399
 
1541
- def _current(self) -> Any:
1400
+ def Provider(self, value: T, *children: Node, key: Optional[str] = None) -> Element:
1401
+ """Provide ``value`` to every descendant of ``children``.
1402
+
1403
+ A Provider contributes no native view of its own; its children
1404
+ mount directly into the surrounding native parent.
1405
+
1406
+ When ``value`` differs from the previous render (identity, then
1407
+ ``==``), every descendant that read the context re-renders,
1408
+ including descendants of memoized components that skipped.
1409
+
1410
+ Args:
1411
+ value: Value made available to descendants.
1412
+ *children: Subtree(s) under which the provider applies.
1413
+ key: Stable identity for keyed reconciliation.
1414
+
1415
+ Example:
1416
+ ```python
1417
+ Theme = pn.create_context({"primary": "#007AFF"})
1418
+
1419
+ @pn.component
1420
+ def App():
1421
+ return Theme.Provider({"primary": "#FF0000"}, Header(), Body())
1422
+ ```
1423
+ """
1424
+ return Element(self, {"value": value}, children, key=key)
1425
+
1426
+ def current(self) -> T:
1427
+ """Return the innermost provided value, or ``default``."""
1542
1428
  return self._stack[-1] if self._stack else self.default
1543
1429
 
1430
+ def __repr__(self) -> str:
1431
+ return f"<Context {self.name or id(self):x}>" if self.name is None else f"<Context {self.name}>"
1432
+
1433
+ # Rendering support: the reconciler pushes/pops provided values
1434
+ # while it walks a Provider's subtree.
1435
+
1436
+ def _push(self, value: T) -> None:
1437
+ self._stack.append(value)
1544
1438
 
1545
- def create_context(default: Any = None) -> Context:
1439
+ def _pop(self) -> None:
1440
+ self._stack.pop()
1441
+
1442
+
1443
+ def create_context(default: T = None, *, name: Optional[str] = None) -> Context[T]: # type: ignore[assignment,unused-ignore]
1546
1444
  """Create a new context with an optional default value.
1547
1445
 
1548
1446
  Args:
1549
1447
  default: Returned by [`use_context`][pythonnative.use_context]
1550
- when there is no enclosing
1551
- [`Provider`][pythonnative.Provider].
1448
+ when there is no enclosing Provider.
1449
+ name: Optional label shown in diagnostics.
1552
1450
 
1553
1451
  Returns:
1554
- A fresh `Context` instance.
1452
+ A fresh [`Context`][pythonnative.Context].
1555
1453
 
1556
1454
  Example:
1557
1455
  ```python
1558
- import pythonnative as pn
1559
-
1560
- ThemeContext = pn.create_context({"primary": "#007AFF"})
1456
+ Theme = pn.create_context({"primary": "#007AFF"}, name="Theme")
1561
1457
  ```
1562
1458
  """
1563
- return Context(default)
1459
+ return Context(default, name=name)
1564
1460
 
1565
1461
 
1566
- def use_context(context: Context) -> Any:
1567
- """Read the current value of `context` from the nearest `Provider`.
1462
+ def use_context(context: Context[T]) -> T:
1463
+ """Read the current value of ``context`` from the nearest Provider.
1568
1464
 
1569
- If no enclosing `Provider` exists, returns the context's default.
1465
+ If no enclosing Provider exists, returns the context's default.
1570
1466
  The component is registered as a subscriber: when the nearest
1571
1467
  Provider's value changes, the component re-renders even if a
1572
1468
  memoized ancestor skipped.
1573
1469
 
1574
1470
  Args:
1575
- context: The `Context` to read from.
1471
+ context: The [`Context`][pythonnative.Context] to read from.
1576
1472
 
1577
1473
  Returns:
1578
- The current value for `context`.
1474
+ The current value for ``context``.
1579
1475
 
1580
1476
  Raises:
1581
- RuntimeError: If called outside a `@component` function.
1477
+ RuntimeError: If called outside a ``@component`` function.
1582
1478
  """
1583
- ctx = _get_hook_state()
1584
- if ctx is None:
1585
- raise RuntimeError("use_context must be called inside a @component function")
1479
+ ctx = _require_hook_state("use_context")
1586
1480
  ctx.record_hook("use_context")
1587
- value = context._current()
1481
+ value = context.current()
1588
1482
  ctx.context_deps[id(context)] = value
1589
1483
  return value
1590
1484
 
1591
1485
 
1592
1486
  # ======================================================================
1593
- # Provider element helper
1487
+ # System back button
1594
1488
  # ======================================================================
1595
1489
 
1596
1490
 
1597
- def Provider(context: "Context", value: Any, *children: Element) -> Element:
1598
- """Provide ``value`` for ``context`` to all descendants of ``children``.
1599
-
1600
- Accepts any number of children (varargs). A Provider contributes no
1601
- native view of its own; its children mount directly into the
1602
- surrounding native parent.
1603
-
1604
- When ``value`` differs from the previous render (identity, then
1605
- ``==``), every descendant that read the context via
1606
- [`use_context`][pythonnative.use_context] re-renders, including
1607
- descendants of memoized components that skipped.
1608
-
1609
- Args:
1610
- context: The [`Context`][pythonnative.hooks.Context] to set.
1611
- value: Value made available to descendants via
1612
- [`use_context`][pythonnative.use_context].
1613
- *children: Subtree(s) under which the provider applies.
1614
-
1615
- Returns:
1616
- An [`Element`][pythonnative.Element] that the reconciler treats
1617
- as a context boundary.
1618
-
1619
- Example:
1620
- ```python
1621
- import pythonnative as pn
1622
-
1623
- ThemeContext = pn.create_context({"primary": "#007AFF"})
1624
-
1625
- @pn.component
1626
- def App():
1627
- return pn.Provider(
1628
- ThemeContext,
1629
- {"primary": "#FF0000"},
1630
- Header(),
1631
- Body(),
1632
- )
1633
- ```
1634
- """
1635
- return Element("__Provider__", {"__context__": context, "__value__": value}, list(children))
1636
-
1637
-
1638
- def memo(component_fn: Callable[..., Element]) -> Callable[..., Element]:
1639
- """Skip a function component's render when its props haven't changed.
1640
-
1641
- Decorate a ``@component``-wrapped function to opt into shallow-prop
1642
- memoization. When the reconciler re-renders the parent tree, a
1643
- memoized child is skipped (its previously-rendered subtree is
1644
- reused) iff:
1645
-
1646
- - Its props are shallowly equal to the previous render's props
1647
- (callables compared by identity, scalars by ``==``).
1648
- - None of its internal ``use_state`` / ``use_reducer`` setters fired
1649
- since the last render.
1650
-
1651
- Pair with [`use_callback`][pythonnative.use_callback] when passing
1652
- callbacks as props, otherwise a fresh closure will defeat the memo.
1653
-
1654
- Args:
1655
- component_fn: A function previously decorated with
1656
- [`component`][pythonnative.component].
1657
-
1658
- Returns:
1659
- The same function, marked for memoization.
1660
-
1661
- Example:
1662
- ```python
1663
- import pythonnative as pn
1664
-
1665
- @pn.memo
1666
- @pn.component
1667
- def ExpensiveRow(label: str):
1668
- ...
1669
- ```
1670
- """
1671
- component_fn._pn_memo = True
1672
- # ``@component`` builds a wrapper that emits an ``Element`` whose
1673
- # ``type`` is the underlying function, so propagate the marker to
1674
- # ``__wrapped__`` so the reconciler can find it via ``Element.type``.
1675
- wrapped = getattr(component_fn, "__wrapped__", None)
1676
- if wrapped is not None:
1677
- wrapped._pn_memo = True
1678
- return component_fn
1679
-
1680
-
1681
- # ======================================================================
1682
- # Navigation
1683
- # ======================================================================
1684
-
1685
- _NavigationContext: Context = create_context(None)
1686
-
1687
-
1688
- class NavigationHandle:
1689
- """Handle returned by [`use_navigation`][pythonnative.use_navigation].
1690
-
1691
- Wraps the host's push/pop primitives so screens can navigate
1692
- without knowing the underlying native navigation stack. The
1693
- typical user-facing surface is the declarative handle returned by
1694
- a [`Stack`][pythonnative.create_stack_navigator]; this class is
1695
- the lower-level fallback used when no navigator is rendered (and
1696
- as the bridge that declarative navigators delegate to when they
1697
- need to push real native screens).
1698
-
1699
- Example:
1700
- ```python
1701
- import pythonnative as pn
1702
-
1703
- @pn.component
1704
- def HomeScreen():
1705
- nav = pn.use_navigation()
1706
- return pn.Button(
1707
- "Open Detail",
1708
- on_press=lambda: nav.navigate("Detail", {"id": 42}),
1709
- )
1710
- ```
1711
- """
1712
-
1713
- def __init__(self, host: Any) -> None:
1714
- self._host = host
1715
-
1716
- def navigate(self, component: Any, params: Optional[Dict[str, Any]] = None) -> None:
1717
- """Push ``component`` onto the navigation stack.
1718
-
1719
- Args:
1720
- component: A ``@component`` function or a dotted Python
1721
- path (e.g. ``"app.detail.DetailScreen"``). When a
1722
- Stack navigator is the root of the app, prefer the
1723
- declarative ``nav.navigate("Detail", params)`` form
1724
- returned by ``use_navigation()`` (it pushes by route
1725
- name and the host re-uses its own ``App`` component).
1726
- params: Optional dict of arguments serialized into the
1727
- target screen.
1728
- """
1729
- self._host._push(component, params)
1730
-
1731
- def go_back(self) -> None:
1732
- """Pop the current screen and return to the previous one."""
1733
- self._host._pop()
1734
-
1735
- def get_params(self) -> Dict[str, Any]:
1736
- """Return the params dict passed to this screen.
1737
-
1738
- Returns:
1739
- The dict supplied by the caller's
1740
- [`navigate`][pythonnative.hooks.NavigationHandle.navigate]
1741
- call, or an empty dict if none was supplied.
1742
- """
1743
- return self._host._get_nav_args()
1744
-
1745
-
1746
- def use_navigation() -> NavigationHandle:
1747
- """Return a [`NavigationHandle`][pythonnative.hooks.NavigationHandle] for the screen.
1748
-
1749
- Returns:
1750
- The handle bound to the current screen's host.
1751
-
1752
- Raises:
1753
- RuntimeError: If called outside a component rendered via
1754
- [`create_screen`][pythonnative.create_screen].
1755
- """
1756
- handle = use_context(_NavigationContext)
1757
- if handle is None:
1758
- raise RuntimeError(
1759
- "use_navigation() called outside a PythonNative screen. "
1760
- "Ensure your component is rendered via create_screen()."
1761
- )
1762
- return handle
1763
-
1764
-
1765
1491
  def use_back_handler(handler: Callable[[], bool]) -> None:
1766
1492
  """Intercept the system back action for this screen.
1767
1493
 
@@ -1786,12 +1512,10 @@ def use_back_handler(handler: Callable[[], bool]) -> None:
1786
1512
  the back action.
1787
1513
 
1788
1514
  Raises:
1789
- RuntimeError: If called outside a `@component` function.
1515
+ RuntimeError: If called outside a ``@component`` function.
1790
1516
 
1791
1517
  Example:
1792
1518
  ```python
1793
- import pythonnative as pn
1794
-
1795
1519
  @pn.component
1796
1520
  def Editor():
1797
1521
  dirty, set_dirty = pn.use_state(False)
@@ -1799,16 +1523,14 @@ def use_back_handler(handler: Callable[[], bool]) -> None:
1799
1523
  ...
1800
1524
  ```
1801
1525
  """
1802
- ctx = _get_hook_state()
1803
- if ctx is None:
1804
- raise RuntimeError("use_back_handler must be called inside a @component function")
1526
+ ctx = _require_hook_state("use_back_handler")
1805
1527
 
1806
1528
  latest: Ref[Callable[[], bool]] = use_ref(handler)
1807
1529
  latest.current = handler
1808
1530
 
1809
1531
  def _register() -> Optional[Callable[[], None]]:
1810
- reconciler = ctx._reconciler
1811
- if reconciler is None or not hasattr(reconciler, "register_back_handler"):
1532
+ owner = ctx.owner
1533
+ if owner is None:
1812
1534
  return None
1813
1535
 
1814
1536
  def _trampoline() -> bool:
@@ -1822,68 +1544,41 @@ def use_back_handler(handler: Callable[[], bool]) -> None:
1822
1544
  raise
1823
1545
  return True
1824
1546
 
1825
- return reconciler.register_back_handler(_trampoline)
1547
+ return owner.register_back_handler(_trampoline)
1826
1548
 
1827
1549
  use_effect(_register, [])
1828
1550
 
1829
1551
 
1830
- # ======================================================================
1831
- # @component decorator
1832
- # ======================================================================
1833
-
1834
-
1835
- def component(func: Callable) -> Callable[..., Element]:
1836
- """Mark a function as a PythonNative component.
1837
-
1838
- The decorated function may use hooks (`use_state`, `use_effect`,
1839
- etc.) and returns an [`Element`][pythonnative.Element] tree.
1840
- Each call site creates an independent component instance with its
1841
- own hook state.
1842
-
1843
- Positional arguments are mapped onto the function's positional
1844
- parameters. If the function declares `*args`, positional arguments
1845
- instead become the special `children` prop.
1846
-
1847
- Args:
1848
- func: The function to wrap.
1849
-
1850
- Returns:
1851
- A wrapper that, when called, returns an `Element` whose `type`
1852
- is `func` itself.
1853
-
1854
- Example:
1855
- ```python
1856
- import pythonnative as pn
1857
-
1858
- @pn.component
1859
- def Greeting(name: str = "World"):
1860
- return pn.Text(f"Hello, {name}!")
1861
- ```
1862
- """
1863
- sig = inspect.signature(func)
1864
- positional_params = [
1865
- name
1866
- for name, p in sig.parameters.items()
1867
- if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
1868
- ]
1869
- has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
1870
-
1871
- def wrapper(*args: Any, **kwargs: Any) -> Element:
1872
- props: dict = dict(kwargs)
1873
-
1874
- if args:
1875
- if has_var_positional:
1876
- props["children"] = list(args)
1877
- else:
1878
- for i, arg in enumerate(args):
1879
- if i < len(positional_params):
1880
- props[positional_params[i]] = arg
1881
-
1882
- key = props.pop("key", None)
1883
- return Element(func, props, [], key=key)
1884
-
1885
- wrapper.__wrapped__ = func # noqa: B010
1886
- wrapper.__name__ = func.__name__
1887
- wrapper.__qualname__ = func.__qualname__
1888
- wrapper._pn_component = True # noqa: B010
1889
- return wrapper
1552
+ __all__ = [
1553
+ "Context",
1554
+ "HookState",
1555
+ "MutationCall",
1556
+ "MutationState",
1557
+ "QueryResult",
1558
+ "Ref",
1559
+ "RenderOwner",
1560
+ "create_context",
1561
+ "current_hook_state",
1562
+ "install_hook_state",
1563
+ "restore_hook_state",
1564
+ "use_back_handler",
1565
+ "use_callback",
1566
+ "use_color_scheme",
1567
+ "use_context",
1568
+ "use_deferred_value",
1569
+ "use_effect",
1570
+ "use_imperative_handle",
1571
+ "use_keyboard_height",
1572
+ "use_layout_effect",
1573
+ "use_memo",
1574
+ "use_mutation",
1575
+ "use_query",
1576
+ "use_reducer",
1577
+ "use_ref",
1578
+ "use_resource",
1579
+ "use_safe_area_insets",
1580
+ "use_state",
1581
+ "use_subscription",
1582
+ "use_transition",
1583
+ "use_window_dimensions",
1584
+ ]