pulse-framework 0.1.55__py3-none-any.whl → 0.1.57__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.
Files changed (70) hide show
  1. pulse/__init__.py +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/codegen/codegen.py +43 -12
  6. pulse/component.py +104 -0
  7. pulse/components/for_.py +30 -4
  8. pulse/components/if_.py +28 -5
  9. pulse/components/react_router.py +61 -3
  10. pulse/context.py +39 -5
  11. pulse/cookies.py +108 -4
  12. pulse/decorators.py +193 -24
  13. pulse/env.py +56 -2
  14. pulse/form.py +198 -5
  15. pulse/helpers.py +7 -1
  16. pulse/hooks/core.py +135 -5
  17. pulse/hooks/effects.py +61 -77
  18. pulse/hooks/init.py +60 -1
  19. pulse/hooks/runtime.py +241 -0
  20. pulse/hooks/setup.py +77 -0
  21. pulse/hooks/stable.py +58 -1
  22. pulse/hooks/state.py +107 -20
  23. pulse/js/__init__.py +40 -24
  24. pulse/js/array.py +9 -6
  25. pulse/js/console.py +15 -12
  26. pulse/js/date.py +9 -6
  27. pulse/js/document.py +5 -2
  28. pulse/js/error.py +7 -4
  29. pulse/js/json.py +9 -6
  30. pulse/js/map.py +8 -5
  31. pulse/js/math.py +9 -6
  32. pulse/js/navigator.py +5 -2
  33. pulse/js/number.py +9 -6
  34. pulse/js/obj.py +16 -13
  35. pulse/js/object.py +9 -6
  36. pulse/js/promise.py +19 -13
  37. pulse/js/pulse.py +28 -25
  38. pulse/js/react.py +94 -55
  39. pulse/js/regexp.py +7 -4
  40. pulse/js/set.py +8 -5
  41. pulse/js/string.py +9 -6
  42. pulse/js/weakmap.py +8 -5
  43. pulse/js/weakset.py +8 -5
  44. pulse/js/window.py +6 -3
  45. pulse/messages.py +5 -0
  46. pulse/middleware.py +147 -76
  47. pulse/plugin.py +76 -5
  48. pulse/queries/client.py +186 -39
  49. pulse/queries/common.py +52 -3
  50. pulse/queries/infinite_query.py +154 -2
  51. pulse/queries/mutation.py +127 -7
  52. pulse/queries/query.py +112 -11
  53. pulse/react_component.py +66 -3
  54. pulse/reactive.py +314 -30
  55. pulse/reactive_extensions.py +106 -26
  56. pulse/render_session.py +304 -173
  57. pulse/request.py +46 -11
  58. pulse/routing.py +140 -4
  59. pulse/serializer.py +71 -0
  60. pulse/state.py +177 -9
  61. pulse/test_helpers.py +15 -0
  62. pulse/transpiler/__init__.py +0 -3
  63. pulse/transpiler/py_module.py +1 -7
  64. pulse/user_session.py +119 -18
  65. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.dist-info}/METADATA +5 -5
  66. pulse_framework-0.1.57.dist-info/RECORD +127 -0
  67. pulse/transpiler/react_component.py +0 -44
  68. pulse_framework-0.1.55.dist-info/RECORD +0 -127
  69. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.dist-info}/WHEEL +0 -0
  70. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.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: Expr, *, result: Literal[True]) -> asyncio.Future[Any]: ...
65
+ def run_js(expr: Any, *, result: Literal[True]) -> asyncio.Future[Any]: ...
50
66
 
51
67
 
52
68
  @overload
53
- def run_js(expr: Expr, *, result: Literal[False] = ...) -> None: ...
69
+ def run_js(expr: Any, *, result: Literal[False] = ...) -> None: ...
54
70
 
55
71
 
56
- def run_js(expr: Expr, *, result: bool = False) -> asyncio.Future[Any] | None:
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, render: "RenderSession", route: Route | Layout, route_info: RouteInfo
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.queue = None
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
- disconnect_queue_timeout: float = 2.0,
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: Any, exc: Exception):
151
- details = {"effect": getattr(effect, "name", "<unnamed>")}
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 path, mount in self.route_mounts.items():
314
+ for mount in self.route_mounts.values():
168
315
  if mount.state == "active":
169
- mount.state = "pending"
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 mount.state == "pending" and mount.queue is not None:
197
- mount.queue.append(message)
198
- elif mount.state == "active" and self._send_message:
199
- self._send_message(message)
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, path: str, route_info: RouteInfo | None = None
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 vdom_init or navigate_to message.
260
- - First call: creates RouteMount in PENDING state, starts queue
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
- path = ensure_absolute_path(path)
264
- mount = self.route_mounts.get(path)
265
- is_new = mount is None
383
+ normalized = [ensure_absolute_path(path) for path in paths]
384
+
385
+ results: dict[str, ServerInitMessage | ServerNavigateToMessage] = {}
266
386
 
267
- if is_new:
387
+ for path in normalized:
268
388
  route = self.routes.find(path)
269
389
  info = route_info or route.default_route_info()
270
- mount = RouteMount(self, route, info)
271
- mount.state = "pending"
272
- mount.queue = []
273
- self.route_mounts[path] = mount
274
- elif route_info:
275
- mount.route.update(route_info)
276
-
277
- with PulseContext.update(render=self, route=mount.route):
278
- try:
279
- vdom = mount.tree.render()
280
- if is_new:
281
- mount.initialized = True
282
- except RedirectInterrupt as r:
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
- return ServerNavigateToMessage(
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 ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
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: fresh render, transition to ACTIVE
421
+ - IDLE: request reload
310
422
  - ACTIVE: update route_info
311
- - No mount: create fresh
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
- # No prerender, create fresh
318
- route = self.routes.find(path)
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
- if mount.state == "pending":
326
- # Flush queue, go active
327
- self._cancel_queue_timeout(mount)
328
- if mount.queue:
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.route.update(route_info)
443
+ mount.update_route(route_info)
354
444
  except Exception as e:
355
445
  self.report_error(path, "navigate", e)
356
446
 
357
- def detach(self, path: str):
358
- """Client no longer wants updates. Dispose Effect, remove mount."""
359
- path = ensure_absolute_path(path)
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
- mount = self.route_mounts.pop(path)
364
- self._cancel_queue_timeout(mount)
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 _create_render_effect(self, mount: RouteMount, path: str):
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
- session = ctx.session
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
- def _render_effect():
378
- with PulseContext.update(session=session, render=self, route=mount.route):
379
- try:
380
- if not mount.initialized:
381
- vdom = mount.tree.render()
382
- mount.initialized = True
383
- self.send(
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
- mount.effect = Effect(
414
- _render_effect,
415
- immediate=True,
416
- name=f"{path}:render",
417
- on_error=lambda e: self.report_error(path, "render", e),
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: Expr, *, result: Literal[True], timeout: float = ...
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: 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: Expr, *, result: bool = False, timeout: float = 10.0
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
- If False (default), returns None (fire-and-forget).
694
+ If False (default), returns None (fire-and-forget).
569
695
  timeout: Maximum seconds to wait for result (default 10s, only applies when
570
- result=True). Future raises asyncio.TimeoutError if exceeded.
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