pulse-framework 0.1.51__py3-none-any.whl → 0.1.52__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 (84) hide show
  1. pulse/__init__.py +542 -562
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +0 -14
  4. pulse/cli/cmd.py +96 -80
  5. pulse/cli/dependencies.py +10 -41
  6. pulse/cli/folder_lock.py +3 -3
  7. pulse/cli/helpers.py +40 -67
  8. pulse/cli/logging.py +102 -0
  9. pulse/cli/packages.py +16 -0
  10. pulse/cli/processes.py +40 -23
  11. pulse/codegen/codegen.py +70 -35
  12. pulse/codegen/js.py +2 -4
  13. pulse/codegen/templates/route.py +94 -146
  14. pulse/component.py +115 -0
  15. pulse/components/for_.py +1 -1
  16. pulse/components/if_.py +1 -1
  17. pulse/components/react_router.py +16 -22
  18. pulse/{html → dom}/events.py +1 -1
  19. pulse/{html → dom}/props.py +6 -6
  20. pulse/{html → dom}/tags.py +11 -11
  21. pulse/dom/tags.pyi +480 -0
  22. pulse/form.py +7 -6
  23. pulse/hooks/init.py +1 -13
  24. pulse/js/__init__.py +37 -41
  25. pulse/js/__init__.pyi +22 -2
  26. pulse/js/_types.py +5 -3
  27. pulse/js/array.py +121 -38
  28. pulse/js/console.py +9 -9
  29. pulse/js/date.py +22 -19
  30. pulse/js/document.py +8 -4
  31. pulse/js/error.py +12 -14
  32. pulse/js/json.py +4 -3
  33. pulse/js/map.py +17 -7
  34. pulse/js/math.py +2 -2
  35. pulse/js/navigator.py +4 -4
  36. pulse/js/number.py +8 -8
  37. pulse/js/object.py +9 -13
  38. pulse/js/promise.py +25 -9
  39. pulse/js/regexp.py +6 -6
  40. pulse/js/set.py +20 -8
  41. pulse/js/string.py +7 -7
  42. pulse/js/weakmap.py +6 -6
  43. pulse/js/weakset.py +6 -6
  44. pulse/js/window.py +17 -14
  45. pulse/messages.py +1 -4
  46. pulse/react_component.py +3 -1001
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/transpiler/__init__.py +84 -114
  51. pulse/transpiler/builtins.py +661 -343
  52. pulse/transpiler/errors.py +78 -2
  53. pulse/transpiler/function.py +463 -133
  54. pulse/transpiler/id.py +18 -0
  55. pulse/transpiler/imports.py +230 -325
  56. pulse/transpiler/js_module.py +218 -209
  57. pulse/transpiler/modules/__init__.py +16 -13
  58. pulse/transpiler/modules/asyncio.py +45 -26
  59. pulse/transpiler/modules/json.py +12 -8
  60. pulse/transpiler/modules/math.py +161 -216
  61. pulse/transpiler/modules/pulse/__init__.py +5 -0
  62. pulse/transpiler/modules/pulse/tags.py +231 -0
  63. pulse/transpiler/modules/typing.py +33 -28
  64. pulse/transpiler/nodes.py +1607 -923
  65. pulse/transpiler/py_module.py +118 -95
  66. pulse/transpiler/react_component.py +51 -0
  67. pulse/transpiler/transpiler.py +593 -437
  68. pulse/transpiler/vdom.py +255 -0
  69. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
  70. pulse_framework-0.1.52.dist-info/RECORD +120 -0
  71. pulse/html/tags.pyi +0 -470
  72. pulse/transpiler/constants.py +0 -110
  73. pulse/transpiler/context.py +0 -26
  74. pulse/transpiler/ids.py +0 -16
  75. pulse/transpiler/modules/re.py +0 -466
  76. pulse/transpiler/modules/tags.py +0 -268
  77. pulse/transpiler/utils.py +0 -4
  78. pulse/vdom.py +0 -599
  79. pulse_framework-0.1.51.dist-info/RECORD +0 -119
  80. /pulse/{html → dom}/__init__.py +0 -0
  81. /pulse/{html → dom}/elements.py +0 -0
  82. /pulse/{html → dom}/svg.py +0 -0
  83. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
  84. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
