pulse-framework 0.1.74__py3-none-any.whl → 0.1.76__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.
pulse/refs.py ADDED
@@ -0,0 +1,893 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import re
6
+ import uuid
7
+ from collections.abc import Callable
8
+ from typing import Any, Generic, Literal, TypeVar, cast, overload, override
9
+
10
+ from pulse.channel import Channel
11
+ from pulse.context import PulseContext
12
+ from pulse.helpers import Disposable
13
+ from pulse.hooks.core import HookMetadata, HookState, hooks
14
+ from pulse.hooks.state import collect_component_identity
15
+ from pulse.scheduling import create_future, create_task
16
+
17
+ T = TypeVar("T")
18
+ Number = int | float
19
+
20
+ _ATTR_ALIASES: dict[str, str] = {
21
+ "className": "class",
22
+ "htmlFor": "for",
23
+ "tabIndex": "tabindex",
24
+ }
25
+
26
+ _ATTR_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_:\-\.]*$")
27
+
28
+ _GETTABLE_PROPS: set[str] = {
29
+ "value",
30
+ "checked",
31
+ "disabled",
32
+ "readOnly",
33
+ "selectedIndex",
34
+ "selectionStart",
35
+ "selectionEnd",
36
+ "selectionDirection",
37
+ "scrollTop",
38
+ "scrollLeft",
39
+ "scrollHeight",
40
+ "scrollWidth",
41
+ "clientWidth",
42
+ "clientHeight",
43
+ "offsetWidth",
44
+ "offsetHeight",
45
+ "innerText",
46
+ "textContent",
47
+ "className",
48
+ "id",
49
+ "name",
50
+ "type",
51
+ "tabIndex",
52
+ }
53
+
54
+ _SETTABLE_PROPS: set[str] = {
55
+ "value",
56
+ "checked",
57
+ "disabled",
58
+ "readOnly",
59
+ "selectedIndex",
60
+ "selectionStart",
61
+ "selectionEnd",
62
+ "selectionDirection",
63
+ "scrollTop",
64
+ "scrollLeft",
65
+ "className",
66
+ "id",
67
+ "name",
68
+ "type",
69
+ "tabIndex",
70
+ }
71
+
72
+
73
+ def _normalize_attr_name(name: str) -> str:
74
+ return _ATTR_ALIASES.get(name, name)
75
+
76
+
77
+ def _validate_attr_name(name: str) -> str:
78
+ if not isinstance(name, str):
79
+ raise TypeError("ref attribute name must be a string")
80
+ trimmed = name.strip()
81
+ if not trimmed:
82
+ raise ValueError("ref attribute name must be non-empty")
83
+ normalized = _normalize_attr_name(trimmed)
84
+ if not _ATTR_NAME_PATTERN.match(normalized):
85
+ raise ValueError(f"Invalid attribute name: {normalized}")
86
+ if normalized.lower().startswith("on"):
87
+ raise ValueError("ref attribute name cannot start with 'on'")
88
+ return normalized
89
+
90
+
91
+ def _validate_prop_name(name: str, *, settable: bool) -> str:
92
+ if not isinstance(name, str):
93
+ raise TypeError("ref property name must be a string")
94
+ trimmed = name.strip()
95
+ if not trimmed:
96
+ raise ValueError("ref property name must be non-empty")
97
+ if trimmed not in _GETTABLE_PROPS:
98
+ raise ValueError(f"Unsupported ref property: {trimmed}")
99
+ if settable and trimmed not in _SETTABLE_PROPS:
100
+ raise ValueError(f"Ref property is read-only: {trimmed}")
101
+ return trimmed
102
+
103
+
104
+ class RefNotMounted(RuntimeError):
105
+ """Raised when a ref operation is attempted before mount."""
106
+
107
+
108
+ class RefTimeout(asyncio.TimeoutError):
109
+ """Raised when waiting for a ref mount times out."""
110
+
111
+
112
+ class RefHandle(Disposable, Generic[T]):
113
+ """Server-side handle for a client DOM ref."""
114
+
115
+ __slots__: tuple[str, ...] = (
116
+ "_channel",
117
+ "id",
118
+ "_mounted",
119
+ "_mount_waiters",
120
+ "_mount_handlers",
121
+ "_unmount_handlers",
122
+ "_owns_channel",
123
+ "_remove_mount",
124
+ "_remove_unmount",
125
+ )
126
+
127
+ _channel: Channel
128
+ id: str
129
+ _mounted: bool
130
+ _mount_waiters: list[asyncio.Future[None]]
131
+ _mount_handlers: list[Callable[[], Any]]
132
+ _unmount_handlers: list[Callable[[], Any]]
133
+ _owns_channel: bool
134
+ _remove_mount: Callable[[], None] | None
135
+ _remove_unmount: Callable[[], None] | None
136
+
137
+ def __init__(
138
+ self,
139
+ channel: Channel,
140
+ *,
141
+ ref_id: str | None = None,
142
+ owns_channel: bool = True,
143
+ ) -> None:
144
+ self._channel = channel
145
+ self.id = ref_id or uuid.uuid4().hex
146
+ self._mounted = False
147
+ self._mount_waiters = []
148
+ self._mount_handlers = []
149
+ self._unmount_handlers = []
150
+ self._owns_channel = owns_channel
151
+ self._remove_mount = self._channel.on("ref:mounted", self._on_mounted)
152
+ self._remove_unmount = self._channel.on("ref:unmounted", self._on_unmounted)
153
+
154
+ @property
155
+ def channel_id(self) -> str:
156
+ return self._channel.id
157
+
158
+ @property
159
+ def mounted(self) -> bool:
160
+ return self._mounted
161
+
162
+ def on_mount(self, handler: Callable[[], Any]) -> Callable[[], None]:
163
+ self._mount_handlers.append(handler)
164
+
165
+ def _remove() -> None:
166
+ try:
167
+ self._mount_handlers.remove(handler)
168
+ except ValueError:
169
+ return
170
+
171
+ return _remove
172
+
173
+ def on_unmount(self, handler: Callable[[], Any]) -> Callable[[], None]:
174
+ self._unmount_handlers.append(handler)
175
+
176
+ def _remove() -> None:
177
+ try:
178
+ self._unmount_handlers.remove(handler)
179
+ except ValueError:
180
+ return
181
+
182
+ return _remove
183
+
184
+ async def wait_mounted(self, timeout: float | None = None) -> None:
185
+ if self._mounted:
186
+ return
187
+ fut = create_future()
188
+ self._mount_waiters.append(fut)
189
+ try:
190
+ if timeout is None:
191
+ await fut
192
+ else:
193
+ await asyncio.wait_for(fut, timeout=timeout)
194
+ except asyncio.TimeoutError as exc:
195
+ raise RefTimeout("Timed out waiting for ref to mount") from exc
196
+ finally:
197
+ if fut in self._mount_waiters:
198
+ self._mount_waiters.remove(fut)
199
+
200
+ def focus(self, *, prevent_scroll: bool | None = None) -> None:
201
+ payload = None
202
+ if prevent_scroll is not None:
203
+ if not isinstance(prevent_scroll, bool):
204
+ raise TypeError("focus() prevent_scroll must be a bool")
205
+ payload = {"preventScroll": prevent_scroll}
206
+ self._emit("focus", payload)
207
+
208
+ def blur(self) -> None:
209
+ self._emit("blur")
210
+
211
+ def click(self) -> None:
212
+ self._emit("click")
213
+
214
+ def submit(self) -> None:
215
+ self._emit("submit")
216
+
217
+ def reset(self) -> None:
218
+ self._emit("reset")
219
+
220
+ def scroll_into_view(
221
+ self,
222
+ *,
223
+ behavior: str | None = None,
224
+ block: str | None = None,
225
+ inline: str | None = None,
226
+ ) -> None:
227
+ payload = {
228
+ k: v
229
+ for k, v in {
230
+ "behavior": behavior,
231
+ "block": block,
232
+ "inline": inline,
233
+ }.items()
234
+ if v is not None
235
+ }
236
+ self._emit("scrollIntoView", payload if payload else None)
237
+
238
+ def scroll_to(
239
+ self,
240
+ *,
241
+ top: float | int | None = None,
242
+ left: float | int | None = None,
243
+ behavior: str | None = None,
244
+ ) -> None:
245
+ if top is not None and not isinstance(top, (int, float)):
246
+ raise TypeError("scroll_to() top must be a number")
247
+ if left is not None and not isinstance(left, (int, float)):
248
+ raise TypeError("scroll_to() left must be a number")
249
+ if behavior is not None and not isinstance(behavior, str):
250
+ raise TypeError("scroll_to() behavior must be a string")
251
+ payload = {
252
+ k: v
253
+ for k, v in {
254
+ "top": top,
255
+ "left": left,
256
+ "behavior": behavior,
257
+ }.items()
258
+ if v is not None
259
+ }
260
+ self._emit("scrollTo", payload if payload else None)
261
+
262
+ def scroll_by(
263
+ self,
264
+ *,
265
+ top: float | int | None = None,
266
+ left: float | int | None = None,
267
+ behavior: str | None = None,
268
+ ) -> None:
269
+ if top is not None and not isinstance(top, (int, float)):
270
+ raise TypeError("scroll_by() top must be a number")
271
+ if left is not None and not isinstance(left, (int, float)):
272
+ raise TypeError("scroll_by() left must be a number")
273
+ if behavior is not None and not isinstance(behavior, str):
274
+ raise TypeError("scroll_by() behavior must be a string")
275
+ payload = {
276
+ k: v
277
+ for k, v in {
278
+ "top": top,
279
+ "left": left,
280
+ "behavior": behavior,
281
+ }.items()
282
+ if v is not None
283
+ }
284
+ self._emit("scrollBy", payload if payload else None)
285
+
286
+ async def measure(self, *, timeout: float | None = None) -> dict[str, Any] | None:
287
+ result = await self._request("measure", timeout=timeout)
288
+ if result is None:
289
+ return None
290
+ if isinstance(result, dict):
291
+ return result
292
+ raise TypeError("measure() expected dict result")
293
+
294
+ async def get_value(self, *, timeout: float | None = None) -> Any:
295
+ return await self._request("getValue", timeout=timeout)
296
+
297
+ async def set_value(self, value: Any, *, timeout: float | None = None) -> Any:
298
+ return await self._request("setValue", {"value": value}, timeout=timeout)
299
+
300
+ async def get_text(self, *, timeout: float | None = None) -> str | None:
301
+ result = await self._request("getText", timeout=timeout)
302
+ if result is None:
303
+ return None
304
+ if isinstance(result, str):
305
+ return result
306
+ raise TypeError("get_text() expected string result")
307
+
308
+ async def set_text(self, text: str, *, timeout: float | None = None) -> str | None:
309
+ result = await self._request("setText", {"text": text}, timeout=timeout)
310
+ if result is None:
311
+ return None
312
+ if isinstance(result, str):
313
+ return result
314
+ raise TypeError("set_text() expected string result")
315
+
316
+ def select(self) -> None:
317
+ self._emit("select")
318
+
319
+ def set_selection_range(
320
+ self, start: int, end: int, *, direction: str | None = None
321
+ ) -> None:
322
+ if not isinstance(start, int) or not isinstance(end, int):
323
+ raise TypeError("set_selection_range() requires integer start/end")
324
+ if direction is not None and not isinstance(direction, str):
325
+ raise TypeError("set_selection_range() direction must be a string")
326
+ payload: dict[str, Any] = {"start": start, "end": end}
327
+ if direction is not None:
328
+ payload["direction"] = direction
329
+ self._emit("setSelectionRange", payload)
330
+
331
+ @overload
332
+ async def get_attr(
333
+ self,
334
+ name: Literal[
335
+ "className",
336
+ "class",
337
+ "id",
338
+ "name",
339
+ "type",
340
+ "title",
341
+ "placeholder",
342
+ "role",
343
+ "href",
344
+ "src",
345
+ "alt",
346
+ "htmlFor",
347
+ "for",
348
+ "tabIndex",
349
+ "tabindex",
350
+ "aria-label",
351
+ "aria-hidden",
352
+ "data-test",
353
+ "value",
354
+ ],
355
+ *,
356
+ timeout: float | None = None,
357
+ ) -> str | None: ...
358
+
359
+ @overload
360
+ async def get_attr(
361
+ self, name: str, *, timeout: float | None = None
362
+ ) -> str | None: ...
363
+
364
+ async def get_attr(self, name: str, *, timeout: float | None = None) -> str | None:
365
+ normalized = _validate_attr_name(name)
366
+ result = await self._request("getAttr", {"name": normalized}, timeout=timeout)
367
+ if result is None:
368
+ return None
369
+ if isinstance(result, str):
370
+ return result
371
+ raise TypeError("get_attr() expected string result")
372
+
373
+ @overload
374
+ async def set_attr(
375
+ self,
376
+ name: Literal[
377
+ "className",
378
+ "class",
379
+ "id",
380
+ "name",
381
+ "type",
382
+ "title",
383
+ "placeholder",
384
+ "role",
385
+ "href",
386
+ "src",
387
+ "alt",
388
+ "htmlFor",
389
+ "for",
390
+ "tabIndex",
391
+ "tabindex",
392
+ "aria-label",
393
+ "aria-hidden",
394
+ "data-test",
395
+ "value",
396
+ ],
397
+ value: str | int | float | bool | None,
398
+ *,
399
+ timeout: float | None = None,
400
+ ) -> str | None: ...
401
+
402
+ @overload
403
+ async def set_attr(
404
+ self,
405
+ name: str,
406
+ value: Any,
407
+ *,
408
+ timeout: float | None = None,
409
+ ) -> str | None: ...
410
+
411
+ async def set_attr(
412
+ self, name: str, value: Any, *, timeout: float | None = None
413
+ ) -> str | None:
414
+ normalized = _validate_attr_name(name)
415
+ result = await self._request(
416
+ "setAttr", {"name": normalized, "value": value}, timeout=timeout
417
+ )
418
+ if result is None:
419
+ return None
420
+ if isinstance(result, str):
421
+ return result
422
+ raise TypeError("set_attr() expected string result")
423
+
424
+ async def remove_attr(self, name: str, *, timeout: float | None = None) -> None:
425
+ normalized = _validate_attr_name(name)
426
+ await self._request("removeAttr", {"name": normalized}, timeout=timeout)
427
+
428
+ @overload
429
+ async def get_prop(
430
+ self, name: Literal["value"], *, timeout: float | None = None
431
+ ) -> str: ...
432
+
433
+ @overload
434
+ async def get_prop(
435
+ self, name: Literal["checked"], *, timeout: float | None = None
436
+ ) -> bool: ...
437
+
438
+ @overload
439
+ async def get_prop(
440
+ self, name: Literal["disabled"], *, timeout: float | None = None
441
+ ) -> bool: ...
442
+
443
+ @overload
444
+ async def get_prop(
445
+ self, name: Literal["readOnly"], *, timeout: float | None = None
446
+ ) -> bool: ...
447
+
448
+ @overload
449
+ async def get_prop(
450
+ self, name: Literal["selectedIndex"], *, timeout: float | None = None
451
+ ) -> Number: ...
452
+
453
+ @overload
454
+ async def get_prop(
455
+ self, name: Literal["selectionStart"], *, timeout: float | None = None
456
+ ) -> Number | None: ...
457
+
458
+ @overload
459
+ async def get_prop(
460
+ self, name: Literal["selectionEnd"], *, timeout: float | None = None
461
+ ) -> Number | None: ...
462
+
463
+ @overload
464
+ async def get_prop(
465
+ self, name: Literal["selectionDirection"], *, timeout: float | None = None
466
+ ) -> str | None: ...
467
+
468
+ @overload
469
+ async def get_prop(
470
+ self, name: Literal["scrollTop"], *, timeout: float | None = None
471
+ ) -> Number: ...
472
+
473
+ @overload
474
+ async def get_prop(
475
+ self, name: Literal["scrollLeft"], *, timeout: float | None = None
476
+ ) -> Number: ...
477
+
478
+ @overload
479
+ async def get_prop(
480
+ self, name: Literal["scrollHeight"], *, timeout: float | None = None
481
+ ) -> Number: ...
482
+
483
+ @overload
484
+ async def get_prop(
485
+ self, name: Literal["scrollWidth"], *, timeout: float | None = None
486
+ ) -> Number: ...
487
+
488
+ @overload
489
+ async def get_prop(
490
+ self, name: Literal["clientWidth"], *, timeout: float | None = None
491
+ ) -> Number: ...
492
+
493
+ @overload
494
+ async def get_prop(
495
+ self, name: Literal["clientHeight"], *, timeout: float | None = None
496
+ ) -> Number: ...
497
+
498
+ @overload
499
+ async def get_prop(
500
+ self, name: Literal["offsetWidth"], *, timeout: float | None = None
501
+ ) -> Number: ...
502
+
503
+ @overload
504
+ async def get_prop(
505
+ self, name: Literal["offsetHeight"], *, timeout: float | None = None
506
+ ) -> Number: ...
507
+
508
+ @overload
509
+ async def get_prop(
510
+ self, name: Literal["innerText"], *, timeout: float | None = None
511
+ ) -> str: ...
512
+
513
+ @overload
514
+ async def get_prop(
515
+ self, name: Literal["textContent"], *, timeout: float | None = None
516
+ ) -> str | None: ...
517
+
518
+ @overload
519
+ async def get_prop(
520
+ self, name: Literal["className"], *, timeout: float | None = None
521
+ ) -> str: ...
522
+
523
+ @overload
524
+ async def get_prop(
525
+ self, name: Literal["id"], *, timeout: float | None = None
526
+ ) -> str: ...
527
+
528
+ @overload
529
+ async def get_prop(
530
+ self, name: Literal["name"], *, timeout: float | None = None
531
+ ) -> str: ...
532
+
533
+ @overload
534
+ async def get_prop(
535
+ self, name: Literal["type"], *, timeout: float | None = None
536
+ ) -> str: ...
537
+
538
+ @overload
539
+ async def get_prop(
540
+ self, name: Literal["tabIndex"], *, timeout: float | None = None
541
+ ) -> Number: ...
542
+
543
+ @overload
544
+ async def get_prop(self, name: str, *, timeout: float | None = None) -> Any: ...
545
+
546
+ async def get_prop(self, name: str, *, timeout: float | None = None) -> Any:
547
+ prop = _validate_prop_name(name, settable=False)
548
+ return await self._request("getProp", {"name": prop}, timeout=timeout)
549
+
550
+ @overload
551
+ async def set_prop(
552
+ self, name: Literal["value"], value: str, *, timeout: float | None = None
553
+ ) -> str: ...
554
+
555
+ @overload
556
+ async def set_prop(
557
+ self, name: Literal["checked"], value: bool, *, timeout: float | None = None
558
+ ) -> bool: ...
559
+
560
+ @overload
561
+ async def set_prop(
562
+ self, name: Literal["disabled"], value: bool, *, timeout: float | None = None
563
+ ) -> bool: ...
564
+
565
+ @overload
566
+ async def set_prop(
567
+ self, name: Literal["readOnly"], value: bool, *, timeout: float | None = None
568
+ ) -> bool: ...
569
+
570
+ @overload
571
+ async def set_prop(
572
+ self,
573
+ name: Literal["selectedIndex"],
574
+ value: Number,
575
+ *,
576
+ timeout: float | None = None,
577
+ ) -> Number: ...
578
+
579
+ @overload
580
+ async def set_prop(
581
+ self,
582
+ name: Literal["selectionStart"],
583
+ value: Number | None,
584
+ *,
585
+ timeout: float | None = None,
586
+ ) -> Number | None: ...
587
+
588
+ @overload
589
+ async def set_prop(
590
+ self,
591
+ name: Literal["selectionEnd"],
592
+ value: Number | None,
593
+ *,
594
+ timeout: float | None = None,
595
+ ) -> Number | None: ...
596
+
597
+ @overload
598
+ async def set_prop(
599
+ self,
600
+ name: Literal["selectionDirection"],
601
+ value: str | None,
602
+ *,
603
+ timeout: float | None = None,
604
+ ) -> str | None: ...
605
+
606
+ @overload
607
+ async def set_prop(
608
+ self, name: Literal["scrollTop"], value: Number, *, timeout: float | None = None
609
+ ) -> Number: ...
610
+
611
+ @overload
612
+ async def set_prop(
613
+ self,
614
+ name: Literal["scrollLeft"],
615
+ value: Number,
616
+ *,
617
+ timeout: float | None = None,
618
+ ) -> Number: ...
619
+
620
+ @overload
621
+ async def set_prop(
622
+ self, name: Literal["className"], value: str, *, timeout: float | None = None
623
+ ) -> str: ...
624
+
625
+ @overload
626
+ async def set_prop(
627
+ self, name: Literal["id"], value: str, *, timeout: float | None = None
628
+ ) -> str: ...
629
+
630
+ @overload
631
+ async def set_prop(
632
+ self, name: Literal["name"], value: str, *, timeout: float | None = None
633
+ ) -> str: ...
634
+
635
+ @overload
636
+ async def set_prop(
637
+ self, name: Literal["type"], value: str, *, timeout: float | None = None
638
+ ) -> str: ...
639
+
640
+ @overload
641
+ async def set_prop(
642
+ self, name: Literal["tabIndex"], value: Number, *, timeout: float | None = None
643
+ ) -> Number: ...
644
+
645
+ @overload
646
+ async def set_prop(
647
+ self, name: str, value: Any, *, timeout: float | None = None
648
+ ) -> Any: ...
649
+
650
+ async def set_prop(
651
+ self, name: str, value: Any, *, timeout: float | None = None
652
+ ) -> Any:
653
+ prop = _validate_prop_name(name, settable=True)
654
+ return await self._request(
655
+ "setProp", {"name": prop, "value": value}, timeout=timeout
656
+ )
657
+
658
+ async def set_style(
659
+ self, styles: dict[str, Any], *, timeout: float | None = None
660
+ ) -> None:
661
+ if not isinstance(styles, dict):
662
+ raise TypeError("set_style() requires a dict")
663
+ for key, value in styles.items():
664
+ if not isinstance(key, str) or not key:
665
+ raise ValueError("set_style() keys must be non-empty strings")
666
+ if isinstance(value, bool):
667
+ raise TypeError("set_style() values must be string, number, or None")
668
+ if value is not None and not isinstance(value, (str, int, float)):
669
+ raise TypeError("set_style() values must be string, number, or None")
670
+ await self._request("setStyle", {"styles": styles}, timeout=timeout)
671
+
672
+ def _emit(self, op: str, payload: Any = None) -> None:
673
+ self._ensure_mounted()
674
+ self._channel.emit(
675
+ "ref:call",
676
+ {"refId": self.id, "op": op, "payload": payload},
677
+ )
678
+
679
+ async def _request(
680
+ self,
681
+ op: str,
682
+ payload: Any = None,
683
+ *,
684
+ timeout: float | None = None,
685
+ ) -> Any:
686
+ self._ensure_mounted()
687
+ return await self._channel.request(
688
+ "ref:request",
689
+ {"refId": self.id, "op": op, "payload": payload},
690
+ timeout=timeout,
691
+ )
692
+
693
+ def _ensure_mounted(self) -> None:
694
+ if not self._mounted:
695
+ raise RefNotMounted("Ref is not mounted")
696
+
697
+ def _on_mounted(self, payload: Any) -> None:
698
+ if isinstance(payload, dict):
699
+ ref_id = cast(dict[str, Any], payload).get("refId")
700
+ if ref_id is not None and str(ref_id) != self.id:
701
+ return
702
+ self._mounted = True
703
+ for fut in list(self._mount_waiters):
704
+ if not fut.done():
705
+ fut.set_result(None)
706
+ self._mount_waiters.clear()
707
+ self._run_handlers(self._mount_handlers, label="mount")
708
+
709
+ def _on_unmounted(self, payload: Any) -> None:
710
+ if isinstance(payload, dict):
711
+ ref_id = cast(dict[str, Any], payload).get("refId")
712
+ if ref_id is not None and str(ref_id) != self.id:
713
+ return
714
+ self._mounted = False
715
+ self._run_handlers(self._unmount_handlers, label="unmount")
716
+
717
+ def _run_handlers(self, handlers: list[Callable[[], Any]], *, label: str) -> None:
718
+ for handler in list(handlers):
719
+ try:
720
+ result = handler()
721
+ except Exception:
722
+ # Fail early: propagate on next render via error log if desired
723
+ raise
724
+ if inspect.isawaitable(result):
725
+ task = create_task(result, name=f"ref:{self.id}:{label}")
726
+
727
+ def _on_done(done_task: asyncio.Future[Any]) -> None:
728
+ if done_task.cancelled():
729
+ return
730
+ try:
731
+ done_task.result()
732
+ except asyncio.CancelledError:
733
+ return
734
+ except Exception as exc:
735
+ loop = done_task.get_loop()
736
+ loop.call_exception_handler(
737
+ {
738
+ "message": f"Unhandled exception in ref {label} handler",
739
+ "exception": exc,
740
+ "context": {"ref_id": self.id, "handler": label},
741
+ }
742
+ )
743
+
744
+ task.add_done_callback(_on_done)
745
+
746
+ @override
747
+ def dispose(self) -> None:
748
+ self._mounted = False
749
+ if self._remove_mount is not None:
750
+ self._remove_mount()
751
+ self._remove_mount = None
752
+ if self._remove_unmount is not None:
753
+ self._remove_unmount()
754
+ self._remove_unmount = None
755
+ for fut in list(self._mount_waiters):
756
+ if not fut.done():
757
+ fut.set_exception(RefNotMounted("Ref disposed"))
758
+ self._mount_waiters.clear()
759
+ self._mount_handlers.clear()
760
+ self._unmount_handlers.clear()
761
+ if self._owns_channel:
762
+ self._channel.close()
763
+
764
+ @override
765
+ def __repr__(self) -> str:
766
+ return f"RefHandle(id={self.id}, channel={self.channel_id})"
767
+
768
+
769
+ class RefHookState(HookState):
770
+ __slots__: tuple[str, ...] = (
771
+ "instances",
772
+ "called_keys",
773
+ "_channel",
774
+ )
775
+ instances: dict[tuple[str, Any], RefHandle[Any]]
776
+ called_keys: set[tuple[str, Any]]
777
+ _channel: Channel | None
778
+
779
+ def __init__(self) -> None:
780
+ super().__init__()
781
+ self.instances = {}
782
+ self.called_keys = set()
783
+ self._channel = None
784
+
785
+ def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
786
+ if key is None:
787
+ return ("code", identity)
788
+ return ("key", key)
789
+
790
+ @override
791
+ def on_render_start(self, render_cycle: int) -> None:
792
+ super().on_render_start(render_cycle)
793
+ self.called_keys.clear()
794
+
795
+ def get_or_create(
796
+ self, identity: Any, key: str | None
797
+ ) -> tuple[RefHandle[Any], bool]:
798
+ full_identity = self._make_key(identity, key)
799
+ if full_identity in self.called_keys:
800
+ if key is None:
801
+ raise RuntimeError(
802
+ "`pulse.ref` can only be called once per component render at the same location. "
803
+ + "Use the `key` parameter to disambiguate: ps.ref(key=unique_value)"
804
+ )
805
+ raise RuntimeError(
806
+ f"`pulse.ref` can only be called once per component render with key='{key}'"
807
+ )
808
+ self.called_keys.add(full_identity)
809
+
810
+ existing = self.instances.get(full_identity)
811
+ if existing is not None:
812
+ if existing.__disposed__:
813
+ key_label = f"key='{key}'" if key is not None else "callsite"
814
+ raise RuntimeError(
815
+ "`pulse.ref` found a disposed cached RefHandle for "
816
+ + key_label
817
+ + ". Do not dispose handles returned by `pulse.ref`."
818
+ )
819
+ return existing, False
820
+
821
+ if self._channel is None or self._channel.closed:
822
+ ctx = PulseContext.get()
823
+ if ctx.render is None:
824
+ raise RuntimeError("ref() requires an active render session")
825
+ self._channel = ctx.render.get_ref_channel()
826
+ handle = RefHandle(self._channel, owns_channel=False)
827
+ self.instances[full_identity] = handle
828
+ return handle, True
829
+
830
+ @override
831
+ def dispose(self) -> None:
832
+ for handle in self.instances.values():
833
+ try:
834
+ handle.dispose()
835
+ except Exception:
836
+ pass
837
+ self._channel = None
838
+ self.instances.clear()
839
+
840
+
841
+ ref_hook_state = hooks.create(
842
+ "pulse:core.ref",
843
+ factory=RefHookState,
844
+ metadata=HookMetadata(
845
+ owner="pulse.core",
846
+ description="Internal storage for pulse.ref handles",
847
+ ),
848
+ )
849
+
850
+
851
+ def ref(
852
+ *,
853
+ key: str | None = None,
854
+ on_mount: Callable[[], Any] | None = None,
855
+ on_unmount: Callable[[], Any] | None = None,
856
+ ) -> RefHandle[Any]:
857
+ """Create or retrieve a stable ref handle for a component.
858
+
859
+ Args:
860
+ key: Optional key to disambiguate multiple refs created at the same callsite.
861
+ on_mount: Optional handler called when the ref mounts.
862
+ on_unmount: Optional handler called when the ref unmounts.
863
+ """
864
+ if key is not None and not isinstance(key, str):
865
+ raise TypeError("ref() key must be a string")
866
+ if key == "":
867
+ raise ValueError("ref() requires a non-empty string key")
868
+ if on_mount is not None and not callable(on_mount):
869
+ raise TypeError("ref() on_mount must be callable")
870
+ if on_unmount is not None and not callable(on_unmount):
871
+ raise TypeError("ref() on_unmount must be callable")
872
+
873
+ identity: Any
874
+ if key is None:
875
+ frame = inspect.currentframe()
876
+ assert frame is not None
877
+ caller = frame.f_back
878
+ assert caller is not None
879
+ identity = collect_component_identity(caller)
880
+ else:
881
+ identity = key
882
+
883
+ hook_state = ref_hook_state()
884
+ handle, created = hook_state.get_or_create(identity, key)
885
+ if created:
886
+ if on_mount is not None:
887
+ handle.on_mount(on_mount)
888
+ if on_unmount is not None:
889
+ handle.on_unmount(on_unmount)
890
+ return handle
891
+
892
+
893
+ __all__ = ["RefHandle", "RefNotMounted", "RefTimeout", "ref"]