@ape-egg/vibe 1.9.8 → 2.0.0
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 +19 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/runtime/affected.js +190 -147
- package/runtime/index.js +8 -0
- package/runtime/iterate.js +216 -130
- package/runtime/pre-compiled-iterations.js +0 -1
- package/runtime/utils.js +25 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.0] - 2026-06-06
|
|
4
|
+
|
|
5
|
+
Version rolled to 2.0.0. **Still Beta — no behavior changes since 1.9.9.** The major bump consolidates the 1.9.x line (runtime reactive core, components, optional compiler, `$.unsafe` raw-HTML, plus the recent reactivity-correctness, iteration teardown-leak, and scoped-state performance fixes); it does not signal a stability promotion or any breaking change.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## [1.9.9] - 2026-06-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- Test: `tests/e2e/iteration-teardown-leak.spec.js` (no retained DOM after GC; repeated fill/empty cycles don't accumulate)
|
|
15
|
+
|
|
16
|
+
### Performance
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
3
22
|
## [1.9.8] - 2026-06-01
|
|
4
23
|
|
|
5
24
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version
|
|
3
|
+
**Version 2.0.0 (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
|
|
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,24 +85,87 @@ 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++;
|
|
37
105
|
}
|
|
38
|
-
return
|
|
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
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return !matched;
|
|
138
|
+
};
|
|
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;
|
|
39
169
|
};
|
|
40
170
|
|
|
41
171
|
// Identifier-followed-by-`(` — i.e. an attempted function or method call.
|
|
@@ -170,8 +300,18 @@ const recursive = (
|
|
|
170
300
|
// overlaying spread of state/newState then writes the *current*
|
|
171
301
|
// values from this update cycle. Both merged states are plain
|
|
172
302
|
// objects with up-to-date values.
|
|
173
|
-
|
|
174
|
-
|
|
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 };
|
|
175
315
|
// scopedStateForHydration must reflect the new state so hydrate's
|
|
176
316
|
// bindings inside the row see post-update values. instance.scopedState
|
|
177
317
|
// is frozen against whatever target renderIteration was called with
|
|
@@ -283,70 +423,30 @@ const recursive = (
|
|
|
283
423
|
return affected;
|
|
284
424
|
}
|
|
285
425
|
|
|
286
|
-
|
|
287
|
-
BINDING_REGEX.lastIndex = 0;
|
|
288
|
-
|
|
289
|
-
const matches = [];
|
|
290
|
-
let match;
|
|
291
|
-
while ((match = BINDING_REGEX.exec(tree.parsed))) {
|
|
292
|
-
matches.push({ outer: match[0], inner: match[1], input: match.input });
|
|
293
|
-
}
|
|
426
|
+
const matches = textBindingsOf(tree);
|
|
294
427
|
|
|
295
428
|
if (matches.length) {
|
|
296
|
-
|
|
297
|
-
|
|
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.
|
|
298
432
|
const isInitialHydration = state === newState;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const noMatch = !shallowState.some((key) =>
|
|
308
|
-
matchesKey(resolvedInner, key),
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
let shouldAffect = false;
|
|
312
|
-
let relevantKeys = [];
|
|
313
|
-
|
|
314
|
-
if (isInitialHydration) {
|
|
315
|
-
// Initial hydration: affect all matched keys
|
|
316
|
-
relevantKeys = shallowNewState.filter((key) =>
|
|
317
|
-
matchesKey(resolvedInner, key),
|
|
318
|
-
);
|
|
319
|
-
shouldAffect = noMatch || relevantKeys.length > 0;
|
|
320
|
-
} else {
|
|
321
|
-
// Update: only affect if value changed
|
|
322
|
-
const changedKeys = shallowNewState.filter(
|
|
323
|
-
(key) =>
|
|
324
|
-
matchesKey(resolvedInner, key) && state[key] !== newState[key],
|
|
325
|
-
);
|
|
326
|
-
relevantKeys =
|
|
327
|
-
changedKeys.length > 0
|
|
328
|
-
? changedKeys
|
|
329
|
-
: shallowState.filter((key) => matchesKey(resolvedInner, key));
|
|
330
|
-
shouldAffect = noMatch || changedKeys.length > 0;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (shouldAffect) {
|
|
334
|
-
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
|
+
}
|
|
335
441
|
}
|
|
336
|
-
|
|
337
|
-
checkedMatches.push({
|
|
338
|
-
...m,
|
|
339
|
-
matches: relevantKeys,
|
|
340
|
-
});
|
|
341
442
|
}
|
|
342
443
|
|
|
343
444
|
if (hasAffected) {
|
|
344
|
-
for (const m of
|
|
445
|
+
for (const m of matches) {
|
|
345
446
|
affected.push({
|
|
346
447
|
matchOuter: m.outer,
|
|
347
448
|
matchInner: m.inner,
|
|
348
449
|
input: m.input,
|
|
349
|
-
matches: m.matches,
|
|
350
450
|
element: tree.element,
|
|
351
451
|
textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
|
|
352
452
|
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
@@ -357,42 +457,14 @@ const recursive = (
|
|
|
357
457
|
|
|
358
458
|
// Check attribute bindings
|
|
359
459
|
if (tree.attributes) {
|
|
360
|
-
const shallowState = Object.keys(state);
|
|
361
|
-
const shallowNewState = Object.keys(newState);
|
|
362
460
|
const isInitialHydration = state === newState;
|
|
461
|
+
const keySet = isInitialHydration ? null : keySetOf(newState);
|
|
363
462
|
|
|
364
|
-
for (const
|
|
365
|
-
|
|
366
|
-
const attrMatches = [];
|
|
367
|
-
let attrMatch;
|
|
368
|
-
while ((attrMatch = BINDING_REGEX.exec(attrValue))) {
|
|
369
|
-
attrMatches.push({ outer: attrMatch[0], inner: attrMatch[1] });
|
|
370
|
-
}
|
|
371
|
-
|
|
463
|
+
for (const { attrName, matches: attrMatches } of attrBindingsOf(tree)) {
|
|
464
|
+
const attrValue = tree.attributes[attrName];
|
|
372
465
|
for (const m of attrMatches) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const noMatch = !shallowState.some((key) =>
|
|
377
|
-
matchesKey(resolvedInner, key),
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
let shouldAffect = false;
|
|
381
|
-
|
|
382
|
-
if (isInitialHydration) {
|
|
383
|
-
// Initial hydration: affect all matched keys
|
|
384
|
-
const newMatches = shallowNewState.filter((key) =>
|
|
385
|
-
matchesKey(resolvedInner, key),
|
|
386
|
-
);
|
|
387
|
-
shouldAffect = noMatch || newMatches.length > 0;
|
|
388
|
-
} else {
|
|
389
|
-
// Update: only affect if value changed
|
|
390
|
-
const changedKeys = shallowNewState.filter(
|
|
391
|
-
(key) =>
|
|
392
|
-
matchesKey(resolvedInner, key) && state[key] !== newState[key],
|
|
393
|
-
);
|
|
394
|
-
shouldAffect = noMatch || changedKeys.length > 0;
|
|
395
|
-
}
|
|
466
|
+
const shouldAffect =
|
|
467
|
+
isInitialHydration || bindingAffected(m.tokens, keySet, state, newState);
|
|
396
468
|
|
|
397
469
|
if (shouldAffect) {
|
|
398
470
|
affected.push({
|
|
@@ -411,51 +483,22 @@ const recursive = (
|
|
|
411
483
|
|
|
412
484
|
// Check name bindings (bindings in attribute names)
|
|
413
485
|
if (tree.nameBindings) {
|
|
414
|
-
const shallowState = Object.keys(state);
|
|
415
|
-
const shallowNewState = Object.keys(newState);
|
|
416
486
|
const isInitialHydration = state === newState;
|
|
487
|
+
const keyMap = isInitialHydration ? null : nameKeyMapOf(newState);
|
|
417
488
|
|
|
418
|
-
for (const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
let nameMatch;
|
|
422
|
-
while ((nameMatch = BINDING_REGEX.exec(nameBinding))) {
|
|
423
|
-
nameMatches.push({ outer: nameMatch[0], inner: nameMatch[1] });
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
for (const m of nameMatches) {
|
|
427
|
-
// Resolve this.property to componentId.property
|
|
428
|
-
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
429
|
-
// HTML lowercases attribute names, so expression may be lowercase while state
|
|
430
|
-
// keys are camelCase. Match case-insensitively by trying both direct and lowercased.
|
|
431
|
-
const matchKey = (key) =>
|
|
432
|
-
matchesKey(resolvedInner, key) ||
|
|
433
|
-
matchesKey(resolvedInner, key.toLowerCase());
|
|
434
|
-
|
|
435
|
-
const noMatch = !shallowState.some(matchKey);
|
|
436
|
-
|
|
437
|
-
let shouldAffect = false;
|
|
438
|
-
|
|
439
|
-
if (isInitialHydration) {
|
|
440
|
-
const newMatches = shallowNewState.filter(matchKey);
|
|
441
|
-
shouldAffect = noMatch || newMatches.length > 0;
|
|
442
|
-
} else {
|
|
443
|
-
const changedKeys = shallowNewState.filter(
|
|
444
|
-
(key) => matchKey(key) && state[key] !== newState[key],
|
|
445
|
-
);
|
|
446
|
-
shouldAffect = noMatch || changedKeys.length > 0;
|
|
447
|
-
}
|
|
489
|
+
for (const m of nameBindingsOf(tree)) {
|
|
490
|
+
const shouldAffect =
|
|
491
|
+
isInitialHydration || nameBindingAffected(m.tokens, keyMap, state, newState);
|
|
448
492
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
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
|
+
});
|
|
459
502
|
}
|
|
460
503
|
}
|
|
461
504
|
}
|
package/runtime/index.js
CHANGED
|
@@ -683,6 +683,14 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
683
683
|
// manifest's node-path entry iteration.
|
|
684
684
|
Object.defineProperty(manifest, '__live', { value: $, enumerable: false, configurable: true });
|
|
685
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
|
+
|
|
686
694
|
// Bind `$` inside every expression to this live root proxy (see setRootState
|
|
687
695
|
// in utils.js). Done before the first hydration pass so `$.unsafe` and the
|
|
688
696
|
// other reserved methods are reachable from the initial render onward.
|
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,63 @@ 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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
// Don't clobber a slot with undefined — mirrors the mount-time guard in
|
|
392
|
+
// resolveIterationComponentProps. forEachIterWrapper reaches every
|
|
393
|
+
// [data-vibe-iter-prop] descendant, including components owned by a
|
|
394
|
+
// DEEPER iteration (e.g. a cell component inside a nested each). Their
|
|
395
|
+
// prop expressions reference the inner each's alias, which isn't in this
|
|
396
|
+
// (outer) row's scope, so they evaluate to undefined here. Skipping keeps
|
|
397
|
+
// the value the inner iteration's own update already set with the correct
|
|
398
|
+
// scope, instead of wiping it to undefined and leaving raw @[...] bindings.
|
|
399
|
+
if (value === undefined) continue;
|
|
400
|
+
if (registry[id] !== value) {
|
|
401
|
+
registry[id] = value;
|
|
402
|
+
changed.add(id);
|
|
380
403
|
}
|
|
404
|
+
} catch {
|
|
405
|
+
// Leave previous registry value in place — same fail-safe as
|
|
406
|
+
// resolveIterationComponentProps's mount-time path.
|
|
381
407
|
}
|
|
382
408
|
}
|
|
383
|
-
}
|
|
409
|
+
});
|
|
410
|
+
return changed;
|
|
384
411
|
};
|
|
385
412
|
|
|
386
413
|
// Walk an inlined component's parsed tree and force `updateIteration` on any
|
|
@@ -394,18 +421,21 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
394
421
|
// in sync. Without this, an `<inner-component>` whose template iterates over
|
|
395
422
|
// an array prop stays frozen on its initial-render items when the prop's
|
|
396
423
|
// contents change.
|
|
397
|
-
const
|
|
398
|
-
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope) => {
|
|
424
|
+
const REGISTRY_SLOT_REGEX = /__vibeIterProps\.(_p\d+)/;
|
|
425
|
+
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
|
|
399
426
|
if (!tree) return;
|
|
400
427
|
if (tree.type === 'iteration') {
|
|
401
428
|
const arrPath = tree.meta?.arrayPath;
|
|
402
|
-
|
|
429
|
+
const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
|
|
430
|
+
// Skip iterations whose backing slot didn't change this cycle. changedSlots
|
|
431
|
+
// is undefined only on legacy/unguarded calls — fall back to always-update.
|
|
432
|
+
if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
|
|
403
433
|
updateIteration(tree, state, state, manifest, parentScope);
|
|
404
434
|
}
|
|
405
435
|
}
|
|
406
436
|
if (tree.children) {
|
|
407
437
|
for (const k in tree.children) {
|
|
408
|
-
forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope);
|
|
438
|
+
forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope, changedSlots);
|
|
409
439
|
}
|
|
410
440
|
}
|
|
411
441
|
};
|
|
@@ -416,30 +446,19 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope)
|
|
|
416
446
|
// component.js) — that tree retains the original `@[...]` binding text even
|
|
417
447
|
// after the wrapper's live DOM has been hydrated, so subsequent affected→
|
|
418
448
|
// 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, {});
|
|
449
|
+
const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
|
|
450
|
+
forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
|
|
451
|
+
const tree = wrapper._vibeIterTree;
|
|
452
|
+
const affectedList = affected(tree, oldState, newState);
|
|
453
|
+
if (affectedList.length > 0) {
|
|
454
|
+
hydrate(affectedList, newState, manifest, oldState);
|
|
441
455
|
}
|
|
442
|
-
|
|
456
|
+
// Bindings into the iteration-prop registry are visited above, but iteration
|
|
457
|
+
// nodes whose arrayPath resolves through the registry need explicit driving:
|
|
458
|
+
// the refreshed slot is a side effect `affected()` can't see. Update only the
|
|
459
|
+
// iterations whose slot actually changed this cycle (changedSlots).
|
|
460
|
+
forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
|
|
461
|
+
});
|
|
443
462
|
};
|
|
444
463
|
|
|
445
464
|
// Apply DOM-property writes that compileBatchFn collected. Each batch row
|
|
@@ -764,7 +783,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
764
783
|
...Reflect.ownKeys(globalState),
|
|
765
784
|
])];
|
|
766
785
|
|
|
767
|
-
|
|
786
|
+
// Overlay = the aliases this proxy resolves locally (localVars wins over
|
|
787
|
+
// parentScope, matching the get order below). affected's descent reuses it for
|
|
788
|
+
// a cheap plain merge instead of materializing the proxy.
|
|
789
|
+
const overlay =
|
|
790
|
+
Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
|
|
791
|
+
|
|
792
|
+
const proxy = new Proxy(globalState, {
|
|
768
793
|
get(target, prop) {
|
|
769
794
|
if (prop in localVars) return localVars[prop];
|
|
770
795
|
if (prop in parentScope) return parentScope[prop];
|
|
@@ -799,6 +824,8 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
799
824
|
return Reflect.getOwnPropertyDescriptor(target, prop);
|
|
800
825
|
},
|
|
801
826
|
});
|
|
827
|
+
|
|
828
|
+
return rememberScopedKeys(proxy, cachedKeys, overlay);
|
|
802
829
|
};
|
|
803
830
|
|
|
804
831
|
// Render all iterations in the parsed tree
|
|
@@ -832,8 +859,7 @@ export const setRenderAllConditionals = (fn) => {
|
|
|
832
859
|
|
|
833
860
|
// Initial render of an iteration block
|
|
834
861
|
export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
|
|
835
|
-
const { arrayPath,
|
|
836
|
-
iterationNode.meta;
|
|
862
|
+
const { arrayPath, startComment, endComment } = iterationNode.meta;
|
|
837
863
|
|
|
838
864
|
// Already rendered - updates go through updateIteration
|
|
839
865
|
if (iterationNode.runtime.instances?.length > 0) {
|
|
@@ -898,55 +924,8 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
898
924
|
// Fall through to runtime path if compiled failed
|
|
899
925
|
}
|
|
900
926
|
|
|
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);
|
|
927
|
+
// Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
|
|
928
|
+
renderInstances(iterationNode, array, state, manifest, parentScope);
|
|
950
929
|
|
|
951
930
|
// Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
|
|
952
931
|
// Also store runtime data on the DOM node so it persists across re-parses
|
|
@@ -1103,7 +1082,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1103
1082
|
operations.forEach((op) => {
|
|
1104
1083
|
switch (op.type) {
|
|
1105
1084
|
case 'REMOVE':
|
|
1106
|
-
removeInstance(iterationNode, op.index);
|
|
1085
|
+
removeInstance(iterationNode, op.index, manifest);
|
|
1107
1086
|
break;
|
|
1108
1087
|
case 'ADD':
|
|
1109
1088
|
addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
|
|
@@ -1135,18 +1114,97 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1135
1114
|
stampScopes(iterationNode, manifest, parentScope);
|
|
1136
1115
|
};
|
|
1137
1116
|
|
|
1117
|
+
// Prune removed iteration rows from the global manifest (dotPath -> element)
|
|
1118
|
+
// and parsed tree. Both views still reference rows that mounted globally-tracked
|
|
1119
|
+
// content (e.g. a <component src> registers its subtree in the manifest). Vibe
|
|
1120
|
+
// disconnects the page MutationObserver while it reconciles, so the removals
|
|
1121
|
+
// below are never observed — left unpruned, the entries pin detached subtrees
|
|
1122
|
+
// (memory leak) and bloat every later affected/hydrate walk (the
|
|
1123
|
+
// combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
|
|
1124
|
+
const releaseRemovedSubtrees = (removedRoots, manifest) => {
|
|
1125
|
+
if (!manifest || removedRoots.length === 0) return;
|
|
1126
|
+
const tree = manifest.__tree;
|
|
1127
|
+
|
|
1128
|
+
// Reverse the manifest (dotPath -> element) once so each removed root resolves
|
|
1129
|
+
// to its path. An element can be registered at more than one path (a component
|
|
1130
|
+
// wrapper appears at both its own node and an inlined child slot); keep the
|
|
1131
|
+
// SHORTEST so the path scopes the whole row subtree, not an inner slot.
|
|
1132
|
+
// __live / __tree are non-enumerable, so for-in skips them.
|
|
1133
|
+
const pathOf = new Map();
|
|
1134
|
+
for (const key in manifest) {
|
|
1135
|
+
const el = manifest[key];
|
|
1136
|
+
if (!el) continue;
|
|
1137
|
+
const existing = pathOf.get(el);
|
|
1138
|
+
if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const removedEls = new Set(); // top-level removed roots — tree prune cascades subtrees
|
|
1142
|
+
const removedPaths = new Set(); // their manifest paths — manifest prune is path-scoped
|
|
1143
|
+
for (let i = 0; i < removedRoots.length; i++) {
|
|
1144
|
+
const root = removedRoots[i];
|
|
1145
|
+
if (!root) continue;
|
|
1146
|
+
removedEls.add(root);
|
|
1147
|
+
const path = pathOf.get(root);
|
|
1148
|
+
if (path !== undefined) removedPaths.add(path);
|
|
1149
|
+
}
|
|
1150
|
+
if (removedPaths.size === 0 && removedEls.size === 0) return;
|
|
1151
|
+
|
|
1152
|
+
// Manifest: drop every entry at or under a removed root's path. Path scope (not
|
|
1153
|
+
// element identity) is what catches content hoisted out of the DOM — an inactive
|
|
1154
|
+
// conditional branch template sits in a detached container yet stays registered
|
|
1155
|
+
// under its row's path.
|
|
1156
|
+
if (removedPaths.size > 0) {
|
|
1157
|
+
for (const key in manifest) {
|
|
1158
|
+
if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Parsed tree: delete each removed root's node — its whole subtree (nested
|
|
1163
|
+
// conditionals/iterations and their branch templates) goes with it.
|
|
1164
|
+
if (tree) pruneTreeNodes(tree, removedEls);
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
// True when `key` is, or is a descendant of, any path in `removedPaths`.
|
|
1168
|
+
const pathUnderRemoved = (key, removedPaths) => {
|
|
1169
|
+
if (removedPaths.has(key)) return true;
|
|
1170
|
+
for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
|
|
1171
|
+
if (removedPaths.has(key.slice(0, i))) return true;
|
|
1172
|
+
}
|
|
1173
|
+
return false;
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
const pruneTreeNodes = (node, removedEls) => {
|
|
1177
|
+
const children = node.children;
|
|
1178
|
+
if (!children) return;
|
|
1179
|
+
for (const key in children) {
|
|
1180
|
+
const child = children[key];
|
|
1181
|
+
if (!child) continue;
|
|
1182
|
+
if (removedEls.has(child.element)) delete children[key];
|
|
1183
|
+
else pruneTreeNodes(child, removedEls);
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1138
1187
|
// Bulk replacement: clear all DOM and re-render from scratch
|
|
1139
1188
|
// Used when arrays share no common keys (avoids O(n²) LCS)
|
|
1140
1189
|
const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
1141
1190
|
const { template, startComment, endComment } = iterationNode.meta;
|
|
1142
1191
|
const parent = startComment.parentNode;
|
|
1143
1192
|
|
|
1144
|
-
// Clear all existing DOM between comments using Range (single operation)
|
|
1193
|
+
// Clear all existing DOM between comments using Range (single operation),
|
|
1194
|
+
// then prune the removed subtrees from the manifest + tree. Collect the LIVE
|
|
1195
|
+
// nodes in the range rather than the instances' clonedNodes: a <component src>
|
|
1196
|
+
// row is replaced in place by component.js, so clonedNodes can point at the
|
|
1197
|
+
// stale original wrapper, not the processed content actually being removed.
|
|
1145
1198
|
if (iterationNode.runtime.instances.length > 0) {
|
|
1199
|
+
const removedRoots = [];
|
|
1200
|
+
for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
|
|
1201
|
+
removedRoots.push(cur);
|
|
1202
|
+
}
|
|
1146
1203
|
const range = document.createRange();
|
|
1147
1204
|
range.setStartAfter(startComment);
|
|
1148
1205
|
range.setEndBefore(endComment);
|
|
1149
1206
|
range.deleteContents();
|
|
1207
|
+
releaseRemovedSubtrees(removedRoots, manifest);
|
|
1150
1208
|
}
|
|
1151
1209
|
|
|
1152
1210
|
if (newArray.length === 0) {
|
|
@@ -1165,26 +1223,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
1165
1223
|
return;
|
|
1166
1224
|
}
|
|
1167
1225
|
|
|
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);
|
|
1226
|
+
// Complex templates: one shared clone+hydrate render loop.
|
|
1227
|
+
renderInstances(iterationNode, newArray, state, manifest, parentScope);
|
|
1188
1228
|
};
|
|
1189
1229
|
|
|
1190
1230
|
// Find an instance's canonical in-DOM anchor (the first of its cloned nodes
|
|
@@ -1232,6 +1272,10 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1232
1272
|
const anchor = findInstanceAnchor(instance, parent);
|
|
1233
1273
|
const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
1234
1274
|
|
|
1275
|
+
// Return the live nodes actually removed so callers can prune them from the
|
|
1276
|
+
// manifest + tree (clonedNodes can be stale once a <component src> row is
|
|
1277
|
+
// replaced in place by component.js).
|
|
1278
|
+
const removed = [];
|
|
1235
1279
|
if (anchor) {
|
|
1236
1280
|
let cur = anchor;
|
|
1237
1281
|
// endComment caps the walk even if nextAnchor ordering is ever
|
|
@@ -1239,6 +1283,7 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1239
1283
|
while (cur && cur !== nextAnchor && cur !== endComment) {
|
|
1240
1284
|
const nextSibling = cur.nextSibling;
|
|
1241
1285
|
parent.removeChild(cur);
|
|
1286
|
+
removed.push(cur);
|
|
1242
1287
|
cur = nextSibling;
|
|
1243
1288
|
}
|
|
1244
1289
|
}
|
|
@@ -1246,8 +1291,12 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1246
1291
|
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
1247
1292
|
for (let i = 0; i < nodes.length; i++) {
|
|
1248
1293
|
const n = nodes[i];
|
|
1249
|
-
if (n && n.parentNode && n.parentNode !== parent)
|
|
1294
|
+
if (n && n.parentNode && n.parentNode !== parent) {
|
|
1295
|
+
n.parentNode.removeChild(n);
|
|
1296
|
+
removed.push(n);
|
|
1297
|
+
}
|
|
1250
1298
|
}
|
|
1299
|
+
return removed;
|
|
1251
1300
|
};
|
|
1252
1301
|
|
|
1253
1302
|
// Build a fresh instance's DOM + tree + scope from the iteration template.
|
|
@@ -1285,6 +1334,31 @@ const finalizeInstance = (built, manifest, parentScope) => {
|
|
|
1285
1334
|
}
|
|
1286
1335
|
};
|
|
1287
1336
|
|
|
1337
|
+
// Build, finalize, and commit a fresh set of instances for `array` in one
|
|
1338
|
+
// batched DOM insertion, replacing iterationNode.runtime.instances. The single
|
|
1339
|
+
// clone+hydrate render loop shared by initial render (renderIteration) and full
|
|
1340
|
+
// rebuild (bulkReplace) — they differ only in their preamble, not this loop.
|
|
1341
|
+
const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
|
|
1342
|
+
const { startComment, endComment } = iterationNode.meta;
|
|
1343
|
+
const parent = startComment.parentNode;
|
|
1344
|
+
const instances = [];
|
|
1345
|
+
const frag = document.createDocumentFragment();
|
|
1346
|
+
const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
|
|
1347
|
+
for (let i = 0; i < array.length; i++) {
|
|
1348
|
+
const built = buildInstance(iterationNode, array[i], i, state, parentScope, liveItemAt(liveArray, i, array[i]));
|
|
1349
|
+
for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
|
|
1350
|
+
finalizeInstance(built, manifest, parentScope);
|
|
1351
|
+
instances.push({
|
|
1352
|
+
element: built.element, tree: built.tree, item: array[i], liveItem: built.liveItem, index: i,
|
|
1353
|
+
clonedNodes: built.clonedNodes, scopedState: built.scopedState,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
parent.insertBefore(frag, endComment);
|
|
1357
|
+
iterationNode.runtime.instances = instances;
|
|
1358
|
+
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
1359
|
+
stampScopes(iterationNode, manifest, parentScope);
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1288
1362
|
const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
|
|
1289
1363
|
const parent = iterationNode.meta.startComment.parentNode;
|
|
1290
1364
|
const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
|
|
@@ -1297,10 +1371,11 @@ const addInstance = (iterationNode, item, index, state, manifest, parentScope) =
|
|
|
1297
1371
|
});
|
|
1298
1372
|
};
|
|
1299
1373
|
|
|
1300
|
-
const removeInstance = (iterationNode, index) => {
|
|
1374
|
+
const removeInstance = (iterationNode, index, manifest) => {
|
|
1301
1375
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
1302
1376
|
const parent = iterationNode.meta.startComment.parentNode;
|
|
1303
|
-
|
|
1377
|
+
const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
|
|
1378
|
+
releaseRemovedSubtrees(removed, manifest);
|
|
1304
1379
|
iterationNode.runtime.instances.splice(index, 1);
|
|
1305
1380
|
};
|
|
1306
1381
|
|
|
@@ -1337,7 +1412,12 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
|
|
|
1337
1412
|
|
|
1338
1413
|
const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
|
|
1339
1414
|
const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
|
|
1340
|
-
|
|
1415
|
+
// Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
|
|
1416
|
+
// so the replaced subtree is released (same removal contract as removeInstance).
|
|
1417
|
+
// Order matters: prune the detached old nodes before finalizeInstance registers
|
|
1418
|
+
// the new ones, so the new entries are never touched.
|
|
1419
|
+
const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
|
|
1420
|
+
releaseRemovedSubtrees(removed, manifest);
|
|
1341
1421
|
const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
1342
1422
|
for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
|
|
1343
1423
|
finalizeInstance(built, manifest, parentScope);
|
|
@@ -1371,19 +1451,25 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
|
|
|
1371
1451
|
}
|
|
1372
1452
|
|
|
1373
1453
|
const { itemAlias, indexAlias } = iterationNode.meta;
|
|
1374
|
-
const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
|
|
1375
1454
|
const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
|
|
1376
|
-
|
|
1455
|
+
// The "old" scoped state is exactly last cycle's "new" one — same global
|
|
1456
|
+
// snapshot (this cycle's oldState) and same item/index — already stored on the
|
|
1457
|
+
// instance. Reuse it instead of allocating a second proxy per row per frame
|
|
1458
|
+
// (createScopedState is a combat hot spot). Falls back on the first update
|
|
1459
|
+
// after an add, before scopedState has been recorded.
|
|
1460
|
+
const oldScopedState =
|
|
1461
|
+
instance.scopedState ||
|
|
1462
|
+
createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);
|
|
1377
1463
|
const newScopedState = createScopedState(newState, newLocalVars, parentScope);
|
|
1378
1464
|
|
|
1379
|
-
refreshIterationComponentProps(instance.clonedNodes, newScopedState);
|
|
1465
|
+
const changedSlots = refreshIterationComponentProps(instance.clonedNodes, newScopedState);
|
|
1380
1466
|
|
|
1381
1467
|
const affectedList = affected(instance.tree, oldScopedState, newScopedState);
|
|
1382
1468
|
if (affectedList.length > 0) {
|
|
1383
1469
|
hydrate(affectedList, newScopedState, manifest, oldScopedState);
|
|
1384
1470
|
}
|
|
1385
1471
|
|
|
1386
|
-
hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
|
|
1472
|
+
hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest, changedSlots);
|
|
1387
1473
|
|
|
1388
1474
|
instance.item = newItem;
|
|
1389
1475
|
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';
|
package/runtime/utils.js
CHANGED
|
@@ -4,6 +4,27 @@ 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
|
+
|
|
7
28
|
// The live root reactive proxy, wired once at boot by index.js. Used ONLY to
|
|
8
29
|
// reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
|
|
9
30
|
// for state reads.
|
|
@@ -83,8 +104,10 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
// Get state keys once
|
|
87
|
-
|
|
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);
|
|
88
111
|
const keyCount = stateKeys.length;
|
|
89
112
|
|
|
90
113
|
// Cache lookup: expression + key signature → compiled function
|