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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pythonnative/hooks.py CHANGED
@@ -4,12 +4,26 @@ 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.
8
-
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.
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.
10
+
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. An
19
+ effect may be an ``async def``; it runs as a task on the framework
20
+ loop and is cancelled when its dependencies change or the component
21
+ unmounts.
22
+
23
+ The current hook state travels in a :mod:`contextvars` context rather
24
+ than a plain global, so ``async def`` component bodies keep their hook
25
+ identity across ``await`` boundaries even when several coroutine
26
+ renders interleave on the event loop.
13
27
 
14
28
  Example:
15
29
  ```python
@@ -20,27 +34,83 @@ Example:
20
34
  count, set_count = pn.use_state(initial)
21
35
  return pn.Column(
22
36
  pn.Text(f"Count: {count}"),
23
- pn.Button("+", on_click=lambda: set_count(count + 1)),
37
+ pn.Button("+", on_press=lambda: set_count(count + 1)),
24
38
  )
25
39
  ```
26
40
  """
27
41
 
28
42
  import asyncio
29
43
  import inspect
30
- import threading
31
44
  from contextlib import contextmanager
45
+ from contextvars import ContextVar, Token
32
46
  from dataclasses import dataclass, field, replace
33
47
  from typing import Any, Awaitable, Callable, Dict, Generator, Generic, List, Optional, Tuple, TypeVar
34
48
 
49
+ from . import diagnostics
35
50
  from .element import Element
51
+ from .suspense import CoroDriver, Resource
36
52
 
37
53
  T = TypeVar("T")
38
54
 
39
55
  _SENTINEL = object()
40
56
 
41
- _hook_context: threading.local = threading.local()
57
+ # The component whose body is currently executing. A ContextVar (not a
58
+ # global or thread-local) so coroutine component bodies resume with the
59
+ # right hook state after every ``await``, no matter how renders
60
+ # interleave on the loop.
61
+ _hook_context: "ContextVar[Optional[HookState]]" = ContextVar("pn_hook_state", default=None)
62
+
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)
67
+
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)
72
+
73
+
74
+ # ======================================================================
75
+ # Ref
76
+ # ======================================================================
77
+
78
+
79
+ class Ref(Generic[T]):
80
+ """Mutable container returned by [`use_ref`][pythonnative.use_ref].
81
+
82
+ A ``Ref`` holds one value on its ``current`` attribute. Mutating
83
+ ``current`` never triggers a re-render, which makes refs the right
84
+ place for timers, last-seen values, and imperative handles.
85
+
86
+ When a ``Ref`` is passed to a built-in element via the ``ref=``
87
+ prop, the reconciler populates ``current`` with the underlying
88
+ native view (``UIView`` on iOS, ``android.view.View`` on Android,
89
+ a Tk widget on desktop) after commit, and clears it back to
90
+ ``None`` on unmount. Composite components (e.g.
91
+ [`FlatList`][pythonnative.FlatList]) instead publish a typed
92
+ controller object on ``current`` via
93
+ [`use_imperative_handle`][pythonnative.use_imperative_handle].
94
+
95
+ Attributes:
96
+ current: The referenced value. ``None`` until populated.
97
+ """
98
+
99
+ __slots__ = ("current", "_pn_tag", "_pn_frame")
100
+
101
+ def __init__(self, initial: Optional[T] = None) -> None:
102
+ self.current: Optional[T] = initial
103
+ # Internal: the native view tag, populated by the reconciler
104
+ # when the ref is attached to a built-in element.
105
+ self._pn_tag: Optional[int] = None
106
+ # Internal: the last committed frame ``(x, y, w, h)``, mirrored
107
+ # by the layout pass so Python code can read measured geometry
108
+ # without a native round-trip.
109
+ self._pn_frame: Optional[Tuple[float, float, float, float]] = None
110
+
111
+ def __repr__(self) -> str:
112
+ return f"Ref({self.current!r})"
42
113
 
43
- _batch_context: threading.local = threading.local()
44
114
 
45
115
  # ======================================================================
46
116
  # Hook state container
@@ -53,44 +123,69 @@ class HookState:
53
123
  Each `@component` instance owns one `HookState`. Hooks are matched
54
124
  to slots by call order, so they must always be called in the same
55
125
  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.
126
+ (layout effects into ``_pending_layout_effects``, passive effects
127
+ into ``_pending_effects``) and flushed by the reconciler in two
128
+ phases after native mutations commit.
59
129
 
60
130
  Attributes:
61
131
  states: One entry per `use_state` / `use_reducer` call.
62
132
  effects: One `(deps, cleanup)` tuple per `use_effect` call.
133
+ layout_effects: One `(deps, cleanup)` tuple per
134
+ `use_layout_effect` call.
63
135
  memos: One `(deps, value)` tuple per `use_memo` / `use_callback`.
64
- refs: One mutable dict per `use_ref` call.
136
+ refs: One [`Ref`][pythonnative.Ref] per `use_ref` call.
65
137
  """
66
138
 
67
139
  __slots__ = (
68
140
  "states",
69
141
  "effects",
142
+ "layout_effects",
70
143
  "memos",
71
144
  "refs",
145
+ "resources",
72
146
  "state_index",
73
147
  "effect_index",
148
+ "layout_effect_index",
74
149
  "memo_index",
75
150
  "ref_index",
151
+ "resource_index",
152
+ "context_deps",
76
153
  "_trigger_render",
77
154
  "_pending_effects",
155
+ "_pending_layout_effects",
156
+ "_pending_effects_mark",
157
+ "_pending_layout_effects_mark",
78
158
  "_dirty",
79
159
  "_vnode",
80
160
  "_reconciler",
161
+ "_hook_log",
162
+ "_hook_signature",
163
+ "_component_name",
164
+ "_async_driver",
81
165
  )
