pulse-framework 0.1.53__py3-none-any.whl → 0.1.55__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 (41) hide show
  1. pulse/__init__.py +3 -3
  2. pulse/app.py +34 -20
  3. pulse/code_analysis.py +38 -0
  4. pulse/codegen/codegen.py +18 -50
  5. pulse/codegen/templates/route.py +100 -56
  6. pulse/component.py +24 -6
  7. pulse/components/for_.py +17 -2
  8. pulse/cookies.py +38 -2
  9. pulse/env.py +4 -4
  10. pulse/hooks/init.py +174 -14
  11. pulse/hooks/state.py +105 -0
  12. pulse/js/__init__.py +12 -9
  13. pulse/js/obj.py +79 -0
  14. pulse/js/pulse.py +112 -0
  15. pulse/js/react.py +457 -0
  16. pulse/messages.py +13 -13
  17. pulse/proxy.py +18 -5
  18. pulse/render_session.py +282 -266
  19. pulse/renderer.py +36 -73
  20. pulse/serializer.py +5 -2
  21. pulse/transpiler/__init__.py +13 -0
  22. pulse/transpiler/assets.py +66 -0
  23. pulse/transpiler/builtins.py +0 -20
  24. pulse/transpiler/dynamic_import.py +131 -0
  25. pulse/transpiler/emit_context.py +49 -0
  26. pulse/transpiler/errors.py +29 -11
  27. pulse/transpiler/function.py +36 -5
  28. pulse/transpiler/imports.py +33 -27
  29. pulse/transpiler/js_module.py +73 -20
  30. pulse/transpiler/modules/pulse/tags.py +35 -15
  31. pulse/transpiler/nodes.py +121 -36
  32. pulse/transpiler/py_module.py +1 -1
  33. pulse/transpiler/react_component.py +4 -11
  34. pulse/transpiler/transpiler.py +32 -26
  35. pulse/user_session.py +10 -0
  36. pulse_framework-0.1.55.dist-info/METADATA +196 -0
  37. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
  38. pulse/hooks/states.py +0 -285
  39. pulse_framework-0.1.53.dist-info/METADATA +0 -18
  40. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
  41. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
pulse/render_session.py CHANGED
@@ -31,7 +31,7 @@ from pulse.routing import (
31
31
  )
32
32
  from pulse.state import State
33
33
  from pulse.transpiler.id import next_id
34
- from pulse.transpiler.nodes import Expr, Node, emit
34
+ from pulse.transpiler.nodes import Expr
35
35
 
36
36
  if TYPE_CHECKING:
37
37
  from pulse.channel import ChannelsManager
@@ -46,14 +46,14 @@ class JsExecError(Exception):
46
46
 
47
47
  # Module-level convenience wrapper
48
48
  @overload
49
- def run_js(expr: Expr | str, *, result: Literal[True]) -> asyncio.Future[Any]: ...
49
+ def run_js(expr: Expr, *, result: Literal[True]) -> asyncio.Future[Any]: ...
50
50
 
51
51
 
52
52
  @overload
53
- def run_js(expr: Expr | str, *, result: Literal[False] = ...) -> None: ...
53
+ def run_js(expr: Expr, *, result: Literal[False] = ...) -> None: ...
54
54
 
55
55
 
