pythonnative 0.23.0__py3-none-any.whl → 0.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pythonnative/hooks.py CHANGED
@@ -4,12 +4,18 @@ Provides React-like hooks for managing state, effects, memoization,
4
4
  context, and navigation within function components decorated with
5
5
  [`component`][pythonnative.component]. Hooks must be called at the top
6
6
  level of a component (not inside conditionals or loops) so they map to
7
- the same slot across renders.
7
+ the same slot across renders. In dev mode the framework verifies this
8
+ and raises [`HookOrderError`][pythonnative.diagnostics.HookOrderError]
9
+ on a violation instead of silently cross-wiring state.
8
10
 
9
- Effects are queued during the render phase and flushed *after* the
10
- reconciler commits native-view mutations. This ordering guarantees that
11
- effect callbacks can safely measure layout or interact with the
12
- committed native tree.
11
+ Two effect phases exist, mirroring React:
12
+
13
+ - [`use_layout_effect`][pythonnative.use_layout_effect] callbacks run
14
+ synchronously inside the commit, after native mutations and the
15
+ layout pass have been applied. They can measure committed frames and
16
+ issue imperative view commands before the user sees the new frame.
17
+ - [`use_effect`][pythonnative.use_effect] callbacks (passive effects)
18
+ run after the layout effects, at the end of the same commit.
13
19
 
14
20
  Example:
15
21
  ```python
@@ -20,7 +26,7 @@ Example:
20
26
  count, set_count = pn.use_state(initial)
21
27
  return pn.Column(
22
28
  pn.Text(f"Count: {count}"),
23
- pn.Button("+", on_click=lambda: set_count(count + 1)),
29
+ pn.Button("+", on_press=lambda: set_count(count + 1)),
24
30
  )
25
31
  ```
26
32
  """
@@ -32,6 +38,7 @@ from contextlib import contextmanager
32
38
  from dataclasses import dataclass, field, replace
33
39
  from typing import Any, Awaitable, Callable, Dict, Generator, Generic, List, Optional, Tuple, TypeVar
34
40
 
41
+ from . import diagnostics
35
42
  from .element import Element
36
43
 
37
44
  T = TypeVar("T")
@@ -42,6 +49,48 @@ _hook_context: threading.local = threading.local()
42
49
 
43
50
  _batch_context: threading.local = threading.local()
44
51
 
52
+
53
+ # ======================================================================
54
+ # Ref
55
+ # ======================================================================
56
+
57
+
58
+ class Ref(Generic[T]):
59
+ """Mutable container returned by [`use_ref`][pythonnative.use_ref].
60
+
61
+ A ``Ref`` holds one value on its ``current`` attribute. Mutating
62
+ ``current`` never triggers a re-render, which makes refs the right
63
+ place for timers, last-seen values, and imperative handles.
64
+
65
+ When a ``Ref`` is passed to a built-in element via the ``ref=``
66
+ prop, the reconciler populates ``current`` with the underlying
67
+ native view (``UIView`` on iOS, ``android.view.View`` on Android,
68
+ a Tk widget on desktop) after commit, and clears it back to
69
+ ``None`` on unmount. Composite components (e.g.
70
+ [`FlatList`][pythonnative.FlatList]) instead publish a typed
71
+ controller object on ``current`` via
72
+ [`use_imperative_handle`][pythonnative.use_imperative_handle].
73
+
74
+ Attributes:
75
+ current: The referenced value. ``None`` until populated.
76
+ """
77
+
78
+ __slots__ = ("current", "_pn_tag", "_pn_frame")
79
+
80
+ def __init__(self, initial: Optional[T] = None) -> None:
81
+ self.current: Optional[T] = initial
82
+ # Internal: the native view tag, populated by the reconciler
83
+ # when the ref is attached to a built-in element.
84
+ self._pn_tag: Optional[int] = None
85
+ # Internal: the last committed frame ``(x, y, w, h)``, mirrored
86
+ # by the layout pass so Python code can read measured geometry
87
+ # without a native round-trip.
88
+ self._pn_frame: Optional[Tuple[float, float, float, float]] = None
89
+
90
+ def __repr__(self) -> str:
91
+ return f"Ref({self.current!r})"
92
+
93
+
45
94
  # ======================================================================
46
95
  # Hook state container
47
96
  # ======================================================================
@@ -53,44 +102,61 @@ class HookState:
53
102
  Each `@component` instance owns one `HookState`. Hooks are matched
54
103
  to slots by call order, so they must always be called in the same
55
104
  order across renders. Effects scheduled during render are deferred
56
- into `_pending_effects` and flushed after the reconciler commits
57
- native mutations, which guarantees effect callbacks can safely
58
- interact with the committed native tree.
105
+ (layout effects into ``_pending_layout_effects``, passive effects
106
+ into ``_pending_effects``) and flushed by the reconciler in two
107
+ phases after native mutations commit.
59
108
 
60
109
  Attributes:
61
110
  states: One entry per `use_state` / `use_reducer` call.
62
111
  effects: One `(deps, cleanup)` tuple per `use_effect` call.
112
+ layout_effects: One `(deps, cleanup)` tuple per
113
+ `use_layout_effect` call.
63
114
  memos: One `(deps, value)` tuple per `use_memo` / `use_callback`.
64
- refs: One mutable dict per `use_ref` call.
115
+ refs: One [`Ref`][pythonnative.Ref] per `use_ref` call.
65
116
  """