pulse/renderer.py CHANGED
@@ -1,212 +1,159 @@
1
+ from __future__ import annotations
2
+
1
3
  import inspect
2
- from collections.abc import Callable, Sequence
4
+ from collections.abc import Callable, Iterable
3
5
  from dataclasses import dataclass
4
6
  from typing import Any, NamedTuple, TypeAlias, cast
5
7
 
6
8
  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
10
- from pulse.vdom import (
11
- VDOM,
12
- Callback,
13
- Callbacks,
14
- ComponentNode,
9
+ from pulse.hooks.core import HookContext
10
+ from pulse.transpiler import Import
11
+ from pulse.transpiler.function import Constant, JsFunction, JsxFunction
12
+ from pulse.transpiler.nodes import (
13
+ Child,
14
+ Children,
15
15
  Element,
16
+ Expr,
17
+ Literal,
16
18
  Node,
17
- PathDelta,
18
- Props,
19
+ PulseNode,
20
+ Value,
21
+ )
22
+ from pulse.transpiler.vdom import (
23
+ VDOM,
19
24
  ReconciliationOperation,
25
+ RegistryRef,
20
26
  ReplaceOperation,
21
- UpdateCallbacksOperation,
22
- UpdateJsExprPathsOperation,
23
27
  UpdatePropsDelta,
24
28
  UpdatePropsOperation,
25
- UpdateRenderPropsOperation,
29
+ VDOMElement,
26
30
  VDOMNode,
27
31
  VDOMOperation,
32
+ VDOMPropValue,
28
33
  )
29
34
 
35
+ PropValue: TypeAlias = Node | Callable[..., Any]
30
36
 
31
- def is_jsexpr(value: object) -> bool:
32
- """Check if a value is a JSExpr or Import."""
33
- return isinstance(value, (JSExpr, Import))
37
+ FRAGMENT_TAG = ""
38
+ MOUNT_PREFIX = "$$"
39
+ CALLBACK_PLACEHOLDER = "$cb"
34
40
 
35
41
 
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()
42
+ class Callback(NamedTuple):
43
+ fn: Callable[..., Any]
44
+ n_args: int
42
45
 
43
46
 
44
- RenderPath: TypeAlias = str
47
+ Callbacks = dict[str, Callback]
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class DiffPropsResult:
52
+ normalized: dict[str, PropValue]
53
+ delta_set: dict[str, VDOMPropValue]
54
+ delta_remove: set[str]
55
+ render_prop_reconciles: list["RenderPropTask"]
56
+ eval_keys: set[str]
57
+ eval_changed: bool
58
+
59
+
60
+ class RenderPropTask(NamedTuple):
61
+ key: str
62
+ previous: Element | PulseNode
63
+ current: Element | PulseNode
64
+ path: str
45
65
 
46
66
 
47
67
  class RenderTree:
48
- root: Element
68
+ root: Node
49
69
  callbacks: Callbacks
50
- render_props: set[str]
51
- jsexpr_paths: set[str] # paths containing JS expressions
70
+ operations: list[VDOMOperation]
71
+ _normalized: Node | None
52
72
 
53
- def __init__(self, root: Element) -> None:
73
+ def __init__(self, root: Node) -> None:
54
74
  self.root = root
55
75
  self.callbacks = {}
56
- self.render_props = set()
57
- self.jsexpr_paths = set()
58
- self.normalized: Element | None = None
76
+ self.operations = []
77
+ self._normalized = None
59
78
 
60
79
  def render(self) -> VDOM:
61
80
  renderer = Renderer()
62
81
  vdom, normalized = renderer.render_tree(self.root)
63
82
  self.root = normalized
64
83
  self.callbacks = renderer.callbacks
65
- self.render_props = renderer.render_props
66
- self.jsexpr_paths = renderer.jsexpr_paths
67
- self.normalized = normalized
84
+ self._normalized = normalized
68
85
  return vdom
69
86
 
70
- def diff(self, new_tree: Element) -> list[VDOMOperation]:
71
- if self.normalized is None:
87
+ def diff(self, new_tree: Node) -> list[VDOMOperation]:
88
+ if self._normalized is None:
72
89
  raise RuntimeError("RenderTree.render must be called before diff")
73
90
 
74
91
  renderer = Renderer()
75
- normalized = renderer.reconcile_tree(self.normalized, new_tree, path="")
76
-
77
- callback_prev = set(self.callbacks.keys())
78
- callback_next = set(renderer.callbacks.keys())
79
- callback_add = sorted(callback_next - callback_prev)
80
- callback_remove = sorted(callback_prev - callback_next)
81
-
82
- render_props_prev = self.render_props
83
- render_props_next = renderer.render_props
84
- render_props_add = sorted(render_props_next - render_props_prev)
85
- render_props_remove = sorted(render_props_prev - render_props_next)
86
-
87
- prefix: list[VDOMOperation] = []
88
-
89
- if callback_add or callback_remove:
90
- callback_delta: PathDelta = {}
91
- if callback_add:
92
- callback_delta["add"] = callback_add
93
- if callback_remove:
94
- callback_delta["remove"] = callback_remove
95
- prefix.append(
96
- UpdateCallbacksOperation(
97
- type="update_callbacks", path="", data=callback_delta
98
- )
99
- )
100
-
101
- if render_props_add or render_props_remove:
102
- render_props_delta: PathDelta = {}
103
- if render_props_add:
104
- render_props_delta["add"] = render_props_add
105
- if render_props_remove:
106
- render_props_delta["remove"] = render_props_remove
107
- prefix.append(
108
- UpdateRenderPropsOperation(
109
- type="update_render_props", path="", data=render_props_delta
110
- )
111
- )
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
-
129
- ops = prefix + renderer.operations if prefix else renderer.operations
92
+ normalized = renderer.reconcile_tree(self._normalized, new_tree, path="")
130
93
 
131
94
  self.callbacks = renderer.callbacks
132
- self.render_props = renderer.render_props
133
- self.jsexpr_paths = renderer.jsexpr_paths
134
- self.normalized = normalized
95
+ self._normalized = normalized
135
96
  self.root = normalized
136
97
 
137
- return ops
98
+ return renderer.operations
138
99
 
139
100
  def unmount(self) -> None:
140
- if self.normalized is not None:
141
- unmount_element(self.normalized)
142
- self.normalized = None
101
+ if self._normalized is not None:
102
+ unmount_element(self._normalized)
103
+ self._normalized = None
143
104
  self.callbacks.clear()
144
- self.render_props.clear()
145
- self.jsexpr_paths.clear()
146
-
147
105
 
148
- # Prefix for JSExpr values - code is embedded after the colon
149
- JSEXPR_PREFIX = "$js:"
150
-
151
-
152
- @dataclass(slots=True)
153
- class DiffPropsResult:
154
- normalized: Props
155
- delta_set: Props
156
- delta_remove: set[str]
157
- render_prop_reconciles: list["RenderPropTask"]
158
-
159
-
160
- class RenderPropTask(NamedTuple):
161
- key: str
162
- previous: Element
163
- current: Element
164
- path: RenderPath
106
+ @property
107
+ def normalized(self) -> Node | None:
108
+ return self._normalized
165
109
 
166
110
 
167
111
  class Renderer:
168
112
  def __init__(self) -> None:
169
113
  self.callbacks: Callbacks = {}
170
- self.render_props: set[str] = set()
171
- self.jsexpr_paths: set[str] = set()
172
114
  self.operations: list[VDOMOperation] = []
173
115
 
174
116
  # ------------------------------------------------------------------
175
117
  # Rendering helpers
176
118
  # ------------------------------------------------------------------
177
119
 
178
- def render_tree(self, node: Element, path: RenderPath = "") -> tuple[VDOM, Element]:
179
- if isinstance(node, ComponentNode):
120
+ def render_tree(self, node: Node, path: str = "") -> tuple[Any, Node]:
121
+ if isinstance(node, PulseNode):
180
122
  return self.render_component(node, path)
181
- if isinstance(node, Node):
123
+ if isinstance(node, Element):
182
124
  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)