82
166
 
83
167
  def __init__(self) -> None:
84
168
  self.states: List[Any] = []
85
169
  self.effects: List[Tuple[Any, Any]] = []
170
+ self.layout_effects: List[Tuple[Any, Any]] = []
86
171
  self.memos: List[Tuple[Any, Any]] = []
87
- self.refs: List[dict] = []
172
+ self.refs: List[Ref] = []
173
+ # One ``(deps, Resource)`` per ``use_resource`` call.
174
+ self.resources: List[Tuple[Any, Resource]] = []
88
175
  self.state_index: int = 0
89
176
  self.effect_index: int = 0
177
+ self.layout_effect_index: int = 0
90
178
  self.memo_index: int = 0
91
179
  self.ref_index: int = 0
180
+ self.resource_index: int = 0
181
+ # Contexts read during the last completed render, keyed by
182
+ # ``id(context)``. The reconciler consults this when a
183
+ # Provider's value changes so consumers re-render even when a
184
+ # memoized ancestor skipped (reactive context).
185
+ self.context_deps: Dict[int, Any] = {}
92
186
  self._trigger_render: Optional[Callable[[], None]] = None
93
187
  self._pending_effects: List[Tuple[int, Callable, Any]] = []
188
+ self._pending_layout_effects: List[Tuple[int, Callable, Any]] = []
94
189
  # Cleared by the reconciler after each successful render.
95
190
  # ``use_state`` / ``use_reducer`` setters flip it to ``True``
96
191
  # whenever they actually mutate state, so [`memo`][pythonnative.memo]
@@ -104,28 +199,140 @@ class HookState:
104
199
  # again when it unmounts.
105
200
  self._vnode: Any = None
106
201
  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.
202
+ # Dev-mode hook-order guard: the sequence of hook kinds called
203
+ # during the in-flight render, and the signature captured from
204
+ # the first successful render.
205
+ self._hook_log: Optional[List[str]] = None
206
+ self._hook_signature: Optional[List[str]] = None
207
+ self._component_name: str = ""
208
+ # For ``async def`` components: the CoroDriver running the
209
+ # in-flight body, cancelled when a newer render supersedes it.
210
+ self._async_driver: Optional[CoroDriver] = None
211
+ # Effect-queue lengths at ``begin_render``, so a suspended
212
+ # render can be rolled back without double-queueing effects.
213
+ self._pending_effects_mark: int = 0
214
+ self._pending_layout_effects_mark: int = 0
215
+
216
+ def begin_render(self, component_name: str = "") -> None:
217
+ """Prepare for a render pass: reset cursors and the dev-mode hook log.
218
+
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.
114
222
  """
115
223
  self.state_index = 0
116
224
  self.effect_index = 0
225
+ self.layout_effect_index = 0
117
226
  self.memo_index = 0
118
227
  self.ref_index = 0
228
+ self.resource_index = 0
229
+ self.context_deps = {}
230
+ if component_name:
231
+ self._component_name = component_name
232
+ self._hook_log = [] if diagnostics.is_dev() else None
233
+ self._pending_effects_mark = len(self._pending_effects)
234
+ self._pending_layout_effects_mark = len(self._pending_layout_effects)
235
+
236
+ def abort_render(self) -> None:
237
+ """Roll back a suspended render's effect queue.
238
+
239
+ A suspended body re-runs from the top on retry, so any effects
240
+ it queued before suspending would otherwise be queued twice.
241
+ """
242
+ del self._pending_effects[self._pending_effects_mark :]
243
+ del self._pending_layout_effects[self._pending_layout_effects_mark :]
244
+ self._hook_log = None
245
+
246
+ def finish_render(self) -> None:
247
+ """Finalize a successful render: lock in / verify the hook signature.
248
+
249
+ Only called when the component body returned without raising,
250
+ so a failed render never corrupts the signature.
251
+
252
+ Raises:
253
+ HookOrderError: In dev mode, when this render called fewer
254
+ hooks than the previous one.
255
+ """
256
+ log = self._hook_log
257
+ self._hook_log = None
258
+ if log is None:
259
+ return
260
+ if self._hook_signature is None:
261
+ self._hook_signature = log
262
+ return
263
+ if len(log) < len(self._hook_signature):
264
+ missing = self._hook_signature[len(log)]
265
+ raise diagnostics.HookOrderError(
266
+ f"{self._component_name or 'Component'} rendered fewer hooks than the previous "
267
+ f"render (expected {missing!r} at position {len(log) + 1}). Hooks must be called "
268
+ "unconditionally, in the same order, on every render."
269
+ )
270
+
271
+ def record_hook(self, kind: str) -> None:
272
+ """Record a hook call for the dev-mode order guard.
273
+
274
+ Raises:
275
+ HookOrderError: In dev mode, when the hook at this position
276
+ differs from (or extends past) the previous render.
277
+ """
278
+ log = self._hook_log
279
+ if log is None:
280
+ return
281
+ position = len(log)
282
+ log.append(kind)
283
+ signature = self._hook_signature
284
+ if signature is None:
285
+ return
286
+ if position >= len(signature):
287
+ raise diagnostics.HookOrderError(
288
+ f"{self._component_name or 'Component'} rendered more hooks than the previous "
289
+ f"render ({kind!r} at position {position + 1}). Hooks must be called "
290
+ "unconditionally, in the same order, on every render."
291
+ )
292
+ if signature[position] != kind:
293
+ raise diagnostics.HookOrderError(
294
+ f"{self._component_name or 'Component'} called {kind!r} at position "
295
+ f"{position + 1}, but the previous render called {signature[position]!r} there. "
296
+ "Hooks must be called unconditionally, in the same order, on every render."
297
+ )
298
+
299
+ 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
+ """
306
+ self._hook_signature = None
307
+
308
+ def flush_layout_effects(self) -> None:
309
+ """Run layout effects queued during render (commit phase, pre-paint)."""
310
+ pending = self._pending_layout_effects
311
+ self._pending_layout_effects = []
312
+ self._pending_layout_effects_mark = 0
313
+ for idx, effect_fn, deps in pending:
314
+ _, prev_cleanup = self.layout_effects[idx]
315
+ if callable(prev_cleanup):
316
+ try:
317
+ prev_cleanup()
318
+ except Exception:
319
+ pass
320
+ cleanup = _activate_effect(effect_fn)
321
+ self.layout_effects[idx] = (list(deps) if deps is not None else None, cleanup)
119
322
 
120
323
  def flush_pending_effects(self) -> None:
121
- """Run effects queued during render, after native commit.
324
+ """Run passive effects queued during render, after native commit.
122
325
 
123
326
  For each pending effect, the previous cleanup is invoked first
124
327
  (if any), then the new effect callback. The new return value
125
- becomes the next cleanup.
328
+ becomes the next cleanup. Effects that are ``async def`` (or
329
+ that return an awaitable) run as tasks on the framework loop;
330
+ their cleanup cancels the task, and a callable returned by the
331
+ coroutine runs as an additional cleanup once it completed.
126
332
  """