66
117
 
67
118
  __slots__ = (
68
119
  "states",
69
120
  "effects",
121
+ "layout_effects",
70
122
  "memos",
71
123
  "refs",
72
124
  "state_index",
73
125
  "effect_index",
126
+ "layout_effect_index",
74
127
  "memo_index",
75
128
  "ref_index",
129
+ "context_deps",
76
130
  "_trigger_render",
77
131
  "_pending_effects",
132
+ "_pending_layout_effects",
78
133
  "_dirty",
79
134
  "_vnode",
80
135
  "_reconciler",
136
+ "_hook_log",
137
+ "_hook_signature",
138
+ "_component_name",
81
139
  )
82
140
 
83
141
  def __init__(self) -> None:
84
142
  self.states: List[Any] = []
85
143
  self.effects: List[Tuple[Any, Any]] = []
144
+ self.layout_effects: List[Tuple[Any, Any]] = []
86
145
  self.memos: List[Tuple[Any, Any]] = []
87
- self.refs: List[dict] = []
146
+ self.refs: List[Ref] = []
88
147
  self.state_index: int = 0
89
148
  self.effect_index: int = 0
149
+ self.layout_effect_index: int = 0
90
150
  self.memo_index: int = 0
91
151
  self.ref_index: int = 0
152
+ # Contexts read during the last completed render, keyed by
153
+ # ``id(context)``. The reconciler consults this when a
154
+ # Provider's value changes so consumers re-render even when a
155
+ # memoized ancestor skipped (reactive context).
156
+ self.context_deps: Dict[int, Any] = {}
92
157
  self._trigger_render: Optional[Callable[[], None]] = None
93
158
  self._pending_effects: List[Tuple[int, Callable, Any]] = []
159
+ self._pending_layout_effects: List[Tuple[int, Callable, Any]] = []
94
160
  # Cleared by the reconciler after each successful render.
95
161
  # ``use_state`` / ``use_reducer`` setters flip it to ``True``
96
162
  # whenever they actually mutate state, so [`memo`][pythonnative.memo]
@@ -104,21 +170,108 @@ class HookState:
104
170
  # again when it unmounts.
105
171
  self._vnode: Any = None
106
172
  self._reconciler: Any = None