56
- def run_js(expr: Expr | str, *, result: bool = False) -> asyncio.Future[Any] | None:
56
+ def run_js(expr: Expr, *, result: bool = False) -> asyncio.Future[Any] | None:
57
57
  """Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
58
58
  ctx = PulseContext.get()
59
59
  if ctx.render is None:
@@ -61,14 +61,19 @@ def run_js(expr: Expr | str, *, result: bool = False) -> asyncio.Future[Any] | N
61
61
  return ctx.render.run_js(expr, result=result)
62
62
 
63
63
 
64
+ MountState = Literal["pending", "active", "idle"]
65
+
66
+
64
67
  class RouteMount:
65
68
  render: "RenderSession"
66
69
  route: RouteContext
67
70
  tree: RenderTree
68
71
  effect: Effect | None
69
72
  _pulse_ctx: PulseContext | None
70
- element: Node
71
- rendered: bool
73
+ initialized: bool
74
+ state: MountState
75
+ queue: list[ServerMessage] | None
76
+ queue_timeout: asyncio.TimerHandle | None
72
77
 
73
78
  def __init__(
74
79
  self, render: "RenderSession", route: Route | Layout, route_info: RouteInfo
@@ -77,9 +82,11 @@ class RouteMount:
77
82
  self.route = RouteContext(route_info, route)
78
83
  self.effect = None
79
84
  self._pulse_ctx = None
80
- self.element = route.render()
81
- self.tree = RenderTree(self.element)
82
- self.rendered = False
85
+ self.tree = RenderTree(route.render())
86
+ self.initialized = False
87
+ self.state = "pending"
88
+ self.queue = None
89
+ self.queue_timeout = None
83
90
 
84
91
 
85
92
  class RenderSession:
@@ -90,6 +97,8 @@ class RenderSession:
90
97
  query_store: QueryStore
91
98
  route_mounts: dict[str, RouteMount]
92
99
  connected: bool
100
+ prerender_queue_timeout: float
101
+ disconnect_queue_timeout: float
93
102
  _server_address: str | None
94
103
  _client_address: str | None
95
104
  _send_message: Callable[[ServerMessage], Any] | None
@@ -104,6 +113,8 @@ class RenderSession:
104
113
  *,
105
114
  server_address: str | None = None,
106
115
  client_address: str | None = None,
116
+ prerender_queue_timeout: float = 5.0,
117
+ disconnect_queue_timeout: float = 2.0,
107
118
  ) -> None:
108
119
  from pulse.channel import ChannelsManager
109
120
  from pulse.form import FormRegistry
@@ -111,21 +122,18 @@ class RenderSession:
111
122
  self.id = id
112
123
  self.routes = routes
113
124
  self.route_mounts = {}
114
- # Base server address for building absolute API URLs (e.g., http://localhost:8000)
115
125
  self._server_address = server_address
116
- # Best-effort client address, captured at prerender or socket connect time
117
126
  self._client_address = client_address
118
127
  self._send_message = None
119
- # Registry of per-session global singletons (created via ps.global_state without id)
120
128
  self._global_states = {}
121
129
  self.query_store = QueryStore()
122
- # Connection state
123
130
  self.connected = False
124
131
  self.channels = ChannelsManager(self)
125
132
  self.forms = FormRegistry(self)
126
133
  self._pending_api = {}
127
- # Pending JS execution results (for awaiting run_js().result())
128
134
  self._pending_js_results = {}
135
+ self.prerender_queue_timeout = prerender_queue_timeout
136
+ self.disconnect_queue_timeout = disconnect_queue_timeout
129
137
 
130
138
  @property
131
139
  def server_address(self) -> str:
@@ -139,34 +147,57 @@ class RenderSession:
139
147
  raise RuntimeError("Client address not set")
140
148
  return self._client_address
141
149
 
142
- # Effect error handler (batch-level) to surface runtime errors
143
150
  def _on_effect_error(self, effect: Any, exc: Exception):
144
- # TODO: wirte into effects created within a Render
145
-
146
- # We don't want to couple effects to routing; broadcast to all active paths
147
151
  details = {"effect": getattr(effect, "name", "<unnamed>")}
148
152
  for path in list(self.route_mounts.keys()):
149
153
  self.report_error(path, "effect", exc, details)
150
154
 
155
+ # ---- Connection lifecycle ----
156
+
151
157
  def connect(self, send_message: Callable[[ServerMessage], Any]):
158
+ """WebSocket connected. Set sender, don't auto-flush (attach does that)."""
152
159
  self._send_message = send_message
153
160
  self.connected = True
154
- # Don't flush buffer or resume effects here - mount() handles reconnection
155
- # by resetting mount.rendered and resuming effects to send fresh vdom_init
156
161
 
157
162
  def disconnect(self):
158
- """Called when client disconnects - pause render effects."""
163
+ """WebSocket disconnected. Start queuing briefly before pausing."""
159
164
  self._send_message = None
160
165
  self.connected = False
161
- for mount in self.route_mounts.values():
162
- if mount.effect:
163
- mount.effect.pause()
166
+
167
+ for path, mount in self.route_mounts.items():
168
+ 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
+ )
175
+
176
+ # ---- Message routing ----
164
177
 
165
178
  def send(self, message: ServerMessage):