127
333
  pending = self._pending_effects
128
334
  self._pending_effects = []
335
+ self._pending_effects_mark = 0
129
336
  for idx, effect_fn, deps in pending:
130
337
  _, prev_cleanup = self.effects[idx]
131
338
  if callable(prev_cleanup):
@@ -133,16 +340,25 @@ class HookState:
133
340
  prev_cleanup()
134
341
  except Exception:
135
342
  pass
136
- cleanup = effect_fn()
343
+ cleanup = _activate_effect(effect_fn)
137
344
  self.effects[idx] = (list(deps) if deps is not None else None, cleanup)
138
345
 
139
346
  def cleanup_all_effects(self) -> None:
140
347
  """Run every outstanding cleanup function, then clear state.
141
348
 
142
- Called when the component instance is unmounted by the
143
- reconciler.
349
+ Layout-effect cleanups run before passive-effect cleanups,
350
+ matching the mount order in reverse. Also cancels in-flight
351
+ resources and any pending ``async def`` body. Called when the
352
+ component instance is unmounted by the reconciler.
144
353
  """
145
- for i, (deps, cleanup) in enumerate(self.effects):
354
+ for i, (_deps, cleanup) in enumerate(self.layout_effects):
355
+ if callable(cleanup):
356
+ try:
357
+ cleanup()
358
+ except Exception:
359
+ pass
360
+ self.layout_effects[i] = (_SENTINEL, None)
361
+ for i, (_deps, cleanup) in enumerate(self.effects):
146
362
  if callable(cleanup):
147
363
  try:
148
364
  cleanup()
@@ -150,21 +366,39 @@ class HookState:
150
366
  pass
151
367
  self.effects[i] = (_SENTINEL, None)
152
368
  self._pending_effects = []
369
+ self._pending_layout_effects = []
370
+ self._pending_effects_mark = 0
371
+ self._pending_layout_effects_mark = 0
372
+ for _deps, resource in self.resources:
373
+ try:
374
+ resource.cancel()
375
+ except Exception:
376
+ pass
377
+ self.resources = []
378
+ driver = self._async_driver
379
+ self._async_driver = None
380
+ if driver is not None:
381
+ driver.cancel()
153
382
 
154
383
 
155
384
  # ======================================================================
156
- # Thread-local context helpers
385
+ # Context helpers
157
386
  # ======================================================================
158
387
 
159
388
 
160
389
  def _get_hook_state() -> Optional[HookState]:
161
390
  """Return the active `HookState`, or `None` if no render is in flight."""
162
- return getattr(_hook_context, "current", None)
391
+ return _hook_context.get()
163
392
 
164
393
 
165
- def _set_hook_state(state: Optional[HookState]) -> None:
166
- """Install `state` as the active `HookState` for the current thread."""
167
- _hook_context.current = state
394
+ def _set_hook_state(state: Optional[HookState]) -> "Token[Optional[HookState]]":
395
+ """Install `state` as the active `HookState`; returns the reset token."""
396
+ return _hook_context.set(state)
397
+
398
+
399
+ def _reset_hook_state(token: "Token[Optional[HookState]]") -> None:
400
+ """Restore the hook state that was active before `_set_hook_state`."""
401
+ _hook_context.reset(token)
168
402
 
169
403
 
170
404
  def _deps_changed(prev: Any, current: Any) -> bool:
@@ -178,19 +412,126 @@ def _deps_changed(prev: Any, current: Any) -> bool:
178
412
  return any(p is not c and p != c for p, c in zip(prev, current))
179
413
 
180
414
 