190
- return node, node
125
+ if isinstance(node, Value):
126
+ json_value = coerce_json(node.value, path)
127
+ return json_value, json_value
128
+ if isinstance(node, Expr):
129
+ return node.render(), node
130
+ if is_json_primitive(node):
131
+ return node, node
132
+ raise TypeError(f"Unsupported node type: {type(node).__name__}")
191
133
 
192
134
  def render_component(
193
- self, component: ComponentNode, path: RenderPath
194
- ) -> tuple[VDOM, ComponentNode]:
135
+ self, component: PulseNode, path: str
136
+ ) -> tuple[VDOM, PulseNode]:
137
+ if component.hooks is None:
138
+ component.hooks = HookContext()
195
139
  with component.hooks:
196
140
  rendered = component.fn(*component.args, **component.kwargs)
197
141
  vdom, normalized_child = self.render_tree(rendered, path)
198
142
  component.contents = normalized_child
199
143
  return vdom, component
200
144
 
201
- def render_node(self, element: Node, path: RenderPath) -> tuple[VDOMNode, Node]:
202
- vdom_node: VDOMNode = {"tag": element.tag}
203
- if element.key is not None:
204
- vdom_node["key"] = element.key
145
+ def render_node(self, element: Element, path: str) -> tuple[VDOMNode, Element]:
146
+ tag = self.render_tag(element.tag)
147
+ vdom_node: VDOMElement = {"tag": tag}
148
+ if (key_val := key_value(element)) is not None:
149
+ vdom_node["key"] = key_val
205
150
 