107
-
108
- def reset_index(self) -> None:
109
- """Reset every per-hook cursor to ``0``.
110
-
111
- Called by the reconciler at the start of every render pass so
112
- the next render reads slots in the same order they were
113
- written.
173
+ # Dev-mode hook-order guard: the sequence of hook kinds called
174
+ # during the in-flight render, and the signature captured from
175
+ # the first successful render.
176
+ self._hook_log: Optional[List[str]] = None
177
+ self._hook_signature: Optional[List[str]] = None
178
+ self._component_name: str = ""
179
+
180
+ def begin_render(self, component_name: str = "") -> None:
181
+ """Prepare for a render pass: reset cursors and the dev-mode hook log.
182
+
183
+ Called by the reconciler before each invocation of the
184
+ component body so the next render reads slots in the same
185
+ order they were written.
114
186
  """
115
187
  self.state_index = 0
116
188
  self.effect_index = 0
189
+ self.layout_effect_index = 0
117
190
  self.memo_index = 0
118
191
  self.ref_index = 0
192
+ self.context_deps = {}
193
+ if component_name:
194
+ self._component_name = component_name
195
+ self._hook_log = [] if diagnostics.is_dev() else None
196
+
197
+ def finish_render(self) -> None:
198
+ """Finalize a successful render: lock in / verify the hook signature.
199
+
200
+ Only called when the component body returned without raising,
201
+ so a failed render never corrupts the signature.
202
+
203
+ Raises:
204
+ HookOrderError: In dev mode, when this render called fewer
205
+ hooks than the previous one.
206
+ """
207
+ log = self._hook_log
208
+ self._hook_log = None
209
+ if log is None:
210
+ return
211
+ if self._hook_signature is None:
212
+ self._hook_signature = log
213
+ return
214
+ if len(log) < len(self._hook_signature):
215
+ missing = self._hook_signature[len(log)]
216
+ raise diagnostics.HookOrderError(
217
+ f"{self._component_name or 'Component'} rendered fewer hooks than the previous "
218
+ f"render (expected {missing!r} at position {len(log) + 1}). Hooks must be called "
219
+ "unconditionally, in the same order, on every render."
220
+ )
221
+
222
+ def record_hook(self, kind: str) -> None:
223
+ """Record a hook call for the dev-mode order guard.
224
+
225
+ Raises:
226
+ HookOrderError: In dev mode, when the hook at this position
227
+ differs from (or extends past) the previous render.
228
+ """
229
+ log = self._hook_log
230
+ if log is None:
231
+ return
232
+ position = len(log)
233
+ log.append(kind)
234
+ signature = self._hook_signature
235
+ if signature is None:
236
+ return
237
+ if position >= len(signature):
238
+ raise diagnostics.HookOrderError(
239
+ f"{self._component_name or 'Component'} rendered more hooks than the previous "
240
+ f"render ({kind!r} at position {position + 1}). Hooks must be called "
241
+ "unconditionally, in the same order, on every render."
242
+ )
243
+ if signature[position] != kind:
244
+ raise diagnostics.HookOrderError(
245
+ f"{self._component_name or 'Component'} called {kind!r} at position "
246
+ f"{position + 1}, but the previous render called {signature[position]!r} there. "
247
+ "Hooks must be called unconditionally, in the same order, on every render."
248
+ )
249
+
250
+ def reset_hook_signature(self) -> None:
251
+ """Forget the recorded hook signature (used by Fast Refresh).
252
+
253
+ After a hot reload swaps in a new component body, the old
254
+ signature no longer applies; the next render records a fresh
255
+ one.
256
+ """
257
+ self._hook_signature = None
258
+
259
+ def flush_layout_effects(self) -> None:
260
+ """Run layout effects queued during render (commit phase, pre-paint)."""
261
+ pending = self._pending_layout_effects
262
+ self._pending_layout_effects = []
263
+ for idx, effect_fn, deps in pending:
264
+ _, prev_cleanup = self.layout_effects[idx]
265
+ if callable(prev_cleanup):
266
+ try:
267
+ prev_cleanup()
268
+ except Exception:
269
+ pass
270
+ cleanup = effect_fn()
271
+ self.layout_effects[idx] = (list(deps) if deps is not None else None, cleanup)
119
272
 
120
273
  def flush_pending_effects(self) -> None:
121
- """Run effects queued during render, after native commit.
274
+ """Run passive effects queued during render, after native commit.
122
275
 
123
276
  For each pending effect, the previous cleanup is invoked first
124
277
  (if any), then the new effect callback. The new return value
@@ -139,10 +292,18 @@ class HookState:
139
292
  def cleanup_all_effects(self) -> None:
140
293
  """Run every outstanding cleanup function, then clear state.