166
- # If a sender is available (connected or during prerender capture), send immediately.
167
- # Otherwise, drop the message - we'll send full VDOM state on reconnection.
168
- if self._send_message:
179
+ """Route message based on mount state."""
180
+ # Global messages (not path-specific) go directly if connected
181
+ path = message.get("path")
182
+ if path is None:
183
+ if self._send_message:
184
+ self._send_message(message)
185
+ return
186
+
187
+ # Normalize path for lookup
188
+ path = ensure_absolute_path(path)
189
+ mount = self.route_mounts.get(path)
190
+ if not mount:
191
+ # Unknown path - send directly if connected (for js_exec, etc.)
192
+ if self._send_message:
193
+ self._send_message(message)
194
+ return
195
+
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:
169
199
  self._send_message(message)
200
+ # idle: drop (effect should be paused anyway)
170
201
 
171
202
  def report_error(
172
203
  self,
@@ -195,35 +226,242 @@ class RenderSession:
195
226
  traceback.format_exc(),
196
227
  )
197
228
 
229
+ # ---- State transitions ----
230
+
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
+ # ---- Prerendering ----
254
+
255
+ def prerender(
256
+ self, path: str, route_info: RouteInfo | None = None
257
+ ) -> ServerInitMessage | ServerNavigateToMessage:
258
+ """
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
262
+ """
263
+ path = ensure_absolute_path(path)
264
+ mount = self.route_mounts.get(path)
265
+ is_new = mount is None
266
+
267
+ if is_new:
268
+ route = self.routes.find(path)
269
+ 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:
283
+ 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
+ )
300
+
301
+ return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
302
+
303
+ # ---- Client lifecycle ----
304
+
305
+ def attach(self, path: str, route_info: RouteInfo):
306
+ """
307
+ Client ready to receive updates for path.
308
+ - PENDING: flush queue, transition to ACTIVE
309
+ - IDLE: fresh render, transition to ACTIVE
310
+ - ACTIVE: update route_info
311
+ - No mount: create fresh
312
+ """
313
+ path = ensure_absolute_path(path)
314
+ mount = self.route_mounts.get(path)
315
+
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)
323
+ return
324
+
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)
347
+
348
+ def update_route(self, path: str, route_info: RouteInfo):
349
+ """Update routing state (query params, etc.) for attached path."""
350
+ path = ensure_absolute_path(path)
351
+ try:
352
+ mount = self.get_route_mount(path)
353
+ mount.route.update(route_info)
354
+ except Exception as e:
355
+ self.report_error(path, "navigate", e)
356
+
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:
361
+ return
362
+ 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()
368
+ except Exception as e:
369
+ self.report_error(path, "unmount", e)
370
+
371
+ # ---- Effect creation ----
372
+
373
+ def _create_render_effect(self, mount: RouteMount, path: str):
374
+ ctx = PulseContext.get()
375
+ session = ctx.session
376
+
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
+ )
412
+
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),
418
+ )
419
+
420
+ # ---- Helpers ----
421
+
198
422
  def close(self):
199
423
  self.forms.dispose()
200
424
  for path in list(self.route_mounts.keys()):
201
- self.unmount(path)
425
+ self.detach(path)
202
426
  self.route_mounts.clear()
203
- # Dispose per-session global singletons if they expose dispose()
204
427
  for value in self._global_states.values():
205
428
  value.dispose()
206
429
  self._global_states.clear()
207
- # Dispose all channels for this render session
208
430
  for channel_id in list(self.channels._channels.keys()): # pyright: ignore[reportPrivateUsage]
209
431
  channel = self.channels._channels.get(channel_id) # pyright: ignore[reportPrivateUsage]
210
432
  if channel:
211
433
  channel.closed = True
212
434
  self.channels.dispose_channel(channel, reason="render.close")
213
- # Cancel pending API calls
214
435
  for fut in self._pending_api.values():
215
436
  if not fut.done():
216
437
  fut.cancel()
217
438
  self._pending_api.clear()
218
- # Cancel pending JS execution results
219
439
  for fut in self._pending_js_results.values():
220
440
  if not fut.done():
221
441
  fut.cancel()
222
442
  self._pending_js_results.clear()
223
- # The effect will be garbage collected, and with it the dependencies
224
443
  self._send_message = None
225
444
  self.connected = False
226
445
 
