@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 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 1.9.8 (Beta)** — A runtime-first reactive framework with optional compilation.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.8",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -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
- const matchesKey = (matchStr, key) => {
22
- if (
23
- matchStr === key ||
24
- matchStr.startsWith(key + ".") ||
25
- matchStr.startsWith(key + "[")
26
- )
27
- return true;
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
- while ((i = matchStr.indexOf(key, i)) !== -1) {
32
- const before = i === 0 ? "" : matchStr[i - 1];
33
- const after =
34
- i + key.length >= matchStr.length ? "" : matchStr[i + key.length];
35
- if (!isIdentChar(before) && !isIdentChar(after)) return true;
36
- i += key.length;
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 false;
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
- const mergedOldState = { ...instance.scopedState, ...state };
174
- const mergedNewState = { ...instance.scopedState, ...newState };
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
- // Reset regex state for reuse
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
- const shallowState = Object.keys(state);
297
- const shallowNewState = Object.keys(newState);
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
- let hasAffected = false;
301
- const checkedMatches = [];
302
-
303
- for (const m of matches) {
304
- // Resolve this.property to componentId.property
305
- const resolvedInner = resolveThisPath(m.inner, tree.element);
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 checkedMatches) {
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 [attrName, attrValue] of Object.entries(tree.attributes)) {
365
- BINDING_REGEX.lastIndex = 0;
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
- // Resolve this.property to componentId.property
374
- const resolvedInner = resolveThisPath(m.inner, tree.element);
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 nameBinding of tree.nameBindings) {
419
- BINDING_REGEX.lastIndex = 0;
420
- const nameMatches = [];
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
- if (shouldAffect) {
450
- affected.push({
451
- type: "nameBinding",
452
- nameBinding,
453
- matchOuter: m.outer,
454
- matchInner: m.inner, // Keep original, evalInScope will resolve this.
455
- element: tree.element,
456
- scopedState: scopedStateForHydration,
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.
@@ -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
- for (let n = 0; n < clonedNodes.length; n++) {
362
- const node = liveCloneNode(clonedNodes[n]);
363
- if (!node || node.nodeType !== 1) continue;
364
- const wrappers = [];
365
- if (node._vibeIterPropExprs) wrappers.push(node);
366
- const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
367
- if (found) for (let i = 0; i < found.length; i++) wrappers.push(found[i]);
368
- for (let w = 0; w < wrappers.length; w++) {
369
- const el = wrappers[w];
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
+ // 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 REGISTRY_PATH_REGEX = /__vibeIterProps\._p\d+/;
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
- if (arrPath && REGISTRY_PATH_REGEX.test(arrPath)) {
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
- for (let n = 0; n < clonedNodes.length; n++) {
421
- const node = liveCloneNode(clonedNodes[n]);
422
- if (!node || node.nodeType !== 1) continue;
423
- const wrappers = [];
424
- if (node._vibeIterTree) wrappers.push(node);
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
- return new Proxy(globalState, {
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, itemAlias, indexAlias, template, startComment, endComment } =
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: clone and hydrate each item (handles nested iterations/conditionals)
902
- const instances = [];
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: build each instance, batch-append into a DocumentFragment,
1169
- // finalize (mark managed + render nested), then commit to the DOM in one
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) n.parentNode.removeChild(n);
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
- if (parent) detachInstanceDom(iterationNode, index, parent);
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
- detachInstanceDom(iterationNode, index, parent);
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
- const oldScopedState = createScopedState(oldState, oldLocalVars, parentScope);
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 (triggers ownKeys trap only once for Proxies)
87
- const stateKeys = Object.keys(state);
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