pythonnative 0.24.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
@@ -15,7 +15,15 @@ Two effect phases exist, mirroring React:
15
15
  layout pass have been applied. They can measure committed frames and
16
16
  issue imperative view commands before the user sees the new frame.
17
17
  - [`use_effect`][pythonnative.use_effect] callbacks (passive effects)
18
- run after the layout effects, at the end of the same commit.
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.
19
27
 
20
28
  Example:
21
29
  ```python
@@ -33,21 +41,34 @@ Example:
33
41
 
34
42
  import asyncio
35
43
  import inspect
36
- import threading
37
44
  from contextlib import contextmanager
45
+ from contextvars import ContextVar, Token
38
46
  from dataclasses import dataclass, field, replace
39
47
  from typing import Any, Awaitable, Callable, Dict, Generator, Generic, List, Optional, Tuple, TypeVar
40
48
 
41
49
  from . import diagnostics
42
50
  from .element import Element
51
+ from .suspense import CoroDriver, Resource
43
52
 
44
53
  T = TypeVar("T")
45
54
 
46
55
  _SENTINEL = object()
47
56
 
48
- _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)
49
67
 
50
- _batch_context: threading.local = threading.local()
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)
51
72
 
52
73
 
53
74
  # ======================================================================
@@ -121,21 +142,26 @@ class HookState:
121
142
  "layout_effects",
122
143
  "memos",
123
144
  "refs",
145
+ "resources",
124
146
  "state_index",
125
147
  "effect_index",
126
148
  "layout_effect_index",
127
149
  "memo_index",
128
150
  "ref_index",
151
+ "resource_index",
129
152
  "context_deps",
130
153
  "_trigger_render",
131
154
  "_pending_effects",
132
155
  "_pending_layout_effects",
156
+ "_pending_effects_mark",
157
+ "_pending_layout_effects_mark",
133
158
  "_dirty",
134
159
  "_vnode",
135
160
  "_reconciler",
136
161
  "_hook_log",
137
162
  "_hook_signature",
138
163
  "_component_name",
164
+ "_async_driver",
139
165
  )
140
166
 
141
167
  def __init__(self) -> None:
@@ -144,11 +170,14 @@ class HookState:
144
170
  self.layout_effects: List[Tuple[Any, Any]] = []
145
171
  self.memos: List[Tuple[Any, Any]] = []
146
172
  self.refs: List[Ref] = []
173
+ # One ``(deps, Resource)`` per ``use_resource`` call.
174
+ self.resources: List[Tuple[Any, Resource]] = []
147
175
  self.state_index: int = 0
148
176
  self.effect_index: int = 0
149
177
  self.layout_effect_index: int = 0
150
178
  self.memo_index: int = 0
151
179
  self.ref_index: int = 0
180
+ self.resource_index: int = 0
152
181
  # Contexts read during the last completed render, keyed by
153
182
  # ``id(context)``. The reconciler consults this when a
154
183
  # Provider's value changes so consumers re-render even when a
@@ -176,6 +205,13 @@ class HookState:
176
205
  self._hook_log: Optional[List[str]] = None
177
206
  self._hook_signature: Optional[List[str]] = None
178
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
179
215
 
180
216
  def begin_render(self, component_name: str = "") -> None:
181
217
  """Prepare for a render pass: reset cursors and the dev-mode hook log.
@@ -189,10 +225,23 @@ class HookState:
189
225
  self.layout_effect_index = 0
190
226
  self.memo_index = 0
191
227
  self.ref_index = 0
228
+ self.resource_index = 0
192
229
  self.context_deps = {}
193
230
  if component_name:
194
231
  self._component_name = component_name
195
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
196
245
 
197
246
  def finish_render(self) -> None:
198
247
  """Finalize a successful render: lock in / verify the hook signature.
@@ -260,6 +309,7 @@ class HookState:
260
309
  """Run layout effects queued during render (commit phase, pre-paint)."""
261
310
  pending = self._pending_layout_effects
262
311
  self._pending_layout_effects = []
312
+ self._pending_layout_effects_mark = 0
263
313
  for idx, effect_fn, deps in pending:
264
314
  _, prev_cleanup = self.layout_effects[idx]
265
315
  if callable(prev_cleanup):
@@ -267,7 +317,7 @@ class HookState:
267
317
  prev_cleanup()
268
318
  except Exception:
269
319
  pass
270
- cleanup = effect_fn()
320
+ cleanup = _activate_effect(effect_fn)
271
321
  self.layout_effects[idx] = (list(deps) if deps is not None else None, cleanup)
272
322
 
273
323
  def flush_pending_effects(self) -> None:
@@ -275,10 +325,14 @@ class HookState:
275
325
 
276
326
  For each pending effect, the previous cleanup is invoked first
277
327
  (if any), then the new effect callback. The new return value
278
- 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.
279
332
  """
280
333
  pending = self._pending_effects
281
334
  self._pending_effects = []
335
+ self._pending_effects_mark = 0
282
336
  for idx, effect_fn, deps in pending:
283
337
  _, prev_cleanup = self.effects[idx]
284
338
  if callable(prev_cleanup):
@@ -286,15 +340,16 @@ class HookState:
286
340
  prev_cleanup()
287
341
  except Exception:
288
342
  pass
289
- cleanup = effect_fn()
343
+ cleanup = _activate_effect(effect_fn)
290
344
  self.effects[idx] = (list(deps) if deps is not None else None, cleanup)
291
345
 
292
346
  def cleanup_all_effects(self) -> None:
293
347
  """Run every outstanding cleanup function, then clear state.
294
348
 
295
349
  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.
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.
298
353
  """
299
354
  for i, (_deps, cleanup) in enumerate(self.layout_effects):
300
355
  if callable(cleanup):
@@ -312,21 +367,38 @@ class HookState:
312
367
  self.effects[i] = (_SENTINEL, None)
313
368
  self._pending_effects = []
314
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()
315
382
 
316
383
 
317
384
  # ======================================================================
318
- # Thread-local context helpers
385
+ # Context helpers
319
386
  # ======================================================================
320
387
 
321
388
 
322
389
  def _get_hook_state() -> Optional[HookState]:
323
390
  """Return the active `HookState`, or `None` if no render is in flight."""
324
- return getattr(_hook_context, "current", None)
391
+ return _hook_context.get()
392
+
393
+
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)
325
397
 
326
398
 
327
- def _set_hook_state(state: Optional[HookState]) -> None:
328
- """Install `state` as the active `HookState` for the current thread."""
329
- _hook_context.current = state
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)
330
402
 
331
403
 
332
404
  def _deps_changed(prev: Any, current: Any) -> bool:
@@ -340,19 +412,126 @@ def _deps_changed(prev: Any, current: Any) -> bool:
340
412
  return any(p is not c and p != c for p, c in zip(prev, current))
341
413
 
342
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
+
343
465
  # ======================================================================
344
- # Batching helpers
466
+ # Batching and transition scheduling
345
467
  # ======================================================================
346
468
 
347
469
 
348
470
  def _schedule_trigger(trigger: Callable[[], None]) -> None:
349
471
  """Run ``trigger`` immediately, or defer it inside a `batch_updates` block."""
350
- if getattr(_batch_context, "depth", 0) > 0:
351
- _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)
352
476
  else:
353
477
  trigger()
354
478
 
355
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
+
356
535
  def _notify_state_changed(ctx: "HookState") -> None:
357
536
  """Mark ``ctx``'s component dirty and schedule a render after a state change.
358
537
 
@@ -361,7 +540,9 @@ def _notify_state_changed(ctx: "HookState") -> None:
361
540
  ``flush_dirty``, which re-renders only the components marked here
362
541
  rather than the whole app. The dirty mark is eager (so several
363
542
  setters coalesce), while the render trigger respects
364
- [`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]).
365
546
  """
366
547
  ctx._dirty = True
367
548
  reconciler = ctx._reconciler
@@ -369,7 +550,10 @@ def _notify_state_changed(ctx: "HookState") -> None:
369
550
  if reconciler is not None and vnode is not None:
370
551
  reconciler.mark_dirty(vnode)
371
552
  if ctx._trigger_render:
372
- _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)
373
557
 
374
558
 
375
559
  @contextmanager
@@ -393,18 +577,19 @@ def batch_updates() -> Generator[None, None, None]:
393
577
  set_name("hello")
394
578
  ```
395
579
  """