446
+ def get_route_mount(self, path: str) -> RouteMount:
447
+ path = ensure_absolute_path(path)
448
+ mount = self.route_mounts.get(path)
449
+ if not mount:
450
+ raise ValueError(f"No active route for '{path}'")
451
+ return mount
452
+
453
+ def get_global_state(self, key: str, factory: Callable[[], Any]) -> Any:
454
+ """Return a per-session singleton for the provided key."""
455
+ inst = self._global_states.get(key)
456
+ if inst is None:
457
+ inst = factory()
458
+ self._global_states[key] = inst
459
+ return inst
460
+
461
+ def flush(self):
462
+ with PulseContext.update(render=self):
463
+ flush_effects()
464
+
227
465
  def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
228
466
  mount = self.route_mounts[path]
229
467
  cb = mount.tree.callbacks[key]
@@ -241,6 +479,8 @@ class RenderSession:
241
479
  except Exception as e:
242
480
  report(e)
243
481
 
482
+ # ---- API calls ----
483
+
244
484
  async def call_api(
245
485
  self,
246
486
  url_or_path: str,
@@ -251,17 +491,7 @@ class RenderSession:
251
491
  credentials: str = "include",
252
492
  timeout: float = 30.0,
253
493
  ) -> dict[str, Any]:
254
- """Request the client to perform a fetch and await the result.
255
-
256
- Accepts either an absolute URL (http/https) or a relative path. When a
257
- relative path is provided, it is resolved against this session's
258
- server_address.
259
-
260
- Args:
261
- timeout: Maximum seconds to wait for response (default 30s).
262
- Raises asyncio.TimeoutError if exceeded.
263
- """
264
- # Resolve to absolute URL if a relative path is passed
494
+ """Request the client to perform a fetch and await the result."""
265
495
  if url_or_path.startswith("http://") or url_or_path.startswith("https://"):
266
496
  url = url_or_path
267
497
  else:
@@ -270,8 +500,8 @@ class RenderSession:
270
500
  raise RuntimeError(
271
501
  "Server address unavailable. Ensure App.run_codegen/asgi_factory set server_address."
272
502
  )
273
- path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
274
- url = f"{base}{path}"
503
+ api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
504
+ url = f"{base}{api_path}"
275
505
  corr_id = uuid.uuid4().hex
276
506
  fut = create_future_on_loop()
277
507
  self._pending_api[corr_id] = fut
@@ -312,27 +542,28 @@ class RenderSession:
312
542
  )
313
543
 
314
544
  # ---- JS Execution ----
545
+
315
546
  @overload
316
547
  def run_js(
317
- self, expr: Expr | str, *, result: Literal[True], timeout: float = ...
548
+ self, expr: Expr, *, result: Literal[True], timeout: float = ...
318
549
  ) -> asyncio.Future[object]: ...
319
550
 
320
551
  @overload
321
552
  def run_js(
322
553
  self,
323
- expr: Expr | str,
554
+ expr: Expr,
324
555
  *,
325
556
  result: Literal[False] = ...,
326
557
  timeout: float = ...,
327
558
  ) -> None: ...
328
559
 
329
560
  def run_js(
330
- self, expr: Expr | str, *, result: bool = False, timeout: float = 10.0
561
+ self, expr: Expr, *, result: bool = False, timeout: float = 10.0
331
562
  ) -> asyncio.Future[object] | None:
332
563
  """Execute JavaScript on the client.
333
564
 
334
565
  Args:
335
- expr: A Expr (e.g. from calling a @javascript function) or raw JS string.
566
+ expr: An Expr from calling a @javascript function.
336
567
  result: If True, returns a Future that resolves with the JS return value.
337
568
  If False (default), returns None (fire-and-forget).
338
569
  timeout: Maximum seconds to wait for result (default 10s, only applies when
@@ -358,19 +589,10 @@ class RenderSession:
358
589
  async def on_click():
359
590
  pos = await run_js(get_scroll_position(), result=True)
360
591
  print(pos["x"], pos["y"])
361
-
362
- Example - Raw JS string:
363
- def on_click():
364
- run_js("console.log('Hello from Python!')")
365
592
  """
366
593
  ctx = PulseContext.get()
367
594
  exec_id = next_id()
368
595
 