206
151
  props = element.props or {}
207
- props_result = self.diff_props({}, props, path)
152
+ props_result = self.diff_props({}, props, path, prev_eval=set())
208
153
  if props_result.delta_set:
209
154
  vdom_node["props"] = props_result.delta_set
155
+ if props_result.eval_keys:
156
+ vdom_node["eval"] = sorted(props_result.eval_keys)
210
157
 
211
158
  for task in props_result.render_prop_reconciles:
212
159
  normalized_value = self.reconcile_tree(
@@ -217,7 +164,7 @@ class Renderer:
217
164
  element.props = props_result.normalized or None
218
165
 
219
166
  children_vdom: list[VDOM] = []
220
- normalized_children: list[Element] = []
167
+ normalized_children: list[Node] = []
221
168
  for idx, child in enumerate(normalize_children(element.children)):
222
169
  child_path = join_path(path, idx)
223
170
  child_vdom, normalized_child = self.render_tree(child, child_path)
@@ -236,10 +183,14 @@ class Renderer:
236
183
 
237
184
  def reconcile_tree(
238
185
  self,
239
- previous: Element,
240
- current: Element,
241
- path: RenderPath = "",
242
- ) -> Element:
186
+ previous: Node,
187
+ current: Node,
188
+ path: str = "",
189
+ ) -> Node:
190
+ if isinstance(current, Value):
191
+ current = coerce_json(current.value, path)
192
+ if isinstance(previous, Value):
193
+ previous = coerce_json(previous.value, path)
243
194
  if not same_node(previous, current):
244
195
  unmount_element(previous)
245
196
  new_vdom, normalized = self.render_tree(current, path)
@@ -248,23 +199,26 @@ class Renderer:
248
199
  )
249
200
  return normalized
250
201
 
251
- if isinstance(previous, ComponentNode) and isinstance(current, ComponentNode):
202
+ if isinstance(previous, PulseNode) and isinstance(current, PulseNode):
252
203
  return self.reconcile_component(previous, current, path)
253
204
 
254
- if isinstance(previous, Node) and isinstance(current, Node):
205
+ if isinstance(previous, Element) and isinstance(current, Element):
255
206
  return self.reconcile_element(previous, current, path)
256
207
 
257
208
  return current
258
209
 
259
210
  def reconcile_component(
260
211
  self,
261
- previous: ComponentNode,
262
- current: ComponentNode,
263
- path: RenderPath,
264
- ) -> ComponentNode:
212
+ previous: PulseNode,
213
+ current: PulseNode,
214
+ path: str,
215
+ ) -> PulseNode:
265
216
  current.hooks = previous.hooks
266
217
  current.contents = previous.contents
267
218
 
219
+ if current.hooks is None:
220
+ current.hooks = HookContext()
221
+
268
222
  with current.hooks:
269
223
  rendered = current.fn(*current.args, **current.kwargs)
270
224
 
@@ -281,20 +235,27 @@ class Renderer:
281
235
 