415
+ def _activate_effect(effect_fn: Callable) -> Any:
416
+ """Invoke an effect callback, running coroutine effects as tasks.
417
+
418
+ Synchronous effects return their cleanup directly. When the effect
419
+ is an ``async def`` (or returns an awaitable), the coroutine runs
420
+ as a task on the framework loop and the returned cleanup cancels
421
+ it; if the coroutine already finished and returned a callable, that
422
+ callable runs as the cleanup instead.
423
+ """
424
+ result = effect_fn()
425
+ if not inspect.isawaitable(result):
426
+ return result
427
+
428
+ from .runtime import run_async
429
+
430
+ future = run_async(result)
431
+
432
+ def _observe(fut: Any) -> None:
433
+ # Surface unhandled async-effect crashes instead of letting the
434
+ # future's exception vanish unobserved: RedBox in dev mode,
435
+ # traceback in production.
436
+ if fut.cancelled():
437
+ return
438
+ exc = fut.exception()
439
+ if exc is None or isinstance(exc, asyncio.CancelledError):
440
+ return
441
+ if not diagnostics.report_error(exc, phase="async effect"):
442
+ import traceback
443
+
444
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
445
+
446
+ future.add_done_callback(_observe)
447
+
448
+ def _cleanup() -> None:
449
+ if future.cancelled():
450
+ return
451
+ if future.done():
452
+ if future.exception() is None:
453
+ returned = future.result()
454
+ if callable(returned):
455
+ try:
456
+ returned()
457
+ except Exception:
458
+ pass
459
+ return
460
+ future.cancel()
461
+
462
+ return _cleanup
463
+
464
+
181
465
  # ======================================================================
182
- # Batching helpers
466
+ # Batching and transition scheduling
183
467
  # ======================================================================
184
468
 
185
469
 
186
470
  def _schedule_trigger(trigger: Callable[[], None]) -> None:
187
471
  """Run ``trigger`` immediately, or defer it inside a `batch_updates` block."""
188
- if getattr(_batch_context, "depth", 0) > 0:
189
- _batch_context.pending_trigger = trigger
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)
190
476
  else:
191
477
  trigger()
192
478
 
193
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
+
194
535
  def _notify_state_changed(ctx: "HookState") -> None:
195
536
  """Mark ``ctx``'s component dirty and schedule a render after a state change.
196
537
 
@@ -199,7 +540,9 @@ def _notify_state_changed(ctx: "HookState") -> None:
199
540
  ``flush_dirty``, which re-renders only the components marked here
200
541
  rather than the whole app. The dirty mark is eager (so several
201
542
  setters coalesce), while the render trigger respects
202
- [`batch_updates`][pythonnative.batch_updates].
543
+ [`batch_updates`][pythonnative.batch_updates] and defers to a later
544
+ loop turn inside a transition (see
545
+ [`use_transition`][pythonnative.use_transition]).
203
546
  """
204
547
  ctx._dirty = True
205
548
  reconciler = ctx._reconciler
@@ -207,7 +550,10 @@ def _notify_state_changed(ctx: "HookState") -> None:
207
550
  if reconciler is not None and vnode is not None:
208
551
  reconciler.mark_dirty(vnode)
209
552
  if ctx._trigger_render:
210
- _schedule_trigger(ctx._trigger_render)
553
+ if _transition_var.get():
554
+ _defer_transition_trigger(ctx._trigger_render)
555
+ else:
556
+ _schedule_trigger(ctx._trigger_render)
211
557
 
212
558
 
213
559
  @contextmanager
@@ -231,18 +577,19 @@ def batch_updates() -> Generator[None, None, None]:
231
577
  set_name("hello")
232
578
  ```
233
579
  """
234
- depth = getattr(_batch_context, "depth", 0)
235
- _batch_context.depth = depth + 1
580
+ depth = _batch_depth.get()
581
+ depth_token = _batch_depth.set(depth + 1)
582
+ pending_token = None
236
583
  if depth == 0:
237
- _batch_context.pending_trigger = None
584
+ pending_token = _batch_pending.set([])
238
585
  try:
239
586
  yield
240
587
  finally:
241
- _batch_context.depth -= 1
242
- if _batch_context.depth == 0:
243
- trigger = _batch_context.pending_trigger
244
- _batch_context.pending_trigger = None
245
- if trigger is not None:
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:
246
593
  trigger()
247
594
 
248
595
 
@@ -278,13 +625,14 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
278
625
  count, set_count = pn.use_state(0)
279
626
  return pn.Button(
280
627
  f"Count: {count}",
281
- on_click=lambda: set_count(count + 1),
628
+ on_press=lambda: set_count(count + 1),
282
629
  )
283
630
  ```
284
631
  """
285
632
  ctx = _get_hook_state()
286
633
  if ctx is None:
287
634
  raise RuntimeError("use_state must be called inside a @component function")
635
+ ctx.record_hook("use_state")
288
636
 
289
637
  idx = ctx.state_index
290
638
  ctx.state_index += 1
@@ -343,14 +691,15 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
343
691
  def Counter():
344
692
  count, dispatch = pn.use_reducer(reducer, 0)
345
693
  return pn.Row(
346
- pn.Button("+", on_click=lambda: dispatch("increment")),
347
- pn.Button("Reset", on_click=lambda: dispatch("reset")),
694
+ pn.Button("+", on_press=lambda: dispatch("increment")),
695
+ pn.Button("Reset", on_press=lambda: dispatch("reset")),
348
696
  )
349
697
  ```
350
698
  """
351
699
  ctx = _get_hook_state()
352
700
  if ctx is None:
353
701
  raise RuntimeError("use_reducer must be called inside a @component function")
702
+ ctx.record_hook("use_reducer")
354
703
 
355
704
  idx = ctx.state_index
356
705
  ctx.state_index += 1
@@ -384,12 +733,20 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
384
733
  - `[]`: mount only.
385
734
  - `[a, b]`: when `a` or `b` change (compared by identity, then `==`).
386
735
 
387
- `effect` may return a cleanup callable; the previous cleanup runs
388
- before the next effect (and on unmount).
736
+ A synchronous `effect` may return a cleanup callable; the previous
737
+ cleanup runs before the next effect (and on unmount).
738
+
739
+ An **async** `effect` (an ``async def``) runs as a task on the
740
+ framework loop. When `deps` change or the component unmounts, the
741
+ in-flight task is cancelled (:class:`asyncio.CancelledError` is
742
+ raised at its current ``await``), giving async effects structured
743
+ cancellation for free. If the coroutine finishes and returns a
744
+ callable, that callable runs as the cleanup instead.
389
745
 