141
294
 
142
- Called when the component instance is unmounted by the
143
- reconciler.
295
+ Layout-effect cleanups run before passive-effect cleanups,
296
+ matching the mount order in reverse. Called when the component
297
+ instance is unmounted by the reconciler.
144
298
  """
145
- for i, (deps, cleanup) in enumerate(self.effects):
299
+ for i, (_deps, cleanup) in enumerate(self.layout_effects):
300
+ if callable(cleanup):
301
+ try:
302
+ cleanup()
303
+ except Exception:
304
+ pass
305
+ self.layout_effects[i] = (_SENTINEL, None)
306
+ for i, (_deps, cleanup) in enumerate(self.effects):
146
307
  if callable(cleanup):
147
308
  try:
148
309
  cleanup()
@@ -150,6 +311,7 @@ class HookState:
150
311
  pass
151
312
  self.effects[i] = (_SENTINEL, None)
152
313
  self._pending_effects = []
314
+ self._pending_layout_effects = []
153
315
 
154
316
 
155
317
  # ======================================================================
@@ -278,13 +440,14 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
278
440
  count, set_count = pn.use_state(0)
279
441
  return pn.Button(
280
442
  f"Count: {count}",
281
- on_click=lambda: set_count(count + 1),
443
+ on_press=lambda: set_count(count + 1),
282
444
  )
283
445
  ```
284
446
  """
285
447
  ctx = _get_hook_state()
286
448
  if ctx is None:
287
449
  raise RuntimeError("use_state must be called inside a @component function")
450
+ ctx.record_hook("use_state")
288
451
 
289
452
  idx = ctx.state_index
290
453
  ctx.state_index += 1
@@ -343,14 +506,15 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
343
506
  def Counter():
344
507
  count, dispatch = pn.use_reducer(reducer, 0)
345
508
  return pn.Row(
346
- pn.Button("+", on_click=lambda: dispatch("increment")),
347
- pn.Button("Reset", on_click=lambda: dispatch("reset")),
509
+ pn.Button("+", on_press=lambda: dispatch("increment")),
510
+ pn.Button("Reset", on_press=lambda: dispatch("reset")),
348
511
  )