369
- if isinstance(expr, str):
370
- code = expr
371
- else:
372
- code = emit(expr)
373
-
374
596
  # Get route pattern path (e.g., "/users/:id") not pathname (e.g., "/users/123")
375
597
  # This must match the path used to key views on the client side
376
598
  path = ctx.route.pulse_route.unique_path() if ctx.route else "/"
@@ -380,7 +602,7 @@ class RenderSession:
380
602
  type="js_exec",
381
603
  path=path,
382
604
  id=exec_id,
383
- code=code,
605
+ expr=expr.render(),
384
606
  )
385
607
  )
386
608
 
@@ -389,7 +611,6 @@ class RenderSession:
389
611
  future: asyncio.Future[object] = loop.create_future()
390
612
  self._pending_js_results[exec_id] = future
391
613
 
392
- # Schedule auto-timeout
393
614
  def _on_timeout() -> None:
394
615
  self._pending_js_results.pop(exec_id, None)
395
616
  if not future.done():
@@ -415,208 +636,3 @@ class RenderSession:
415
636
  fut.set_exception(JsExecError(error))
416
637
  else:
417
638
  fut.set_result(data.get("result"))
418
-
419
- def create_route_mount(self, path: str, route_info: RouteInfo | None = None):
420
- route = self.routes.find(path)
421
- mount = RouteMount(self, route, route_info or route.default_route_info())
422
- self.route_mounts[path] = mount
423
- return mount
424
-
425
- def prerender_mount_capture(
426
- self, path: str, route_info: RouteInfo | None = None
427
- ) -> ServerInitMessage | ServerNavigateToMessage:
428
- """
429
- Mount the route and run the render effect immediately, capturing the
430
- initial message instead of sending over a socket.
431
-
432
- Returns a dict:
433
- { "type": "vdom_init", "vdom": VDOM } or
434
- { "type": "navigate_to", "path": str, "replace": bool }
435
- """
436
- # If already mounted (e.g., repeated prerender), do nothing special.
437
- if path in self.route_mounts:
438
- # Run a diff and synthesize an update; however, for prerender we
439
- # expect initial mount. Return current tree as a full VDOM.
440
- mount = self.get_route_mount(path)
441
- with PulseContext.update(route=mount.route):
442
- vdom = mount.tree.render()
443
- normalized_root = getattr(mount.tree, "_normalized", None)
444
- if normalized_root is not None:
445
- mount.element = normalized_root
446
- mount.rendered = True
447
- return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
448
-
449
- captured: ServerInitMessage | ServerNavigateToMessage | None = None
450
-
451
- def _capture(msg: ServerMessage):
452
- nonlocal captured
453
- # Only capture the first relevant message for this path
454
- if captured is not None:
455
- return
456
- if msg["type"] == "vdom_init" and msg["path"] == path:
457
- captured = ServerInitMessage(
458
- type="vdom_init", path=path, vdom=msg.get("vdom")
459
- )
460
- elif msg["type"] == "navigate_to":
461
- captured = ServerNavigateToMessage(
462
- type="navigate_to",
463
- path=msg["path"],
464
- replace=msg["replace"],
465
- hard=msg.get("hard", False),
466
- )
467
-
468
- prev_sender = self._send_message
469
- try:
470
- self._send_message = _capture
471
- # Reuse normal mount flow which creates and runs the effect
472
- self.mount(path, route_info or self.routes.find(path).default_route_info())
473
- # Flush any scheduled effects to stabilize output
474
- self.flush()
475
- finally:
476
- self._send_message = prev_sender
477
-
478
- # Fallback: if nothing captured (shouldn't happen), return full VDOM
479
- if captured is None:
480
- mount = self.get_route_mount(path)
481
- with PulseContext.update(route=mount.route):
482
- vdom = mount.tree.render()
483
- normalized_root = getattr(mount.tree, "_normalized", None)
484
- if normalized_root is not None:
485
- mount.element = normalized_root
486
- mount.rendered = True
487
- return ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
488
-
489
- return captured
490
-
491
- def get_route_mount(
492
- self,
493
- path: str,
494
- ):
495
- path = ensure_absolute_path(path)
496
- mount = self.route_mounts.get(path)
497
- if not mount:
498
- raise ValueError(f"No active route for '{path}'")
499
- return mount
500
-
501
- # ---- Session-local global state registry ----
502
- def get_global_state(self, key: str, factory: Callable[[], Any]) -> Any:
503
- """Return a per-session singleton for the provided key."""
504
- inst = self._global_states.get(key)
505
- if inst is None:
506
- inst = factory()
507
- self._global_states[key] = inst
508
- return inst
509
-
510
- def render(self, path: str, route_info: RouteInfo | None = None):
511
- mount = self.create_route_mount(path, route_info)
512
- with PulseContext.update(route=mount.route):
513
- vdom = mount.tree.render()
514
- normalized_root = getattr(mount.tree, "_normalized", None)
515
- if normalized_root is not None:
516
- mount.element = normalized_root
517
- mount.rendered = True
518
- return vdom
519
-
520
- def rerender(self, path: str):
521
- mount = self.get_route_mount(path)
522
- with PulseContext.update(route=mount.route):
523
- ops = mount.tree.diff(mount.element)
524
- normalized_root = getattr(mount.tree, "_normalized", None)
525
- if normalized_root is not None:
526
- mount.element = normalized_root
527
- return ops
528
-
529
- def mount(self, path: str, route_info: RouteInfo):
530
- if path in self.route_mounts:
531
- # Route already mounted - this is a reconnection case.
532
- # Reset rendered flag so effect sends vdom_init, update route info,
533
- # and resume the paused effect.
534
- mount = self.route_mounts[path]
535
- mount.rendered = False
536
- mount.route.update(route_info)
537
- if mount.effect and mount.effect.paused:
538
- mount.effect.resume()
539
- return
540
-
541
- mount = self.create_route_mount(path, route_info)
542
- # Get current context + add RouteContext. Save it to be able to mount it
543
- # whenever the render effect reruns.
544
- ctx = PulseContext.get()
545
- session = ctx.session
546
-
547
- def _render_effect():
548
- # Always ensure both render and route are present in context
549
- with PulseContext.update(session=session, render=self, route=mount.route):
550
- try:
551
- if not mount.rendered:
552
- vdom = mount.tree.render()
553
- normalized_root = getattr(mount.tree, "_normalized", None)
554
- if normalized_root is not None:
555
- mount.element = normalized_root
556
- mount.rendered = True
557
- self.send(
558
- ServerInitMessage(type="vdom_init", path=path, vdom=vdom)
559
- )
560
- else:
561
- ops = mount.tree.diff(mount.element)
562
- normalized_root = getattr(mount.tree, "_normalized", None)
563
- if normalized_root is not None:
564
- mount.element = normalized_root
565
- if ops:
566
- self.send(
567
- ServerUpdateMessage(
568
- type="vdom_update", path=path, ops=ops
569
- )
570
- )
571
- except RedirectInterrupt as r:
572
- # Prefer client-side navigation over emitting VDOM operations
573
- self.send(
574
- ServerNavigateToMessage(
575
- type="navigate_to",
576
- path=r.path,
577
- replace=r.replace,
578
- hard=False,
579
- )
580
- )
581
- except NotFoundInterrupt:
582
- # Use app-configured not-found path; fallback to '/404'
583
- self.send(
584
- ServerNavigateToMessage(
585
- type="navigate_to",
586
- path=ctx.app.not_found,
587
- replace=True,
588
- hard=False,
589
- )
590
- )
591
-
592
- mount.effect = Effect(
593
- _render_effect,
594
- immediate=True,
595
- name=f"{path}:render",
596
- on_error=lambda e: self.report_error(path, "render", e),
597
- )
598
-
599
- def flush(self):
600
- # Ensure effects (including route render effects) run with this session
601
- # bound on the PulseContext so hooks like ps.global_state work
602
- with PulseContext.update(render=self):
603
- flush_effects()
604
-
605
- def navigate(self, path: str, route_info: RouteInfo):
606
- # Route is already mounted, we can just update the routing state
607
- try:
608
- mount = self.get_route_mount(path)
609
- mount.route.update(route_info)
610
- except Exception as e:
611
- self.report_error(path, "navigate", e)
612
-
613
- def unmount(self, path: str):
614
- if path not in self.route_mounts:
615
- return
616
- try:
617
- mount = self.route_mounts.pop(path)
618
- mount.tree.unmount()
619
- if mount.effect:
620
- mount.effect.dispose()
621
- except Exception as e:
622
- self.report_error(path, "unmount", e)