390
746
  Args:
391
- effect: A zero-arg callable invoked after commit. Optionally
392
- returns a cleanup callable.
747
+ effect: A zero-arg callable invoked after commit: either a
748
+ synchronous function (optionally returning a cleanup
749
+ callable) or an ``async def``.
393
750
  deps: Dependency list, or `None` to run on every render.
394
751
 
395
752
  Raises:
@@ -397,25 +754,27 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
397
754
 
398
755
  Example:
399
756
  ```python
757
+ import asyncio
758
+
400
759
  import pythonnative as pn
401
760
 
402
761
  @pn.component
403
- def Timer():
404
- seconds, set_seconds = pn.use_state(0)
762
+ def Clock():
763
+ now, set_now = pn.use_state("")
405
764
 
406
- def tick():
407
- import threading
408
- t = threading.Timer(1.0, lambda: set_seconds(seconds + 1))
409
- t.start()
410
- return t.cancel
765
+ async def tick():
766
+ while True:
767
+ set_now(time.strftime("%H:%M:%S"))
768
+ await asyncio.sleep(1)
411
769
 
412
- pn.use_effect(tick, [seconds])
413
- return pn.Text(f"Elapsed: {seconds}s")
770
+ pn.use_effect(tick, [])
771
+ return pn.Text(now)
414
772
  ```