282
236
  def reconcile_element(
283
237
  self,
284
- previous: Node,
285
- current: Node,
286
- path: RenderPath,
287
- ) -> Node:
238
+ previous: Element,
239
+ current: Element,
240
+ path: str,
241
+ ) -> Element:
288
242
  prev_props = previous.props or {}
289
243
  new_props = current.props or {}
290
- props_result = self.diff_props(prev_props, new_props, path)
291
-
292
- if props_result.delta_set or props_result.delta_remove:
244
+ prev_eval = eval_keys_for_props(prev_props)
245
+ props_result = self.diff_props(prev_props, new_props, path, prev_eval)
246
+
247
+ if (
248
+ props_result.delta_set
249
+ or props_result.delta_remove
250
+ or props_result.eval_changed
251
+ ):
293
252
  delta: UpdatePropsDelta = {}
294
253
  if props_result.delta_set:
295
254
  delta["set"] = props_result.delta_set
296
255
  if props_result.delta_remove:
297
256
  delta["remove"] = sorted(props_result.delta_remove)
257
+ if props_result.eval_changed:
258
+ delta["eval"] = sorted(props_result.eval_keys)
298
259
  self.operations.append(
299
260
  UpdatePropsOperation(type="update_props", path=path, data=delta)
300
261
  )
@@ -311,58 +272,50 @@ class Renderer:
311
272
  prev_children, next_children, path
312
273
  )
313
274
 
314
- # Mutate the current node to avoid allocations
315
275
  current.props = props_result.normalized or None
316
276
  current.children = normalized_children
317
277
  return current
318
278
 
319
279
  def reconcile_children(
320
280
  self,
321
- c1: list[Element],
322
- c2: list[Element],
323
- path: RenderPath,
324
- ) -> list[Element]:
281
+ c1: list[Node],
282
+ c2: list[Node],
283
+ path: str,
284
+ ) -> list[Node]:
325
285
  if not c1 and not c2:
326
286
  return []
327
287
 
328
288
  N1 = len(c1)
329
289
  N2 = len(c2)
330
- norm: list[Element] = [None] * N2
290
+ norm: list[Node | None] = [None] * N2
331
291
  N = min(N1, N2)
332
292
  i = 0
333
- # Fast path: if elements haven't changed, perform a single pass
334
293
  while i < N:
335
294
  x1 = c1[i]
336
295
  x2 = c2[i]
337
296
  if not same_node(x1, x2):
338
- break # enter keyed reconciliation
297
+ break
339
298
  norm[i] = self.reconcile_tree(x1, x2, join_path(path, i))
340
299
  i += 1
341
300
 
342
- # Exits if previous and current children lists are of the same size and
343
- # the previous loop did not break. Also works for empty lists.
344
301
  if i == N1 == N2:
345
302
  return norm
346
303
 
347
- # Enter keyed reconciliation. We emit the reconciliation op in advance,
348
- # as further ops will use the post-reconciliation paths.
349
304
  op = ReconciliationOperation(
350
305
  type="reconciliation", path=path, N=len(c2), new=([], []), reuse=([], [])
351
306
  )
352
307
  self.operations.append(op)
353
308
 
354
- # Build key index
355
309
  keys_to_old_idx: dict[str, int] = {}
356
310
  for j1 in range(i, N1):
357
- if key := getattr(c1[j1], "key", None):
311
+ key = key_value(c1[j1])
312
+ if key is not None:
358
313
  keys_to_old_idx[key] = j1
359
314
 
360
- # Build the reconciliation instructions
361
315
  reused = [False] * (N1 - i)
362
316
  for j2 in range(i, N2):
363
317
  x2 = c2[j2]
364
- # Case 1: this is a keyed node, try to reuse it if it already existed
365
- k = getattr(x2, "key", None)
318
+ k = key_value(x2)
366
319
  if k is not None:
367
320
  j1 = keys_to_old_idx.get(k)
368
321
  if j1 is not None:
@@ -374,21 +327,18 @@ class Renderer:
374
327
  op["reuse"][0].append(j2)
375
328
  op["reuse"][1].append(j1)
376
329
  continue
377
- # Case 2: try to reuse the node at the same position
378
- if not k and j2 < N1:
330
+ if k is None and j2 < N1:
379
331
  x1 = c1[j2]
380
332
  if same_node(x1, x2):
381
333
  reused[j2 - i] = True