349
512
  ```
350
513
  """
351
514
  ctx = _get_hook_state()
352
515
  if ctx is None:
353
516
  raise RuntimeError("use_reducer must be called inside a @component function")
517
+ ctx.record_hook("use_reducer")
354
518
 
355
519
  idx = ctx.state_index
356
520
  ctx.state_index += 1
@@ -416,6 +580,7 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
416
580
  ctx = _get_hook_state()
417
581
  if ctx is None:
418
582
  raise RuntimeError("use_effect must be called inside a @component function")
583
+ ctx.record_hook("use_effect")
419
584
 
420
585
  idx = ctx.effect_index
421
586
  ctx.effect_index += 1
@@ -430,6 +595,61 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
430
595
  ctx._pending_effects.append((idx, effect, deps))
431
596
 
432
597
 
598
+ def use_layout_effect(effect: Callable, deps: Optional[list] = None) -> None:
599
+ """Schedule a side effect that runs synchronously inside the commit.
600
+
601
+ Like [`use_effect`][pythonnative.use_effect], but the callback
602
+ fires *before* passive effects, immediately after native mutations
603
+ and the layout pass are applied. Use it when you need to measure a
604
+ committed frame (via a [`Ref`][pythonnative.Ref]) or issue an
605
+ imperative view command before the user sees the new frame, for
606
+ example scrolling a list into position on mount.
607
+
608
+ Prefer `use_effect` for everything else; layout effects block the
609
+ commit, so heavy work here delays the frame.
610
+
611
+ Args:
612
+ effect: A zero-arg callable invoked during commit. Optionally
613
+ returns a cleanup callable.
614
+ deps: Dependency list, or `None` to run on every render.
615
+
616
+ Raises:
617
+ RuntimeError: If called outside a `@component` function.
618
+
619
+ Example:
620
+ ```python
621
+ import pythonnative as pn
622
+
623
+ @pn.component
624
+ def AutoScrollList(items):
625
+ list_ref = pn.use_ref()
626
+
627
+ def scroll_to_bottom():
628
+ if list_ref.current is not None:
629
+ list_ref.current.scroll_to_end(animated=False)
630
+
631
+ pn.use_layout_effect(scroll_to_bottom, [len(items)])
632
+ return pn.FlatList(items, render_item=Row, ref=list_ref)
633
+ ```
634
+ """
635
+ ctx = _get_hook_state()
636
+ if ctx is None:
637
+ raise RuntimeError("use_layout_effect must be called inside a @component function")
638
+ ctx.record_hook("use_layout_effect")
639
+
640
+ idx = ctx.layout_effect_index
641
+ ctx.layout_effect_index += 1
642
+
643
+ if idx >= len(ctx.layout_effects):
644
+ ctx.layout_effects.append((_SENTINEL, None))
645
+ ctx._pending_layout_effects.append((idx, effect, deps))
646
+ return
647
+
648
+ prev_deps, _prev_cleanup = ctx.layout_effects[idx]
649
+ if _deps_changed(prev_deps, deps):
650
+ ctx._pending_layout_effects.append((idx, effect, deps))
651
+
652
+
433
653
  def use_memo(factory: Callable[[], T], deps: list) -> T:
434
654
  """Return a memoized value that is recomputed only when `deps` change.
435
655
 
@@ -451,6 +671,7 @@ def use_memo(factory: Callable[[], T], deps: list) -> T:
451
671
  ctx = _get_hook_state()
452
672
  if ctx is None:
453
673
  raise RuntimeError("use_memo must be called inside a @component function")
674
+ ctx.record_hook("use_memo")
454
675
 
455
676
  idx = ctx.memo_index
456
677
  ctx.memo_index += 1
@@ -486,23 +707,26 @@ def use_callback(callback: Callable, deps: list) -> Callable:
486
707
  return use_memo(lambda: callback, deps)
487
708
 
488
709
 
489
- def use_ref(initial: Any = None) -> dict:
490
- """Return a mutable ref dict ``{"current": initial}`` that persists across renders.
710
+ def use_ref(initial: Optional[T] = None) -> Ref[T]:
711
+ """Return a [`Ref`][pythonnative.Ref] that persists across renders.
491
712
 
492
713
  Refs are useful for storing values that must survive renders without
493
714
  triggering them: timers, last-seen values, native handles, and so on.
494
715
 
495
- The ``current`` key is also populated by the reconciler with the
716
+ ``ref.current`` is also populated by the reconciler with the
496
717
  underlying native view (`UIView` on iOS, `android.view.View` on
497
- Android) when the ref is passed via the ``ref=`` prop on a built-in
498
- element. This is how ``Animated.View`` obtains a handle to the
499
- native view it animates without going through the reconciler.
718
+ Android, a Tk widget on desktop) when the ref is passed via the
719
+ ``ref=`` prop on a built-in element, and cleared to ``None`` when
720
+ that element unmounts. Composite components such as
721
+ [`FlatList`][pythonnative.FlatList] publish a typed controller
722
+ object instead (see
723
+ [`use_imperative_handle`][pythonnative.use_imperative_handle]).
500
724
 
501
725
  Args:
502
- initial: Value placed at `ref["current"]` on first render.
726
+ initial: Value placed at ``ref.current`` on first render.
503
727
 
504
728
  Returns:
505
- A dict with a single `"current"` key. Mutations to the dict do
729
+ A [`Ref`][pythonnative.Ref]. Mutations to ``ref.current`` do
506
730
  *not* trigger re-renders.
507
731
 
508
732
  Raises:
@@ -511,18 +735,69 @@ def use_ref(initial: Any = None) -> dict:
511
735
  ctx = _get_hook_state()
512
736
  if ctx is None:
513
737
  raise RuntimeError("use_ref must be called inside a @component function")
738
+ ctx.record_hook("use_ref")
514
739
 
515
740
  idx = ctx.ref_index
516
741
  ctx.ref_index += 1
517
742
 
518
743
  if idx >= len(ctx.refs):
519
- ref: dict = {"current": initial}
744
+ ref: Ref[T] = Ref(initial)
520
745
  ctx.refs.append(ref)
521
746
  return ref
522
747
 
523
748
  return ctx.refs[idx]
524
749
 
525
750
 
751
+ def use_imperative_handle(
752
+ ref: Optional[Ref],
753
+ factory: Callable[[], Any],
754
+ deps: Optional[list] = None,
755
+ ) -> None:
756
+ """Publish a controller object on ``ref.current``.
757
+
758
+ The composite-component counterpart to passing ``ref=`` to a
759
+ built-in element. Call it inside a component that accepts a
760
+ ``ref`` prop to expose a curated imperative API (rather than the
761
+ raw native view) to the parent. The handle is installed during the
762
+ commit's layout-effect phase and cleared back to ``None`` on
763
+ unmount.
764
+
765
+ Args:
766
+ ref: The [`Ref`][pythonnative.Ref] received via the component's
767
+ ``ref`` prop. ``None`` is allowed (the parent didn't
768
+ request a handle), in which case this is a no-op.
769
+ factory: Zero-arg callable returning the handle object.
770
+ deps: Dependency list controlling when the handle is rebuilt.
771
+ Defaults to ``[]`` semantics only if you pass ``[]``;
772
+ ``None`` rebuilds on every render, matching effects.
773
+
774
+ Raises:
775
+ RuntimeError: If called outside a `@component` function.
776
+
777
+ Example:
778
+ ```python
779
+ import pythonnative as pn
780
+
781
+ @pn.component
782
+ def VideoPlayer(source, ref=None):
783
+ pn.use_imperative_handle(ref, lambda: PlayerController(...), [source])
784
+ return pn.View(...)
785
+ ```
786
+ """
787
+
788
+ def _install() -> Optional[Callable[[], None]]:
789
+ if ref is None:
790
+ return None
791
+ ref.current = factory()
792
+
793
+ def _clear() -> None:
794
+ ref.current = None
795
+
796
+ return _clear
797
+
798
+ use_layout_effect(_install, deps)
799
+
800
+
526
801
  # ======================================================================
527
802
  # Async hooks
528
803
  # ======================================================================
@@ -570,6 +845,22 @@ def use_async_effect(
570
845
  def _sync_effect() -> Callable[[], None]:
571
846
  future = run_async(effect())
572
847
 
848
+ def _observe(fut: Any) -> None:
849
+ # Surface unhandled async-effect crashes instead of letting
850
+ # the future's exception vanish unobserved: RedBox in dev
851
+ # mode, traceback in production.
852
+ if fut.cancelled():
853
+ return
854
+ exc = fut.exception()
855
+ if exc is None or isinstance(exc, asyncio.CancelledError):
856
+ return
857
+ if not diagnostics.report_error(exc, phase="async effect"):
858
+ import traceback
859
+
860
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
861
+
862
+ future.add_done_callback(_observe)
863
+
573
864
  def _cancel() -> None:
574
865
  future.cancel()
575
866
 
@@ -704,7 +995,7 @@ class MutationCall(Generic[T]):
704
995
  Example:
705
996
  ```python
