@ape-egg/vibe 1.9.7 → 1.9.9
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.
- package/CHANGELOG.md +27 -0
- package/README.md +34 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/compiler/value_stamper.rs +27 -1
- package/package.json +1 -1
- package/runtime/affected.js +223 -147
- package/runtime/hydrate.js +23 -1
- package/runtime/index.js +35 -1
- package/runtime/iterate.js +207 -130
- package/runtime/pre-compiled-iterations.js +0 -1
- package/runtime/raw-html.js +15 -0
- package/runtime/utils.js +64 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.9] - 2026-06-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Iteration teardown leaked removed rows** (`runtime/iterate.js`, `runtime/affected.js`, `runtime/index.js`) — when an iteration's array emptied or rows were removed, Vibe deleted the DOM but kept internal references to the detached subtrees: the per-row parsed trees, inlined per-row component trees, and the flat manifest entries (`dotPath -> element`). Those structures pinned the removed nodes in memory and made every later reconcile/affected walk traverse dead nodes — in Battle Brawlers, combat fps decayed on each Reset→Start cycle. The page MutationObserver is disconnected while Vibe renders, so removal happens unobserved and the normal observer-driven cleanup never fires; teardown must prune itself. `index.js` now pairs the flat manifest with its parsed tree via a non-enumerable `manifest.__tree` (mirroring `manifest.__live`), and `iterate.js`'s new `releaseRemovedSubtrees` prunes both views together on row removal — deleting each removed root's manifest paths (path-scoped) and tree node (whose subtree cascade covers nested iterations/conditionals/components). Proven with a WeakRef + forced-GC e2e test: after emptying the iteration, zero removed elements remain reachable.
|
|
8
|
+
- Test: `tests/e2e/iteration-teardown-leak.spec.js` (no retained DOM after GC; repeated fill/empty cycles don't accumulate)
|
|
9
|
+
|
|
10
|
+
### Performance
|
|
11
|
+
|
|
12
|
+
- **Scoped-state key/overlay lookups avoided proxy traps on the combat hot path** (`runtime/utils.js`, `runtime/iterate.js`, `runtime/affected.js`) — `createScopedState` now records each scoped-state proxy's precomputed key list and its small local overlay (loop aliases `item`/`index` + parent aliases) in WeakMaps via `rememberScopedKeys`. `evalInScope` reads the keys through `ownKeysOf` instead of `Object.keys(proxy)` (which fired the proxy's `getOwnPropertyDescriptor` trap for every key, every eval), and `affected`'s iteration descent rebuilds its per-instance merged snapshot via a cheap `{...currentGlobals, ...overlay}` plain merge (`scopedOverlayOf`) instead of spreading the proxy over every global key.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## [1.9.8] - 2026-06-01
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Raw-HTML rendering via `$.unsafe(expr)`** (`runtime/raw-html.js` (new), `runtime/hydrate.js`, `runtime/index.js`, `runtime/utils.js`, `compiler/src/compiler/value_stamper.rs`) — a binding that renders a trusted string as real markup instead of escaping it; Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`. `$.unsafe(str)` wraps a string in a `RawHtml` marker. When the binding is the **sole content of its element** (`<p>@[$.unsafe(desc)]</p>`), the runtime sets `innerHTML` from the string and marks the injected subtree opaque (`managedNodes`) so the MutationObserver never re-walks it — injected markup is **inert**, matching Svelte. Mixed into surrounding text it falls back to escaped literal text (via the marker's `toString()`). Reactive in every scope (top-level, iterations, conditionals, components). Compiled mode paints the markup raw at stamp time (`value_stamper.rs` defines `$.unsafe` in the QuickJS context) while the manifest preserves the `@[$.unsafe(...)]` marker for runtime re-hydration. **Trusted input only — no sanitizing.**
|
|
21
|
+
- Test: `tests/unit/raw-html.test.js`, `tests/e2e/unsafe-html.spec.js` (pure render, escaped-text fallback, reactivity, in-iteration, in-conditional, inert injected markup), and `$.unsafe` stamping in `tests/compiler/runtime`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **`$` inside expressions must read from the per-cycle state, not the live root** (`runtime/utils.js`) — surfaced while wiring `$.unsafe`. The reactivity engine detects change by evaluating each expression against an old snapshot and a new snapshot and comparing; binding `$` to the live root proxy made both reads identical, silently defeating change detection for any expression reading through `$` or `this.` (which compiles to `$['id']…`) — e.g. a computed iteration array or conditional gated on component state would stop re-rendering. `evalInScope` now resolves `$` to the same state object the diff cycle is evaluating, and reaches the root's non-enumerable helper methods (`unsafe`, `on`, `reconcile`) via a thin per-state fallback proxy so `$.unsafe` stays callable from plain snapshots on the update path. State reads stay on the snapshot (diffable); only missing method names fall through to root. Scoped states already delegate to root, so the hot path is unaffected.
|
|
26
|
+
- Test: `tests/unit/dollar-binding-scope.test.js` (a `$`-expression yields different values for two snapshots → diffable; `$.unsafe` resolves from a plain snapshot lacking it)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
3
30
|
## [1.9.7] - 2026-06-01
|
|
4
31
|
|
|
5
32
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version 1.9.
|
|
3
|
+
**Version 1.9.9 (Beta)** — A runtime-first reactive framework with optional compilation.
|
|
4
4
|
|
|
5
5
|
No virtual DOM. No build step required. Just modern JavaScript. When you need production optimizations, add the optional Rust-based compiler.
|
|
6
6
|
|
|
@@ -105,6 +105,26 @@ Vibe supports bindings in three positions:
|
|
|
105
105
|
<!-- /each -->
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
The "array" position accepts any JS expression evaluated in scope, not just a state path:
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<!-- each items.filter(i => i.active) as item -->...<!-- /each -->
|
|
112
|
+
<!-- each Array.from({ length: count }) as n, index -->...<!-- /each -->
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Keyed iteration** — give each row a stable identity with `(keyExpr)` so survivors keep their DOM (and listeners / animation state) when the list reorders or items are removed. The key comes before the optional index. Without a key, Vibe falls back to an index-coupled hash and bulk-re-renders the tail on reorder.
|
|
116
|
+
|
|
117
|
+
```html
|
|
118
|
+
<!-- each rows as row (row.id) -->
|
|
119
|
+
<li>@[row.label]</li>
|
|
120
|
+
<!-- /each -->
|
|
121
|
+
|
|
122
|
+
<!-- key + index -->
|
|
123
|
+
<!-- each rows as row (row.id), index -->
|
|
124
|
+
<li>@[index]: @[row.label]</li>
|
|
125
|
+
<!-- /each -->
|
|
126
|
+
```
|
|
127
|
+
|
|
108
128
|
Nested iteration with dot paths:
|
|
109
129
|
|
|
110
130
|
```html
|
|
@@ -218,6 +238,19 @@ Skip reactive processing for an element:
|
|
|
218
238
|
<code vibe-dehydrate>@[this] displays literally</code>
|
|
219
239
|
```
|
|
220
240
|
|
|
241
|
+
### Raw HTML (`$.unsafe`)
|
|
242
|
+
|
|
243
|
+
`@[expr]` escapes its output (`textContent`) — safe by default. To render **trusted** markup instead, wrap the value in `$.unsafe(...)`; the binding sets `innerHTML`. This is Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`.
|
|
244
|
+
|
|
245
|
+
```html
|
|
246
|
+
<p>@[$.unsafe(description)]</p>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- The binding **must be the sole content of its element** (innerHTML semantics). Mixed into surrounding text, it falls back to escaped literal text.
|
|
250
|
+
- Injected markup is **inert** — `@[...]` / `<!-- if -->` / `<!-- each -->` inside it are not processed (matches Svelte `{@html}`); the subtree is opaque to the MutationObserver.
|
|
251
|
+
- Fully **reactive** — re-renders on value change; works in iterations, conditionals, and components. Compiled mode paints the markup at stamp time and re-hydrates at runtime.
|
|
252
|
+
- **Trusted input only** — no sanitizing. Don't pass user-supplied strings.
|
|
253
|
+
|
|
221
254
|
### Internal Names (don't collide)
|
|
222
255
|
|
|
223
256
|
These are used by the runtime — don't repurpose them in your code:
|
|
Binary file
|
|
Binary file
|
|
@@ -64,7 +64,14 @@ impl<'a> ValueStamper<'a> {
|
|
|
64
64
|
context.with(|ctx| -> Result<(), String> {
|
|
65
65
|
let state_str = serde_json::to_string(state)
|
|
66
66
|
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
|
67
|
-
|
|
67
|
+
// Expose state both as bare globals (via Object.assign) and as a
|
|
68
|
+
// persistent `$` global carrying the runtime `unsafe` helper, so a
|
|
69
|
+
// `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
|
|
70
|
+
// time — mirroring the runtime, where `$.unsafe` marks raw HTML.
|
|
71
|
+
ctx.eval::<(), _>(format!(
|
|
72
|
+
"const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
|
|
73
|
+
state_str
|
|
74
|
+
))
|
|
68
75
|
.map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
|
|
69
76
|
Ok(())
|
|
70
77
|
})?;
|
|
@@ -714,6 +721,25 @@ mod tests {
|
|
|
714
721
|
assert_eq!(result, "<div>Hello @[missing]</div>");
|
|
715
722
|
}
|
|
716
723
|
|
|
724
|
+
#[test]
|
|
725
|
+
fn stamp_unsafe_emits_raw_html() {
|
|
726
|
+
let state = json!({ "desc": "Gain <span data-green>+3%</span>." });
|
|
727
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
728
|
+
let html = String::from("<p>@[$.unsafe(desc)]</p>");
|
|
729
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
730
|
+
// Raw markup is painted directly — not escaped, not left as a marker.
|
|
731
|
+
assert_eq!(result, "<p>Gain <span data-green>+3%</span>.</p>");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
#[test]
|
|
735
|
+
fn stamp_unsafe_nested_path() {
|
|
736
|
+
let state = json!({ "tooltip": { "props": { "description": "<em>x</em>" } } });
|
|
737
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
738
|
+
let html = String::from("<p>@[$.unsafe(tooltip.props.description)]</p>");
|
|
739
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
740
|
+
assert_eq!(result, "<p><em>x</em></p>");
|
|
741
|
+
}
|
|
742
|
+
|
|
717
743
|
#[test]
|
|
718
744
|
fn stamp_attribute_binding() {
|
|
719
745
|
let state = json!({ "firstName": "John" });
|
package/package.json
CHANGED
package/runtime/affected.js
CHANGED
|
@@ -1,7 +1,74 @@
|
|
|
1
1
|
import { resolvePath, deepEqual } from "./iteration-utils.js";
|
|
2
2
|
import { extractDependencies } from "./conditionals.js";
|
|
3
3
|
import { BINDING_REGEX } from "./constants.js";
|
|
4
|
-
import { evalInScope, resolveThisPath } from "./utils.js";
|
|
4
|
+
import { evalInScope, resolveThisPath, ownKeysOf, scopedOverlayOf } from "./utils.js";
|
|
5
|
+
|
|
6
|
+
// The @[...] bindings in a tree node are static: the parsed template string and
|
|
7
|
+
// the element's component scope don't change between renders. Re-running the
|
|
8
|
+
// regex + resolveThisPath on every affected-walk was a large per-frame cost in
|
|
9
|
+
// hot iterations (combat at 60fps re-extracts every binding of every row). Cache
|
|
10
|
+
// the extracted + scope-resolved bindings on the node, keyed by the parsed source
|
|
11
|
+
// and element so a re-parse or element swap recomputes.
|
|
12
|
+
const textBindingsOf = (tree) => {
|
|
13
|
+
if (tree._tbSrc === tree.parsed && tree._tbEl === tree.element) return tree._tb;
|
|
14
|
+
const out = [];
|
|
15
|
+
BINDING_REGEX.lastIndex = 0;
|
|
16
|
+
let m;
|
|
17
|
+
while ((m = BINDING_REGEX.exec(tree.parsed))) {
|
|
18
|
+
const resolvedInner = resolveThisPath(m[1], tree.element);
|
|
19
|
+
out.push({
|
|
20
|
+
outer: m[0],
|
|
21
|
+
inner: m[1],
|
|
22
|
+
input: tree.parsed,
|
|
23
|
+
resolvedInner,
|
|
24
|
+
tokens: tokensOf(resolvedInner),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
tree._tb = out;
|
|
28
|
+
tree._tbSrc = tree.parsed;
|
|
29
|
+
tree._tbEl = tree.element;
|
|
30
|
+
return out;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const attrBindingsOf = (tree) => {
|
|
34
|
+
if (tree._abSrc === tree.attributes && tree._abEl === tree.element) return tree._ab;
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const attrName in tree.attributes) {
|
|
37
|
+
const attrValue = tree.attributes[attrName];
|
|
38
|
+
BINDING_REGEX.lastIndex = 0;
|
|
39
|
+
const ms = [];
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = BINDING_REGEX.exec(attrValue))) {
|
|
42
|
+
const resolvedInner = resolveThisPath(m[1], tree.element);
|
|
43
|
+
ms.push({ outer: m[0], inner: m[1], resolvedInner, tokens: tokensOf(resolvedInner) });
|
|
44
|
+
}
|
|
45
|
+
if (ms.length) out.push({ attrName, matches: ms });
|
|
46
|
+
}
|
|
47
|
+
tree._ab = out;
|
|
48
|
+
tree._abSrc = tree.attributes;
|
|
49
|
+
tree._abEl = tree.element;
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Name bindings (`<icon @[fx.icon]>`) live as raw strings on tree.nameBindings.
|
|
54
|
+
// Extract + this-resolve + tokenize them once, same cache discipline as the
|
|
55
|
+
// text/attr binding caches above.
|
|
56
|
+
const nameBindingsOf = (tree) => {
|
|
57
|
+
if (tree._nbSrc === tree.nameBindings && tree._nbEl === tree.element) return tree._nb;
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const nameBinding of tree.nameBindings) {
|
|
60
|
+
BINDING_REGEX.lastIndex = 0;
|
|
61
|
+
let m;
|
|
62
|
+
while ((m = BINDING_REGEX.exec(nameBinding))) {
|
|
63
|
+
const resolvedInner = resolveThisPath(m[1], tree.element);
|
|
64
|
+
out.push({ nameBinding, outer: m[0], inner: m[1], resolvedInner, tokens: tokensOf(resolvedInner) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
tree._nb = out;
|
|
68
|
+
tree._nbSrc = tree.nameBindings;
|
|
69
|
+
tree._nbEl = tree.element;
|
|
70
|
+
return out;
|
|
71
|
+
};
|
|
5
72
|
|
|
6
73
|
// Evaluate conditional expression
|
|
7
74
|
const evaluateCondition = (expression, state, element = null) =>
|
|
@@ -18,26 +85,99 @@ const isIdentChar = (c) =>
|
|
|
18
85
|
c === "_" ||
|
|
19
86
|
c === "$";
|
|
20
87
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Search for key as a standalone identifier (word boundaries on both sides)
|
|
88
|
+
// Split an expression into its identifier tokens — every maximal run of
|
|
89
|
+
// identifier characters. This is exactly the set of standalone identifiers the
|
|
90
|
+
// old per-key `matchesKey` scan could match, so intersecting these tokens with
|
|
91
|
+
// the state-key set is equivalent to "which state keys does this binding read"
|
|
92
|
+
// for identifier-named keys, but costs O(expr length) once (cached) instead of
|
|
93
|
+
// O(stateKeys) on every frame. Deduped so the per-frame change check stays tight.
|
|
94
|
+
const tokensOf = (expr) => {
|
|
95
|
+
const seen = new Set();
|
|
30
96
|
let i = 0;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
97
|
+
const n = expr.length;
|
|
98
|
+
while (i < n) {
|
|
99
|
+
if (isIdentChar(expr[i])) {
|
|
100
|
+
let j = i + 1;
|
|
101
|
+
while (j < n && isIdentChar(expr[j])) j++;
|
|
102
|
+
seen.add(expr.slice(i, j));
|
|
103
|
+
i = j;
|
|
104
|
+
} else i++;
|
|
105
|
+
}
|
|
106
|
+
return [...seen];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Memoize the own-key set of a state snapshot so binding checks do O(1)
|
|
110
|
+
// membership tests instead of scanning the key array. Keyed by the snapshot
|
|
111
|
+
// object itself — scoped-state proxies and merged plain objects are reused
|
|
112
|
+
// across every node of a single affected() descent, so the Set is built once
|
|
113
|
+
// per snapshot, not once per binding.
|
|
114
|
+
const keySetCache = new WeakMap();
|
|
115
|
+
const keySetOf = (snapshot) => {
|
|
116
|
+
let set = keySetCache.get(snapshot);
|
|
117
|
+
if (!set) {
|
|
118
|
+
set = new Set(ownKeysOf(snapshot));
|
|
119
|
+
keySetCache.set(snapshot, set);
|
|
120
|
+
}
|
|
121
|
+
return set;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// A binding is affected on update when it reads a state key whose value changed,
|
|
125
|
+
// OR when it reads no known state key at all (can't prove it's unaffected, so
|
|
126
|
+
// re-evaluate — hydrate self-guards the DOM write). False can only be returned
|
|
127
|
+
// when every key the binding reads is present AND unchanged.
|
|
128
|
+
const bindingAffected = (tokens, keySet, state, newState) => {
|
|
129
|
+
let matched = false;
|
|
130
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
131
|
+
const t = tokens[i];
|
|
132
|
+
if (keySet.has(t)) {
|
|
133
|
+
matched = true;
|
|
134
|
+
if (state[t] !== newState[t]) return true;
|
|
135
|
+
}
|
|
37
136
|
}
|
|
38
|
-
return
|
|
137
|
+
return !matched;
|
|
39
138
|
};
|
|
40
139
|
|
|
140
|
+
// Name bindings (`<icon @[fx.convertIcon]>`) arrive lowercased from HTML while
|
|
141
|
+
// state keys stay camelCase, so they match keys case-insensitively. This map
|
|
142
|
+
// resolves a (possibly lowercased) token back to its real key. Memoized per
|
|
143
|
+
// snapshot like keySetOf.
|
|
144
|
+
const nameKeyMapCache = new WeakMap();
|
|
145
|
+
const nameKeyMapOf = (snapshot) => {
|
|
146
|
+
let map = nameKeyMapCache.get(snapshot);
|
|
147
|
+
if (!map) {
|
|
148
|
+
map = new Map();
|
|
149
|
+
for (const key of ownKeysOf(snapshot)) {
|
|
150
|
+
map.set(key, key);
|
|
151
|
+
const lower = key.toLowerCase();
|
|
152
|
+
if (!map.has(lower)) map.set(lower, key);
|
|
153
|
+
}
|
|
154
|
+
nameKeyMapCache.set(snapshot, map);
|
|
155
|
+
}
|
|
156
|
+
return map;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const nameBindingAffected = (tokens, keyMap, state, newState) => {
|
|
160
|
+
let matched = false;
|
|
161
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
162
|
+
const realKey = keyMap.get(tokens[i]);
|
|
163
|
+
if (realKey !== undefined) {
|
|
164
|
+
matched = true;
|
|
165
|
+
if (state[realKey] !== newState[realKey]) return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return !matched;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Identifier-followed-by-`(` — i.e. an attempted function or method call.
|
|
172
|
+
// Used to flag conditional expressions whose result can't be trusted to the
|
|
173
|
+
// snapshot-equality short-circuit because the called function may read live
|
|
174
|
+
// `$` (or other state-holding globals) via closure rather than the state
|
|
175
|
+
// snapshot affected.js passes in. False positives are acceptable: when the
|
|
176
|
+
// expression contains a call, we fall back to "if any state key changed,
|
|
177
|
+
// re-evaluate"; mountBranch's branchChanged guard suppresses the no-op DOM
|
|
178
|
+
// churn when the value didn't actually flip.
|
|
179
|
+
const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
|
|
180
|
+
|
|
41
181
|
// Resolve a clone-list entry to its live counterpart. processComponent swaps
|
|
42
182
|
// the original `<component src>` for a post-process `<component>` wrapper and
|
|
43
183
|
// records the new node on the original via `_vibeReplacedBy`. Mirrors
|
|
@@ -160,8 +300,18 @@ const recursive = (
|
|
|
160
300
|
// overlaying spread of state/newState then writes the *current*
|
|
161
301
|
// values from this update cycle. Both merged states are plain
|
|
162
302
|
// objects with up-to-date values.
|
|
163
|
-
|
|
164
|
-
|
|
303
|
+
// The merged snapshot only needs this row's aliases overlaid on the
|
|
304
|
+
// current globals (`state`/`newState` already carry every global key).
|
|
305
|
+
// Recover the small overlay recorded at scope creation and plain-merge
|
|
306
|
+
// it — spreading instance.scopedState (a Proxy) instead fires its traps
|
|
307
|
+
// over every global key, per instance, per frame (a combat hot spot).
|
|
308
|
+
const overlay = scopedOverlayOf(instance.scopedState);
|
|
309
|
+
const mergedOldState = overlay
|
|
310
|
+
? { ...state, ...overlay }
|
|
311
|
+
: { ...instance.scopedState, ...state };
|
|
312
|
+
const mergedNewState = overlay
|
|
313
|
+
? { ...newState, ...overlay }
|
|
314
|
+
: { ...instance.scopedState, ...newState };
|
|
165
315
|
// scopedStateForHydration must reflect the new state so hydrate's
|
|
166
316
|
// bindings inside the row see post-update values. instance.scopedState
|
|
167
317
|
// is frozen against whatever target renderIteration was called with
|
|
@@ -235,6 +385,29 @@ const recursive = (
|
|
|
235
385
|
return affected;
|
|
236
386
|
}
|
|
237
387
|
|
|
388
|
+
// Function-call short-circuit: when the expression contains a `(`-style
|
|
389
|
+
// call, the snapshot eval can be blind — the called helper may read live
|
|
390
|
+
// `$` via closure rather than the passed state, so `oldValue` and
|
|
391
|
+
// `newValue` both run against the *current* values and the equality check
|
|
392
|
+
// never fires. Flag as affected whenever any state key actually changed
|
|
393
|
+
// this cycle so updateConditional re-runs against live state; if the
|
|
394
|
+
// result hasn't really flipped, mountBranch's branchChanged guard absorbs
|
|
395
|
+
// the call cheaply. Skip during initial hydration (state === newState).
|
|
396
|
+
if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
|
|
397
|
+
const stateKeys = Object.keys(state);
|
|
398
|
+
const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
|
|
399
|
+
if (anyChanged) {
|
|
400
|
+
affected.push({
|
|
401
|
+
type: "conditional",
|
|
402
|
+
node: tree,
|
|
403
|
+
changeType: "expression-fn-call",
|
|
404
|
+
scopedState: newState,
|
|
405
|
+
oldScopedState: state,
|
|
406
|
+
});
|
|
407
|
+
return affected;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
238
411
|
// Condition didn't change, check for affected elements inside active branch
|
|
239
412
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
240
413
|
return recursive(
|
|
@@ -250,70 +423,30 @@ const recursive = (
|
|
|
250
423
|
return affected;
|
|
251
424
|
}
|
|
252
425
|
|
|
253
|
-
|
|
254
|
-
BINDING_REGEX.lastIndex = 0;
|
|
255
|
-
|
|
256
|
-
const matches = [];
|
|
257
|
-
let match;
|
|
258
|
-
while ((match = BINDING_REGEX.exec(tree.parsed))) {
|
|
259
|
-
matches.push({ outer: match[0], inner: match[1], input: match.input });
|
|
260
|
-
}
|
|
426
|
+
const matches = textBindingsOf(tree);
|
|
261
427
|
|
|
262
428
|
if (matches.length) {
|
|
263
|
-
|
|
264
|
-
|
|
429
|
+
// A text node is hydrated as a whole (every @[…] re-interpolated together),
|
|
430
|
+
// so the node is all-or-nothing: if any of its bindings is affected, push
|
|
431
|
+
// them all. Initial hydration affects everything.
|
|
265
432
|
const isInitialHydration = state === newState;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const noMatch = !shallowState.some((key) =>
|
|
275
|
-
matchesKey(resolvedInner, key),
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
let shouldAffect = false;
|
|
279
|
-
let relevantKeys = [];
|
|
280
|
-
|
|
281
|
-
if (isInitialHydration) {
|
|
282
|
-
// Initial hydration: affect all matched keys
|
|
283
|
-
relevantKeys = shallowNewState.filter((key) =>
|
|
284
|
-
matchesKey(resolvedInner, key),
|
|
285
|
-
);
|
|
286
|
-
shouldAffect = noMatch || relevantKeys.length > 0;
|
|
287
|
-
} else {
|
|
288
|
-
// Update: only affect if value changed
|
|
289
|
-
const changedKeys = shallowNewState.filter(
|
|
290
|
-
(key) =>
|
|
291
|
-
matchesKey(resolvedInner, key) && state[key] !== newState[key],
|
|
292
|
-
);
|
|
293
|
-
relevantKeys =
|
|
294
|
-
changedKeys.length > 0
|
|
295
|
-
? changedKeys
|
|
296
|
-
: shallowState.filter((key) => matchesKey(resolvedInner, key));
|
|
297
|
-
shouldAffect = noMatch || changedKeys.length > 0;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (shouldAffect) {
|
|
301
|
-
hasAffected = true;
|
|
433
|
+
let hasAffected = isInitialHydration;
|
|
434
|
+
if (!isInitialHydration) {
|
|
435
|
+
const keySet = keySetOf(newState);
|
|
436
|
+
for (const m of matches) {
|
|
437
|
+
if (bindingAffected(m.tokens, keySet, state, newState)) {
|
|
438
|
+
hasAffected = true;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
302
441
|
}
|
|
303
|
-
|
|
304
|
-
checkedMatches.push({
|
|
305
|
-
...m,
|
|
306
|
-
matches: relevantKeys,
|
|
307
|
-
});
|
|
308
442
|
}
|
|
309
443
|
|
|
310
444
|
if (hasAffected) {
|
|
311
|
-
for (const m of
|
|
445
|
+
for (const m of matches) {
|
|
312
446
|
affected.push({
|
|
313
447
|
matchOuter: m.outer,
|
|
314
448
|
matchInner: m.inner,
|
|
315
449
|
input: m.input,
|
|
316
|
-
matches: m.matches,
|
|
317
450
|
element: tree.element,
|
|
318
451
|
textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
|
|
319
452
|
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
@@ -324,42 +457,14 @@ const recursive = (
|
|
|
324
457
|
|
|
325
458
|
// Check attribute bindings
|
|
326
459
|
if (tree.attributes) {
|
|
327
|
-
const shallowState = Object.keys(state);
|
|
328
|
-
const shallowNewState = Object.keys(newState);
|
|
329
460
|
const isInitialHydration = state === newState;
|
|
461
|
+
const keySet = isInitialHydration ? null : keySetOf(newState);
|
|
330
462
|
|
|
331
|
-
for (const
|
|
332
|
-
|
|
333
|
-
const attrMatches = [];
|
|
334
|
-
let attrMatch;
|
|
335
|
-
while ((attrMatch = BINDING_REGEX.exec(attrValue))) {
|
|
336
|
-
attrMatches.push({ outer: attrMatch[0], inner: attrMatch[1] });
|
|
337
|
-
}
|
|
338
|
-
|
|
463
|
+
for (const { attrName, matches: attrMatches } of attrBindingsOf(tree)) {
|
|
464
|
+
const attrValue = tree.attributes[attrName];
|
|
339
465
|
for (const m of attrMatches) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const noMatch = !shallowState.some((key) =>
|
|
344
|
-
matchesKey(resolvedInner, key),
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
let shouldAffect = false;
|
|
348
|
-
|
|
349
|
-
if (isInitialHydration) {
|
|
350
|
-
// Initial hydration: affect all matched keys
|
|
351
|
-
const newMatches = shallowNewState.filter((key) =>
|
|
352
|
-
matchesKey(resolvedInner, key),
|
|
353
|
-
);
|
|
354
|
-
shouldAffect = noMatch || newMatches.length > 0;
|
|
355
|
-
} else {
|
|
356
|
-
// Update: only affect if value changed
|
|
357
|
-
const changedKeys = shallowNewState.filter(
|
|
358
|
-
(key) =>
|
|
359
|
-
matchesKey(resolvedInner, key) && state[key] !== newState[key],
|
|
360
|
-
);
|
|
361
|
-
shouldAffect = noMatch || changedKeys.length > 0;
|
|
362
|
-
}
|
|
466
|
+
const shouldAffect =
|
|
467
|
+
isInitialHydration || bindingAffected(m.tokens, keySet, state, newState);
|
|
363
468
|
|
|
364
469
|
if (shouldAffect) {
|
|
365
470
|
affected.push({
|
|
@@ -378,51 +483,22 @@ const recursive = (
|
|
|
378
483
|
|
|
379
484
|
// Check name bindings (bindings in attribute names)
|
|
380
485
|
if (tree.nameBindings) {
|
|
381
|
-
const shallowState = Object.keys(state);
|
|
382
|
-
const shallowNewState = Object.keys(newState);
|
|
383
486
|
const isInitialHydration = state === newState;
|
|
487
|
+
const keyMap = isInitialHydration ? null : nameKeyMapOf(newState);
|
|
384
488
|
|
|
385
|
-
for (const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
let nameMatch;
|
|
389
|
-
while ((nameMatch = BINDING_REGEX.exec(nameBinding))) {
|
|
390
|
-
nameMatches.push({ outer: nameMatch[0], inner: nameMatch[1] });
|
|
391
|
-
}
|
|
489
|
+
for (const m of nameBindingsOf(tree)) {
|
|
490
|
+
const shouldAffect =
|
|
491
|
+
isInitialHydration || nameBindingAffected(m.tokens, keyMap, state, newState);
|
|
392
492
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const noMatch = !shallowState.some(matchKey);
|
|
403
|
-
|
|
404
|
-
let shouldAffect = false;
|
|
405
|
-
|
|
406
|
-
if (isInitialHydration) {
|
|
407
|
-
const newMatches = shallowNewState.filter(matchKey);
|
|
408
|
-
shouldAffect = noMatch || newMatches.length > 0;
|
|
409
|
-
} else {
|
|
410
|
-
const changedKeys = shallowNewState.filter(
|
|
411
|
-
(key) => matchKey(key) && state[key] !== newState[key],
|
|
412
|
-
);
|
|
413
|
-
shouldAffect = noMatch || changedKeys.length > 0;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (shouldAffect) {
|
|
417
|
-
affected.push({
|
|
418
|
-
type: "nameBinding",
|
|
419
|
-
nameBinding,
|
|
420
|
-
matchOuter: m.outer,
|
|
421
|
-
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
422
|
-
element: tree.element,
|
|
423
|
-
scopedState: scopedStateForHydration,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
493
|
+
if (shouldAffect) {
|
|
494
|
+
affected.push({
|
|
495
|
+
type: "nameBinding",
|
|
496
|
+
nameBinding: m.nameBinding,
|
|
497
|
+
matchOuter: m.outer,
|
|
498
|
+
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
499
|
+
element: tree.element,
|
|
500
|
+
scopedState: scopedStateForHydration,
|
|
501
|
+
});
|
|
426
502
|
}
|
|
427
503
|
}
|
|
428
504
|
}
|
package/runtime/hydrate.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { updateIteration } from './iterate.js';
|
|
2
|
-
import { updateConditional } from './conditionals.js';
|
|
2
|
+
import { updateConditional, managedNodes } from './conditionals.js';
|
|
3
3
|
import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
|
|
4
4
|
import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
|
|
5
|
+
import { RawHtml } from './raw-html.js';
|
|
5
6
|
|
|
6
7
|
export default (affected, state, manifest = {}, oldState = {}) => {
|
|
7
8
|
affected.forEach((aff) => {
|
|
@@ -131,6 +132,27 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
131
132
|
// Evaluate the expression with state as context
|
|
132
133
|
const evaluated = evalInScope(matchInner, effectiveState, element);
|
|
133
134
|
|
|
135
|
+
// Raw-HTML render: `$.unsafe(str)` returns a RawHtml marker. When the
|
|
136
|
+
// binding is the sole content of its element (`<p>@[$.unsafe(x)]</p>`),
|
|
137
|
+
// set innerHTML from the trusted string instead of escaping via
|
|
138
|
+
// textContent. The injected subtree is marked opaque (managedNodes) so
|
|
139
|
+
// the MutationObserver never re-walks or hydrates it — raw HTML is inert,
|
|
140
|
+
// matching Svelte {@html}. Any non-pure use (marker spliced into
|
|
141
|
+
// surrounding text, or an element with other children) falls through to
|
|
142
|
+
// the normal path, where RawHtml.toString() degrades to escaped text.
|
|
143
|
+
const isPureBinding = input.trim() === matchOuter;
|
|
144
|
+
if (evaluated instanceof RawHtml && isPureBinding &&
|
|
145
|
+
(element._vibeRawHtml || element.childNodes.length === 1)) {
|
|
146
|
+
const html = evaluated.html;
|
|
147
|
+
if (element._vibeRawHtmlValue !== html) {
|
|
148
|
+
element.innerHTML = html;
|
|
149
|
+
for (const child of element.children) managedNodes.add(child);
|
|
150
|
+
element._vibeRawHtmlValue = html;
|
|
151
|
+
}
|
|
152
|
+
element._vibeRawHtml = true;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
const toReplace = input.replaceAll(matchOuter, evaluated).trim();
|
|
135
157
|
|
|
136
158
|
affected.forEach((innerAff) => {
|
package/runtime/index.js
CHANGED
|
@@ -3,7 +3,8 @@ import parse from './parse.js';
|
|
|
3
3
|
import createManifest from './manifest.js';
|
|
4
4
|
import hydrate from './hydrate.js';
|
|
5
5
|
import affected from './affected.js';
|
|
6
|
-
import { deepMerge, hash } from './utils.js';
|
|
6
|
+
import { deepMerge, hash, setRootState } from './utils.js';
|
|
7
|
+
import { unsafe } from './raw-html.js';
|
|
7
8
|
import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
|
|
8
9
|
import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
|
|
9
10
|
import { installScopeResolver } from './loop-scope.js';
|
|
@@ -655,6 +656,16 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
655
656
|
enumerable: false,
|
|
656
657
|
});
|
|
657
658
|
|
|
659
|
+
// Mark a trusted string as raw HTML. A binding that is the sole content of
|
|
660
|
+
// its element — `<p>@[$.unsafe(desc)]</p>` — sets innerHTML from the string
|
|
661
|
+
// instead of escaping it via textContent. Trusted input only (no sanitizing,
|
|
662
|
+
// like Svelte {@html}). Non-enumerable so it never leaks into state snapshots.
|
|
663
|
+
Object.defineProperty($, 'unsafe', {
|
|
664
|
+
value: unsafe,
|
|
665
|
+
enumerable: false,
|
|
666
|
+
configurable: true,
|
|
667
|
+
});
|
|
668
|
+
|
|
658
669
|
// Pure-render path for surgical component HMR. Given raw component template
|
|
659
670
|
// HTML, callsite props, slot HTML, and existing componentIds, returns the
|
|
660
671
|
// processed HTML string the plugin's HMR handler can hand to $.reconcile.
|
|
@@ -672,6 +683,29 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
672
683
|
// manifest's node-path entry iteration.
|
|
673
684
|
Object.defineProperty(manifest, '__live', { value: $, enumerable: false, configurable: true });
|
|
674
685
|
|
|
686
|
+
// Pair the flat manifest (dotPath -> element) with its parsed tree so removal
|
|
687
|
+
// paths can prune both views together. The page MutationObserver is
|
|
688
|
+
// disconnected while Vibe renders (iteration/conditional teardown removes DOM
|
|
689
|
+
// unobserved), so those paths must prune the manifest + tree themselves; this
|
|
690
|
+
// gives them the tree root without threading it through every call. Same
|
|
691
|
+
// non-enumerable contract as __live.
|
|
692
|
+
Object.defineProperty(manifest, '__tree', { value: parsedTree, enumerable: false, configurable: true });
|
|
693
|
+
|
|
694
|
+
// Bind `$` inside every expression to this live root proxy (see setRootState
|
|
695
|
+
// in utils.js). Done before the first hydration pass so `$.unsafe` and the
|
|
696
|
+
// other reserved methods are reachable from the initial render onward.
|
|
697
|
+
setRootState($);
|
|
698
|
+
|
|
699
|
+
// Publish the real proxy on `window.$` BEFORE the first hydration pass.
|
|
700
|
+
// boot.js sets `window.$ = main(...)`, but until main returns it's the
|
|
701
|
+
// pre-boot placeholder (vibeInstance with no state keys). User helpers
|
|
702
|
+
// defined on `window` that close over `$` — e.g. a global `brawlerActivity`
|
|
703
|
+
// function reading `$.combat?.duration` — would then read the placeholder
|
|
704
|
+
// during initial render and treat the whole world as empty. Assigning the
|
|
705
|
+
// live proxy here lets those closures see the right `$` from the very
|
|
706
|
+
// first conditional/binding eval.
|
|
707
|
+
if (typeof window !== 'undefined') window.$ = $;
|
|
708
|
+
|
|
675
709
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
676
710
|
const initialState = extractPlainValue($);
|
|
677
711
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
package/runtime/iterate.js
CHANGED
|
@@ -2,7 +2,7 @@ import parse from './parse.js';
|
|
|
2
2
|
import affected from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
4
|
import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
|
|
5
|
-
import { resolveThisPath, evalInScope, findComponentIdForElement } from './utils.js';
|
|
5
|
+
import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys } from './utils.js';
|
|
6
6
|
import { managedNodes } from './conditionals.js';
|
|
7
7
|
import { stampInstanceScopes } from './loop-scope.js';
|
|
8
8
|
import {
|
|
@@ -351,36 +351,54 @@ const liveCloneNode = (node) => {
|
|
|
351
351
|
return cur;
|
|
352
352
|
};
|
|
353
353
|
|
|
354
|
+
// Walk a row's live cloned nodes and invoke `fn` for every inlined-component
|
|
355
|
+
// wrapper carrying `marker` — the resolved node itself plus any
|
|
356
|
+
// `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
|
|
357
|
+
// inlined-hydrate passes are identical except for this marker and the per-wrapper
|
|
358
|
+
// work, so they share this walk. (`[data-vibe-iter-prop]` always implies
|
|
359
|
+
// `_vibeIterPropExprs`, set together at mount, so filtering descendants by the
|
|
360
|
+
// marker matches the historical "take all, skip those without exprs" behavior.)
|
|
361
|
+
const forEachIterWrapper = (clonedNodes, marker, fn) => {
|
|
362
|
+
for (let n = 0; n < clonedNodes.length; n++) {
|
|
363
|
+
const node = liveCloneNode(clonedNodes[n]);
|
|
364
|
+
if (!node || node.nodeType !== 1) continue;
|
|
365
|
+
if (node[marker]) fn(node);
|
|
366
|
+
const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
|
|
367
|
+
if (found) for (let i = 0; i < found.length; i++) {
|
|
368
|
+
if (found[i][marker]) fn(found[i]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
354
373
|
// Walk a row's clones for any element tagged as iteration-prop owner —
|
|
355
374
|
// pre-process `<component src>` (still has the src attribute) and post-process
|
|
356
375
|
// `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
|
|
357
376
|
// expression, re-evaluate against the row's new scoped state and write into
|
|
358
377
|
// the registry slot the inlined bindings already reference. Idempotent: same
|
|
359
378
|
// scoped state → same value → no-op write.
|
|
379
|
+
// Returns the set of registry slot ids whose value actually changed this
|
|
380
|
+
// refresh. A slot holding the same reference (e.g. a static ability array on a
|
|
381
|
+
// combatant that only moved) is a no-op, so forceRegistryBackedIterationUpdates
|
|
382
|
+
// can skip re-diffing the nested iteration it feeds — the dominant cost when a
|
|
383
|
+
// row carries large static nested iterations.
|
|
360
384
|
const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const exprs = el._vibeIterPropExprs;
|
|
371
|
-
if (!exprs) continue;
|
|
372
|
-
const registry = ensureIterPropsRegistry();
|
|
373
|
-
for (let e = 0; e < exprs.length; e++) {
|
|
374
|
-
try {
|
|
375
|
-
const value = evalInScope(exprs[e].expr, scopedState, el);
|
|
376
|
-
registry[exprs[e].id] = value;
|
|
377
|
-
} catch {
|
|
378
|
-
// Leave previous registry value in place — same fail-safe as
|
|
379
|
-
// resolveIterationComponentProps's mount-time path.
|
|
385
|
+
const changed = new Set();
|
|
386
|
+
const registry = ensureIterPropsRegistry();
|
|
387
|
+
forEachIterWrapper(clonedNodes, '_vibeIterPropExprs', (el) => {
|
|
388
|
+
for (const { id, expr } of el._vibeIterPropExprs) {
|
|
389
|
+
try {
|
|
390
|
+
const value = evalInScope(expr, scopedState, el);
|
|
391
|
+
if (registry[id] !== value) {
|
|
392
|
+
registry[id] = value;
|
|
393
|
+
changed.add(id);
|
|
380
394
|
}
|
|
395
|
+
} catch {
|
|
396
|
+
// Leave previous registry value in place — same fail-safe as
|
|
397
|
+
// resolveIterationComponentProps's mount-time path.
|
|
381
398
|
}
|
|
382
399
|
}
|
|
383
|
-
}
|
|
400
|
+
});
|
|
401
|
+
return changed;
|
|
384
402
|
};
|
|
385
403
|
|
|
386
404
|
// Walk an inlined component's parsed tree and force `updateIteration` on any
|
|
@@ -394,18 +412,21 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
394
412
|
// in sync. Without this, an `<inner-component>` whose template iterates over
|
|
395
413
|
// an array prop stays frozen on its initial-render items when the prop's
|
|
396
414
|
// contents change.
|
|
397
|
-
const
|
|
398
|
-
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope) => {
|
|
415
|
+
const REGISTRY_SLOT_REGEX = /__vibeIterProps\.(_p\d+)/;
|
|
416
|
+
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
|
|
399
417
|
if (!tree) return;
|
|
400
418
|
if (tree.type === 'iteration') {
|
|
401
419
|
const arrPath = tree.meta?.arrayPath;
|
|
402
|
-
|
|
420
|
+
const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
|
|
421
|
+
// Skip iterations whose backing slot didn't change this cycle. changedSlots
|
|
422
|
+
// is undefined only on legacy/unguarded calls — fall back to always-update.
|
|
423
|
+
if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
|
|
403
424
|
updateIteration(tree, state, state, manifest, parentScope);
|
|
404
425
|
}
|
|
405
426
|
}
|
|
406
427
|
if (tree.children) {
|
|
407
428
|
for (const k in tree.children) {
|
|
408
|
-
forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope);
|
|
429
|
+
forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope, changedSlots);
|
|
409
430
|
}
|
|
410
431
|
}
|
|
411
432
|
};
|
|
@@ -416,30 +437,19 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope)
|
|
|
416
437
|
// component.js) — that tree retains the original `@[...]` binding text even
|
|
417
438
|
// after the wrapper's live DOM has been hydrated, so subsequent affected→
|
|
418
439
|
// hydrate passes work the same way they would on initial render.
|
|
419
|
-
const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest) => {
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
|
|
426
|
-
if (found) for (let i = 0; i < found.length; i++) {
|
|
427
|
-
if (found[i]._vibeIterTree) wrappers.push(found[i]);
|
|
428
|
-
}
|
|
429
|
-
for (let w = 0; w < wrappers.length; w++) {
|
|
430
|
-
const wrapper = wrappers[w];
|
|
431
|
-
const tree = wrapper._vibeIterTree;
|
|
432
|
-
const affectedList = affected(tree, oldState, newState);
|
|
433
|
-
if (affectedList.length > 0) {
|
|
434
|
-
hydrate(affectedList, newState, manifest, oldState);
|
|
435
|
-
}
|
|
436
|
-
// Bindings into the iteration-prop registry are visited above, but
|
|
437
|
-
// iteration nodes whose arrayPath resolves through the registry need
|
|
438
|
-
// explicit driving: the refreshed slot is a side effect that
|
|
439
|
-
// `affected()` can't see. Walk the tree and update them directly.
|
|
440
|
-
forceRegistryBackedIterationUpdates(tree, newState, manifest, {});
|
|
440
|
+
const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
|
|
441
|
+
forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
|
|
442
|
+
const tree = wrapper._vibeIterTree;
|
|
443
|
+
const affectedList = affected(tree, oldState, newState);
|
|
444
|
+
if (affectedList.length > 0) {
|
|
445
|
+
hydrate(affectedList, newState, manifest, oldState);
|
|
441
446
|
}
|
|
442
|
-
|
|
447
|
+
// Bindings into the iteration-prop registry are visited above, but iteration
|
|
448
|
+
// nodes whose arrayPath resolves through the registry need explicit driving:
|
|
449
|
+
// the refreshed slot is a side effect `affected()` can't see. Update only the
|
|
450
|
+
// iterations whose slot actually changed this cycle (changedSlots).
|
|
451
|
+
forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
|
|
452
|
+
});
|
|
443
453
|
};
|
|
444
454
|
|
|
445
455
|
// Apply DOM-property writes that compileBatchFn collected. Each batch row
|
|
@@ -764,7 +774,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
764
774
|
...Reflect.ownKeys(globalState),
|
|
765
775
|
])];
|
|
766
776
|
|
|
767
|
-
|
|
777
|
+
// Overlay = the aliases this proxy resolves locally (localVars wins over
|
|
778
|
+
// parentScope, matching the get order below). affected's descent reuses it for
|
|
779
|
+
// a cheap plain merge instead of materializing the proxy.
|
|
780
|
+
const overlay =
|
|
781
|
+
Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
|
|
782
|
+
|
|
783
|
+
const proxy = new Proxy(globalState, {
|
|
768
784
|
get(target, prop) {
|
|
769
785
|
if (prop in localVars) return localVars[prop];
|
|
770
786
|
if (prop in parentScope) return parentScope[prop];
|
|
@@ -799,6 +815,8 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
799
815
|
return Reflect.getOwnPropertyDescriptor(target, prop);
|
|
800
816
|
},
|
|
801
817
|
});
|
|
818
|
+
|
|
819
|
+
return rememberScopedKeys(proxy, cachedKeys, overlay);
|
|
802
820
|
};
|
|
803
821
|
|
|
804
822
|
// Render all iterations in the parsed tree
|
|
@@ -832,8 +850,7 @@ export const setRenderAllConditionals = (fn) => {
|
|
|
832
850
|
|
|
833
851
|
// Initial render of an iteration block
|
|
834
852
|
export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
|
|
835
|
-
const { arrayPath,
|
|
836
|
-
iterationNode.meta;
|
|
853
|
+
const { arrayPath, startComment, endComment } = iterationNode.meta;
|
|
837
854
|
|
|
838
855
|
// Already rendered - updates go through updateIteration
|
|
839
856
|
if (iterationNode.runtime.instances?.length > 0) {
|
|
@@ -898,55 +915,8 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
898
915
|
// Fall through to runtime path if compiled failed
|
|
899
916
|
}
|
|
900
917
|
|
|
901
|
-
// Standard path:
|
|
902
|
-
|
|
903
|
-
const templateNodes = template.element.childNodes;
|
|
904
|
-
const frag = document.createDocumentFragment();
|
|
905
|
-
// Resolve the iteration's owning component once — `this.X` bindings inside
|
|
906
|
-
// row content resolve against this id while the cloned subtree is still
|
|
907
|
-
// detached during hydrate (see findComponentIdForElement's detached fallback).
|
|
908
|
-
const componentId = findComponentIdForElement(startComment.parentElement);
|
|
909
|
-
|
|
910
|
-
// Live `$`-proxy elements for the loop alias, so nested conditionals (which
|
|
911
|
-
// stamp `__vibeScope` from this scoped state) and `$scope` handlers receive
|
|
912
|
-
// the same identity the app sees through `$`. `inst.item` stays the plain
|
|
913
|
-
// snapshot value for the diff.
|
|
914
|
-
const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
|
|
915
|
-
|
|
916
|
-
for (let i = 0; i < array.length; i++) {
|
|
917
|
-
const item = array[i];
|
|
918
|
-
const liveItem = liveItemAt(liveArray, i, item);
|
|
919
|
-
const localVars = { [itemAlias]: liveItem, [indexAlias]: i };
|
|
920
|
-
const scopedState = createScopedState(state, localVars, parentScope);
|
|
921
|
-
|
|
922
|
-
// Clone, parse, hydrate
|
|
923
|
-
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
|
|
924
|
-
|
|
925
|
-
// Pre-resolve <component src> binding props against iteration scope (see helper comment)
|
|
926
|
-
resolveIterationComponentProps(clonedNodes, scopedState);
|
|
927
|
-
|
|
928
|
-
// Collect nodes in DocumentFragment (single DOM insertion at end)
|
|
929
|
-
for (let j = 0; j < clonedNodes.length; j++) {
|
|
930
|
-
frag.appendChild(clonedNodes[j]);
|
|
931
|
-
if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// Recursively render nested iterations and conditionals
|
|
935
|
-
if (tree) {
|
|
936
|
-
const nestedScope = { ...parentScope, ...localVars };
|
|
937
|
-
renderAllIterations(tree, scopedState, manifest, nestedScope);
|
|
938
|
-
_renderAllConditionals(tree, scopedState, manifest, nestedScope);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
instances.push({ element, tree, item, liveItem, index: i, clonedNodes, scopedState });
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// Single DOM insertion for all items
|
|
945
|
-
parent.insertBefore(frag, endComment);
|
|
946
|
-
iterationNode.runtime.instances = instances;
|
|
947
|
-
|
|
948
|
-
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
949
|
-
stampScopes(iterationNode, manifest, parentScope);
|
|
918
|
+
// Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
|
|
919
|
+
renderInstances(iterationNode, array, state, manifest, parentScope);
|
|
950
920
|
|
|
951
921
|
// Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
|
|
952
922
|
// Also store runtime data on the DOM node so it persists across re-parses
|
|
@@ -1103,7 +1073,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1103
1073
|
operations.forEach((op) => {
|
|
1104
1074
|
switch (op.type) {
|
|
1105
1075
|
case 'REMOVE':
|
|
1106
|
-
removeInstance(iterationNode, op.index);
|
|
1076
|
+
removeInstance(iterationNode, op.index, manifest);
|
|
1107
1077
|
break;
|
|
1108
1078
|
case 'ADD':
|
|
1109
1079
|
addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
|
|
@@ -1135,18 +1105,97 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1135
1105
|
stampScopes(iterationNode, manifest, parentScope);
|
|
1136
1106
|
};
|
|
1137
1107
|
|
|
1108
|
+
// Prune removed iteration rows from the global manifest (dotPath -> element)
|
|
1109
|
+
// and parsed tree. Both views still reference rows that mounted globally-tracked
|
|
1110
|
+
// content (e.g. a <component src> registers its subtree in the manifest). Vibe
|
|
1111
|
+
// disconnects the page MutationObserver while it reconciles, so the removals
|
|
1112
|
+
// below are never observed — left unpruned, the entries pin detached subtrees
|
|
1113
|
+
// (memory leak) and bloat every later affected/hydrate walk (the
|
|
1114
|
+
// combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
|
|
1115
|
+
const releaseRemovedSubtrees = (removedRoots, manifest) => {
|
|
1116
|
+
if (!manifest || removedRoots.length === 0) return;
|
|
1117
|
+
const tree = manifest.__tree;
|
|
1118
|
+
|
|
1119
|
+
// Reverse the manifest (dotPath -> element) once so each removed root resolves
|
|
1120
|
+
// to its path. An element can be registered at more than one path (a component
|
|
1121
|
+
// wrapper appears at both its own node and an inlined child slot); keep the
|
|
1122
|
+
// SHORTEST so the path scopes the whole row subtree, not an inner slot.
|
|
1123
|
+
// __live / __tree are non-enumerable, so for-in skips them.
|
|
1124
|
+
const pathOf = new Map();
|
|
1125
|
+
for (const key in manifest) {
|
|
1126
|
+
const el = manifest[key];
|
|
1127
|
+
if (!el) continue;
|
|
1128
|
+
const existing = pathOf.get(el);
|
|
1129
|
+
if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const removedEls = new Set(); // top-level removed roots — tree prune cascades subtrees
|
|
1133
|
+
const removedPaths = new Set(); // their manifest paths — manifest prune is path-scoped
|
|
1134
|
+
for (let i = 0; i < removedRoots.length; i++) {
|
|
1135
|
+
const root = removedRoots[i];
|
|
1136
|
+
if (!root) continue;
|
|
1137
|
+
removedEls.add(root);
|
|
1138
|
+
const path = pathOf.get(root);
|
|
1139
|
+
if (path !== undefined) removedPaths.add(path);
|
|
1140
|
+
}
|
|
1141
|
+
if (removedPaths.size === 0 && removedEls.size === 0) return;
|
|
1142
|
+
|
|
1143
|
+
// Manifest: drop every entry at or under a removed root's path. Path scope (not
|
|
1144
|
+
// element identity) is what catches content hoisted out of the DOM — an inactive
|
|
1145
|
+
// conditional branch template sits in a detached container yet stays registered
|
|
1146
|
+
// under its row's path.
|
|
1147
|
+
if (removedPaths.size > 0) {
|
|
1148
|
+
for (const key in manifest) {
|
|
1149
|
+
if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Parsed tree: delete each removed root's node — its whole subtree (nested
|
|
1154
|
+
// conditionals/iterations and their branch templates) goes with it.
|
|
1155
|
+
if (tree) pruneTreeNodes(tree, removedEls);
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
// True when `key` is, or is a descendant of, any path in `removedPaths`.
|
|
1159
|
+
const pathUnderRemoved = (key, removedPaths) => {
|
|
1160
|
+
if (removedPaths.has(key)) return true;
|
|
1161
|
+
for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
|
|
1162
|
+
if (removedPaths.has(key.slice(0, i))) return true;
|
|
1163
|
+
}
|
|
1164
|
+
return false;
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
const pruneTreeNodes = (node, removedEls) => {
|
|
1168
|
+
const children = node.children;
|
|
1169
|
+
if (!children) return;
|
|
1170
|
+
for (const key in children) {
|
|
1171
|
+
const child = children[key];
|
|
1172
|
+
if (!child) continue;
|
|
1173
|
+
if (removedEls.has(child.element)) delete children[key];
|
|
1174
|
+
else pruneTreeNodes(child, removedEls);
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1138
1178
|
// Bulk replacement: clear all DOM and re-render from scratch
|
|
1139
1179
|
// Used when arrays share no common keys (avoids O(n²) LCS)
|
|
1140
1180
|
const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
1141
1181
|
const { template, startComment, endComment } = iterationNode.meta;
|
|
1142
1182
|
const parent = startComment.parentNode;
|
|
1143
1183
|
|
|
1144
|
-
// Clear all existing DOM between comments using Range (single operation)
|
|
1184
|
+
// Clear all existing DOM between comments using Range (single operation),
|
|
1185
|
+
// then prune the removed subtrees from the manifest + tree. Collect the LIVE
|
|
1186
|
+
// nodes in the range rather than the instances' clonedNodes: a <component src>
|
|
1187
|
+
// row is replaced in place by component.js, so clonedNodes can point at the
|
|
1188
|
+
// stale original wrapper, not the processed content actually being removed.
|
|
1145
1189
|
if (iterationNode.runtime.instances.length > 0) {
|
|
1190
|
+
const removedRoots = [];
|
|
1191
|
+
for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
|
|
1192
|
+
removedRoots.push(cur);
|
|
1193
|
+
}
|
|
1146
1194
|
const range = document.createRange();
|
|
1147
1195
|
range.setStartAfter(startComment);
|
|
1148
1196
|
range.setEndBefore(endComment);
|
|
1149
1197
|
range.deleteContents();
|
|
1198
|
+
releaseRemovedSubtrees(removedRoots, manifest);
|
|
1150
1199
|
}
|
|
1151
1200
|
|
|
1152
1201
|
if (newArray.length === 0) {
|
|
@@ -1165,26 +1214,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
1165
1214
|
return;
|
|
1166
1215
|
}
|
|
1167
1216
|
|
|
1168
|
-
// Complex templates:
|
|
1169
|
-
|
|
1170
|
-
// parent.insertBefore call.
|
|
1171
|
-
const instances = [];
|
|
1172
|
-
const frag = document.createDocumentFragment();
|
|
1173
|
-
const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
|
|
1174
|
-
for (let i = 0; i < newArray.length; i++) {
|
|
1175
|
-
const built = buildInstance(iterationNode, newArray[i], i, state, parentScope, liveItemAt(liveArray, i, newArray[i]));
|
|
1176
|
-
for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
|
|
1177
|
-
finalizeInstance(built, manifest, parentScope);
|
|
1178
|
-
instances.push({
|
|
1179
|
-
element: built.element, tree: built.tree, item: newArray[i], liveItem: built.liveItem, index: i,
|
|
1180
|
-
clonedNodes: built.clonedNodes, scopedState: built.scopedState,
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
parent.insertBefore(frag, endComment);
|
|
1184
|
-
iterationNode.runtime.instances = instances;
|
|
1185
|
-
|
|
1186
|
-
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
1187
|
-
stampScopes(iterationNode, manifest, parentScope);
|
|
1217
|
+
// Complex templates: one shared clone+hydrate render loop.
|
|
1218
|
+
renderInstances(iterationNode, newArray, state, manifest, parentScope);
|
|
1188
1219
|
};
|
|
1189
1220
|
|
|
1190
1221
|
// Find an instance's canonical in-DOM anchor (the first of its cloned nodes
|
|
@@ -1232,6 +1263,10 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1232
1263
|
const anchor = findInstanceAnchor(instance, parent);
|
|
1233
1264
|
const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
1234
1265
|
|
|
1266
|
+
// Return the live nodes actually removed so callers can prune them from the
|
|
1267
|
+
// manifest + tree (clonedNodes can be stale once a <component src> row is
|
|
1268
|
+
// replaced in place by component.js).
|
|
1269
|
+
const removed = [];
|
|
1235
1270
|
if (anchor) {
|
|
1236
1271
|
let cur = anchor;
|
|
1237
1272
|
// endComment caps the walk even if nextAnchor ordering is ever
|
|
@@ -1239,6 +1274,7 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1239
1274
|
while (cur && cur !== nextAnchor && cur !== endComment) {
|
|
1240
1275
|
const nextSibling = cur.nextSibling;
|
|
1241
1276
|
parent.removeChild(cur);
|
|
1277
|
+
removed.push(cur);
|
|
1242
1278
|
cur = nextSibling;
|
|
1243
1279
|
}
|
|
1244
1280
|
}
|
|
@@ -1246,8 +1282,12 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1246
1282
|
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
1247
1283
|
for (let i = 0; i < nodes.length; i++) {
|
|
1248
1284
|
const n = nodes[i];
|
|
1249
|
-
if (n && n.parentNode && n.parentNode !== parent)
|
|
1285
|
+
if (n && n.parentNode && n.parentNode !== parent) {
|
|
1286
|
+
n.parentNode.removeChild(n);
|
|
1287
|
+
removed.push(n);
|
|
1288
|
+
}
|
|
1250
1289
|
}
|
|
1290
|
+
return removed;
|
|
1251
1291
|
};
|
|
1252
1292
|
|
|
1253
1293
|
// Build a fresh instance's DOM + tree + scope from the iteration template.
|
|
@@ -1285,6 +1325,31 @@ const finalizeInstance = (built, manifest, parentScope) => {
|
|
|
1285
1325
|
}
|
|
1286
1326
|
};
|
|
1287
1327
|
|
|
1328
|
+
// Build, finalize, and commit a fresh set of instances for `array` in one
|
|
1329
|
+
// batched DOM insertion, replacing iterationNode.runtime.instances. The single
|
|
1330
|
+
// clone+hydrate render loop shared by initial render (renderIteration) and full
|
|
1331
|
+
// rebuild (bulkReplace) — they differ only in their preamble, not this loop.
|
|
1332
|
+
const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
|
|
1333
|
+
const { startComment, endComment } = iterationNode.meta;
|
|
1334
|
+
const parent = startComment.parentNode;
|
|
1335
|
+
const instances = [];
|
|
1336
|
+
const frag = document.createDocumentFragment();
|
|
1337
|
+
const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
|
|
1338
|
+
for (let i = 0; i < array.length; i++) {
|
|
1339
|
+
const built = buildInstance(iterationNode, array[i], i, state, parentScope, liveItemAt(liveArray, i, array[i]));
|
|
1340
|
+
for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
|
|
1341
|
+
finalizeInstance(built, manifest, parentScope);
|
|
1342
|
+
instances.push({
|
|
1343
|
+
element: built.element, tree: built.tree, item: array[i], liveItem: built.liveItem, index: i,
|
|
1344
|
+
clonedNodes: built.clonedNodes, scopedState: built.scopedState,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
parent.insertBefore(frag, endComment);
|
|
1348
|
+
iterationNode.runtime.instances = instances;
|
|
1349
|
+
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
1350
|
+
stampScopes(iterationNode, manifest, parentScope);
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1288
1353
|
const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
|
|
1289
1354
|
const parent = iterationNode.meta.startComment.parentNode;
|
|
1290
1355
|
const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
|
|
@@ -1297,10 +1362,11 @@ const addInstance = (iterationNode, item, index, state, manifest, parentScope) =
|
|
|
1297
1362
|
});
|
|
1298
1363
|
};
|
|
1299
1364
|
|
|
1300
|
-
const removeInstance = (iterationNode, index) => {
|
|
1365
|
+
const removeInstance = (iterationNode, index, manifest) => {
|
|
1301
1366
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
1302
1367
|
const parent = iterationNode.meta.startComment.parentNode;
|
|
1303
|
-
|
|
1368
|
+
const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
|
|
1369
|
+
releaseRemovedSubtrees(removed, manifest);
|
|
1304
1370
|
iterationNode.runtime.instances.splice(index, 1);
|
|
1305
1371
|
};
|
|
1306
1372
|
|
|
@@ -1337,7 +1403,12 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
|
|
|
1337
1403
|
|
|
1338
1404
|
const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
|
|
1339
1405
|
const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
|
|
1340
|
-
|
|
1406
|
+
// Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
|
|
1407
|
+
// so the replaced subtree is released (same removal contract as removeInstance).
|
|
1408
|
+
// Order matters: prune the detached old nodes before finalizeInstance registers
|
|
1409
|
+
// the new ones, so the new entries are never touched.
|
|
1410
|
+
const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
|
|
1411
|
+
releaseRemovedSubtrees(removed, manifest);
|
|
1341
1412
|
const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
1342
1413
|
for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
|
|
1343
1414
|
finalizeInstance(built, manifest, parentScope);
|
|
@@ -1371,19 +1442,25 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
|
|
|
1371
1442
|
}
|
|
1372
1443
|
|
|
1373
1444
|
const { itemAlias, indexAlias } = iterationNode.meta;
|
|
1374
|
-
const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
|
|
1375
1445
|
const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
|
|
1376
|
-
|
|
1446
|
+
// The "old" scoped state is exactly last cycle's "new" one — same global
|
|
1447
|
+
// snapshot (this cycle's oldState) and same item/index — already stored on the
|
|
1448
|
+
// instance. Reuse it instead of allocating a second proxy per row per frame
|
|
1449
|
+
// (createScopedState is a combat hot spot). Falls back on the first update
|
|
1450
|
+
// after an add, before scopedState has been recorded.
|
|
1451
|
+
const oldScopedState =
|
|
1452
|
+
instance.scopedState ||
|
|
1453
|
+
createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);
|
|
1377
1454
|
const newScopedState = createScopedState(newState, newLocalVars, parentScope);
|
|
1378
1455
|
|
|
1379
|
-
refreshIterationComponentProps(instance.clonedNodes, newScopedState);
|
|
1456
|
+
const changedSlots = refreshIterationComponentProps(instance.clonedNodes, newScopedState);
|
|
1380
1457
|
|
|
1381
1458
|
const affectedList = affected(instance.tree, oldScopedState, newScopedState);
|
|
1382
1459
|
if (affectedList.length > 0) {
|
|
1383
1460
|
hydrate(affectedList, newScopedState, manifest, oldScopedState);
|
|
1384
1461
|
}
|
|
1385
1462
|
|
|
1386
|
-
hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
|
|
1463
|
+
hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest, changedSlots);
|
|
1387
1464
|
|
|
1388
1465
|
instance.item = newItem;
|
|
1389
1466
|
instance.scopedState = newScopedState;
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* Uses pre-compiled batch functions from manifest generated by the compiler.
|
|
6
6
|
*
|
|
7
7
|
* This is the production implementation that runs compiled code generated at build time.
|
|
8
|
-
* Based on the prototype in _vibe-compiled-iteration-batch.js
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { stampInstanceScopes } from './loop-scope.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Marker for trusted raw-HTML output. `$.unsafe(str)` wraps a string so the
|
|
2
|
+
// hydrate path knows to set innerHTML instead of escaping via textContent.
|
|
3
|
+
// toString() returns the raw string so any non-pure use (the marker spliced
|
|
4
|
+
// into surrounding text) degrades to escaped literal text automatically — the
|
|
5
|
+
// browser escapes it when it lands in a text node.
|
|
6
|
+
export class RawHtml {
|
|
7
|
+
constructor(html) {
|
|
8
|
+
this.html = html == null ? '' : String(html);
|
|
9
|
+
}
|
|
10
|
+
toString() {
|
|
11
|
+
return this.html;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const unsafe = (html) => new RawHtml(html);
|
package/runtime/utils.js
CHANGED
|
@@ -4,6 +4,61 @@ import { THIS_PROP_REGEX } from './constants.js';
|
|
|
4
4
|
let hashCounter = 0;
|
|
5
5
|
export const hash = () => `_${hashCounter++}`;
|
|
6
6
|
|
|
7
|
+
// Scoped-state proxy -> its precomputed key list. The affected-walk needs the
|
|
8
|
+
// key set per tree node on every frame; calling Object.keys() on a scoped-state
|
|
9
|
+
// proxy fires its getOwnPropertyDescriptor trap for every key — a measurable
|
|
10
|
+
// combat hot spot. createScopedState records the keys here via
|
|
11
|
+
// rememberScopedKeys; ownKeysOf reads them, falling back to Object.keys for
|
|
12
|
+
// plain (non-scoped) states.
|
|
13
|
+
const scopedKeys = new WeakMap();
|
|
14
|
+
// Scoped-state proxy -> its local overlay (loop aliases item/index + any parent
|
|
15
|
+
// aliases). affected's iteration descent rebuilds a plain merged snapshot per
|
|
16
|
+
// instance; spreading the proxy to recover the aliases fires its traps over
|
|
17
|
+
// every global key. Recording the small overlay lets the descent do a cheap
|
|
18
|
+
// plain merge (`{...currentGlobals, ...overlay}`) instead.
|
|
19
|
+
const scopedOverlay = new WeakMap();
|
|
20
|
+
export const rememberScopedKeys = (state, keys, overlay) => {
|
|
21
|
+
scopedKeys.set(state, keys);
|
|
22
|
+
if (overlay) scopedOverlay.set(state, overlay);
|
|
23
|
+
return state;
|
|
24
|
+
};
|
|
25
|
+
export const ownKeysOf = (state) => scopedKeys.get(state) || Object.keys(state);
|
|
26
|
+
export const scopedOverlayOf = (state) => scopedOverlay.get(state);
|
|
27
|
+
|
|
28
|
+
// The live root reactive proxy, wired once at boot by index.js. Used ONLY to
|
|
29
|
+
// reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
|
|
30
|
+
// for state reads.
|
|
31
|
+
let rootProxy = null;
|
|
32
|
+
export const setRootState = (proxy) => { rootProxy = proxy; };
|
|
33
|
+
|
|
34
|
+
// Resolve the `$` identifier for an expression. `$` must read from the SAME
|
|
35
|
+
// state object the diff cycle is evaluating — affected.js / iterate.js compare
|
|
36
|
+
// an expression's value against an old snapshot and a new snapshot, so binding
|
|
37
|
+
// `$` to the live root instead would make both reads identical and defeat
|
|
38
|
+
// change detection (the whole reactivity engine). So state reads stay on the
|
|
39
|
+
// passed `state`.
|
|
40
|
+
//
|
|
41
|
+
// The catch: the root's reserved methods (`unsafe`, `on`, `reconcile`, …) are
|
|
42
|
+
// non-enumerable and don't survive the plain `{...state}` snapshots, so a
|
|
43
|
+
// plain snapshot can't reach `$.unsafe`. Wrap such a state in a thin proxy that
|
|
44
|
+
// serves its own keys (snapshot reads, fully diffable) and falls back to the
|
|
45
|
+
// live root only for keys it lacks (the helper methods). Scoped states already
|
|
46
|
+
// delegate to the live root (their target is `$`), so they need no wrapper.
|
|
47
|
+
const dollarCache = new WeakMap();
|
|
48
|
+
const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
|
|
49
|
+
const dollarFor = (state) => {
|
|
50
|
+
if (!rootProxy || state === rootProxy || RESERVED_PROBE in state) return state;
|
|
51
|
+
let wrapped = dollarCache.get(state);
|
|
52
|
+
if (!wrapped) {
|
|
53
|
+
wrapped = new Proxy(state, {
|
|
54
|
+
get: (t, k) => (k in t ? t[k] : rootProxy[k]),
|
|
55
|
+
has: (t, k) => k in t || k in rootProxy,
|
|
56
|
+
});
|
|
57
|
+
dollarCache.set(state, wrapped);
|
|
58
|
+
}
|
|
59
|
+
return wrapped;
|
|
60
|
+
};
|
|
61
|
+
|
|
7
62
|
// Function compilation cache: avoids creating new Function() for repeated expressions
|
|
8
63
|
// Key: normalized expression + '\0' + state keys joined by '\0'
|
|
9
64
|
const fnCache = new Map();
|
|
@@ -49,8 +104,10 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
49
104
|
}
|
|
50
105
|
}
|
|
51
106
|
|
|
52
|
-
// Get state keys once
|
|
53
|
-
|
|
107
|
+
// Get state keys once. ownKeysOf reads the scoped-state proxy's precomputed
|
|
108
|
+
// key list instead of Object.keys() — the latter fires getOwnPropertyDescriptor
|
|
109
|
+
// for every key, a per-eval combat hot spot.
|
|
110
|
+
const stateKeys = ownKeysOf(state);
|
|
54
111
|
const keyCount = stateKeys.length;
|
|
55
112
|
|
|
56
113
|
// Cache lookup: expression + key signature → compiled function
|
|
@@ -113,10 +170,13 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
113
170
|
fnCache.set(cacheKey, fn);
|
|
114
171
|
}
|
|
115
172
|
|
|
116
|
-
// Build values array matching the cached function's parameter order
|
|
173
|
+
// Build values array matching the cached function's parameter order. Bare
|
|
174
|
+
// identifiers resolve from the passed (possibly scoped) state; the `$`
|
|
175
|
+
// identifier resolves via dollarFor — the same state for reads (so old/new
|
|
176
|
+
// diffing works), with helper-method fallback to the live root.
|
|
117
177
|
const values = new Array(keyCount + 1);
|
|
118
178
|
for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
|
|
119
|
-
values[keyCount] = state;
|
|
179
|
+
values[keyCount] = dollarFor(state);
|
|
120
180
|
|
|
121
181
|
const result = fn(...values);
|
|
122
182
|
|