382
334
  norm[j2] = self.reconcile_tree(x1, x2, join_path(path, j2))
383
335
  continue
384
336
 
385
- # Case 3: this is a new node, render it at the new path
386
337
  vdom, el = self.render_tree(x2, join_path(path, j2))
387
338
  op["new"][0].append(j2)
388
339
  op["new"][1].append(vdom)
389
340
  norm[j2] = el
390
341
 
391
- # Unmount old nodes we haven't reused
392
342
  for j1 in range(i, N1):
393
343
  if not reused[j1 - i]:
394
344
  self.unmount_subtree(c1[j1])
@@ -401,43 +351,26 @@ class Renderer:
401
351
 
402
352
  def diff_props(
403
353
  self,
404
- previous: Props,
405
- current: Props,
406
- path: RenderPath,
354
+ previous: dict[str, PropValue],
355
+ current: dict[str, PropValue],
356
+ path: str,
357
+ prev_eval: set[str],
407
358
  ) -> DiffPropsResult:
408
- updated: Props = {}
409
- normalized: Props | None = None
359
+ updated: dict[str, VDOMPropValue] = {}
360
+ normalized: dict[str, PropValue] | None = None
410
361
  render_prop_tasks: list[RenderPropTask] = []
362
+ eval_keys: set[str] = set()
411
363
  removed_keys = set(previous.keys()) - set(current.keys())
412
364
 
413
365
  for key, value in current.items():
414
366
  old_value = previous.get(key)
415
367
  prop_path = join_path(path, key)
416
368
 
417
- if is_jsexpr(value):
418
- if isinstance(old_value, (Node, ComponentNode)):
419
- unmount_element(old_value)
420
- if normalized is None:
421
- normalized = current.copy()
422
- normalized[key] = 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
434
- continue
435
-
436
- if isinstance(value, (Node, ComponentNode)):
437
- if normalized is None:
438
- normalized = current.copy()
439
- self.render_props.add(prop_path)
440
- if isinstance(old_value, (Node, ComponentNode)):
369
+ if isinstance(value, (Element, PulseNode)):
370
+ eval_keys.add(key)
371
+ if isinstance(old_value, (Element, PulseNode)):
372
+ if normalized is None:
373
+ normalized = current.copy()
441
374
  normalized[key] = old_value