706
997
  # Fire-and-forget:
707
- save_button.on_click = lambda: mutate(post)
998
+ save_button.on_press = lambda: mutate(post)
708
999
 
709
1000
  # Or await for the result:
710
1001
  async def submit():
@@ -760,7 +1051,7 @@ def use_mutation(
760
1051
  state, save = pn.use_mutation(api.create_post)
761
1052
 
762
1053
  return pn.Column(
763
- pn.Button("Save", on_click=lambda: save(post)),
1054
+ pn.Button("Save", on_press=lambda: save(post)),
764
1055
  pn.Text("Saving…") if state.loading else pn.Text(""),
765
1056
  pn.Text(str(state.error)) if state.error else pn.Text(""),
766
1057
  )
@@ -952,6 +1243,10 @@ class Context:
952
1243
  via [`use_context`][pythonnative.use_context]. Use
953
1244
  [`Provider`][pythonnative.Provider] to set the value for a subtree.
954
1245
 
1246
+ Context is *reactive*: when a Provider's value changes, every
1247
+ component that read the context on its last render re-renders,
1248
+ even if a memoized ancestor skipped its own re-render.
1249
+
955
1250
  Attributes:
956
1251
  default: The value returned when no `Provider` ancestor exists.
957
1252
  """
@@ -989,6 +1284,9 @@ def use_context(context: Context) -> Any:
989
1284
  """Read the current value of `context` from the nearest `Provider`.
990
1285
 
991
1286
  If no enclosing `Provider` exists, returns the context's default.
1287
+ The component is registered as a subscriber: when the nearest
1288
+ Provider's value changes, the component re-renders even if a
1289
+ memoized ancestor skipped.
992
1290
 
993
1291
  Args:
994
1292
  context: The `Context` to read from.
@@ -1002,7 +1300,10 @@ def use_context(context: Context) -> Any:
1002
1300
  ctx = _get_hook_state()
1003
1301
  if ctx is None:
1004
1302
  raise RuntimeError("use_context must be called inside a @component function")
1005
- return context._current()
1303
+ ctx.record_hook("use_context")
1304
+ value = context._current()
1305
+ ctx.context_deps[id(context)] = value
1306
+ return value
1006
1307
 
1007
1308
 
1008
1309
  # ======================================================================
@@ -1013,10 +1314,14 @@ def use_context(context: Context) -> Any:
1013
1314
  def Provider(context: "Context", value: Any, *children: Element) -> Element:
1014
1315
  """Provide ``value`` for ``context`` to all descendants of ``children``.
1015
1316
 
1016
- Accepts any number of children (varargs). Multiple children are
1017
- grouped under an internal [`Fragment`][pythonnative.Fragment] so
1018
- they all share the same provided value without an extra wrapping
1019
- native view.
1317
+ Accepts any number of children (varargs). A Provider contributes no
1318
+ native view of its own; its children mount directly into the
1319
+ surrounding native parent.
1320
+
1321
+ When ``value`` differs from the previous render (identity, then
1322
+ ``==``), every descendant that read the context via
1323
+ [`use_context`][pythonnative.use_context] re-renders, including
1324
+ descendants of memoized components that skipped.
1020
1325
 
1021
1326
  Args:
1022
1327
  context: The [`Context`][pythonnative.hooks.Context] to set.
@@ -1044,13 +1349,7 @@ def Provider(context: "Context", value: Any, *children: Element) -> Element:
1044
1349
  )
1045
1350
  ```
1046
1351
  """
1047
- if not children:
1048
- kids: List[Element] = []
1049
- elif len(children) == 1:
1050
- kids = [children[0]]
1051
- else:
1052
- kids = [Element("__Fragment__", {}, list(children))]
1053
- return Element("__Provider__", {"__context__": context, "__value__": value}, kids)
1352
+ return Element("__Provider__", {"__context__": context, "__value__": value}, list(children))
1054
1353
 
1055
1354
 
1056
1355
  def memo(component_fn: Callable[..., Element]) -> Callable[..., Element]:
@@ -1123,7 +1422,7 @@ class NavigationHandle:
1123
1422
  nav = pn.use_navigation()
1124
1423
  return pn.Button(
1125
1424
  "Open Detail",
1126
- on_click=lambda: nav.navigate("Detail", {"id": 42}),
1425
+ on_press=lambda: nav.navigate("Detail", {"id": 42}),
1127
1426
  )
1128
1427
  ```
1129
1428
  """
@@ -1180,6 +1479,71 @@ def use_navigation() -> NavigationHandle:
1180
1479
  return handle
1181
1480
 
1182
1481
 
1482
+ def use_back_handler(handler: Callable[[], bool]) -> None:
1483
+ """Intercept the system back action for this screen.
1484
+
1485
+ On Android this handles the hardware back button and predictive
1486
+ back gesture; in the desktop preview it handles the Escape key.
1487
+ iOS has no system back button, so the handler never fires there
1488
+ (swipe-back is controlled by the navigation stack instead).
1489
+
1490
+ Handlers registered later run first, so a component mounted on top
1491
+ of existing content (a modal, a confirmation sheet) takes priority
1492
+ over handlers that were already mounted. Return ``True`` to consume
1493
+ the event and stop both remaining handlers and the platform's
1494
+ default behavior (popping the screen); return ``False`` to pass it
1495
+ along.
1496
+
1497
+ The latest ``handler`` closure from the most recent render is
1498
+ always the one invoked; registration order is fixed at mount, so
1499
+ re-renders never change priority.
1500
+
1501
+ Args:
1502
+ handler: Zero-arg callable returning ``True`` if it consumed
1503
+ the back action.
1504
+
1505
+ Raises:
1506
+ RuntimeError: If called outside a `@component` function.
1507
+
1508
+ Example:
1509
+ ```python
1510
+ import pythonnative as pn
1511
+
1512
+ @pn.component
1513
+ def Editor():
1514
+ dirty, set_dirty = pn.use_state(False)
1515
+ pn.use_back_handler(lambda: dirty) # block back while dirty
1516
+ ...
1517
+ ```
1518
+ """
1519
+ ctx = _get_hook_state()
1520
+ if ctx is None:
1521
+ raise RuntimeError("use_back_handler must be called inside a @component function")
1522
+
1523
+ latest: Ref[Callable[[], bool]] = use_ref(handler)
1524
+ latest.current = handler
1525
+
1526
+ def _register() -> Optional[Callable[[], None]]:
1527
+ reconciler = ctx._reconciler
1528
+ if reconciler is None or not hasattr(reconciler, "register_back_handler"):
1529
+ return None
1530
+
1531
+ def _trampoline() -> bool:
1532
+ fn = latest.current
1533
+ if fn is None:
1534
+ return False
1535
+ try:
1536
+ return bool(fn())
1537
+ except Exception as exc:
1538
+ if not diagnostics.report_error(exc, phase="back handler"):
1539
+ raise
1540
+ return True
1541
+
1542
+ return reconciler.register_back_handler(_trampoline)
1543
+
1544
+ use_effect(_register, [])
1545
+
1546
+
1183
1547
  # ======================================================================
1184
1548
  # @component decorator
1185
1549
  # ======================================================================