415
773
  """
416
774
  ctx = _get_hook_state()
417
775
  if ctx is None:
418
776
  raise RuntimeError("use_effect must be called inside a @component function")
777
+ ctx.record_hook("use_effect")
419
778
 
420
779
  idx = ctx.effect_index
421
780
  ctx.effect_index += 1
@@ -430,6 +789,61 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
430
789
  ctx._pending_effects.append((idx, effect, deps))
431
790
 
432
791
 
792
+ def use_layout_effect(effect: Callable, deps: Optional[list] = None) -> None:
793
+ """Schedule a side effect that runs synchronously inside the commit.
794
+
795
+ Like [`use_effect`][pythonnative.use_effect], but the callback
796
+ fires *before* passive effects, immediately after native mutations
797
+ and the layout pass are applied. Use it when you need to measure a
798
+ committed frame (via a [`Ref`][pythonnative.Ref]) or issue an
799
+ imperative view command before the user sees the new frame, for
800
+ example scrolling a list into position on mount.
801
+
802
+ Prefer `use_effect` for everything else; layout effects block the
803
+ commit, so heavy work here delays the frame.
804
+
805
+ Args:
806
+ effect: A zero-arg callable invoked during commit. Optionally
807
+ returns a cleanup callable.
808
+ deps: Dependency list, or `None` to run on every render.
809
+
810
+ 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
+ ```
828
+ """
829
+ ctx = _get_hook_state()
830
+ if ctx is None:
831
+ raise RuntimeError("use_layout_effect must be called inside a @component function")
832
+ ctx.record_hook("use_layout_effect")
833
+
834
+ idx = ctx.layout_effect_index
835
+ ctx.layout_effect_index += 1
836
+
837
+ if idx >= len(ctx.layout_effects):
838
+ ctx.layout_effects.append((_SENTINEL, None))
839
+ ctx._pending_layout_effects.append((idx, effect, deps))
840
+ return
841
+
842
+ prev_deps, _prev_cleanup = ctx.layout_effects[idx]
843
+ if _deps_changed(prev_deps, deps):
844
+ ctx._pending_layout_effects.append((idx, effect, deps))
845
+
846
+
433
847
  def use_memo(factory: Callable[[], T], deps: list) -> T:
434
848
  """Return a memoized value that is recomputed only when `deps` change.
435
849
 
@@ -451,6 +865,7 @@ def use_memo(factory: Callable[[], T], deps: list) -> T:
451
865
  ctx = _get_hook_state()
452
866
  if ctx is None:
453
867
  raise RuntimeError("use_memo must be called inside a @component function")
868
+ ctx.record_hook("use_memo")
454
869
 
455
870
  idx = ctx.memo_index
456
871
  ctx.memo_index += 1
@@ -486,23 +901,26 @@ def use_callback(callback: Callable, deps: list) -> Callable:
486
901
  return use_memo(lambda: callback, deps)
487
902
 
488
903
 
489
- def use_ref(initial: Any = None) -> dict:
490
- """Return a mutable ref dict ``{"current": initial}`` that persists across renders.
904
+ def use_ref(initial: Optional[T] = None) -> Ref[T]:
905
+ """Return a [`Ref`][pythonnative.Ref] that persists across renders.
491
906
 
492
907
  Refs are useful for storing values that must survive renders without
493
908
  triggering them: timers, last-seen values, native handles, and so on.
494
909
 
495
- The ``current`` key is also populated by the reconciler with the
910
+ ``ref.current`` is also populated by the reconciler with the
496
911
  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.
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
915
+ [`FlatList`][pythonnative.FlatList] publish a typed controller
916
+ object instead (see
917
+ [`use_imperative_handle`][pythonnative.use_imperative_handle]).
500
918
 
501
919
  Args:
502
- initial: Value placed at `ref["current"]` on first render.
920
+ initial: Value placed at ``ref.current`` on first render.
503
921
 
504
922
  Returns:
505
- A dict with a single `"current"` key. Mutations to the dict do
923
+ A [`Ref`][pythonnative.Ref]. Mutations to ``ref.current`` do
506
924
  *not* trigger re-renders.
507
925
 
508
926
  Raises:
@@ -511,40 +929,153 @@ def use_ref(initial: Any = None) -> dict:
511
929
  ctx = _get_hook_state()
512
930
  if ctx is None:
513
931
  raise RuntimeError("use_ref must be called inside a @component function")
932
+ ctx.record_hook("use_ref")
514
933
 
515
934
  idx = ctx.ref_index
516
935
  ctx.ref_index += 1
517
936
 
518
937
  if idx >= len(ctx.refs):
519
- ref: dict = {"current": initial}
938
+ ref: Ref[T] = Ref(initial)
520
939
  ctx.refs.append(ref)
521
940
  return ref
522
941
 
523
942
  return ctx.refs[idx]
524
943
 
525
944
 
945
+ def use_imperative_handle(
946
+ ref: Optional[Ref],
947
+ factory: Callable[[], Any],
948
+ deps: Optional[list] = None,
949
+ ) -> None:
950
+ """Publish a controller object on ``ref.current``.
951
+
952
+ The composite-component counterpart to passing ``ref=`` to a
953
+ built-in element. Call it inside a component that accepts a
954
+ ``ref`` prop to expose a curated imperative API (rather than the
955
+ raw native view) to the parent. The handle is installed during the
956
+ commit's layout-effect phase and cleared back to ``None`` on
957
+ unmount.
958
+
959
+ Args:
960
+ ref: The [`Ref`][pythonnative.Ref] received via the component's
961
+ ``ref`` prop. ``None`` is allowed (the parent didn't
962
+ request a handle), in which case this is a no-op.
963
+ factory: Zero-arg callable returning the handle object.
964
+ deps: Dependency list controlling when the handle is rebuilt.
965
+ Defaults to ``[]`` semantics only if you pass ``[]``;
966
+ ``None`` rebuilds on every render, matching effects.
967
+
968
+ Raises:
969
+ RuntimeError: If called outside a `@component` function.
970
+
971
+ Example:
972
+ ```python
973
+ import pythonnative as pn
974
+
975
+ @pn.component
976
+ def VideoPlayer(source, ref=None):
977
+ pn.use_imperative_handle(ref, lambda: PlayerController(...), [source])
978
+ return pn.View(...)
979
+ ```
980
+ """
981
+
982
+ def _install() -> Optional[Callable[[], None]]:
983
+ if ref is None:
984
+ return None
985
+ ref.current = factory()
986
+
987
+ def _clear() -> None:
988
+ ref.current = None
989
+
990
+ return _clear
991
+
992
+ use_layout_effect(_install, deps)
993
+
994
+
526
995
  # ======================================================================
527
996
  # Async hooks
528
997
  # ======================================================================
529
998
 
530
999
 
531
- def use_async_effect(
532
- effect: Callable[[], Awaitable[None]],
533
- deps: Optional[list] = None,
534
- ) -> None:
535
- """Schedule an async effect that's cancelled on re-run / unmount.
1000
+ def use_resource(fetcher: Callable[[], Any], deps: Optional[list] = None) -> Resource[Any]:
1001
+ """Start an async fetch and cache it across renders.
1002
+
1003
+ The fetch starts immediately (during render, not after commit) and
1004
+ the resulting [`Resource`][pythonnative.Resource] is cached until
1005
+ ``deps`` change, at which point the old fetch is cancelled and a
1006
+ new one starts. Because results are cached, re-renders resolve
1007
+ instantly; only genuinely new data suspends.
536
1008
 
537
- Like [`use_effect`][pythonnative.use_effect] but takes an
538
- ``async def`` (or any zero-arg callable returning an awaitable).
539
- The coroutine is scheduled on the framework runtime via
540
- [`run_async`][pythonnative.runtime.run_async] after the native
541
- commit. When ``deps`` change (or the component unmounts), the
542
- in-flight future is cancelled.
1009
+ Consume the resource with ``resource.read()`` (suspends the render
1010
+ while pending; pair with a [`Suspense`][pythonnative.Suspense]
1011
+ boundary) or ``await resource`` inside an ``async def`` component.
1012
+ Errors raised by the fetcher re-raise at the read site, so an
1013
+ enclosing [`ErrorBoundary`][pythonnative.ErrorBoundary] catches
1014
+ failures declaratively.
543
1015
 
544
1016
  Args:
545
- effect: A zero-arg callable returning an awaitable. Typically
546
- an ``async def`` defined inside the component.
547
- deps: Dependency list, or ``None`` to re-run on every render.
1017
+ fetcher: Zero-arg ``async def`` (or plain callable) producing
1018
+ the value. Synchronous fetchers resolve immediately and
1019
+ never suspend.
1020
+ deps: Dependency list controlling when to refetch. Defaults to
1021
+ ``[]`` (fetch once per component instance).
1022
+
1023
+ Returns:
1024
+ The cached [`Resource`][pythonnative.Resource].
1025
+
1026
+ Raises:
1027
+ RuntimeError: If called outside a ``@component`` function.
1028
+
1029
+ Example:
1030
+ ```python
1031
+ import pythonnative as pn
1032
+
1033
+
1034
+ @pn.component
1035
+ async def UserCard(user_id):
1036
+ user = await pn.use_resource(lambda: api.get_user(user_id), [user_id])
1037
+ return pn.Text(user["name"])
1038
+ ```
1039
+ """
1040
+ from .suspense import start_resource
1041
+
1042
+ ctx = _get_hook_state()
1043
+ if ctx is None:
1044
+ raise RuntimeError("use_resource must be called inside a @component function")
1045
+ ctx.record_hook("use_resource")
1046
+
1047
+ idx = ctx.resource_index
1048
+ ctx.resource_index += 1
1049
+ deps = [] if deps is None else deps
1050
+
1051
+ if idx >= len(ctx.resources):
1052
+ resource = start_resource(fetcher)
1053
+ ctx.resources.append((list(deps), resource))
1054
+ return resource
1055
+
1056
+ prev_deps, prev_resource = ctx.resources[idx]
1057
+ if not _deps_changed(prev_deps, deps):
1058
+ return prev_resource
1059
+
1060
+ prev_resource.cancel()
1061
+ resource = start_resource(fetcher)
1062
+ ctx.resources[idx] = (list(deps), resource)
1063
+ return resource
1064
+
1065
+
1066
+ def use_transition() -> Tuple[bool, Callable[[Callable[[], None]], None]]:
1067
+ """Return ``(is_pending, start_transition)`` for low-priority updates.
1068
+
1069
+ State updates made inside ``start_transition(fn)`` are marked as
1070
+ *transitions*: instead of re-rendering synchronously, their render
1071
+ is deferred to a later turn of the framework loop, so urgent
1072
+ updates (typing, presses) queued in the meantime render first.
1073
+ ``is_pending`` is ``True`` from the moment ``start_transition`` is
1074
+ called until the deferred render has committed, which is exactly
1075
+ when to show a lightweight busy indicator.
1076
+
1077
+ Returns:
1078
+ A 2-tuple ``(is_pending, start_transition)``.
548
1079
 
549
1080
  Raises:
550
1081
  RuntimeError: If called outside a ``@component`` function.
@@ -555,27 +1086,67 @@ def use_async_effect(
555
1086
 
556
1087
 
557
1088
  @pn.component
558
- def Posts(user_id):
559
- posts, set_posts = pn.use_state([])
1089
+ def Search():
1090
+ query, set_query = pn.use_state("")
1091
+ results_for, set_results_for = pn.use_state("")
1092
+ is_pending, start_transition = pn.use_transition()
560
1093
 
561
- async def load():
562
- set_posts(await api.get_posts(user_id))
1094
+ def on_change(text):
1095
+ set_query(text) # urgent: keep the input responsive
1096
+ start_transition(lambda: set_results_for(text))
563
1097
 
564
- pn.use_async_effect(load, [user_id])
565
- return pn.FlatList(posts, render_item=...)
1098
+ return pn.Column(
1099
+ pn.TextInput(value=query, on_change=on_change),
1100
+ pn.ActivityIndicator() if is_pending else Results(results_for),
1101
+ )
566
1102
  ```
