pulse-framework 0.1.46__py3-none-any.whl → 0.1.48__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 (73) hide show
  1. pulse/__init__.py +9 -23
  2. pulse/app.py +6 -25
  3. pulse/cli/processes.py +1 -0
  4. pulse/codegen/codegen.py +43 -88
  5. pulse/codegen/js.py +35 -5
  6. pulse/codegen/templates/route.py +341 -254
  7. pulse/form.py +1 -1
  8. pulse/helpers.py +51 -27
  9. pulse/hooks/core.py +2 -2
  10. pulse/hooks/effects.py +1 -1
  11. pulse/hooks/init.py +2 -1
  12. pulse/hooks/setup.py +1 -1
  13. pulse/hooks/stable.py +2 -2
  14. pulse/hooks/states.py +2 -2
  15. pulse/html/props.py +3 -2
  16. pulse/html/tags.py +135 -0
  17. pulse/html/tags.pyi +4 -0
  18. pulse/js/__init__.py +110 -0
  19. pulse/js/__init__.pyi +95 -0
  20. pulse/js/_types.py +297 -0
  21. pulse/js/array.py +253 -0
  22. pulse/js/console.py +47 -0
  23. pulse/js/date.py +113 -0
  24. pulse/js/document.py +138 -0
  25. pulse/js/error.py +139 -0
  26. pulse/js/json.py +62 -0
  27. pulse/js/map.py +84 -0
  28. pulse/js/math.py +66 -0
  29. pulse/js/navigator.py +76 -0
  30. pulse/js/number.py +54 -0
  31. pulse/js/object.py +173 -0
  32. pulse/js/promise.py +150 -0
  33. pulse/js/regexp.py +54 -0
  34. pulse/js/set.py +109 -0
  35. pulse/js/string.py +35 -0
  36. pulse/js/weakmap.py +50 -0
  37. pulse/js/weakset.py +45 -0
  38. pulse/js/window.py +199 -0
  39. pulse/messages.py +22 -3
  40. pulse/proxy.py +21 -8
  41. pulse/react_component.py +167 -14
  42. pulse/reactive_extensions.py +5 -5
  43. pulse/render_session.py +144 -34
  44. pulse/renderer.py +80 -115
  45. pulse/routing.py +1 -18
  46. pulse/transpiler/__init__.py +131 -0
  47. pulse/transpiler/builtins.py +731 -0
  48. pulse/transpiler/constants.py +110 -0
  49. pulse/transpiler/context.py +26 -0
  50. pulse/transpiler/errors.py +2 -0
  51. pulse/transpiler/function.py +250 -0
  52. pulse/transpiler/ids.py +16 -0
  53. pulse/transpiler/imports.py +409 -0
  54. pulse/transpiler/js_module.py +274 -0
  55. pulse/transpiler/modules/__init__.py +30 -0
  56. pulse/transpiler/modules/asyncio.py +38 -0
  57. pulse/transpiler/modules/json.py +20 -0
  58. pulse/transpiler/modules/math.py +320 -0
  59. pulse/transpiler/modules/re.py +466 -0
  60. pulse/transpiler/modules/tags.py +268 -0
  61. pulse/transpiler/modules/typing.py +59 -0
  62. pulse/transpiler/nodes.py +1216 -0
  63. pulse/transpiler/py_module.py +119 -0
  64. pulse/transpiler/transpiler.py +938 -0
  65. pulse/transpiler/utils.py +4 -0
  66. pulse/vdom.py +112 -6
  67. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/METADATA +1 -1
  68. pulse_framework-0.1.48.dist-info/RECORD +119 -0
  69. pulse/codegen/imports.py +0 -204
  70. pulse/css.py +0 -155
  71. pulse_framework-0.1.46.dist-info/RECORD +0 -80
  72. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/WHEEL +0 -0
  73. {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/entry_points.txt +0 -0
pulse/render_session.py CHANGED
@@ -4,7 +4,7 @@ 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
7
+ from typing import TYPE_CHECKING, Any, Literal, overload
8
8
 
9
9
  from pulse.context import PulseContext
10
10
  from pulse.helpers import create_future_on_loop, create_task
@@ -14,6 +14,7 @@ from pulse.messages import (
14
14
  ServerErrorMessage,
15
15
  ServerErrorPhase,
16
16
  ServerInitMessage,
17
+ ServerJsExecMessage,
17
18
  ServerMessage,
18
19
  ServerNavigateToMessage,
19
20
  ServerUpdateMessage,
@@ -30,6 +31,9 @@ from pulse.routing import (
30
31
  ensure_absolute_path,
31
32
  )
32
33
  from pulse.state import State
34
+ from pulse.transpiler.context import interpreted_mode
35
+ from pulse.transpiler.ids import generate_id
36
+ from pulse.transpiler.nodes import JSExpr
33
37
  from pulse.vdom import Element
34
38
 
35
39
  if TYPE_CHECKING:
@@ -39,6 +43,27 @@ if TYPE_CHECKING:
39
43
  logger = logging.getLogger(__file__)
40
44
 
41
45
 
46
+ class JsExecError(Exception):
47
+ """Raised when client-side JS execution fails."""
48
+
49
+
50
+ # Module-level convenience wrapper
51
+ @overload
52
+ def run_js(expr: JSExpr | str, *, result: Literal[True]) -> asyncio.Future[Any]: ...
53
+
54
+
55
+ @overload
56
+ def run_js(expr: JSExpr | str, *, result: Literal[False] = ...) -> None: ...
57
+
58
+
59
+ def run_js(expr: JSExpr | str, *, result: bool = False) -> asyncio.Future[Any] | None:
60
+ """Execute JavaScript on the client. Convenience wrapper for RenderSession.run_js()."""
61
+ ctx = PulseContext.get()
62
+ if ctx.render is None:
63
+ raise RuntimeError("run_js() can only be called during callback execution")
64
+ return ctx.render.run_js(expr, result=result)
65
+
66
+
42
67
  class RouteMount:
43
68
  render: "RenderSession"
44
69
  route: RouteContext
@@ -101,6 +126,8 @@ class RenderSession:
101
126
  self.connected = False
102
127
  self.channels = ChannelsManager(self)
103
128
  self.forms = FormRegistry(self)
129
+ # Pending JS execution results (for awaiting run_js().result())
130
+ self._pending_js_results: dict[str, asyncio.Future[Any]] = {}
104
131
 
105
132
  @property
106
133
  def server_address(self) -> str:
@@ -147,7 +174,7 @@ class RenderSession:
147
174
  self,
148
175
  path: str,
149
176
  phase: ServerErrorPhase,
150
- exc: Exception,
177
+ exc: BaseException,
151
178
  details: dict[str, Any] | None = None,
152
179
  ):
153
180
  error_msg: ServerErrorMessage = {
@@ -190,26 +217,20 @@ class RenderSession:
190
217
 
191
218
  def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
192
219
  mount = self.route_mounts[path]
193
- try:
194
- cb = mount.tree.callbacks[key]
195
- fn, n_params = cb.fn, cb.n_args
196
- res = fn(*args[:n_params])
197
- if iscoroutine(res):
198
-
199
- def _on_task_done(t: asyncio.Task[Any]):
200
- try:
201
- t.result()
202
- except Exception as e:
203
- self.report_error(
204
- path,
205
- "callback",
206
- e,
207
- {"callback": key, "async": True},
208
- )
220
+ cb = mount.tree.callbacks[key]
209
221
 
210
- create_task(res, on_done=_on_task_done)
222
+ def report(e: BaseException, is_async: bool = False):
223
+ self.report_error(path, "callback", e, {"callback": key, "async": is_async})
224
+
225
+ try:
226
+ with PulseContext.update(render=self, route=mount.route):
227
+ res = cb.fn(*args[: cb.n_args])
228
+ if iscoroutine(res):
229
+ create_task(
230
+ res, on_done=lambda t: (e := t.exception()) and report(e, True)
231
+ )
211
232
  except Exception as e:
212
- self.report_error(path, "callback", e, {"callback": key})
233
+ report(e)
213
234
 
214
235
  async def call_api(
215
236
  self,
@@ -272,6 +293,94 @@ class RenderSession:
272
293
  }
273
294
  )
274
295
 
296
+ # ---- JS Execution ----
297
+ @overload
298
+ def run_js(
299
+ self, expr: JSExpr | str, *, result: Literal[True]
300
+ ) -> asyncio.Future[object]: ...
301
+
302
+ @overload
303
+ def run_js(self, expr: JSExpr | str, *, result: Literal[False] = ...) -> None: ...
304
+
305
+ def run_js(
306
+ self, expr: JSExpr | str, *, result: bool = False
307
+ ) -> asyncio.Future[object] | None:
308
+ """Execute JavaScript on the client.
309
+
310
+ Args:
311
+ expr: A JSExpr (e.g. from calling a @javascript function) or raw JS string.
312
+ result: If True, returns a Future that resolves with the JS return value.
313
+ If False (default), returns None (fire-and-forget).
314
+
315
+ Returns:
316
+ None if result=False, otherwise a Future resolving to the JS result.
317
+
318
+ Example - Fire and forget:
319
+ @javascript
320
+ def focus_element(selector: str):
321
+ document.querySelector(selector).focus()
322
+
323
+ def on_save():
324
+ save_data()
325
+ run_js(focus_element("#next-input"))
326
+
327
+ Example - Await result:
328
+ @javascript
329
+ def get_scroll_position():
330
+ return {"x": window.scrollX, "y": window.scrollY}
331
+
332
+ async def on_click():
333
+ pos = await run_js(get_scroll_position(), result=True)
334
+ print(pos["x"], pos["y"])
335
+
336
+ Example - Raw JS string:
337
+ def on_click():
338
+ run_js("console.log('Hello from Python!')")
339
+ """
340
+ ctx = PulseContext.get()
341
+ exec_id = generate_id()
342
+
343
+ if isinstance(expr, str):
344
+ code = expr
345
+ else:
346
+ with interpreted_mode():
347
+ code = expr.emit()
348
+
349
+ # Get path from route context, fallback to "/"
350
+ path = ctx.route.pathname if ctx.route else "/"
351
+
352
+ self.send(
353
+ ServerJsExecMessage(
354
+ type="js_exec",
355
+ path=path,
356
+ id=exec_id,
357
+ code=code,
358
+ )
359
+ )
360
+
361
+ if result:
362
+ loop = asyncio.get_running_loop()
363
+ future: asyncio.Future[object] = loop.create_future()
364
+ self._pending_js_results[exec_id] = future
365
+ return future
366
+
367
+ return None
368
+
369
+ def handle_js_result(self, data: dict[str, Any]) -> None:
370
+ """Handle js_result message from client."""
371
+ exec_id = data.get("id")
372
+ if exec_id is None:
373
+ return
374
+ exec_id = str(exec_id)
375
+ fut = self._pending_js_results.pop(exec_id, None)
376
+ if fut is None or fut.done():
377
+ return
378
+ error = data.get("error")
379
+ if error is not None:
380
+ fut.set_exception(JsExecError(error))
381
+ else:
382
+ fut.set_result(data.get("result"))
383
+
275
384
  def create_route_mount(self, path: str, route_info: RouteInfo | None = None):
276
385
  route = self.routes.find(path)
277
386
  mount = RouteMount(self, route, route_info or route.default_route_info())
@@ -300,14 +409,15 @@ class RenderSession:
300
409
  if normalized_root is not None:
301
410
  mount.element = normalized_root
302
411
  mount.rendered = True
303
- return ServerInitMessage(
412
+ msg = ServerInitMessage(
304
413
  type="vdom_init",
305
414
  path=path,
306
415
  vdom=vdom,
307
416
  callbacks=sorted(mount.tree.callbacks.keys()),
308
417
  render_props=sorted(mount.tree.render_props),
309
- css_refs=sorted(mount.tree.css_refs),
418
+ jsexpr_paths=sorted(mount.tree.jsexpr_paths),
310
419
  )
420
+ return msg
311
421
 
312
422
  captured: ServerInitMessage | ServerNavigateToMessage | None = None
313
423
 
@@ -323,7 +433,7 @@ class RenderSession:
323
433
  vdom=msg.get("vdom"),
324
434
  callbacks=msg.get("callbacks", []),
325
435
  render_props=msg.get("render_props", []),
326
- css_refs=msg.get("css_refs", []),
436
+ jsexpr_paths=msg.get("jsexpr_paths", []),
327
437
  )
328
438
  elif msg["type"] == "navigate_to":
329
439
  captured = ServerNavigateToMessage(
@@ -351,14 +461,15 @@ class RenderSession:
351
461
  if normalized_root is not None:
352
462
  mount.element = normalized_root
353
463
  mount.rendered = True
354
- return ServerInitMessage(
464
+ msg = ServerInitMessage(
355
465
  type="vdom_init",
356
466
  path=path,
357
467
  vdom=vdom,
358
468
  callbacks=sorted(mount.tree.callbacks.keys()),
359
469
  render_props=sorted(mount.tree.render_props),
360
- css_refs=sorted(mount.tree.css_refs),
470
+ jsexpr_paths=sorted(mount.tree.jsexpr_paths),
361
471
  )
472
+ return msg
362
473
 
363
474
  return captured
364
475
 
@@ -428,16 +539,15 @@ class RenderSession:
428
539
  if normalized_root is not None:
429
540
  mount.element = normalized_root
430
541
  mount.rendered = True
431
- self.send(
432
- ServerInitMessage(
433
- type="vdom_init",
434
- path=path,
435
- vdom=vdom,
436
- callbacks=sorted(mount.tree.callbacks.keys()),
437
- render_props=sorted(mount.tree.render_props),
438
- css_refs=sorted(mount.tree.css_refs),
439
- )
542
+ msg = ServerInitMessage(
543
+ type="vdom_init",
544
+ path=path,
545
+ vdom=vdom,
546
+ callbacks=sorted(mount.tree.callbacks.keys()),
547
+ render_props=sorted(mount.tree.render_props),
548
+ jsexpr_paths=sorted(mount.tree.jsexpr_paths),
440
549
  )
550
+ self.send(msg)
441
551
  else:
442
552
  ops = mount.tree.diff(mount.element)
443
553
  normalized_root = getattr(mount.tree, "_normalized", None)
pulse/renderer.py CHANGED
@@ -1,17 +1,12 @@
1
1
  import inspect
2
2
  from collections.abc import Callable, Sequence
3
3
  from dataclasses import dataclass
4
- from typing import (
5
- Any,
6
- Literal,
7
- NamedTuple,
8
- TypeAlias,
9
- TypedDict,
10
- cast,
11
- )
4
+ from typing import Any, NamedTuple, TypeAlias, cast
12
5
 
13
- from pulse.css import CssReference
14
6
  from pulse.helpers import values_equal
7
+ from pulse.transpiler.context import interpreted_mode
8
+ from pulse.transpiler.imports import Import
9
+ from pulse.transpiler.nodes import JSExpr
15
10
  from pulse.vdom import (
16
11
  VDOM,
17
12
  Callback,
@@ -19,79 +14,33 @@ from pulse.vdom import (
19
14
  ComponentNode,
20
15
  Element,
21
16
  Node,
17
+ PathDelta,
22
18
  Props,
19
+ ReconciliationOperation,
20
+ ReplaceOperation,
21
+ UpdateCallbacksOperation,
22
+ UpdateJsExprPathsOperation,
23
+ UpdatePropsDelta,
24
+ UpdatePropsOperation,
25
+ UpdateRenderPropsOperation,
23
26
  VDOMNode,
27
+ VDOMOperation,
24
28
  )
25
29
 
26
30
 
27
- class ReplaceOperation(TypedDict):
28
- type: Literal["replace"]
29
- path: str
30
- data: VDOM
31
-
32
-
33
- # This payload makes it easy for the client to rebuild an array of React nodes
34
- # from the previous children array:
35
- # - Allocate array of size N
36
- # - For i in 0..N-1, check the following scenarios
37
- # - i matches the next index in `new` -> use provided tree
38
- # - i matches the next index in `reuse` -> reuse previous child
39
- # - otherwise, reuse the element at the same index
40
- class ReconciliationOperation(TypedDict):
41
- type: Literal["reconciliation"]
42
- path: str
43
- N: int
44
- new: tuple[list[int], list[VDOM]]
45
- reuse: tuple[list[int], list[int]]
46
-
47
-
48
- class UpdatePropsDelta(TypedDict, total=False):
49
- # Only send changed/new keys under `set` and removed keys under `remove`
50
- set: Props
51
- remove: list[str]
52
-
53
-
54
- class UpdatePropsOperation(TypedDict):
55
- type: Literal["update_props"]
56
- path: str
57
- data: UpdatePropsDelta
31
+ def is_jsexpr(value: object) -> bool:
32
+ """Check if a value is a JSExpr or Import."""
33
+ return isinstance(value, (JSExpr, Import))
58
34
 
59
35
 
60
- class PathDelta(TypedDict, total=False):
61
- add: list[str]
62
- remove: list[str]
36
+ def emit_jsexpr(value: "JSExpr | Import") -> str:
37
+ """Emit a JSExpr in interpreted mode (for client-side evaluation)."""
38
+ with interpreted_mode():
39
+ if isinstance(value, Import):
40
+ return value.emit()
41
+ return value.emit()
63
42
 
64
43
 
65
- class UpdateCallbacksOperation(TypedDict):
66
- type: Literal["update_callbacks"]
67
- path: str
68
- data: PathDelta
69
-
70
-
71
- class UpdateCssRefsOperation(TypedDict):
72
- type: Literal["update_css_refs"]
73
- path: str
74
- data: PathDelta
75
-
76
-
77
- class UpdateRenderPropsOperation(TypedDict):
78
- type: Literal["update_render_props"]
79
- path: str
80
- data: PathDelta
81
-
82
-
83
- VDOMOperation: TypeAlias = (
84
- # InsertOperation,
85
- # RemoveOperation,
86
- ReplaceOperation
87
- | UpdatePropsOperation
88
- # | MoveOperation,
89
- | ReconciliationOperation
90
- | UpdateCallbacksOperation
91
- | UpdateCssRefsOperation
92
- | UpdateRenderPropsOperation
93
- )
94
-
95
44
  RenderPath: TypeAlias = str
96
45
 
97
46
 
@@ -99,13 +48,13 @@ class RenderTree:
99
48
  root: Element
100
49
  callbacks: Callbacks
101
50
  render_props: set[str]
102
- css_refs: set[str]
51
+ jsexpr_paths: set[str] # paths containing JS expressions
103
52
 
104
53
  def __init__(self, root: Element) -> None:
105
54
  self.root = root
106
55
  self.callbacks = {}
107
56
  self.render_props = set()
108
- self.css_refs = set()
57
+ self.jsexpr_paths = set()
109
58
  self.normalized: Element | None = None
110
59
 
111
60
  def render(self) -> VDOM:
@@ -114,7 +63,7 @@ class RenderTree:
114
63
  self.root = normalized
115
64
  self.callbacks = renderer.callbacks
116
65
  self.render_props = renderer.render_props
117
- self.css_refs = renderer.css_refs
66
+ self.jsexpr_paths = renderer.jsexpr_paths
118
67
  self.normalized = normalized
119
68
  return vdom
120
69
 
@@ -135,23 +84,8 @@ class RenderTree:
135
84
  render_props_add = sorted(render_props_next - render_props_prev)
136
85
  render_props_remove = sorted(render_props_prev - render_props_next)
137
86
 
138
- css_prev = self.css_refs
139
- css_next = renderer.css_refs
140
- css_add = sorted(css_next - css_prev)
141
- css_remove = sorted(css_prev - css_next)
142
-
143
87
  prefix: list[VDOMOperation] = []
144
88
 
145
- if css_add or css_remove:
146
- css_delta: PathDelta = {}
147
- if css_add:
148
- css_delta["add"] = css_add
149
- if css_remove:
150
- css_delta["remove"] = css_remove
151
- prefix.append(
152
- UpdateCssRefsOperation(type="update_css_refs", path="", data=css_delta)
153
- )
154
-
155
89
  if callback_add or callback_remove:
156
90
  callback_delta: PathDelta = {}
157
91
  if callback_add:
@@ -176,11 +110,27 @@ class RenderTree:
176
110
  )
177
111
  )
178
112
 
113
+ jsexpr_prev = self.jsexpr_paths
114
+ jsexpr_next = renderer.jsexpr_paths
115
+ jsexpr_add = sorted(jsexpr_next - jsexpr_prev)
116
+ jsexpr_remove = sorted(jsexpr_prev - jsexpr_next)
117
+ if jsexpr_add or jsexpr_remove:
118
+ jsexpr_delta: PathDelta = {}
119
+ if jsexpr_add:
120
+ jsexpr_delta["add"] = jsexpr_add
121
+ if jsexpr_remove:
122
+ jsexpr_delta["remove"] = jsexpr_remove
123
+ prefix.append(
124
+ UpdateJsExprPathsOperation(
125
+ type="update_jsexpr_paths", path="", data=jsexpr_delta
126
+ )
127
+ )
128
+
179
129
  ops = prefix + renderer.operations if prefix else renderer.operations
180
130
 
181
131
  self.callbacks = renderer.callbacks
182
132
  self.render_props = renderer.render_props
183
- self.css_refs = renderer.css_refs
133
+ self.jsexpr_paths = renderer.jsexpr_paths
184
134
  self.normalized = normalized
185
135
  self.root = normalized
186
136
 
@@ -192,7 +142,11 @@ class RenderTree:
192
142
  self.normalized = None
193
143
  self.callbacks.clear()
194
144
  self.render_props.clear()
195
- self.css_refs.clear()
145
+ self.jsexpr_paths.clear()
146
+
147
+
148
+ # Prefix for JSExpr values - code is embedded after the colon
149
+ JSEXPR_PREFIX = "$js:"
196
150
 
197
151
 
198
152
  @dataclass(slots=True)
@@ -214,7 +168,7 @@ class Renderer:
214
168
  def __init__(self) -> None:
215
169
  self.callbacks: Callbacks = {}
216
170
  self.render_props: set[str] = set()
217
- self.css_refs: set[str] = set()
171
+ self.jsexpr_paths: set[str] = set()
218
172
  self.operations: list[VDOMOperation] = []
219
173
 
220
174
  # ------------------------------------------------------------------
@@ -226,6 +180,13 @@ class Renderer:
226
180
  return self.render_component(node, path)
227
181
  if isinstance(node, Node):
228
182
  return self.render_node(node, path)
183
+ # Handle JSExpr as children - emit JS code with $js: prefix
184
+ if is_jsexpr(node):
185
+ # Safe cast: is_jsexpr() ensures node is JSExpr | Import
186
+ node_as_jsexpr = cast("JSExpr | Import", cast(object, node))
187
+ js_code = emit_jsexpr(node_as_jsexpr)
188
+ self.jsexpr_paths.add(path)
189
+ return f"{JSEXPR_PREFIX}{js_code}", cast(Element, node)
229
190
  return node, node
230
191
 
231
192
  def render_component(
@@ -453,28 +414,23 @@ class Renderer:
453
414
  old_value = previous.get(key)
454
415
  prop_path = join_path(path, key)
455
416
 
456
- if callable(value):
457
- if isinstance(old_value, (Node, ComponentNode)):
458
- unmount_element(old_value)
459
- if normalized is None:
460
- normalized = current.copy()
461
- normalized[key] = "$cb"
462
- register_callback(
463
- self.callbacks, prop_path, cast(Callable[..., Any], value)
464
- )
465
- if old_value != "$cb":
466
- updated[key] = "$cb"
467
- continue
468
-
469
- if isinstance(value, CssReference):
417
+ if is_jsexpr(value):
470
418
  if isinstance(old_value, (Node, ComponentNode)):
471
419
  unmount_element(old_value)
472
420
  if normalized is None:
473
421
  normalized = current.copy()
474
422
  normalized[key] = value
475
- self.css_refs.add(prop_path)
476
- if not isinstance(old_value, CssReference) or old_value != value:
477
- updated[key] = _css_ref_token(value)
423
+ # Emit the JSExpr with $js: prefix - code is embedded in the value
424
+ js_code = emit_jsexpr(cast("JSExpr | Import", value))
425
+ self.jsexpr_paths.add(prop_path)
426
+ js_value = f"{JSEXPR_PREFIX}{js_code}"
427
+ old_js_code = (
428
+ emit_jsexpr(cast("JSExpr | Import", old_value))
429
+ if is_jsexpr(old_value)
430
+ else None
431
+ )
432
+ if old_js_code != js_code:
433
+ updated[key] = js_value
478
434
  continue
479
435
 
480
436
  if isinstance(value, (Node, ComponentNode)):
@@ -497,6 +453,19 @@ class Renderer:
497
453
  updated[key] = vdom_value
498
454
  continue
499
455
 
456
+ if callable(value):
457
+ if isinstance(old_value, (Node, ComponentNode)):
458
+ unmount_element(old_value)
459
+ if normalized is None:
460
+ normalized = current.copy()
461
+ normalized[key] = "$cb"
462
+ register_callback(
463
+ self.callbacks, prop_path, cast(Callable[..., Any], value)
464
+ )
465
+ if old_value != "$cb":
466
+ updated[key] = "$cb"
467
+ continue
468
+
500
469
  if isinstance(old_value, (Node, ComponentNode)):
501
470
  unmount_element(old_value)
502
471
 
@@ -558,10 +527,6 @@ def same_node(left: Element, right: Element) -> bool:
558
527
  return False
559
528
 
560
529
 
561
- def _css_ref_token(ref: CssReference) -> str:
562
- return f"{ref.module.id}:{ref.name}"
563
-
564
-
565
530
  def unmount_element(element: Element) -> None:
566
531
  if isinstance(element, ComponentNode):
567
532
  if element.contents is not None:
pulse/routing.py CHANGED
@@ -3,7 +3,6 @@ from collections.abc import Sequence
3
3
  from dataclasses import dataclass, field
4
4
  from typing import TypedDict, cast, override
5
5
 
6
- from pulse.css import CssImport, CssModule
7
6
  from pulse.env import env
8
7
  from pulse.react_component import ReactComponent
9
8
  from pulse.reactive_extensions import ReactiveDict
@@ -160,8 +159,6 @@ class Route:
160
159
  render: Component[[]]
161
160
  children: Sequence["Route | Layout"]
162
161
  components: Sequence[ReactComponent[...]] | None
163
- css_modules: Sequence[CssModule] | None
164
- css_imports: Sequence[CssImport] | None
165
162
  is_index: bool
166
163
  is_dynamic: bool
167
164
  dev: bool
@@ -172,8 +169,6 @@ class Route:
172
169
  render: Component[[]],
173
170
  children: "Sequence[Route | Layout] | None" = None,
174
171
  components: "Sequence[ReactComponent[...]] | None" = None,
175
- css_modules: Sequence[CssModule] | None = None,
176
- css_imports: Sequence[CssImport] | None = None,
177
172
  dev: bool = False,
178
173
  ):
179
174
  self.path = ensure_relative_path(path)
@@ -182,8 +177,6 @@ class Route:
182
177
  self.render = render
183
178
  self.children = children or []
184
179
  self.components = components
185
- self.css_modules = css_modules
186
- self.css_imports = css_imports
187
180
  self.dev = dev
188
181
  self.parent: Route | Layout | None = None
189
182
 
@@ -208,7 +201,7 @@ class Route:
208
201
  path = "/".join(self._path_list(include_layouts=False))
209
202
  if self.is_index:
210
203
  path += "index"
211
- path += ".tsx"
204
+ path += ".jsx"
212
205
  # Replace Windows-invalid characters in filenames
213
206
  return _sanitize_filename(path)
214
207
 
@@ -257,8 +250,6 @@ class Layout:
257
250
  render: Component[...]
258
251
  children: Sequence["Route | Layout"]
259
252
  components: Sequence[ReactComponent[...]] | None
260
- css_modules: Sequence[CssModule] | None
261
- css_imports: Sequence[CssImport] | None
262
253
  dev: bool
263
254
 
264
255
  def __init__(
@@ -266,15 +257,11 @@ class Layout:
266
257
  render: "Component[...]",
267
258
  children: "Sequence[Route | Layout] | None" = None,
268
259
  components: "Sequence[ReactComponent[...]] | None" = None,
269
- css_modules: Sequence[CssModule] | None = None,
270
- css_imports: Sequence[CssImport] | None = None,
271
260
  dev: bool = False,
272
261
  ):
273
262
  self.render = render
274
263
  self.children = children or []
275
264
  self.components = components
276
- self.css_modules = css_modules
277
- self.css_imports = css_imports
278
265
  self.dev = dev
279
266
  self.parent: Route | Layout | None = None
280
267
  # 1-based sibling index assigned by RouteTree at each level
@@ -366,8 +353,6 @@ def filter_dev_routes(routes: Sequence[Route | Layout]) -> list[Route | Layout]:
366
353
  render=route.render,
367
354
  children=filtered_children,
368
355
  components=route.components,
369
- css_modules=route.css_modules,
370
- css_imports=route.css_imports,
371
356
  dev=route.dev,
372
357
  )
373
358
  else: # Layout
@@ -375,8 +360,6 @@ def filter_dev_routes(routes: Sequence[Route | Layout]) -> list[Route | Layout]:
375
360
  render=route.render,
376
361
  children=filtered_children,
377
362
  components=route.components,
378
- css_modules=route.css_modules,
379
- css_imports=route.css_imports,
380
363
  dev=route.dev,
381
364
  )
382
365
  filtered.append(filtered_route)