396
- depth = getattr(_batch_context, "depth", 0)
397
- _batch_context.depth = depth + 1
580
+ depth = _batch_depth.get()
581
+ depth_token = _batch_depth.set(depth + 1)
582
+ pending_token = None
398
583
  if depth == 0:
399
- _batch_context.pending_trigger = None
584
+ pending_token = _batch_pending.set([])
400
585
  try:
401
586
  yield
402
587
  finally:
403
- _batch_context.depth -= 1
404
- if _batch_context.depth == 0:
405
- trigger = _batch_context.pending_trigger
406
- _batch_context.pending_trigger = None
407
- 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:
408
593
  trigger()
409
594
 
410
595
 
@@ -548,12 +733,20 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
548
733
  - `[]`: mount only.
549
734
  - `[a, b]`: when `a` or `b` change (compared by identity, then `==`).
550
735
 
551
- `effect` may return a cleanup callable; the previous cleanup runs
552
- 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.
553
745
 
554
746
  Args:
555
- effect: A zero-arg callable invoked after commit. Optionally
556
- 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``.
557
750
  deps: Dependency list, or `None` to run on every render.
558
751
 
559
752
  Raises:
@@ -561,20 +754,21 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
561
754
 
562
755
  Example:
563
756
  ```python
757
+ import asyncio
758
+
564
759
  import pythonnative as pn
565
760
 
566
761
  @pn.component
567
- def Timer():
568
- seconds, set_seconds = pn.use_state(0)
762
+ def Clock():
763
+ now, set_now = pn.use_state("")
569
764
 
570
- def tick():
571
- import threading
572
- t = threading.Timer(1.0, lambda: set_seconds(seconds + 1))
573
- t.start()
574
- return t.cancel
765
+ async def tick():
766
+ while True:
767
+ set_now(time.strftime("%H:%M:%S"))
768
+ await asyncio.sleep(1)
575
769
 
576
- pn.use_effect(tick, [seconds])
577
- return pn.Text(f"Elapsed: {seconds}s")
770
+ pn.use_effect(tick, [])
771
+ return pn.Text(now)
578
772
  ```