567
1103
  """
568
- from .runtime import run_async
1104
+ ctx = _get_hook_state()
1105
+ if ctx is None:
1106
+ raise RuntimeError("use_transition must be called inside a @component function")
569
1107
 
570
- def _sync_effect() -> Callable[[], None]:
571
- future = run_async(effect())
1108
+ is_pending, set_pending = use_state(False)
572
1109
 
573
- def _cancel() -> None:
574
- future.cancel()
1110
+ def start_transition(fn: Callable[[], None]) -> None:
1111
+ set_pending(True)
1112
+ _run_in_transition(fn)
1113
+ _post_transition_callbacks.append(lambda: set_pending(False))
1114
+ _schedule_transition_flush()
575
1115
 
576
- return _cancel
1116
+ start = use_callback(start_transition, [])
1117
+ return is_pending, start
1118
+
1119
+
1120
+ def use_deferred_value(value: Any) -> Any:
1121
+ """Return a copy of ``value`` that lags behind during fast updates.
1122
+
1123
+ The returned value updates in a deferred (transition-priority)
1124
+ render after the urgent render that changed ``value`` has
1125
+ committed. Pass the deferred value to expensive subtrees (a
1126
+ filtered list, a chart) so the urgent part of the UI stays
1127
+ responsive while the expensive part catches up a beat later.
1128
+
1129
+ Args:
1130
+ value: The latest value.
1131
+
1132
+ Returns:
1133
+ The previous value while a newer one is still being adopted,
1134
+ then the latest value.
1135
+
1136
+ Raises:
1137
+ RuntimeError: If called outside a ``@component`` function.
1138
+ """
1139
+ ctx = _get_hook_state()
1140
+ if ctx is None:
1141
+ raise RuntimeError("use_deferred_value must be called inside a @component function")
1142
+
1143
+ deferred, set_deferred = use_state(value)
1144
+
1145
+ def _adopt() -> None:
1146
+ _run_in_transition(lambda: set_deferred(value))
577
1147
 
578
- use_effect(_sync_effect, deps)
1148
+ use_effect(_adopt, [value])
1149
+ return deferred
579
1150
 
580
1151
 
581
1152
  @dataclass(frozen=True)
@@ -704,7 +1275,7 @@ class MutationCall(Generic[T]):
704
1275
  Example:
705
1276
  ```python
706
1277
  # Fire-and-forget:
707
- save_button.on_click = lambda: mutate(post)
1278
+ save_button.on_press = lambda: mutate(post)
708
1279
 
709
1280
  # Or await for the result:
710
1281
  async def submit():
@@ -721,7 +1292,10 @@ class MutationCall(Generic[T]):
721
1292
  self._future = future
722
1293
 
723
1294
  def __await__(self) -> Any:
724
- return asyncio.wrap_future(self._future).__await__()
1295
+ future = self._future
1296
+ if isinstance(future, asyncio.Future):
1297
+ return future.__await__()
1298
+ return asyncio.wrap_future(future).__await__()
725
1299
 
726
1300
  def cancel(self) -> bool:
727
1301
  """Cancel the underlying mutation. Returns whether cancellation succeeded."""
@@ -760,7 +1334,7 @@ def use_mutation(
760
1334
  state, save = pn.use_mutation(api.create_post)
761
1335
 
762
1336
  return pn.Column(
763
- pn.Button("Save", on_click=lambda: save(post)),
1337
+ pn.Button("Save", on_press=lambda: save(post)),
764
1338
  pn.Text("Saving…") if state.loading else pn.Text(""),
765
1339
  pn.Text(str(state.error)) if state.error else pn.Text(""),
766
1340
  )
@@ -952,6 +1526,10 @@ class Context:
952
1526
  via [`use_context`][pythonnative.use_context]. Use
953
1527
  [`Provider`][pythonnative.Provider] to set the value for a subtree.
954
1528
 
1529
+ Context is *reactive*: when a Provider's value changes, every
1530
+ component that read the context on its last render re-renders,
1531
+ even if a memoized ancestor skipped its own re-render.
1532
+
955
1533
  Attributes:
956
1534
  default: The value returned when no `Provider` ancestor exists.
957
1535
  """
