pulse-framework 0.1.50__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.
- pulse/__init__.py +542 -562
- pulse/_examples.py +29 -0
- pulse/app.py +0 -14
- pulse/cli/cmd.py +96 -80
- pulse/cli/dependencies.py +10 -41
- pulse/cli/folder_lock.py +3 -3
- pulse/cli/helpers.py +40 -67
- pulse/cli/logging.py +102 -0
- pulse/cli/packages.py +16 -0
- pulse/cli/processes.py +40 -23
- pulse/codegen/codegen.py +70 -35
- pulse/codegen/js.py +2 -4
- pulse/codegen/templates/route.py +94 -146
- pulse/component.py +115 -0
- pulse/components/for_.py +1 -1
- pulse/components/if_.py +1 -1
- pulse/components/react_router.py +16 -22
- pulse/{html → dom}/events.py +1 -1
- pulse/{html → dom}/props.py +6 -6
- pulse/{html → dom}/tags.py +11 -11
- pulse/dom/tags.pyi +480 -0
- pulse/form.py +7 -6
- pulse/hooks/init.py +1 -13
- pulse/js/__init__.py +37 -41
- pulse/js/__init__.pyi +22 -2
- pulse/js/_types.py +5 -3
- pulse/js/array.py +121 -38
- pulse/js/console.py +9 -9
- pulse/js/date.py +22 -19
- pulse/js/document.py +8 -4
- pulse/js/error.py +12 -14
- pulse/js/json.py +4 -3
- pulse/js/map.py +17 -7
- pulse/js/math.py +2 -2
- pulse/js/navigator.py +4 -4
- pulse/js/number.py +8 -8
- pulse/js/object.py +9 -13
- pulse/js/promise.py +25 -9
- pulse/js/regexp.py +6 -6
- pulse/js/set.py +20 -8
- pulse/js/string.py +7 -7
- pulse/js/weakmap.py +6 -6
- pulse/js/weakset.py +6 -6
- pulse/js/window.py +17 -14
- pulse/messages.py +1 -4
- pulse/react_component.py +3 -999
- pulse/render_session.py +74 -66
- pulse/renderer.py +311 -238
- pulse/routing.py +1 -10
- pulse/serializer.py +11 -1
- pulse/transpiler/__init__.py +84 -114
- pulse/transpiler/builtins.py +661 -343
- pulse/transpiler/errors.py +78 -2
- pulse/transpiler/function.py +463 -133
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +230 -325
- pulse/transpiler/js_module.py +218 -209
- pulse/transpiler/modules/__init__.py +16 -13
- pulse/transpiler/modules/asyncio.py +45 -26
- pulse/transpiler/modules/json.py +12 -8
- pulse/transpiler/modules/math.py +161 -216
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +231 -0
- pulse/transpiler/modules/typing.py +33 -28
- pulse/transpiler/nodes.py +1607 -923
- pulse/transpiler/py_module.py +118 -95
- pulse/transpiler/react_component.py +51 -0
- pulse/transpiler/transpiler.py +593 -437
- pulse/transpiler/vdom.py +255 -0
- {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
- pulse_framework-0.1.52.dist-info/RECORD +120 -0
- pulse/html/tags.pyi +0 -470
- pulse/transpiler/constants.py +0 -110
- pulse/transpiler/context.py +0 -26
- pulse/transpiler/ids.py +0 -16
- pulse/transpiler/modules/re.py +0 -466
- pulse/transpiler/modules/tags.py +0 -268
- pulse/transpiler/utils.py +0 -4
- pulse/vdom.py +0 -667
- pulse_framework-0.1.50.dist-info/RECORD +0 -119
- /pulse/{html → dom}/__init__.py +0 -0
- /pulse/{html → dom}/elements.py +0 -0
- /pulse/{html → dom}/svg.py +0 -0
- {pulse_framework-0.1.50.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.50.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,
|
|
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.
|
|
8
|
-
from pulse.transpiler
|
|
9
|
-
from pulse.transpiler.
|
|
10
|
-
from pulse.
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
+
VDOMElement,
|
|
26
30
|
VDOMNode,
|
|
27
31
|
VDOMOperation,
|
|
32
|
+
VDOMPropValue,
|
|
28
33
|
)
|
|
29
34
|
|
|
35
|
+
PropValue: TypeAlias = Node | Callable[..., Any]
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
FRAGMENT_TAG = ""
|
|
38
|
+
MOUNT_PREFIX = "$$"
|
|
39
|
+
CALLBACK_PLACEHOLDER = "$cb"
|
|
34
40
|
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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:
|
|
68
|
+
root: Node
|
|
49
69
|
callbacks: Callbacks
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
operations: list[VDOMOperation]
|
|
71
|
+
_normalized: Node | None
|
|
52
72
|
|
|
53
|
-
def __init__(self, root:
|
|
73
|
+
def __init__(self, root: Node) -> None:
|
|
54
74
|
self.root = root
|
|
55
75
|
self.callbacks = {}
|
|
56
|
-
self.
|
|
57
|
-
self.
|
|
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.
|
|
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:
|
|
71
|
-
if self.
|
|
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.
|
|
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.
|
|
133
|
-
self.jsexpr_paths = renderer.jsexpr_paths
|
|
134
|
-
self.normalized = normalized
|
|
95
|
+
self._normalized = normalized
|
|
135
96
|
self.root = normalized
|
|
136
97
|
|
|
137
|
-
return
|
|
98
|
+
return renderer.operations
|
|
138
99
|
|
|
139
100
|
def unmount(self) -> None:
|
|
140
|
-
if self.
|
|
141
|
-
unmount_element(self.
|
|
142
|
-
self.
|
|
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
|
-
|
|
149
|
-
|
|
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:
|
|
179
|
-
if isinstance(node,
|
|
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,
|
|
123
|
+
if isinstance(node, Element):
|
|
182
124
|
return self.render_node(node, path)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return
|
|
190
|
-
|
|
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:
|
|
194
|
-
) -> tuple[VDOM,
|
|
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:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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[
|
|
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:
|
|
240
|
-
current:
|
|
241
|
-
path:
|
|
242
|
-
) ->
|
|
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,
|
|
202
|
+
if isinstance(previous, PulseNode) and isinstance(current, PulseNode):
|
|
252
203
|
return self.reconcile_component(previous, current, path)
|
|
253
204
|
|
|
254
|
-
if isinstance(previous,
|
|
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:
|
|
262
|
-
current:
|
|
263
|
-
path:
|
|
264
|
-
) ->
|
|
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:
|
|
285
|
-
current:
|
|
286
|
-
path:
|
|
287
|
-
) ->
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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[
|
|
322
|
-
c2: list[
|
|
323
|
-
path:
|
|
324
|
-
) -> list[
|
|
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[
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
405
|
-
current:
|
|
406
|
-
path:
|
|
354
|
+
previous: dict[str, PropValue],
|
|
355
|
+
current: dict[str, PropValue],
|
|
356
|
+
path: str,
|
|
357
|
+
prev_eval: set[str],
|
|
407
358
|
) -> DiffPropsResult:
|
|
408
|
-
updated:
|
|
409
|
-
normalized:
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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] =
|
|
462
|
-
register_callback(
|
|
463
|
-
|
|
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
|
-
|
|
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] =
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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, (
|
|
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:
|
|
476
|
+
def unmount_subtree(self, node: Node) -> None:
|
|
496
477
|
unmount_element(node)
|
|
497
478
|
|
|
498
479
|
|
|
499
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
585
|
+
def same_node(left: Node, right: Node) -> bool:
|
|
521
586
|
if values_equal(left, right):
|
|
522
587
|
return True
|
|
523
|
-
if isinstance(left,
|
|
524
|
-
return left.tag
|
|
525
|
-
if isinstance(left,
|
|
526
|
-
return left.fn == right.fn and left
|
|
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
|
|
531
|
-
|
|
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
|
|
609
|
+
if element.hooks is not None:
|
|
610
|
+
element.hooks.unmount()
|
|
536
611
|
return
|
|
537
612
|
|
|
538
|
-
if isinstance(element,
|
|
613
|
+
if isinstance(element, Element):
|
|
539
614
|
props = element.props or {}
|
|
540
615
|
for value in props.values():
|
|
541
|
-
if isinstance(value, (
|
|
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
|