442
375
  render_prop_tasks.append(
443
376
  RenderPropTask(
@@ -449,100 +382,240 @@ class Renderer:
449
382
  )
450
383
  else:
451
384
  vdom_value, normalized_value = self.render_tree(value, prop_path)
385
+ if normalized is None:
386
+ normalized = current.copy()
452
387
  normalized[key] = normalized_value
453
- updated[key] = vdom_value
388
+ updated[key] = cast(VDOMPropValue, vdom_value)
389
+ continue
390
+
391
+ if isinstance(value, Value):
392
+ json_value = coerce_json(value.value, prop_path)
393
+ if normalized is None:
394
+ normalized = current.copy()
395
+ normalized[key] = json_value
396
+ if isinstance(old_value, (Element, PulseNode)):
397
+ unmount_element(old_value)
398
+ if key not in previous or not values_equal(json_value, old_value):
399
+ updated[key] = cast(VDOMPropValue, json_value)
400
+ continue
401
+
402
+ if isinstance(value, Expr):
403
+ eval_keys.add(key)
404
+ if isinstance(old_value, (Element, PulseNode)):
405
+ unmount_element(old_value)
406
+ if normalized is None:
407
+ normalized = current.copy()
408
+ normalized[key] = value
409
+ if not (isinstance(old_value, Expr) and values_equal(old_value, value)):
410
+ updated[key] = value.render()
454
411
  continue
455
412
 
456
413
  if callable(value):
457
- if isinstance(old_value, (Node, ComponentNode)):
414
+ eval_keys.add(key)
415
+ if isinstance(old_value, (Element, PulseNode)):
458
416
  unmount_element(old_value)
459
417
  if normalized is None:
460
418
  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"
419
+ normalized[key] = value
420
+ register_callback(self.callbacks, prop_path, value)
421
+ if not callable(old_value):
422
+ updated[key] = CALLBACK_PLACEHOLDER
467
423
  continue
468
424
 
469
- if isinstance(old_value, (Node, ComponentNode)):
425
+ json_value = coerce_json(value, prop_path)
426
+ if isinstance(old_value, (Element, PulseNode)):
470
427
  unmount_element(old_value)
471
-
472
428
  if normalized is not None:
473
- normalized[key] = value
474
-
475
- if key not in previous or not values_equal(value, old_value):
476
- updated[key] = value
429
+ normalized[key] = json_value
430
+ elif json_value is not value:
431
+ normalized = current.copy()
432
+ normalized[key] = json_value
433
+ if key not in previous or not values_equal(json_value, old_value):
434
+ updated[key] = cast(VDOMPropValue, json_value)
477
435
 
478
436
  for key in removed_keys:
479
437
  old_value = previous.get(key)
480
- if isinstance(old_value, (Node, ComponentNode)):
438
+ if isinstance(old_value, (Element, PulseNode)):
481
439
  unmount_element(old_value)
482
440
 
483
441
  normalized_props = normalized if normalized is not None else current.copy()
442
+ eval_changed = eval_keys != prev_eval
484
443
  return DiffPropsResult(
485
444
  normalized=normalized_props,
486
445
  delta_set=updated,
487
446
  delta_remove=removed_keys,
488
447
  render_prop_reconciles=render_prop_tasks,
448
+ eval_keys=eval_keys,
449
+ eval_changed=eval_changed,
489
450
  )
490
451
 
452
+ # ------------------------------------------------------------------
453
+ # Expression + tag rendering
454
+ # ------------------------------------------------------------------
455
+
456
+ def render_tag(self, tag: str | Expr) -> str:
457
+ if isinstance(tag, str):
458
+ return tag
459
+
460
+ key = self.register_component_expr(tag)
461
+ return f"{MOUNT_PREFIX}{key}"
462
+
463
+ def register_component_expr(self, expr: Expr) -> str:
464
+ ref = registry_ref(expr)
465
+ if ref is None:
466
+ raise TypeError(
467
+ "Component tag expressions must be registry-backed Expr values "
468
+ + "(Import/JsFunction/Constant/JsxFunction)."
469
+ )
470
+ return ref["key"]
471
+
491
472
  # ------------------------------------------------------------------
492
473
  # Unmount helper
493
474
  # ------------------------------------------------------------------
494
475
 
495
- def unmount_subtree(self, node: Element) -> None:
476
+ def unmount_subtree(self, node: Node) -> None:
496
477
  unmount_element(node)
497
478
 
498
479
 
499
- def normalize_children(children: Sequence[Element] | None) -> list[Element]:
480
+ # ----------------------------------------------------------------------
481
+ # Helpers
482
+ # ----------------------------------------------------------------------
483
+
484
+
485
+ def registry_ref(expr: Expr) -> RegistryRef | None:
486
+ if isinstance(expr, (Import, JsFunction, Constant, JsxFunction)):
487
+ return {"t": "ref", "key": expr.id}
488
+ return None
489
+
490
+
491
+ def is_json_primitive(value: Any) -> bool:
492
+ return value is None or isinstance(value, (str, int, float, bool))
493
+
494
+
495
+ def coerce_json(value: Any, path: str) -> Any:
496
+ """Convert Python value to JSON-compatible structure.
497
+
498
+ Performs runtime conversions:
499
+ - tuple → list
500
+ - validates dict keys are strings
501
+ """
502
+ if is_json_primitive(value):
503
+ return value
504
+ if isinstance(value, (list, tuple)):
505
+ return [coerce_json(v, path) for v in value]
506
+ if isinstance(value, dict):
507
+ out: dict[str, Any] = {}
508
+ for k, v in value.items():
509
+ if not isinstance(k, str):
510
+ raise TypeError(f"Non-string prop key at {path}: {k!r}")
511
+ out[k] = coerce_json(v, path)
512
+ return out
513
+ raise TypeError(f"Unsupported JSON value at {path}: {type(value).__name__}")
514
+
515
+
516
+ def prop_requires_eval(value: PropValue) -> bool:
517
+ if isinstance(value, Value):
518
+ return False
519
+ if isinstance(value, (Element, PulseNode)):
520
+ return True
521
+ if isinstance(value, Expr):
522
+ return True
523
+ return callable(value)
524
+
525
+
526
+ def eval_keys_for_props(props: dict[str, PropValue]) -> set[str]:
527
+ eval_keys: set[str] = set()
528
+ for key, value in props.items():
529
+ if prop_requires_eval(value):
530
+ eval_keys.add(key)
531
+ return eval_keys
532
+
533
+
534
+ def normalize_children(children: Children | None) -> list[Node]:
500
535
  if not children:
501
536
  return []
502
- return list(children)
537
+
538
+ out: list[Node] = []
539
+ seen_keys: set[str] = set()
540
+
541
+ def register_key(item: Node) -> None:
542
+ key: str | None = None
543
+ if isinstance(item, PulseNode):
544
+ key = item.key
545
+ elif isinstance(item, Element):
546
+ key = key_value(item)
547
+ if key is None:
548
+ return
549
+ if key in seen_keys:
550
+ raise ValueError(f"Duplicate key '{key}'")
551
+ seen_keys.add(key)
552
+
553
+ def visit(item: Child) -> None:
554
+ if isinstance(item, dict):
555
+ raise TypeError("Dict is not a valid child; wrap in Value for props")
556
+ if isinstance(item, Iterable) and not isinstance(item, (str, bytes)):
557
+ for sub in item:
558
+ visit(sub)
559
+ else:
560
+ node = cast(Node, item)
561
+ register_key(node)
562
+ out.append(node)
563
+
564
+ for child in children:
565
+ visit(child)
566
+
567
+ return out
503
568
 
504
569
 
505
570
  def register_callback(
506
571
  callbacks: Callbacks,
507
- path: RenderPath,
572
+ path: str,
508
573
  fn: Callable[..., Any],
509
574
  ) -> None:
510
575
  n_args = len(inspect.signature(fn).parameters)
511
576
  callbacks[path] = Callback(fn=fn, n_args=n_args)
512
577
 
513
578
 
514
- def join_path(prefix: RenderPath, path: str | int) -> RenderPath:
579
+ def join_path(prefix: str, path: str | int) -> str:
515
580
  if prefix:
516
581
  return f"{prefix}.{path}"
517
582
  return str(path)
518
583
 
519
584
 
520
- def same_node(left: Element, right: Element) -> bool:
585
+ def same_node(left: Node, right: Node) -> bool:
521
586
  if values_equal(left, right):
522
587
  return True
523
- if isinstance(left, Node) and isinstance(right, Node):
524
- return left.tag == right.tag and left.key == right.key
525
- if isinstance(left, ComponentNode) and isinstance(right, ComponentNode):
526
- return left.fn == right.fn and left.key == right.key
588
+ if isinstance(left, Element) and isinstance(right, Element):
589
+ return values_equal(left.tag, right.tag) and key_value(left) == key_value(right)
590
+ if isinstance(left, PulseNode) and isinstance(right, PulseNode):
591
+ return left.fn == right.fn and key_value(left) == key_value(right)
527
592
  return False
528
593
 
529
594
 
530
- def unmount_element(element: Element) -> None:
531
- if isinstance(element, ComponentNode):
595
+ def key_value(node: Node | Node) -> str | None:
596
+ key = getattr(node, "key", None)
597
+ if isinstance(key, Literal):
598
+ if not isinstance(key.value, str):
599
+ raise TypeError("Element key must be a string")
600
+ return key.value
601
+ return cast(str | None, key)
602
+
603
+
604
+ def unmount_element(element: Node) -> None:
605
+ if isinstance(element, PulseNode):
532
606
  if element.contents is not None:
533
607
  unmount_element(element.contents)
534
608
  element.contents = None
535
- element.hooks.unmount()
609
+ if element.hooks is not None:
610
+ element.hooks.unmount()
536
611
  return
537
612
 
538
- if isinstance(element, Node):
613
+ if isinstance(element, Element):
539
614
  props = element.props or {}
540
615
  for value in props.values():
541
- if isinstance(value, (Node, ComponentNode)):
616
+ if isinstance(value, (Element, PulseNode)):
542
617
  unmount_element(value)
543
618
  for child in normalize_children(element.children):
544
619
  unmount_element(child)
545
620
  element.children = []
546
621
  return
547
-
548
- # Primitive -> nothing to unmount