@@ -989,6 +1567,9 @@ def use_context(context: Context) -> Any:
989
1567
  """Read the current value of `context` from the nearest `Provider`.
990
1568
 
991
1569
  If no enclosing `Provider` exists, returns the context's default.
1570
+ The component is registered as a subscriber: when the nearest
1571
+ Provider's value changes, the component re-renders even if a
1572
+ memoized ancestor skipped.
992
1573
 
993
1574
  Args:
994
1575
  context: The `Context` to read from.
@@ -1002,7 +1583,10 @@ def use_context(context: Context) -> Any:
1002
1583
  ctx = _get_hook_state()
1003
1584
  if ctx is None:
1004
1585
  raise RuntimeError("use_context must be called inside a @component function")
1005
- return context._current()
1586
+ ctx.record_hook("use_context")
1587
+ value = context._current()
1588
+ ctx.context_deps[id(context)] = value
1589
+ return value
1006
1590
 
1007
1591
 
1008
1592
  # ======================================================================
@@ -1013,10 +1597,14 @@ def use_context(context: Context) -> Any:
1013
1597
  def Provider(context: "Context", value: Any, *children: Element) -> Element:
1014
1598
  """Provide ``value`` for ``context`` to all descendants of ``children``.
1015
1599
 
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.
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.
1020
1608
 
1021
1609
  Args:
1022
1610
  context: The [`Context`][pythonnative.hooks.Context] to set.
@@ -1044,13 +1632,7 @@ def Provider(context: "Context", value: Any, *children: Element) -> Element:
1044
1632
  )
1045
1633
  ```
1046
1634
  """
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)
1635
+ return Element("__Provider__", {"__context__": context, "__value__": value}, list(children))
1054
1636
 
1055
1637
 
1056
1638
  def memo(component_fn: Callable[..., Element]) -> Callable[..., Element]:
@@ -1123,7 +1705,7 @@ class NavigationHandle:
1123
1705
  nav = pn.use_navigation()
1124
1706
  return pn.Button(
1125
1707
  "Open Detail",
1126
- on_click=lambda: nav.navigate("Detail", {"id": 42}),
1708
+ on_press=lambda: nav.navigate("Detail", {"id": 42}),
1127
1709
  )
1128
1710
  ```
1129
1711
  """
@@ -1180,6 +1762,71 @@ def use_navigation() -> NavigationHandle:
1180
1762
  return handle
1181
1763
 
1182
1764
 
1765
+ def use_back_handler(handler: Callable[[], bool]) -> None:
1766
+ """Intercept the system back action for this screen.
1767
+
1768
+ On Android this handles the hardware back button and predictive
1769
+ back gesture; in the desktop preview it handles the Escape key.
1770
+ iOS has no system back button, so the handler never fires there
1771
+ (swipe-back is controlled by the navigation stack instead).
1772
+
1773
+ Handlers registered later run first, so a component mounted on top
1774
+ of existing content (a modal, a confirmation sheet) takes priority
1775
+ over handlers that were already mounted. Return ``True`` to consume
1776
+ the event and stop both remaining handlers and the platform's
1777
+ default behavior (popping the screen); return ``False`` to pass it
1778
+ along.
1779
+
1780
+ The latest ``handler`` closure from the most recent render is
1781
+ always the one invoked; registration order is fixed at mount, so
1782
+ re-renders never change priority.
1783
+
1784
+ Args:
1785
+ handler: Zero-arg callable returning ``True`` if it consumed
1786
+ the back action.
1787
+
1788
+ Raises:
1789
+ RuntimeError: If called outside a `@component` function.
1790
+
1791
+ Example:
1792
+ ```python
1793
+ import pythonnative as pn
1794
+
1795
+ @pn.component
1796
+ def Editor():
1797
+ dirty, set_dirty = pn.use_state(False)
1798
+ pn.use_back_handler(lambda: dirty) # block back while dirty
1799
+ ...
1800
+ ```
1801
+ """
1802
+ ctx = _get_hook_state()
1803
+ if ctx is None:
1804
+ raise RuntimeError("use_back_handler must be called inside a @component function")
1805
+
1806
+ latest: Ref[Callable[[], bool]] = use_ref(handler)
1807
+ latest.current = handler
1808
+
1809
+ def _register() -> Optional[Callable[[], None]]:
1810
+ reconciler = ctx._reconciler
1811
+ if reconciler is None or not hasattr(reconciler, "register_back_handler"):
1812
+ return None
1813
+
1814
+ def _trampoline() -> bool:
1815
+ fn = latest.current
1816
+ if fn is None:
1817
+ return False
1818
+ try:
1819
+ return bool(fn())
1820
+ except Exception as exc:
1821
+ if not diagnostics.report_error(exc, phase="back handler"):
1822
+ raise
1823
+ return True
1824
+
1825
+ return reconciler.register_back_handler(_trampoline)
1826
+
1827
+ use_effect(_register, [])
1828
+
1829
+
1183
1830
  # ======================================================================
1184
1831
  # @component decorator
1185
1832
  # ======================================================================