pulse-framework 0.1.55__py3-none-any.whl → 0.1.56__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 +5 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/codegen/codegen.py +43 -12
- pulse/component.py +104 -0
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +40 -24
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +94 -55
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +0 -3
- pulse/transpiler/py_module.py +1 -7
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/transpiler/react_component.py +0 -44
- pulse_framework-0.1.55.dist-info/RECORD +0 -127
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/render_session.py
CHANGED
|
@@ -4,10 +4,10 @@ import traceback
|
|
|
4
4
|
import uuid
|
|
5
5
|
from asyncio import iscoroutine
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
-
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
8
8
|
|
|
9
9
|
from pulse.context import PulseContext
|
|
10
|
-
from pulse.helpers import create_future_on_loop, create_task
|
|
10
|
+
from pulse.helpers import create_future_on_loop, create_task, later
|
|
11
11
|
from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
|
|
12
12
|
from pulse.messages import (
|
|
13
13
|
ServerApiCallMessage,
|
|
@@ -19,7 +19,7 @@ from pulse.messages import (
|
|
|
19
19
|
ServerUpdateMessage,
|
|
20
20
|
)
|
|
21
21
|
from pulse.queries.store import QueryStore
|
|
22
|
-
from pulse.reactive import Effect, flush_effects
|
|
22
|
+
from pulse.reactive import REACTIVE_CONTEXT, Effect, flush_effects
|
|
23
23
|
from pulse.renderer import RenderTree
|
|
24
24
|
from pulse.routing import (
|
|
25
25
|
Layout,
|
|
@@ -44,16 +44,32 @@ class JsExecError(Exception):
|
|
|
44
44
|
"""Raised when client-side JS execution fails."""
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
class RenderLoopError(RuntimeError):
|
|
48
|
+
path: str
|
|
49
|
+
renders: int
|
|
50
|
+
batch_id: int
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: str, renders: int, batch_id: int) -> None:
|
|
53
|
+
super().__init__(
|
|
54
|
+
"Detected an infinite render loop in Pulse. "
|
|
55
|
+
+ f"Render path '{path}' exceeded {renders} renders in reactive batch {batch_id}. "
|
|
56
|
+
+ "This usually happens when a render or effect mutates state without a guard."
|
|
57
|
+
)
|
|
58
|
+
self.path = path
|
|
59
|
+
self.renders = renders
|
|
60
|
+
self.batch_id = batch_id
|
|
61
|
+
|
|
62
|
+
|
|
47
63
|
# Module-level convenience wrapper
|
|
48
64
|
@overload
|
|
49
|
-
def run_js(expr:
|
|
65
|
+
def run_js(expr: Any, *, result: Literal[True]) -> asyncio.Future[Any]: ...
|
|
50
66
|
|
|
51
67
|
|
|
52
68
|
@overload
|
|
53
|
-
def run_js(expr:
|
|
69
|
+
def run_js(expr: Any, *, result: Literal[False] = ...) -> None: ...
|
|
54
70
|
|
|
55
71
|
|
|
56
|
-
def run_js(expr:
|
|
72
|
+
def run_js(expr: Any, *, result: bool = False) -> asyncio.Future[Any] | None:
|
|
57
73
|
"""Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
|
|
58
74
|
ctx = PulseContext.get()
|
|
59
75
|
if ctx.render is None:
|
|
@@ -61,32 +77,157 @@ def run_js(expr: Expr, *, result: bool = False) -> asyncio.Future[Any] | None:
|
|
|
61
77
|
return ctx.render.run_js(expr, result=result)
|
|
62
78
|
|
|
63
79
|
|
|
64
|
-
MountState = Literal["pending", "active", "idle"]
|
|
80
|
+
MountState = Literal["pending", "active", "idle", "closed"]
|
|
81
|
+
PendingAction = Literal["idle", "dispose"]
|
|
82
|
+
T_Render = TypeVar("T_Render")
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
class RouteMount:
|
|
68
86
|
render: "RenderSession"
|
|
87
|
+
path: str
|
|
69
88
|
route: RouteContext
|
|
70
89
|
tree: RenderTree
|
|
71
90
|
effect: Effect | None
|
|
72
91
|
_pulse_ctx: PulseContext | None
|
|
73
92
|
initialized: bool
|
|
74
93
|
state: MountState
|
|
94
|
+
pending_action: PendingAction | None
|
|
75
95
|
queue: list[ServerMessage] | None
|
|
76
96
|
queue_timeout: asyncio.TimerHandle | None
|
|
97
|
+
render_batch_id: int
|
|
98
|
+
render_batch_renders: int
|
|
77
99
|
|
|
78
100
|
def __init__(
|
|
79
|
-
self,
|
|
101
|
+
self,
|
|
102
|
+
render: "RenderSession",
|
|
103
|
+
path: str,
|
|
104
|
+
route: Route | Layout,
|
|
105
|
+
route_info: RouteInfo,
|
|
80
106
|
) -> None:
|
|
81
107
|
self.render = render
|
|
108
|
+
self.path = ensure_absolute_path(path)
|
|
82
109
|
self.route = RouteContext(route_info, route)
|
|
83
110
|
self.effect = None
|
|
84
111
|
self._pulse_ctx = None
|
|
85
112
|
self.tree = RenderTree(route.render())
|
|
86
113
|
self.initialized = False
|
|
87
114
|
self.state = "pending"
|
|
88
|
-
self.
|
|
115
|
+
self.pending_action = None
|
|
116
|
+
self.queue = []
|
|
89
117
|
self.queue_timeout = None
|
|
118
|
+
self.render_batch_id = -1
|
|
119
|
+
self.render_batch_renders = 0
|
|
120
|
+
|
|
121
|
+
def update_route(self, route_info: RouteInfo) -> None:
|
|
122
|
+
self.route.update(route_info)
|
|
123
|
+
|
|
124
|
+
def _cancel_pending_timeout(self) -> None:
|
|
125
|
+
if self.queue_timeout is not None:
|
|
126
|
+
self.queue_timeout.cancel()
|
|
127
|
+
self.queue_timeout = None
|
|
128
|
+
self.pending_action = None
|
|
129
|
+
|
|
130
|
+
def _on_pending_timeout(self) -> None:
|
|
131
|
+
if self.state != "pending":
|
|
132
|
+
return
|
|
133
|
+
action = self.pending_action
|
|
134
|
+
self.pending_action = None
|
|
135
|
+
if action == "dispose":
|
|
136
|
+
self.render.dispose_mount(self.path, self)
|
|
137
|
+
return
|
|
138
|
+
self.to_idle()
|
|
139
|
+
|
|
140
|
+
def start_pending(self, timeout: float, *, action: PendingAction = "idle") -> None:
|
|
141
|
+
if self.state == "pending":
|
|
142
|
+
prev_action = self.pending_action
|
|
143
|
+
next_action: PendingAction = (
|
|
144
|
+
"dispose" if prev_action == "dispose" or action == "dispose" else "idle"
|
|
145
|
+
)
|
|
146
|
+
self._cancel_pending_timeout()
|
|
147
|
+
self.pending_action = next_action
|
|
148
|
+
self.queue_timeout = later(timeout, self._on_pending_timeout)
|
|
149
|
+
return
|
|
150
|
+
self._cancel_pending_timeout()
|
|
151
|
+
if self.state == "idle" and self.effect:
|
|
152
|
+
self.effect.resume()
|
|
153
|
+
self.state = "pending"
|
|
154
|
+
self.queue = []
|
|
155
|
+
self.pending_action = action
|
|
156
|
+
self.queue_timeout = later(timeout, self._on_pending_timeout)
|
|
157
|
+
|
|
158
|
+
def activate(self, send_message: Callable[[ServerMessage], Any]) -> None:
|
|
159
|
+
if self.state != "pending":
|
|
160
|
+
return
|
|
161
|
+
self._cancel_pending_timeout()
|
|
162
|
+
if self.queue:
|
|
163
|
+
for msg in self.queue:
|
|
164
|
+
send_message(msg)
|
|
165
|
+
self.queue = None
|
|
166
|
+
self.state = "active"
|
|
167
|
+
|
|
168
|
+
def deliver(
|
|
169
|
+
self, message: ServerMessage, send_message: Callable[[ServerMessage], Any]
|
|
170
|
+
):
|
|
171
|
+
if self.state == "pending":
|
|
172
|
+
if self.queue is None:
|
|
173
|
+
raise RuntimeError(f"Pending mount missing queue for {self.path!r}")
|
|
174
|
+
self.queue.append(message)
|
|
175
|
+
return
|
|
176
|
+
if self.state == "active":
|
|
177
|
+
send_message(message)
|
|
178
|
+
return
|
|
179
|
+
if self.state == "closed":
|
|
180
|
+
raise RuntimeError(f"Message sent to closed mount {self.path!r}")
|
|
181
|
+
|
|
182
|
+
def to_idle(self) -> None:
|
|
183
|
+
if self.state != "pending":
|
|
184
|
+
return
|
|
185
|
+
self.state = "idle"
|
|
186
|
+
self.queue = None
|
|
187
|
+
self._cancel_pending_timeout()
|
|
188
|
+
if self.effect:
|
|
189
|
+
self.effect.pause()
|
|
190
|
+
|
|
191
|
+
def ensure_effect(self, *, lazy: bool = False, flush: bool = True) -> None:
|
|
192
|
+
if self.effect is not None:
|
|
193
|
+
if flush:
|
|
194
|
+
self.effect.flush()
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
ctx = PulseContext.get()
|
|
198
|
+
session = ctx.session
|
|
199
|
+
|
|
200
|
+
def _render_effect():
|
|
201
|
+
message = self.render.rerender(self, self.path, session=session)
|
|
202
|
+
if message is not None:
|
|
203
|
+
self.render.send(message)
|
|
204
|
+
|
|
205
|
+
def _report_render_error(exc: Exception) -> None:
|
|
206
|
+
details: dict[str, Any] | None = None
|
|
207
|
+
if isinstance(exc, RenderLoopError):
|
|
208
|
+
details = {
|
|
209
|
+
"renders": exc.renders,
|
|
210
|
+
"batch_id": exc.batch_id,
|
|
211
|
+
}
|
|
212
|
+
self.render.report_error(self.path, "render", exc, details)
|
|
213
|
+
|
|
214
|
+
self.effect = Effect(
|
|
215
|
+
_render_effect,
|
|
216
|
+
immediate=False,
|
|
217
|
+
name=f"{self.path}:render",
|
|
218
|
+
on_error=_report_render_error,
|
|
219
|
+
lazy=lazy,
|
|
220
|
+
)
|
|
221
|
+
if flush:
|
|
222
|
+
self.effect.flush()
|
|
223
|
+
|
|
224
|
+
def dispose(self) -> None:
|
|
225
|
+
self._cancel_pending_timeout()
|
|
226
|
+
self.state = "closed"
|
|
227
|
+
self.queue = None
|
|
228
|
+
self.tree.unmount()
|
|
229
|
+
if self.effect:
|
|
230
|
+
self.effect.dispose()
|
|
90
231
|
|
|
91
232
|
|
|
92
233
|
class RenderSession:
|
|
@@ -98,7 +239,9 @@ class RenderSession:
|
|
|
98
239
|
route_mounts: dict[str, RouteMount]
|
|
99
240
|
connected: bool
|
|
100
241
|
prerender_queue_timeout: float
|
|
242
|
+
detach_queue_timeout: float
|
|
101
243
|
disconnect_queue_timeout: float
|
|
244
|
+
render_loop_limit: int
|
|
102
245
|
_server_address: str | None
|
|
103
246
|
_client_address: str | None
|
|
104
247
|
_send_message: Callable[[ServerMessage], Any] | None
|
|
@@ -114,7 +257,9 @@ class RenderSession:
|
|
|
114
257
|
server_address: str | None = None,
|
|
115
258
|
client_address: str | None = None,
|
|
116
259
|
prerender_queue_timeout: float = 5.0,
|
|
117
|
-
|
|
260
|
+
detach_queue_timeout: float = 15.0,
|
|
261
|
+
disconnect_queue_timeout: float = 300.0,
|
|
262
|
+
render_loop_limit: int = 50,
|
|
118
263
|
) -> None:
|
|
119
264
|
from pulse.channel import ChannelsManager
|
|
120
265
|
from pulse.form import FormRegistry
|
|
@@ -133,7 +278,9 @@ class RenderSession:
|
|
|
133
278
|
self._pending_api = {}
|
|
134
279
|
self._pending_js_results = {}
|
|
135
280
|
self.prerender_queue_timeout = prerender_queue_timeout
|
|
281
|
+
self.detach_queue_timeout = detach_queue_timeout
|
|
136
282
|
self.disconnect_queue_timeout = disconnect_queue_timeout
|
|
283
|
+
self.render_loop_limit = render_loop_limit
|
|
137
284
|
|
|
138
285
|
@property
|
|
139
286
|
def server_address(self) -> str:
|
|
@@ -147,8 +294,8 @@ class RenderSession:
|
|
|
147
294
|
raise RuntimeError("Client address not set")
|
|
148
295
|
return self._client_address
|
|
149
296
|
|
|
150
|
-
def _on_effect_error(self, effect:
|
|
151
|
-
details = {"effect":
|
|
297
|
+
def _on_effect_error(self, effect: Effect, exc: Exception):
|
|
298
|
+
details = {"effect": effect.name or "<unnamed>"}
|
|
152
299
|
for path in list(self.route_mounts.keys()):
|
|
153
300
|
self.report_error(path, "effect", exc, details)
|
|
154
301
|
|
|
@@ -164,14 +311,9 @@ class RenderSession:
|
|
|
164
311
|
self._send_message = None
|
|
165
312
|
self.connected = False
|
|
166
313
|
|
|
167
|
-
for
|
|
314
|
+
for mount in self.route_mounts.values():
|
|
168
315
|
if mount.state == "active":
|
|
169
|
-
mount.
|
|
170
|
-
mount.queue = []
|
|
171
|
-
mount.queue_timeout = self._schedule_timeout(
|
|
172
|
-
self.disconnect_queue_timeout,
|
|
173
|
-
lambda p=path: self._transition_to_idle(p),
|
|
174
|
-
)
|
|
316
|
+
mount.start_pending(self.disconnect_queue_timeout)
|
|
175
317
|
|
|
176
318
|
# ---- Message routing ----
|
|
177
319
|
|
|
@@ -193,10 +335,11 @@ class RenderSession:
|
|
|
193
335
|
self._send_message(message)
|
|
194
336
|
return
|
|
195
337
|
|
|
196
|
-
if
|
|
197
|
-
mount.
|
|
198
|
-
|
|
199
|
-
|
|
338
|
+
if self._send_message:
|
|
339
|
+
mount.deliver(message, self._send_message)
|
|
340
|
+
return
|
|
341
|
+
if mount.state == "pending":
|
|
342
|
+
mount.deliver(message, lambda _: None)
|
|
200
343
|
# idle: drop (effect should be paused anyway)
|
|
201
344
|
|
|
202
345
|
def report_error(
|
|
@@ -228,77 +371,46 @@ class RenderSession:
|
|
|
228
371
|
|
|
229
372
|
# ---- State transitions ----
|
|
230
373
|
|
|
231
|
-
def _schedule_timeout(
|
|
232
|
-
self, delay: float, callback: Callable[[], None]
|
|
233
|
-
) -> asyncio.TimerHandle:
|
|
234
|
-
loop = asyncio.get_event_loop()
|
|
235
|
-
return loop.call_later(delay, callback)
|
|
236
|
-
|
|
237
|
-
def _cancel_queue_timeout(self, mount: RouteMount):
|
|
238
|
-
if mount.queue_timeout is not None:
|
|
239
|
-
mount.queue_timeout.cancel()
|
|
240
|
-
mount.queue_timeout = None
|
|
241
|
-
|
|
242
|
-
def _transition_to_idle(self, path: str):
|
|
243
|
-
mount = self.route_mounts.get(path)
|
|
244
|
-
if mount is None or mount.state != "pending":
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
mount.state = "idle"
|
|
248
|
-
mount.queue = None
|
|
249
|
-
mount.queue_timeout = None
|
|
250
|
-
if mount.effect:
|
|
251
|
-
mount.effect.pause()
|
|
252
|
-
|
|
253
374
|
# ---- Prerendering ----
|
|
254
375
|
|
|
255
376
|
def prerender(
|
|
256
|
-
self,
|
|
257
|
-
) -> ServerInitMessage | ServerNavigateToMessage:
|
|
377
|
+
self, paths: list[str], route_info: RouteInfo | None = None
|
|
378
|
+
) -> dict[str, ServerInitMessage | ServerNavigateToMessage]:
|
|
258
379
|
"""
|
|
259
|
-
Synchronous render for SSR. Returns
|
|
260
|
-
-
|
|
261
|
-
- Subsequent calls: re-renders and returns fresh VDOM
|
|
380
|
+
Synchronous render for SSR. Returns per-path init or navigate_to messages.
|
|
381
|
+
- Creates mounts in PENDING state and starts queue
|
|
262
382
|
"""
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
383
|
+
normalized = [ensure_absolute_path(path) for path in paths]
|
|
384
|
+
|
|
385
|
+
results: dict[str, ServerInitMessage | ServerNavigateToMessage] = {}
|
|
266
386
|
|
|
267
|
-
|
|
387
|
+
for path in normalized:
|
|
268
388
|
route = self.routes.find(path)
|
|
269
389
|
info = route_info or route.default_route_info()
|
|
270
|
-
mount =
|
|
271
|
-
|
|
272
|
-
mount
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
390
|
+
mount = self.route_mounts.get(path)
|
|
391
|
+
|
|
392
|
+
if mount is None:
|
|
393
|
+
mount = RouteMount(self, path, route, info)
|
|
394
|
+
self.route_mounts[path] = mount
|
|
395
|
+
mount.ensure_effect(lazy=True, flush=False)
|
|
396
|
+
else:
|
|
397
|
+
mount.update_route(info)
|
|
398
|
+
if mount.effect is None:
|
|
399
|
+
mount.ensure_effect(lazy=True, flush=False)
|
|
400
|
+
|
|
401
|
+
if mount.state != "active" and mount.queue_timeout is None:
|
|
402
|
+
mount.start_pending(self.prerender_queue_timeout)
|
|
403
|
+
assert mount.effect is not None
|
|
404
|
+
with mount.effect.capture_deps(update_deps=True):
|
|
405
|
+
message = self.render(mount, path)
|
|
406
|
+
|
|
407
|
+
results[path] = message
|
|
408
|
+
if message["type"] == "navigate_to":
|
|
409
|
+
mount.dispose()
|
|
283
410
|
del self.route_mounts[path]
|
|
284
|
-
|
|
285
|
-
type="navigate_to", path=r.path, replace=r.replace, hard=False
|
|
286
|
-
)
|
|
287
|
-
except NotFoundInterrupt:
|
|
288
|
-
del self.route_mounts[path]
|
|
289
|
-
ctx = PulseContext.get()
|
|
290
|
-
return ServerNavigateToMessage(
|
|
291
|
-
type="navigate_to", path=ctx.app.not_found, replace=True, hard=False
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
if is_new:
|
|
295
|
-
self._create_render_effect(mount, path)
|
|
296
|
-
mount.queue_timeout = self._schedule_timeout(
|
|
297
|
-
self.prerender_queue_timeout,
|
|
298
|
-
lambda: self._transition_to_idle(path),
|
|
299
|
-
)
|
|
411
|
+
continue
|
|
300
412
|
|
|
301
|
-
return
|
|
413
|
+
return results
|
|
302
414
|
|
|
303
415
|
# ---- Client lifecycle ----
|
|
304
416
|
|
|
@@ -306,115 +418,129 @@ class RenderSession:
|
|
|
306
418
|
"""
|
|
307
419
|
Client ready to receive updates for path.
|
|
308
420
|
- PENDING: flush queue, transition to ACTIVE
|
|
309
|
-
- IDLE:
|
|
421
|
+
- IDLE: request reload
|
|
310
422
|
- ACTIVE: update route_info
|
|
311
|
-
- No mount:
|
|
423
|
+
- No mount: request reload
|
|
312
424
|
"""
|
|
313
425
|
path = ensure_absolute_path(path)
|
|
314
426
|
mount = self.route_mounts.get(path)
|
|
315
427
|
|
|
316
|
-
if mount is None:
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
mount = RouteMount(self, route, route_info)
|
|
320
|
-
mount.state = "active"
|
|
321
|
-
self.route_mounts[path] = mount
|
|
322
|
-
self._create_render_effect(mount, path)
|
|
428
|
+
if mount is None or mount.state == "idle":
|
|
429
|
+
# Initial render must come from prerender
|
|
430
|
+
self.send({"type": "reload"})
|
|
323
431
|
return
|
|
324
432
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
for msg in mount.queue:
|
|
330
|
-
if self._send_message:
|
|
331
|
-
self._send_message(msg)
|
|
332
|
-
mount.queue = None
|
|
333
|
-
mount.state = "active"
|
|
334
|
-
mount.route.update(route_info)
|
|
335
|
-
|
|
336
|
-
elif mount.state == "idle":
|
|
337
|
-
# Need fresh render
|
|
338
|
-
mount.initialized = False
|
|
339
|
-
mount.state = "active"
|
|
340
|
-
mount.route.update(route_info)
|
|
341
|
-
if mount.effect:
|
|
342
|
-
mount.effect.resume()
|
|
343
|
-
|
|
344
|
-
elif mount.state == "active":
|
|
345
|
-
# Already active, just update route
|
|
346
|
-
mount.route.update(route_info)
|
|
433
|
+
# Update route info for active and pending mounts
|
|
434
|
+
mount.update_route(route_info)
|
|
435
|
+
if mount.state == "pending" and self._send_message:
|
|
436
|
+
mount.activate(self._send_message)
|
|
347
437
|
|
|
348
438
|
def update_route(self, path: str, route_info: RouteInfo):
|
|
349
439
|
"""Update routing state (query params, etc.) for attached path."""
|
|
350
440
|
path = ensure_absolute_path(path)
|
|
351
441
|
try:
|
|
352
442
|
mount = self.get_route_mount(path)
|
|
353
|
-
mount.
|
|
443
|
+
mount.update_route(route_info)
|
|
354
444
|
except Exception as e:
|
|
355
445
|
self.report_error(path, "navigate", e)
|
|
356
446
|
|
|
357
|
-
def
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if path not in self.route_mounts:
|
|
447
|
+
def dispose_mount(self, path: str, mount: RouteMount) -> None:
|
|
448
|
+
current = self.route_mounts.get(path)
|
|
449
|
+
if current is not mount:
|
|
361
450
|
return
|
|
362
451
|
try:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
mount.tree.unmount()
|
|
366
|
-
if mount.effect:
|
|
367
|
-
mount.effect.dispose()
|
|
452
|
+
self.route_mounts.pop(path, None)
|
|
453
|
+
mount.dispose()
|
|
368
454
|
except Exception as e:
|
|
369
455
|
self.report_error(path, "unmount", e)
|
|
370
456
|
|
|
457
|
+
def detach(self, path: str, *, timeout: float | None = None):
|
|
458
|
+
"""Client no longer wants updates. Queue briefly, then dispose."""
|
|
459
|
+
path = ensure_absolute_path(path)
|
|
460
|
+
print(f"Detaching '{path}'")
|
|
461
|
+
mount = self.route_mounts.get(path)
|
|
462
|
+
if not mount:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
if timeout is None:
|
|
466
|
+
timeout = self.detach_queue_timeout
|
|
467
|
+
if timeout <= 0:
|
|
468
|
+
self.dispose_mount(path, mount)
|
|
469
|
+
return
|
|
470
|
+
mount.start_pending(timeout, action="dispose")
|
|
471
|
+
|
|
371
472
|
# ---- Effect creation ----
|
|
372
473
|
|
|
373
|
-
def
|
|
474
|
+
def _check_render_loop(self, mount: RouteMount, path: str) -> None:
|
|
475
|
+
batch_id = REACTIVE_CONTEXT.get().batch.flush_id
|
|
476
|
+
if mount.render_batch_id == batch_id:
|
|
477
|
+
mount.render_batch_renders += 1
|
|
478
|
+
else:
|
|
479
|
+
mount.render_batch_id = batch_id
|
|
480
|
+
mount.render_batch_renders = 1
|
|
481
|
+
if mount.render_batch_renders > self.render_loop_limit:
|
|
482
|
+
if mount.effect:
|
|
483
|
+
mount.effect.pause()
|
|
484
|
+
raise RenderLoopError(path, mount.render_batch_renders, batch_id)
|
|
485
|
+
|
|
486
|
+
def _render_with_interrupts(
|
|
487
|
+
self,
|
|
488
|
+
mount: RouteMount,
|
|
489
|
+
path: str,
|
|
490
|
+
*,
|
|
491
|
+
session: Any | None = None,
|
|
492
|
+
render_fn: Callable[[], T_Render],
|
|
493
|
+
) -> T_Render | ServerNavigateToMessage:
|
|
374
494
|
ctx = PulseContext.get()
|
|
375
|
-
|
|
495
|
+
render_session = ctx.session if session is None else session
|
|
496
|
+
with PulseContext.update(
|
|
497
|
+
session=render_session, render=self, route=mount.route
|
|
498
|
+
):
|
|
499
|
+
try:
|
|
500
|
+
self._check_render_loop(mount, path)
|
|
501
|
+
return render_fn()
|
|
502
|
+
except RedirectInterrupt as r:
|
|
503
|
+
return ServerNavigateToMessage(
|
|
504
|
+
type="navigate_to",
|
|
505
|
+
path=r.path,
|
|
506
|
+
replace=r.replace,
|
|
507
|
+
hard=False,
|
|
508
|
+
)
|
|
509
|
+
except NotFoundInterrupt:
|
|
510
|
+
ctx = PulseContext.get()
|
|
511
|
+
return ServerNavigateToMessage(
|
|
512
|
+
type="navigate_to",
|
|
513
|
+
path=ctx.app.not_found,
|
|
514
|
+
replace=True,
|
|
515
|
+
hard=False,
|
|
516
|
+
)
|
|
376
517
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
385
|
-
)
|
|
386
|
-
else:
|
|
387
|
-
ops = mount.tree.rerender()
|
|
388
|
-
if ops:
|
|
389
|
-
self.send(
|
|
390
|
-
ServerUpdateMessage(
|
|
391
|
-
type="vdom_update", path=path, ops=ops
|
|
392
|
-
)
|
|
393
|
-
)
|
|
394
|
-
except RedirectInterrupt as r:
|
|
395
|
-
self.send(
|
|
396
|
-
ServerNavigateToMessage(
|
|
397
|
-
type="navigate_to",
|
|
398
|
-
path=r.path,
|
|
399
|
-
replace=r.replace,
|
|
400
|
-
hard=False,
|
|
401
|
-
)
|
|
402
|
-
)
|
|
403
|
-
except NotFoundInterrupt:
|
|
404
|
-
self.send(
|
|
405
|
-
ServerNavigateToMessage(
|
|
406
|
-
type="navigate_to",
|
|
407
|
-
path=ctx.app.not_found,
|
|
408
|
-
replace=True,
|
|
409
|
-
hard=False,
|
|
410
|
-
)
|
|
411
|
-
)
|
|
518
|
+
def render(
|
|
519
|
+
self, mount: RouteMount, path: str, *, session: Any | None = None
|
|
520
|
+
) -> ServerInitMessage | ServerNavigateToMessage:
|
|
521
|
+
def _render() -> ServerInitMessage:
|
|
522
|
+
vdom = mount.tree.render()
|
|
523
|
+
mount.initialized = True
|
|
524
|
+
return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
|
|
412
525
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
526
|
+
message = self._render_with_interrupts(
|
|
527
|
+
mount, path, session=session, render_fn=_render
|
|
528
|
+
)
|
|
529
|
+
return message
|
|
530
|
+
|
|
531
|
+
def rerender(
|
|
532
|
+
self, mount: RouteMount, path: str, *, session: Any | None = None
|
|
533
|
+
) -> ServerUpdateMessage | ServerNavigateToMessage | None:
|
|
534
|
+
def _rerender() -> ServerUpdateMessage | None:
|
|
535
|
+
if not mount.initialized:
|
|
536
|
+
raise RuntimeError(f"rerender called before init for {path!r}")
|
|
537
|
+
ops = mount.tree.rerender()
|
|
538
|
+
if ops:
|
|
539
|
+
return ServerUpdateMessage(type="vdom_update", path=path, ops=ops)
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
return self._render_with_interrupts(
|
|
543
|
+
mount, path, session=session, render_fn=_rerender
|
|
418
544
|
)
|
|
419
545
|
|
|
420
546
|
# ---- Helpers ----
|
|
@@ -422,7 +548,7 @@ class RenderSession:
|
|
|
422
548
|
def close(self):
|
|
423
549
|
self.forms.dispose()
|
|
424
550
|
for path in list(self.route_mounts.keys()):
|
|
425
|
-
self.detach(path)
|
|
551
|
+
self.detach(path, timeout=0)
|
|
426
552
|
self.route_mounts.clear()
|
|
427
553
|
for value in self._global_states.values():
|
|
428
554
|
value.dispose()
|
|
@@ -545,29 +671,29 @@ class RenderSession:
|
|
|
545
671
|
|
|
546
672
|
@overload
|
|
547
673
|
def run_js(
|
|
548
|
-
self, expr:
|
|
674
|
+
self, expr: Any, *, result: Literal[True], timeout: float = ...
|
|
549
675
|
) -> asyncio.Future[object]: ...
|
|
550
676
|
|
|
551
677
|
@overload
|
|
552
678
|
def run_js(
|
|
553
679
|
self,
|
|
554
|
-
expr:
|
|
680
|
+
expr: Any,
|
|
555
681
|
*,
|
|
556
682
|
result: Literal[False] = ...,
|
|
557
683
|
timeout: float = ...,
|
|
558
684
|
) -> None: ...
|
|
559
685
|
|
|
560
686
|
def run_js(
|
|
561
|
-
self, expr:
|
|
687
|
+
self, expr: Any, *, result: bool = False, timeout: float = 10.0
|
|
562
688
|
) -> asyncio.Future[object] | None:
|
|
563
689
|
"""Execute JavaScript on the client.
|
|
564
690
|
|
|
565
691
|
Args:
|
|
566
692
|
expr: An Expr from calling a @javascript function.
|
|
567
693
|
result: If True, returns a Future that resolves with the JS return value.
|
|
568
|
-
|
|
694
|
+
If False (default), returns None (fire-and-forget).
|
|
569
695
|
timeout: Maximum seconds to wait for result (default 10s, only applies when
|
|
570
|
-
|
|
696
|
+
result=True). Future raises asyncio.TimeoutError if exceeded.
|
|
571
697
|
|
|
572
698
|
Returns:
|
|
573
699
|
None if result=False, otherwise a Future resolving to the JS result.
|
|
@@ -590,6 +716,11 @@ class RenderSession:
|
|
|
590
716
|
pos = await run_js(get_scroll_position(), result=True)
|
|
591
717
|
print(pos["x"], pos["y"])
|
|
592
718
|
"""
|
|
719
|
+
if not isinstance(expr, Expr):
|
|
720
|
+
raise TypeError(
|
|
721
|
+
f"run_js() requires an Expr (from @javascript function or pulse.js module), got {type(expr).__name__}"
|
|
722
|
+
)
|
|
723
|
+
|
|
593
724
|
ctx = PulseContext.get()
|
|
594
725
|
exec_id = next_id()
|
|
595
726
|
|