pulse-framework 0.1.75__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/__init__.py +12 -0
- pulse/channel.py +6 -3
- pulse/dom/props.py +2 -0
- pulse/refs.py +893 -0
- pulse/render_session.py +27 -0
- pulse/renderer.py +23 -1
- pulse/transpiler/vdom.py +12 -1
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.76.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.76.dist-info}/RECORD +11 -10
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.76.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.76.dist-info}/entry_points.txt +0 -0
pulse/__init__.py
CHANGED
|
@@ -1409,6 +1409,18 @@ from pulse.reactive_extensions import (
|
|
|
1409
1409
|
from pulse.reactive_extensions import (
|
|
1410
1410
|
unwrap as unwrap,
|
|
1411
1411
|
)
|
|
1412
|
+
from pulse.refs import (
|
|
1413
|
+
RefHandle as RefHandle,
|
|
1414
|
+
)
|
|
1415
|
+
from pulse.refs import (
|
|
1416
|
+
RefNotMounted as RefNotMounted,
|
|
1417
|
+
)
|
|
1418
|
+
from pulse.refs import (
|
|
1419
|
+
RefTimeout as RefTimeout,
|
|
1420
|
+
)
|
|
1421
|
+
from pulse.refs import (
|
|
1422
|
+
ref as ref,
|
|
1423
|
+
)
|
|
1412
1424
|
|
|
1413
1425
|
# JavaScript execution
|
|
1414
1426
|
from pulse.render_session import JsExecError as JsExecError
|
pulse/channel.py
CHANGED
|
@@ -81,7 +81,9 @@ class ChannelsManager:
|
|
|
81
81
|
self.pending_requests = {}
|
|
82
82
|
|
|
83
83
|
# ------------------------------------------------------------------
|
|
84
|
-
def create(
|
|
84
|
+
def create(
|
|
85
|
+
self, identifier: str | None = None, *, bind_route: bool = True
|
|
86
|
+
) -> "Channel":
|
|
85
87
|
ctx = PulseContext.get()
|
|
86
88
|
render = ctx.render
|
|
87
89
|
session = ctx.session
|
|
@@ -93,7 +95,7 @@ class ChannelsManager:
|
|
|
93
95
|
raise ValueError(f"Channel id '{channel_id}' is already in use")
|
|
94
96
|
|
|
95
97
|
route_path: str | None = None
|
|
96
|
-
if ctx.route is not None:
|
|
98
|
+
if bind_route and ctx.route is not None:
|
|
97
99
|
# unique_path() returns absolute path, use as-is for keys
|
|
98
100
|
route_path = ctx.route.pulse_route.unique_path()
|
|
99
101
|
|
|
@@ -129,7 +131,8 @@ class ChannelsManager:
|
|
|
129
131
|
if not response_to:
|
|
130
132
|
return
|
|
131
133
|
|
|
132
|
-
|
|
134
|
+
error = message.get("error")
|
|
135
|
+
if error is not None:
|
|
133
136
|
self.resolve_pending_error(response_to, error)
|
|
134
137
|
else:
|
|
135
138
|
self._resolve_pending_success(response_to, message.get("payload"))
|
pulse/dom/props.py
CHANGED
|
@@ -68,6 +68,7 @@ from pulse.dom.events import (
|
|
|
68
68
|
TextAreaDOMEvents,
|
|
69
69
|
)
|
|
70
70
|
from pulse.helpers import CSSProperties
|
|
71
|
+
from pulse.refs import RefHandle
|
|
71
72
|
from pulse.transpiler.nodes import Expr
|
|
72
73
|
|
|
73
74
|
Booleanish = Literal[True, False, "true", "false"]
|
|
@@ -82,6 +83,7 @@ class BaseHTMLProps(TypedDict, total=False):
|
|
|
82
83
|
defaultValue: str | int | list[str]
|
|
83
84
|
suppressContentEditableWarning: bool
|
|
84
85
|
suppressHydrationWarning: bool
|
|
86
|
+
ref: RefHandle[Any]
|
|
85
87
|
|
|
86
88
|
# Standard HTML Attributes
|
|
87
89
|
accessKey: str
|
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"]
|
pulse/render_session.py
CHANGED
|
@@ -6,6 +6,7 @@ from asyncio import iscoroutine
|
|
|
6
6
|
from collections.abc import Awaitable, Callable
|
|
7
7
|
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
8
8
|
|
|
9
|
+
from pulse.channel import Channel
|
|
9
10
|
from pulse.context import PulseContext
|
|
10
11
|
from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
|
|
11
12
|
from pulse.messages import (
|
|
@@ -257,6 +258,8 @@ class RenderSession:
|
|
|
257
258
|
_send_message: Callable[[ServerMessage], Any] | None
|
|
258
259
|
_pending_api: dict[str, asyncio.Future[dict[str, Any]]]
|
|
259
260
|
_pending_js_results: dict[str, asyncio.Future[Any]]
|
|
261
|
+
_ref_channel: Channel | None
|
|
262
|
+
_ref_channels_by_route: dict[str, Channel]
|
|
260
263
|
_global_states: dict[str, State]
|
|
261
264
|
_global_queue: list[ServerMessage]
|
|
262
265
|
_tasks: TaskRegistry
|
|
@@ -290,6 +293,8 @@ class RenderSession:
|
|
|
290
293
|
self.forms = FormRegistry(self)
|
|
291
294
|
self._pending_api = {}
|
|
292
295
|
self._pending_js_results = {}
|
|
296
|
+
self._ref_channel = None
|
|
297
|
+
self._ref_channels_by_route = {}
|
|
293
298
|
self._tasks = TaskRegistry(name=f"render:{id}")
|
|
294
299
|
self._timers = TimerRegistry(tasks=self._tasks, name=f"render:{id}")
|
|
295
300
|
self.query_store = QueryStore()
|
|
@@ -479,6 +484,7 @@ class RenderSession:
|
|
|
479
484
|
return
|
|
480
485
|
try:
|
|
481
486
|
self.route_mounts.pop(path, None)
|
|
487
|
+
self._ref_channels_by_route.pop(path, None)
|
|
482
488
|
mount.dispose()
|
|
483
489
|
except Exception as e:
|
|
484
490
|
self.report_error(path, "unmount", e)
|
|
@@ -486,6 +492,7 @@ class RenderSession:
|
|
|
486
492
|
def detach(self, path: str, *, timeout: float | None = None):
|
|
487
493
|
"""Client no longer wants updates. Queue briefly, then dispose."""
|
|
488
494
|
path = ensure_absolute_path(path)
|
|
495
|
+
self._ref_channels_by_route.pop(path, None)
|
|
489
496
|
mount = self.route_mounts.get(path)
|
|
490
497
|
if not mount:
|
|
491
498
|
return
|
|
@@ -598,6 +605,8 @@ class RenderSession:
|
|
|
598
605
|
if not fut.done():
|
|
599
606
|
fut.cancel()
|
|
600
607
|
self._pending_js_results.clear()
|
|
608
|
+
self._ref_channel = None
|
|
609
|
+
self._ref_channels_by_route.clear()
|
|
601
610
|
# Close any timer that may have been scheduled during cleanup (ex: query GC)
|
|
602
611
|
self._timers.cancel_all()
|
|
603
612
|
self._global_queue = []
|
|
@@ -619,6 +628,24 @@ class RenderSession:
|
|
|
619
628
|
self._global_states[key] = inst
|
|
620
629
|
return inst
|
|
621
630
|
|
|
631
|
+
def get_ref_channel(self) -> Channel:
|
|
632
|
+
ctx = PulseContext.get()
|
|
633
|
+
if ctx.route is None:
|
|
634
|
+
if self._ref_channel is not None and not self._ref_channel.closed:
|
|
635
|
+
return self._ref_channel
|
|
636
|
+
self._ref_channel = self.channels.create(bind_route=False)
|
|
637
|
+
return self._ref_channel
|
|
638
|
+
|
|
639
|
+
route_path = ctx.route.pulse_route.unique_path()
|
|
640
|
+
channel = self._ref_channels_by_route.get(route_path)
|
|
641
|
+
if channel is not None and channel.closed:
|
|
642
|
+
self._ref_channels_by_route.pop(route_path, None)
|
|
643
|
+
channel = None
|
|
644
|
+
if channel is None:
|
|
645
|
+
channel = self.channels.create(bind_route=True)
|
|
646
|
+
self._ref_channels_by_route[route_path] = channel
|
|
647
|
+
return channel
|
|
648
|
+
|
|
622
649
|
def flush(self):
|
|
623
650
|
with PulseContext.update(render=self):
|
|
624
651
|
flush_effects()
|
pulse/renderer.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Any, NamedTuple, TypeAlias, cast
|
|
|
9
9
|
from pulse.debounce import Debounced
|
|
10
10
|
from pulse.helpers import values_equal
|
|
11
11
|
from pulse.hooks.core import HookContext
|
|
12
|
+
from pulse.refs import RefHandle
|
|
12
13
|
from pulse.transpiler import Import
|
|
13
14
|
from pulse.transpiler.function import Constant, JsFunction, JsxFunction
|
|
14
15
|
from pulse.transpiler.nodes import (
|
|
@@ -34,7 +35,7 @@ from pulse.transpiler.vdom import (
|
|
|
34
35
|
VDOMPropValue,
|
|
35
36
|
)
|
|
36
37
|
|
|
37
|
-
PropValue: TypeAlias = Node | Callable[..., Any] | Debounced[Any, Any]
|
|
38
|
+
PropValue: TypeAlias = Node | Callable[..., Any] | Debounced[Any, Any] | RefHandle[Any]
|
|
38
39
|
|
|
39
40
|
FRAGMENT_TAG = ""
|
|
40
41
|
MOUNT_PREFIX = "$$"
|
|
@@ -405,6 +406,25 @@ class Renderer:
|
|
|
405
406
|
updated[key] = value.render()
|
|
406
407
|
continue
|
|
407
408
|
|
|
409
|
+
if isinstance(value, RefHandle):
|
|
410
|
+
if key != "ref":
|
|
411
|
+
raise TypeError("RefHandle can only be used as the 'ref' prop")
|
|
412
|
+
eval_keys.add(key)
|
|
413
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
414
|
+
unmount_element(old_value)
|
|
415
|
+
if normalized is None:
|
|
416
|
+
normalized = current.copy()
|
|
417
|
+
normalized[key] = value
|
|
418
|
+
if not (
|
|
419
|
+
isinstance(old_value, RefHandle) and values_equal(old_value, value)
|
|
420
|
+
):
|
|
421
|
+
updated[key] = {
|
|
422
|
+
"__pulse_ref__": {
|
|
423
|
+
"channelId": value.channel_id,
|
|
424
|
+
"refId": value.id,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
continue
|
|
408
428
|
if isinstance(value, Debounced):
|
|
409
429
|
eval_keys.add(key)
|
|
410
430
|
if isinstance(old_value, (Element, PulseNode)):
|
|
@@ -499,6 +519,8 @@ def prop_requires_eval(value: PropValue) -> bool:
|
|
|
499
519
|
return True
|
|
500
520
|
if isinstance(value, Expr):
|
|
501
521
|
return True
|
|
522
|
+
if isinstance(value, RefHandle):
|
|
523
|
+
return True
|
|
502
524
|
if isinstance(value, Debounced):
|
|
503
525
|
return True
|
|
504
526
|
return callable(value)
|
pulse/transpiler/vdom.py
CHANGED
|
@@ -161,7 +161,18 @@ single sentinel string. Debounced callbacks use "$cb:<delay_ms>" in the wire for
|
|
|
161
161
|
"""
|
|
162
162
|
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
class PulseRefPayload(TypedDict):
|
|
165
|
+
channelId: str
|
|
166
|
+
refId: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PulseRefSpec(TypedDict):
|
|
170
|
+
__pulse_ref__: PulseRefPayload
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
VDOMPropValue: TypeAlias = (
|
|
174
|
+
"JsonValue | VDOMExpr | VDOMElement | CallbackPlaceholder | PulseRefSpec"
|
|
175
|
+
)
|
|
165
176
|
"""Allowed prop value types.
|
|
166
177
|
|
|
167
178
|
Hot path:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
pulse/__init__.py,sha256=
|
|
1
|
+
pulse/__init__.py,sha256=jFSqmTbDLp07bGVr8N7Pa6k0h7Ipq2pYn_tsrr2Ztu8,32881
|
|
2
2
|
pulse/_examples.py,sha256=dFuhD2EVXsbvAeexoG57s4VuN4gWLaTMOEMNYvlPm9A,561
|
|
3
3
|
pulse/app.py,sha256=Bi94rYG-MoldkGa-_CscLMstjTEV8BHVAgDbvapRGzI,36167
|
|
4
|
-
pulse/channel.py,sha256=
|
|
4
|
+
pulse/channel.py,sha256=UkImBCIFr5sWdkpB3dFwwFa-nWyEnl1W3EaLv0BRsMU,15845
|
|
5
5
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
pulse/cli/cmd.py,sha256=LQK_B6iANOAqcQCM0KMTfRbpqGYRaPDkEBvvaAS3qNI,15985
|
|
7
7
|
pulse/cli/dependencies.py,sha256=qU-rF7QyP0Rl1Fl0YKQubrGNBzj84BAbH1uUT3ehxik,4283
|
|
@@ -33,7 +33,7 @@ pulse/decorators.py,sha256=Lskni9Keqfb-xmUliFQe5x-4AcNqrwdvoh0kuz2fXa0,9958
|
|
|
33
33
|
pulse/dom/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
34
|
pulse/dom/elements.py,sha256=YHXkVpfMAC4-0o61fK-E0LGTOM3KMCtBfpHHAwLx7dw,23241
|
|
35
35
|
pulse/dom/events.py,sha256=yHioH8Y-b7raOaZ43JuCxk2lUBryUAcDSc-5VhXtiSI,14699
|
|
36
|
-
pulse/dom/props.py,sha256=
|
|
36
|
+
pulse/dom/props.py,sha256=6F3dE_bShI2WdAVfFG0DbIQom2GcM0iF8B3FBE5bj14,26775
|
|
37
37
|
pulse/dom/svg.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
38
38
|
pulse/dom/tags.py,sha256=U6mKmwB9JAFM6LTESMJcoIejNfnyxIdQo2-TLM5OaZ0,7585
|
|
39
39
|
pulse/dom/tags.pyi,sha256=0BC7zTh22roPBuMQawL8hgI6IrfN8xJZuDIoKMd4QKc,14393
|
|
@@ -106,8 +106,9 @@ pulse/queries/store.py,sha256=iw05_EFpyfiXv5_FV_x4aHtCo00mk0dDPFD461cajcg,3850
|
|
|
106
106
|
pulse/react_component.py,sha256=8RLg4Bi7IcjqbnbEnp4hJpy8t1UsE7mG0UR1Q655LDk,2332
|
|
107
107
|
pulse/reactive.py,sha256=GSh9wSH3THCBjDTafwWttyx7djeKBWV_KqjaKRYUNsA,31393
|
|
108
108
|
pulse/reactive_extensions.py,sha256=yQ1PpdAh4kMvll7R15T72FOg8NFdG_HGBsGc63dawYk,33754
|
|
109
|
-
pulse/
|
|
110
|
-
pulse/
|
|
109
|
+
pulse/refs.py,sha256=-6QlzJwJ_lLgGGvJDl6OTOkoA6799TvBGla54ZTH_jA,22889
|
|
110
|
+
pulse/render_session.py,sha256=bujJ0Ch7wUgCqjlq3Z0e8zlg6xpkMThCD-daUSCw-xc,24406
|
|
111
|
+
pulse/renderer.py,sha256=xFjF9Ttv7M74BpHDHpAp32rTFztwyQDZcEWhOFLI5MU,17553
|
|
111
112
|
pulse/request.py,sha256=N0oFOLiGxpbgSgxznjvu64lG3YyOcZPKC8JFyKx6X7w,6023
|
|
112
113
|
pulse/requirements.py,sha256=nMnE25Uu-TUuQd88jW7m2xwus6fD-HvXxQ9UNb7OOGc,1254
|
|
113
114
|
pulse/routing.py,sha256=oRfVaeIrsbDR9yW9BYwxVWV3HZI7wk21yZX69IVADIU,17279
|
|
@@ -139,12 +140,12 @@ pulse/transpiler/nodes.py,sha256=ObdCFIEvtKMVRO8iy1hIN4L-vC4yPqRvhPS6E344-bE,526
|
|
|
139
140
|
pulse/transpiler/parse.py,sha256=uz_KDnjmjzFSjGtVKRznWg95P0NHM8CafWgvqrqJcOs,1622
|
|
140
141
|
pulse/transpiler/py_module.py,sha256=um4BYLrbs01bpgv2LEBHTbhXXh8Bs174c3ygv5tHHOg,4410
|
|
141
142
|
pulse/transpiler/transpiler.py,sha256=bu33-wGNqHGheT_ZqMnQgEARyPG6xyOvuLuixjxIZnI,42761
|
|
142
|
-
pulse/transpiler/vdom.py,sha256=
|
|
143
|
+
pulse/transpiler/vdom.py,sha256=5ooW9uoWoBEEKBSds27m6Birj3eOuWZ2Qh2nZ4f_kvo,6609
|
|
143
144
|
pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
144
145
|
pulse/types/event_handler.py,sha256=psQCydj-WEtBcFU5JU4mDwvyzkW8V2O0g_VFRU2EOHI,1618
|
|
145
146
|
pulse/user_session.py,sha256=nsnsMgqq2xGJZLpbHRMHUHcLrElMP8WcA4gjGMrcoBk,10208
|
|
146
147
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
147
|
-
pulse_framework-0.1.
|
|
148
|
-
pulse_framework-0.1.
|
|
149
|
-
pulse_framework-0.1.
|
|
150
|
-
pulse_framework-0.1.
|
|
148
|
+
pulse_framework-0.1.76.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
149
|
+
pulse_framework-0.1.76.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
150
|
+
pulse_framework-0.1.76.dist-info/METADATA,sha256=NQtUswIWI_6HPwjmn9htpOBclmshDNxcXdPmhggApNQ,8299
|
|
151
|
+
pulse_framework-0.1.76.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|