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.
- pulse/__init__.py +9 -23
- pulse/app.py +6 -25
- pulse/cli/processes.py +1 -0
- pulse/codegen/codegen.py +43 -88
- pulse/codegen/js.py +35 -5
- pulse/codegen/templates/route.py +341 -254
- pulse/form.py +1 -1
- pulse/helpers.py +51 -27
- pulse/hooks/core.py +2 -2
- pulse/hooks/effects.py +1 -1
- pulse/hooks/init.py +2 -1
- pulse/hooks/setup.py +1 -1
- pulse/hooks/stable.py +2 -2
- pulse/hooks/states.py +2 -2
- pulse/html/props.py +3 -2
- pulse/html/tags.py +135 -0
- pulse/html/tags.pyi +4 -0
- pulse/js/__init__.py +110 -0
- pulse/js/__init__.pyi +95 -0
- pulse/js/_types.py +297 -0
- pulse/js/array.py +253 -0
- pulse/js/console.py +47 -0
- pulse/js/date.py +113 -0
- pulse/js/document.py +138 -0
- pulse/js/error.py +139 -0
- pulse/js/json.py +62 -0
- pulse/js/map.py +84 -0
- pulse/js/math.py +66 -0
- pulse/js/navigator.py +76 -0
- pulse/js/number.py +54 -0
- pulse/js/object.py +173 -0
- pulse/js/promise.py +150 -0
- pulse/js/regexp.py +54 -0
- pulse/js/set.py +109 -0
- pulse/js/string.py +35 -0
- pulse/js/weakmap.py +50 -0
- pulse/js/weakset.py +45 -0
- pulse/js/window.py +199 -0
- pulse/messages.py +22 -3
- pulse/proxy.py +21 -8
- pulse/react_component.py +167 -14
- pulse/reactive_extensions.py +5 -5
- pulse/render_session.py +144 -34
- pulse/renderer.py +80 -115
- pulse/routing.py +1 -18
- pulse/transpiler/__init__.py +131 -0
- pulse/transpiler/builtins.py +731 -0
- pulse/transpiler/constants.py +110 -0
- pulse/transpiler/context.py +26 -0
- pulse/transpiler/errors.py +2 -0
- pulse/transpiler/function.py +250 -0
- pulse/transpiler/ids.py +16 -0
- pulse/transpiler/imports.py +409 -0
- pulse/transpiler/js_module.py +274 -0
- pulse/transpiler/modules/__init__.py +30 -0
- pulse/transpiler/modules/asyncio.py +38 -0
- pulse/transpiler/modules/json.py +20 -0
- pulse/transpiler/modules/math.py +320 -0
- pulse/transpiler/modules/re.py +466 -0
- pulse/transpiler/modules/tags.py +268 -0
- pulse/transpiler/modules/typing.py +59 -0
- pulse/transpiler/nodes.py +1216 -0
- pulse/transpiler/py_module.py +119 -0
- pulse/transpiler/transpiler.py +938 -0
- pulse/transpiler/utils.py +4 -0
- pulse/vdom.py +112 -6
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/METADATA +1 -1
- pulse_framework-0.1.48.dist-info/RECORD +119 -0
- pulse/codegen/imports.py +0 -204
- pulse/css.py +0 -155
- pulse_framework-0.1.46.dist-info/RECORD +0 -80
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/WHEEL +0 -0
- {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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 += ".
|
|
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)
|