pulse-framework 0.1.62__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 +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/renderer.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import NoneType
|
|
7
|
+
from typing import Any, NamedTuple, TypeAlias, cast
|
|
8
|
+
|
|
9
|
+
from pulse.helpers import values_equal
|
|
10
|
+
from pulse.hooks.core import HookContext
|
|
11
|
+
from pulse.transpiler import Import
|
|
12
|
+
from pulse.transpiler.function import Constant, JsFunction, JsxFunction
|
|
13
|
+
from pulse.transpiler.nodes import (
|
|
14
|
+
Child,
|
|
15
|
+
Children,
|
|
16
|
+
Element,
|
|
17
|
+
Expr,
|
|
18
|
+
Literal,
|
|
19
|
+
Node,
|
|
20
|
+
PulseNode,
|
|
21
|
+
Value,
|
|
22
|
+
)
|
|
23
|
+
from pulse.transpiler.vdom import (
|
|
24
|
+
VDOM,
|
|
25
|
+
ReconciliationOperation,
|
|
26
|
+
RegistryRef,
|
|
27
|
+
ReplaceOperation,
|
|
28
|
+
UpdatePropsDelta,
|
|
29
|
+
UpdatePropsOperation,
|
|
30
|
+
VDOMElement,
|
|
31
|
+
VDOMNode,
|
|
32
|
+
VDOMOperation,
|
|
33
|
+
VDOMPropValue,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
PropValue: TypeAlias = Node | Callable[..., Any]
|
|
37
|
+
|
|
38
|
+
FRAGMENT_TAG = ""
|
|
39
|
+
MOUNT_PREFIX = "$$"
|
|
40
|
+
CALLBACK_PLACEHOLDER = "$cb"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Callback(NamedTuple):
|
|
44
|
+
fn: Callable[..., Any]
|
|
45
|
+
n_args: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Callbacks = dict[str, Callback]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class DiffPropsResult:
|
|
53
|
+
normalized: dict[str, PropValue]
|
|
54
|
+
delta_set: dict[str, VDOMPropValue]
|
|
55
|
+
delta_remove: set[str]
|
|
56
|
+
render_prop_reconciles: list["RenderPropTask"]
|
|
57
|
+
eval_keys: set[str]
|
|
58
|
+
eval_changed: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RenderPropTask(NamedTuple):
|
|
62
|
+
key: str
|
|
63
|
+
previous: Element | PulseNode
|
|
64
|
+
current: Element | PulseNode
|
|
65
|
+
path: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RenderTree:
|
|
69
|
+
element: Node
|
|
70
|
+
callbacks: Callbacks
|
|
71
|
+
rendered: bool
|
|
72
|
+
|
|
73
|
+
def __init__(self, element: Node) -> None:
|
|
74
|
+
self.element = element
|
|
75
|
+
self.callbacks = {}
|
|
76
|
+
self.rendered = False
|
|
77
|
+
|
|
78
|
+
def render(self) -> VDOM:
|
|
79
|
+
"""First render. Returns VDOM."""
|
|
80
|
+
renderer = Renderer()
|
|
81
|
+
vdom, self.element = renderer.render_tree(self.element)
|
|
82
|
+
self.callbacks = renderer.callbacks
|
|
83
|
+
self.rendered = True
|
|
84
|
+
return vdom
|
|
85
|
+
|
|
86
|
+
def rerender(self, new_element: Node | None = None) -> list[VDOMOperation]:
|
|
87
|
+
"""Re-render and return update operations.
|
|
88
|
+
|
|
89
|
+
If new_element is provided, reconciles against it (for testing).
|
|
90
|
+
Otherwise, reconciles against the current element (production use).
|
|
91
|
+
"""
|
|
92
|
+
if not self.rendered:
|
|
93
|
+
raise RuntimeError("render() must be called before rerender()")
|
|
94
|
+
target = new_element if new_element is not None else self.element
|
|
95
|
+
renderer = Renderer()
|
|
96
|
+
self.element = renderer.reconcile_tree(self.element, target, path="")
|
|
97
|
+
self.callbacks = renderer.callbacks
|
|
98
|
+
return renderer.operations
|
|
99
|
+
|
|
100
|
+
def unmount(self) -> None:
|
|
101
|
+
if self.rendered:
|
|
102
|
+
unmount_element(self.element)
|
|
103
|
+
self.rendered = False
|
|
104
|
+
self.callbacks.clear()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Renderer:
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self.callbacks: Callbacks = {}
|
|
110
|
+
self.operations: list[VDOMOperation] = []
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Rendering helpers
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def render_tree(self, node: Node, path: str = "") -> tuple[Any, Node]:
|
|
117
|
+
if isinstance(node, PulseNode):
|
|
118
|
+
return self.render_component(node, path)
|
|
119
|
+
if isinstance(node, Element):
|
|
120
|
+
return self.render_node(node, path)
|
|
121
|
+
if isinstance(node, Value):
|
|
122
|
+
return node.value, node.value
|
|
123
|
+
if isinstance(node, Expr):
|
|
124
|
+
return node.render(), node
|
|
125
|
+
# Pass through any other value - serializer will validate
|
|
126
|
+
return node, node
|
|
127
|
+
|
|
128
|
+
def render_component(
|
|
129
|
+
self, component: PulseNode, path: str
|
|
130
|
+
) -> tuple[VDOM, PulseNode]:
|
|
131
|
+
if component.hooks is None:
|
|
132
|
+
component.hooks = HookContext()
|
|
133
|
+
with component.hooks:
|
|
134
|
+
rendered = component.fn(*component.args, **component.kwargs)
|
|
135
|
+
vdom, normalized_child = self.render_tree(rendered, path)
|
|
136
|
+
component.contents = normalized_child
|
|
137
|
+
return vdom, component
|
|
138
|
+
|
|
139
|
+
def render_node(self, element: Element, path: str) -> tuple[VDOMNode, Element]:
|
|
140
|
+
tag = self.render_tag(element.tag)
|
|
141
|
+
vdom_node: VDOMElement = {"tag": tag}
|
|
142
|
+
if (key_val := key_value(element)) is not None:
|
|
143
|
+
vdom_node["key"] = key_val
|
|
144
|
+
|
|
145
|
+
props = element.props_dict()
|
|
146
|
+
props_result = self.diff_props({}, props, path, prev_eval=set())
|
|
147
|
+
if props_result.delta_set:
|
|
148
|
+
vdom_node["props"] = props_result.delta_set
|
|
149
|
+
if props_result.eval_keys:
|
|
150
|
+
vdom_node["eval"] = sorted(props_result.eval_keys)
|
|
151
|
+
|
|
152
|
+
for task in props_result.render_prop_reconciles:
|
|
153
|
+
normalized_value = self.reconcile_tree(
|
|
154
|
+
task.previous, task.current, task.path
|
|
155
|
+
)
|
|
156
|
+
props_result.normalized[task.key] = normalized_value
|
|
157
|
+
|
|
158
|
+
element.props = props_result.normalized or None
|
|
159
|
+
|
|
160
|
+
children_vdom: list[VDOM] = []
|
|
161
|
+
normalized_children: list[Node] = []
|
|
162
|
+
for idx, child in enumerate(normalize_children(element.children)):
|
|
163
|
+
child_path = join_path(path, idx)
|
|
164
|
+
child_vdom, normalized_child = self.render_tree(child, child_path)
|
|
165
|
+
children_vdom.append(child_vdom)
|
|
166
|
+
normalized_children.append(normalized_child)
|
|
167
|
+
|
|
168
|
+
if children_vdom:
|
|
169
|
+
vdom_node["children"] = children_vdom
|
|
170
|
+
element.children = normalized_children
|
|
171
|
+
|
|
172
|
+
return vdom_node, element
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Reconciliation
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def reconcile_tree(
|
|
179
|
+
self,
|
|
180
|
+
previous: Node,
|
|
181
|
+
current: Node,
|
|
182
|
+
path: str = "",
|
|
183
|
+
) -> Node:
|
|
184
|
+
if isinstance(current, Value):
|
|
185
|
+
current = current.value
|
|
186
|
+
if isinstance(previous, Value):
|
|
187
|
+
previous = previous.value
|
|
188
|
+
if not same_node(previous, current):
|
|
189
|
+
unmount_element(previous)
|
|
190
|
+
new_vdom, normalized = self.render_tree(current, path)
|
|
191
|
+
self.operations.append(
|
|
192
|
+
ReplaceOperation(type="replace", path=path, data=new_vdom)
|
|
193
|
+
)
|
|
194
|
+
return normalized
|
|
195
|
+
|
|
196
|
+
if isinstance(previous, PulseNode) and isinstance(current, PulseNode):
|
|
197
|
+
return self.reconcile_component(previous, current, path)
|
|
198
|
+
|
|
199
|
+
if isinstance(previous, Element) and isinstance(current, Element):
|
|
200
|
+
return self.reconcile_element(previous, current, path)
|
|
201
|
+
|
|
202
|
+
return current
|
|
203
|
+
|
|
204
|
+
def reconcile_component(
|
|
205
|
+
self,
|
|
206
|
+
previous: PulseNode,
|
|
207
|
+
current: PulseNode,
|
|
208
|
+
path: str,
|
|
209
|
+
) -> PulseNode:
|
|
210
|
+
current.hooks = previous.hooks
|
|
211
|
+
current.contents = previous.contents
|
|
212
|
+
|
|
213
|
+
if current.hooks is None:
|
|
214
|
+
current.hooks = HookContext()
|
|
215
|
+
|
|
216
|
+
with current.hooks:
|
|
217
|
+
rendered = current.fn(*current.args, **current.kwargs)
|
|
218
|
+
|
|
219
|
+
if current.contents is None:
|
|
220
|
+
new_vdom, normalized = self.render_tree(rendered, path)
|
|
221
|
+
current.contents = normalized
|
|
222
|
+
self.operations.append(
|
|
223
|
+
ReplaceOperation(type="replace", path=path, data=new_vdom)
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
current.contents = self.reconcile_tree(current.contents, rendered, path)
|
|
227
|
+
|
|
228
|
+
return current
|
|
229
|
+
|
|
230
|
+
def reconcile_element(
|
|
231
|
+
self,
|
|
232
|
+
previous: Element,
|
|
233
|
+
current: Element,
|
|
234
|
+
path: str,
|
|
235
|
+
) -> Element:
|
|
236
|
+
prev_props = previous.props_dict()
|
|
237
|
+
new_props = current.props_dict()
|
|
238
|
+
prev_eval = eval_keys_for_props(prev_props)
|
|
239
|
+
props_result = self.diff_props(prev_props, new_props, path, prev_eval)
|
|
240
|
+
|
|
241
|
+
if (
|
|
242
|
+
props_result.delta_set
|
|
243
|
+
or props_result.delta_remove
|
|
244
|
+
or props_result.eval_changed
|
|
245
|
+
):
|
|
246
|
+
delta: UpdatePropsDelta = {}
|
|
247
|
+
if props_result.delta_set:
|
|
248
|
+
delta["set"] = props_result.delta_set
|
|
249
|
+
if props_result.delta_remove:
|
|
250
|
+
delta["remove"] = sorted(props_result.delta_remove)
|
|
251
|
+
if props_result.eval_changed:
|
|
252
|
+
delta["eval"] = sorted(props_result.eval_keys)
|
|
253
|
+
self.operations.append(
|
|
254
|
+
UpdatePropsOperation(type="update_props", path=path, data=delta)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
for task in props_result.render_prop_reconciles:
|
|
258
|
+
normalized_value = self.reconcile_tree(
|
|
259
|
+
task.previous, task.current, task.path
|
|
260
|
+
)
|
|
261
|
+
props_result.normalized[task.key] = normalized_value
|
|
262
|
+
|
|
263
|
+
prev_children = normalize_children(previous.children)
|
|
264
|
+
next_children = normalize_children(current.children)
|
|
265
|
+
normalized_children = self.reconcile_children(
|
|
266
|
+
prev_children, next_children, path
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
current.props = props_result.normalized or None
|
|
270
|
+
current.children = normalized_children
|
|
271
|
+
return current
|
|
272
|
+
|
|
273
|
+
def reconcile_children(
|
|
274
|
+
self,
|
|
275
|
+
c1: list[Node],
|
|
276
|
+
c2: list[Node],
|
|
277
|
+
path: str,
|
|
278
|
+
) -> list[Node]:
|
|
279
|
+
if not c1 and not c2:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
N1 = len(c1)
|
|
283
|
+
N2 = len(c2)
|
|
284
|
+
norm: list[Node | None] = [None] * N2
|
|
285
|
+
N = min(N1, N2)
|
|
286
|
+
i = 0
|
|
287
|
+
while i < N:
|
|
288
|
+
x1 = c1[i]
|
|
289
|
+
x2 = c2[i]
|
|
290
|
+
if not same_node(x1, x2):
|
|
291
|
+
break
|
|
292
|
+
norm[i] = self.reconcile_tree(x1, x2, join_path(path, i))
|
|
293
|
+
i += 1
|
|
294
|
+
|
|
295
|
+
if i == N1 == N2:
|
|
296
|
+
return norm
|
|
297
|
+
|
|
298
|
+
op = ReconciliationOperation(
|
|
299
|
+
type="reconciliation", path=path, N=len(c2), new=([], []), reuse=([], [])
|
|
300
|
+
)
|
|
301
|
+
self.operations.append(op)
|
|
302
|
+
|
|
303
|
+
keys_to_old_idx: dict[str, int] = {}
|
|
304
|
+
for j1 in range(i, N1):
|
|
305
|
+
key = key_value(c1[j1])
|
|
306
|
+
if key is not None:
|
|
307
|
+
keys_to_old_idx[key] = j1
|
|
308
|
+
|
|
309
|
+
reused = [False] * (N1 - i)
|
|
310
|
+
for j2 in range(i, N2):
|
|
311
|
+
x2 = c2[j2]
|
|
312
|
+
k = key_value(x2)
|
|
313
|
+
if k is not None:
|
|
314
|
+
j1 = keys_to_old_idx.get(k)
|
|
315
|
+
if j1 is not None:
|
|
316
|
+
x1 = c1[j1]
|
|
317
|
+
if same_node(x1, x2):
|
|
318
|
+
norm[j2] = self.reconcile_tree(x1, x2, join_path(path, j2))
|
|
319
|
+
reused[j1 - i] = True
|
|
320
|
+
if j1 != j2:
|
|
321
|
+
op["reuse"][0].append(j2)
|
|
322
|
+
op["reuse"][1].append(j1)
|
|
323
|
+
continue
|
|
324
|
+
if k is None and j2 < N1:
|
|
325
|
+
x1 = c1[j2]
|
|
326
|
+
if same_node(x1, x2):
|
|
327
|
+
reused[j2 - i] = True
|
|
328
|
+
norm[j2] = self.reconcile_tree(x1, x2, join_path(path, j2))
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
vdom, el = self.render_tree(x2, join_path(path, j2))
|
|
332
|
+
op["new"][0].append(j2)
|
|
333
|
+
op["new"][1].append(vdom)
|
|
334
|
+
norm[j2] = el
|
|
335
|
+
|
|
336
|
+
for j1 in range(i, N1):
|
|
337
|
+
if not reused[j1 - i]:
|
|
338
|
+
self.unmount_subtree(c1[j1])
|
|
339
|
+
|
|
340
|
+
return norm
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Prop diffing
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def diff_props(
|
|
347
|
+
self,
|
|
348
|
+
previous: dict[str, PropValue],
|
|
349
|
+
current: dict[str, PropValue],
|
|
350
|
+
path: str,
|
|
351
|
+
prev_eval: set[str],
|
|
352
|
+
) -> DiffPropsResult:
|
|
353
|
+
updated: dict[str, VDOMPropValue] = {}
|
|
354
|
+
normalized: dict[str, PropValue] | None = None
|
|
355
|
+
render_prop_tasks: list[RenderPropTask] = []
|
|
356
|
+
eval_keys: set[str] = set()
|
|
357
|
+
removed_keys = set(previous.keys()) - set(current.keys())
|
|
358
|
+
|
|
359
|
+
for key, value in current.items():
|
|
360
|
+
old_value = previous.get(key)
|
|
361
|
+
prop_path = join_path(path, key)
|
|
362
|
+
|
|
363
|
+
if isinstance(value, (Element, PulseNode)):
|
|
364
|
+
eval_keys.add(key)
|
|
365
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
366
|
+
if normalized is None:
|
|
367
|
+
normalized = current.copy()
|
|
368
|
+
normalized[key] = old_value
|
|
369
|
+
render_prop_tasks.append(
|
|
370
|
+
RenderPropTask(
|
|
371
|
+
key=key,
|
|
372
|
+
previous=old_value,
|
|
373
|
+
current=value,
|
|
374
|
+
path=prop_path,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
vdom_value, normalized_value = self.render_tree(value, prop_path)
|
|
379
|
+
if normalized is None:
|
|
380
|
+
normalized = current.copy()
|
|
381
|
+
normalized[key] = normalized_value
|
|
382
|
+
updated[key] = cast(VDOMPropValue, vdom_value)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
if isinstance(value, Value):
|
|
386
|
+
unwrapped = value.value
|
|
387
|
+
if normalized is None:
|
|
388
|
+
normalized = current.copy()
|
|
389
|
+
normalized[key] = unwrapped
|
|
390
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
391
|
+
unmount_element(old_value)
|
|
392
|
+
if key not in previous or not values_equal(unwrapped, old_value):
|
|
393
|
+
updated[key] = cast(VDOMPropValue, unwrapped)
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
if isinstance(value, Expr):
|
|
397
|
+
eval_keys.add(key)
|
|
398
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
399
|
+
unmount_element(old_value)
|
|
400
|
+
if normalized is None:
|
|
401
|
+
normalized = current.copy()
|
|
402
|
+
normalized[key] = value
|
|
403
|
+
if not (isinstance(old_value, Expr) and values_equal(old_value, value)):
|
|
404
|
+
updated[key] = value.render()
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
if callable(value):
|
|
408
|
+
eval_keys.add(key)
|
|
409
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
410
|
+
unmount_element(old_value)
|
|
411
|
+
if normalized is None:
|
|
412
|
+
normalized = current.copy()
|
|
413
|
+
normalized[key] = value
|
|
414
|
+
register_callback(self.callbacks, prop_path, value)
|
|
415
|
+
if not callable(old_value):
|
|
416
|
+
updated[key] = CALLBACK_PLACEHOLDER
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
420
|
+
unmount_element(old_value)
|
|
421
|
+
# No normalization needed - value passes through unchanged
|
|
422
|
+
if key not in previous or not values_equal(value, old_value):
|
|
423
|
+
updated[key] = cast(VDOMPropValue, value)
|
|
424
|
+
|
|
425
|
+
for key in removed_keys:
|
|
426
|
+
old_value = previous.get(key)
|
|
427
|
+
if isinstance(old_value, (Element, PulseNode)):
|
|
428
|
+
unmount_element(old_value)
|
|
429
|
+
|
|
430
|
+
normalized_props = normalized if normalized is not None else current.copy()
|
|
431
|
+
eval_changed = eval_keys != prev_eval
|
|
432
|
+
return DiffPropsResult(
|
|
433
|
+
normalized=normalized_props,
|
|
434
|
+
delta_set=updated,
|
|
435
|
+
delta_remove=removed_keys,
|
|
436
|
+
render_prop_reconciles=render_prop_tasks,
|
|
437
|
+
eval_keys=eval_keys,
|
|
438
|
+
eval_changed=eval_changed,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------------------------------
|
|
442
|
+
# Expression + tag rendering
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def render_tag(self, tag: str | Expr):
|
|
446
|
+
if isinstance(tag, str):
|
|
447
|
+
return tag
|
|
448
|
+
|
|
449
|
+
return self.register_component_expr(tag)
|
|
450
|
+
|
|
451
|
+
def register_component_expr(self, expr: Expr):
|
|
452
|
+
ref = registry_ref(expr)
|
|
453
|
+
if ref is not None:
|
|
454
|
+
return f"{MOUNT_PREFIX}{ref['key']}"
|
|
455
|
+
tag = expr.render()
|
|
456
|
+
if isinstance(tag, (int, float, bool, NoneType)):
|
|
457
|
+
raise TypeError(f"Invalid element tag: {tag}")
|
|
458
|
+
return tag
|
|
459
|
+
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
# Unmount helper
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
def unmount_subtree(self, node: Node) -> None:
|
|
465
|
+
unmount_element(node)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ----------------------------------------------------------------------
|
|
469
|
+
# Helpers
|
|
470
|
+
# ----------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def registry_ref(expr: Expr) -> RegistryRef | None:
|
|
474
|
+
if isinstance(expr, (Import, JsFunction, Constant, JsxFunction)):
|
|
475
|
+
return {"t": "ref", "key": expr.id}
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def prop_requires_eval(value: PropValue) -> bool:
|
|
480
|
+
if isinstance(value, Value):
|
|
481
|
+
return False
|
|
482
|
+
if isinstance(value, (Element, PulseNode)):
|
|
483
|
+
return True
|
|
484
|
+
if isinstance(value, Expr):
|
|
485
|
+
return True
|
|
486
|
+
return callable(value)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def eval_keys_for_props(props: dict[str, PropValue]) -> set[str]:
|
|
490
|
+
eval_keys: set[str] = set()
|
|
491
|
+
for key, value in props.items():
|
|
492
|
+
if prop_requires_eval(value):
|
|
493
|
+
eval_keys.add(key)
|
|
494
|
+
return eval_keys
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def normalize_children(children: Children | None) -> list[Node]:
|
|
498
|
+
if not children:
|
|
499
|
+
return []
|
|
500
|
+
|
|
501
|
+
out: list[Node] = []
|
|
502
|
+
seen_keys: set[str] = set()
|
|
503
|
+
|
|
504
|
+
def register_key(item: Node) -> None:
|
|
505
|
+
key: str | None = None
|
|
506
|
+
if isinstance(item, PulseNode):
|
|
507
|
+
key = item.key
|
|
508
|
+
elif isinstance(item, Element):
|
|
509
|
+
key = key_value(item)
|
|
510
|
+
if key is None:
|
|
511
|
+
return
|
|
512
|
+
if key in seen_keys:
|
|
513
|
+
raise ValueError(f"Duplicate key '{key}'")
|
|
514
|
+
seen_keys.add(key)
|
|
515
|
+
|
|
516
|
+
def visit(item: Child) -> None:
|
|
517
|
+
if isinstance(item, dict):
|
|
518
|
+
raise TypeError("Dict is not a valid child; wrap in Value for props")
|
|
519
|
+
if isinstance(item, Iterable) and not isinstance(item, (str, bytes)):
|
|
520
|
+
for sub in item:
|
|
521
|
+
visit(sub)
|
|
522
|
+
else:
|
|
523
|
+
node = cast(Node, item)
|
|
524
|
+
register_key(node)
|
|
525
|
+
out.append(node)
|
|
526
|
+
|
|
527
|
+
for child in children:
|
|
528
|
+
visit(child)
|
|
529
|
+
|
|
530
|
+
return out
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def register_callback(
|
|
534
|
+
callbacks: Callbacks,
|
|
535
|
+
path: str,
|
|
536
|
+
fn: Callable[..., Any],
|
|
537
|
+
) -> None:
|
|
538
|
+
n_args = len(inspect.signature(fn).parameters)
|
|
539
|
+
callbacks[path] = Callback(fn=fn, n_args=n_args)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def join_path(prefix: str, path: str | int) -> str:
|
|
543
|
+
if prefix:
|
|
544
|
+
return f"{prefix}.{path}"
|
|
545
|
+
return str(path)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def same_node(left: Node, right: Node) -> bool:
|
|
549
|
+
if values_equal(left, right):
|
|
550
|
+
return True
|
|
551
|
+
if isinstance(left, Element) and isinstance(right, Element):
|
|
552
|
+
return values_equal(left.tag, right.tag) and key_value(left) == key_value(right)
|
|
553
|
+
if isinstance(left, PulseNode) and isinstance(right, PulseNode):
|
|
554
|
+
return left.fn == right.fn and key_value(left) == key_value(right)
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def key_value(node: Node | Node) -> str | None:
|
|
559
|
+
key = getattr(node, "key", None)
|
|
560
|
+
if isinstance(key, Literal):
|
|
561
|
+
if not isinstance(key.value, str):
|
|
562
|
+
raise TypeError("Element key must be a string")
|
|
563
|
+
return key.value
|
|
564
|
+
return cast(str | None, key)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def unmount_element(element: Node) -> None:
|
|
568
|
+
if isinstance(element, PulseNode):
|
|
569
|
+
if element.contents is not None:
|
|
570
|
+
unmount_element(element.contents)
|
|
571
|
+
element.contents = None
|
|
572
|
+
if element.hooks is not None:
|
|
573
|
+
element.hooks.unmount()
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
if isinstance(element, Element):
|
|
577
|
+
props = element.props_dict()
|
|
578
|
+
for value in props.values():
|
|
579
|
+
if isinstance(value, (Element, PulseNode)):
|
|
580
|
+
unmount_element(value)
|
|
581
|
+
for child in normalize_children(element.children):
|
|
582
|
+
unmount_element(child)
|
|
583
|
+
element.children = []
|
|
584
|
+
return
|