579
773
  """
580
774
  ctx = _get_hook_state()
@@ -803,23 +997,85 @@ def use_imperative_handle(
803
997
  # ======================================================================
804
998
 
805
999
 
806
- def use_async_effect(
807
- effect: Callable[[], Awaitable[None]],
808
- deps: Optional[list] = None,
809
- ) -> None:
810
- """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.
811
1002
 
812
- Like [`use_effect`][pythonnative.use_effect] but takes an
813
- ``async def`` (or any zero-arg callable returning an awaitable).
814
- The coroutine is scheduled on the framework runtime via
815
- [`run_async`][pythonnative.runtime.run_async] after the native
816
- commit. When ``deps`` change (or the component unmounts), the
817
- in-flight future is cancelled.
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.
1008
+
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.
818
1015
 
819
1016
  Args:
820
- effect: A zero-arg callable returning an awaitable. Typically
821
- an ``async def`` defined inside the component.
822
- 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)``.
823
1079
 
824
1080
  Raises:
825
1081
  RuntimeError: If called outside a ``@component`` function.
@@ -830,43 +1086,67 @@ def use_async_effect(
830
1086
 
831
1087
 
832
1088
  @pn.component
833
- def Posts(user_id):
834
- 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()
835
1093
 
836
- async def load():
837
- 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))
838
1097
 
839
- pn.use_async_effect(load, [user_id])
840
- 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
+ )
841
1102
  ```
842
1103
  """
843
- 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")
844
1107
 
845
- def _sync_effect() -> Callable[[], None]:
846
- future = run_async(effect())
1108
+ is_pending, set_pending = use_state(False)
847
1109
 
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
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()
859
1115
 
860
- traceback.print_exception(type(exc), exc, exc.__traceback__)
1116
+ start = use_callback(start_transition, [])
1117
+ return is_pending, start
861
1118
 
862
- future.add_done_callback(_observe)
863
1119
 
864
- def _cancel() -> None:
865
- future.cancel()
1120
+ def use_deferred_value(value: Any) -> Any:
1121
+ """Return a copy of ``value`` that lags behind during fast updates.
866
1122
 
867
- return _cancel
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))
868
1147
 
869
- use_effect(_sync_effect, deps)
1148
+ use_effect(_adopt, [value])
1149
+ return deferred
870
1150
 
871
1151
 
872
1152
  @dataclass(frozen=True)
@@ -1012,7 +1292,10 @@ class MutationCall(Generic[T]):
1012
1292
  self._future = future
1013
1293
 
1014
1294
  def __await__(self) -> Any:
1015
- 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__()
1016
1299
 
1017
1300
  def cancel(self) -> bool:
1018
1301
  """Cancel the underlying mutation. Returns whether cancellation succeeded."""