@ape-egg/vibe 1.9.8 → 1.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.9] - 2026-06-06
4
+
5
+ ### Fixed
6
+
7
+ - **Iteration teardown leaked removed rows** (`runtime/iterate.js`, `runtime/affected.js`, `runtime/index.js`) — when an iteration's array emptied or rows were removed, Vibe deleted the DOM but kept internal references to the detached subtrees: the per-row parsed trees, inlined per-row component trees, and the flat manifest entries (`dotPath -> element`). Those structures pinned the removed nodes in memory and made every later reconcile/affected walk traverse dead nodes — in Battle Brawlers, combat fps decayed on each Reset→Start cycle. The page MutationObserver is disconnected while Vibe renders, so removal happens unobserved and the normal observer-driven cleanup never fires; teardown must prune itself. `index.js` now pairs the flat manifest with its parsed tree via a non-enumerable `manifest.__tree` (mirroring `manifest.__live`), and `iterate.js`'s new `releaseRemovedSubtrees` prunes both views together on row removal — deleting each removed root's manifest paths (path-scoped) and tree node (whose subtree cascade covers nested iterations/conditionals/components). Proven with a WeakRef + forced-GC e2e test: after emptying the iteration, zero removed elements remain reachable.
8
+ - Test: `tests/e2e/iteration-teardown-leak.spec.js` (no retained DOM after GC; repeated fill/empty cycles don't accumulate)
9
+
10
+ ### Performance
11
+
12
+ - **Scoped-state key/overlay lookups avoided proxy traps on the combat hot path** (`runtime/utils.js`, `runtime/iterate.js`, `runtime/affected.js`) — `createScopedState` now records each scoped-state proxy's precomputed key list and its small local overlay (loop aliases `item`/`index` + parent aliases) in WeakMaps via `rememberScopedKeys`. `evalInScope` reads the keys through `ownKeysOf` instead of `Object.keys(proxy)` (which fired the proxy's `getOwnPropertyDescriptor` trap for every key, every eval), and `affected`'s iteration descent rebuilds its per-instance merged snapshot via a cheap `{...currentGlobals, ...overlay}` plain merge (`scopedOverlayOf`) instead of spreading the proxy over every global key.
13
+
14
+ ---
15
+
3
16
  ## [1.9.8] - 2026-06-01
4
17
 
5
18
  ### 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 1.9.9 (Beta)** — A runtime-first reactive framework with optional compilation.
4
4
 
5
5
  No virtual DOM. No build step required. Just modern JavaScript. When you need production optimizations, add the optional Rust-based compiler.
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.8",
3
+ "version": "1.9.9",
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,54 @@ const liveCloneNode = (node) => {
351
351
  return cur;
352
352
  };
353
353
 
354
+ // Walk a row's live cloned nodes and invoke `fn` for every inlined-component
355
+ // wrapper carrying `marker` — the resolved node itself plus any
356
+ // `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
357
+ // inlined-hydrate passes are identical except for this marker and the per-wrapper
358
+ // work, so they share this walk. (`[data-vibe-iter-prop]` always implies
359
+ // `_vibeIterPropExprs`, set together at mount, so filtering descendants by the
360
+ // marker matches the historical "take all, skip those without exprs" behavior.)
361
+ const forEachIterWrapper = (clonedNodes, marker, fn) => {
362
+ for (let n = 0; n < clonedNodes.length; n++) {
363
+ const node = liveCloneNode(clonedNodes[n]);
364
+ if (!node || node.nodeType !== 1) continue;
365
+ if (node[marker]) fn(node);
366
+ const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
367
+ if (found) for (let i = 0; i < found.length; i++) {
368
+ if (found[i][marker]) fn(found[i]);
369
+ }
370
+ }
371
+ };
372
+
354
373
  // Walk a row's clones for any element tagged as iteration-prop owner —
355
374
  // pre-process `<component src>` (still has the src attribute) and post-process
356
375
  // `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
357
376
  // expression, re-evaluate against the row's new scoped state and write into
358
377
  // the registry slot the inlined bindings already reference. Idempotent: same
359
378
  // scoped state → same value → no-op write.
379
+ // Returns the set of registry slot ids whose value actually changed this
380
+ // refresh. A slot holding the same reference (e.g. a static ability array on a
381
+ // combatant that only moved) is a no-op, so forceRegistryBackedIterationUpdates
382
+ // can skip re-diffing the nested iteration it feeds — the dominant cost when a
383
+ // row carries large static nested iterations.
360
384
  const refreshIterationComponentProps = (clonedNodes, scopedState) => {
361
- 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
+ if (registry[id] !== value) {
392
+ registry[id] = value;
393
+ changed.add(id);
380
394
  }
395
+ } catch {
396
+ // Leave previous registry value in place — same fail-safe as
397
+ // resolveIterationComponentProps's mount-time path.
381
398
  }
382
399
  }
383
- }
400
+ });
401
+ return changed;
384
402
  };
385
403
 
386
404
  // Walk an inlined component's parsed tree and force `updateIteration` on any
@@ -394,18 +412,21 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
394
412
  // in sync. Without this, an `<inner-component>` whose template iterates over
395
413
  // an array prop stays frozen on its initial-render items when the prop's
396
414
  // contents change.
397
- const REGISTRY_PATH_REGEX = /__vibeIterProps\._p\d+/;
398
- const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope) => {
415
+ const REGISTRY_SLOT_REGEX = /__vibeIterProps\.(_p\d+)/;
416
+ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
399
417
  if (!tree) return;
400
418
  if (tree.type === 'iteration') {
401
419
  const arrPath = tree.meta?.arrayPath;
402
- if (arrPath && REGISTRY_PATH_REGEX.test(arrPath)) {
420
+ const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
421
+ // Skip iterations whose backing slot didn't change this cycle. changedSlots
422
+ // is undefined only on legacy/unguarded calls — fall back to always-update.
423
+ if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
403
424
  updateIteration(tree, state, state, manifest, parentScope);
404
425
  }
405
426
  }
406
427
  if (tree.children) {
407
428
  for (const k in tree.children) {
408
- forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope);
429
+ forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope, changedSlots);
409
430
  }
410
431
  }
411
432
  };
@@ -416,30 +437,19 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope)
416
437
  // component.js) — that tree retains the original `@[...]` binding text even
417
438
  // after the wrapper's live DOM has been hydrated, so subsequent affected→
418
439
  // hydrate passes work the same way they would on initial render.
419
- const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest) => {
420
- 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, {});
440
+ const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
441
+ forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
442
+ const tree = wrapper._vibeIterTree;
443
+ const affectedList = affected(tree, oldState, newState);
444
+ if (affectedList.length > 0) {
445
+ hydrate(affectedList, newState, manifest, oldState);
441
446
  }
442
- }
447
+ // Bindings into the iteration-prop registry are visited above, but iteration
448
+ // nodes whose arrayPath resolves through the registry need explicit driving:
449
+ // the refreshed slot is a side effect `affected()` can't see. Update only the
450
+ // iterations whose slot actually changed this cycle (changedSlots).
451
+ forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
452
+ });
443
453
  };
444
454
 
445
455
  // Apply DOM-property writes that compileBatchFn collected. Each batch row
@@ -764,7 +774,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
764
774
  ...Reflect.ownKeys(globalState),
765
775
  ])];
766
776
 
767
- return new Proxy(globalState, {
777
+ // Overlay = the aliases this proxy resolves locally (localVars wins over
778
+ // parentScope, matching the get order below). affected's descent reuses it for
779
+ // a cheap plain merge instead of materializing the proxy.
780
+ const overlay =
781
+ Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
782
+
783
+ const proxy = new Proxy(globalState, {
768
784
  get(target, prop) {
769
785
  if (prop in localVars) return localVars[prop];
770
786
  if (prop in parentScope) return parentScope[prop];
@@ -799,6 +815,8 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
799
815
  return Reflect.getOwnPropertyDescriptor(target, prop);
800
816
  },
801
817
  });
818
+
819
+ return rememberScopedKeys(proxy, cachedKeys, overlay);
802
820
  };
803
821
 
804
822
  // Render all iterations in the parsed tree
@@ -832,8 +850,7 @@ export const setRenderAllConditionals = (fn) => {
832
850
 
833
851
  // Initial render of an iteration block
834
852
  export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
835
- const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } =
836
- iterationNode.meta;
853
+ const { arrayPath, startComment, endComment } = iterationNode.meta;
837
854
 
838
855
  // Already rendered - updates go through updateIteration
839
856
  if (iterationNode.runtime.instances?.length > 0) {
@@ -898,55 +915,8 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
898
915
  // Fall through to runtime path if compiled failed
899
916
  }
900
917
 
901
- // Standard path: 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);
918
+ // Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
919
+ renderInstances(iterationNode, array, state, manifest, parentScope);
950
920
 
951
921
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
952
922
  // Also store runtime data on the DOM node so it persists across re-parses
@@ -1103,7 +1073,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1103
1073
  operations.forEach((op) => {
1104
1074
  switch (op.type) {
1105
1075
  case 'REMOVE':
1106
- removeInstance(iterationNode, op.index);
1076
+ removeInstance(iterationNode, op.index, manifest);
1107
1077
  break;
1108
1078
  case 'ADD':
1109
1079
  addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
@@ -1135,18 +1105,97 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1135
1105
  stampScopes(iterationNode, manifest, parentScope);
1136
1106
  };
1137
1107
 
1108
+ // Prune removed iteration rows from the global manifest (dotPath -> element)
1109
+ // and parsed tree. Both views still reference rows that mounted globally-tracked
1110
+ // content (e.g. a <component src> registers its subtree in the manifest). Vibe
1111
+ // disconnects the page MutationObserver while it reconciles, so the removals
1112
+ // below are never observed — left unpruned, the entries pin detached subtrees
1113
+ // (memory leak) and bloat every later affected/hydrate walk (the
1114
+ // combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
1115
+ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1116
+ if (!manifest || removedRoots.length === 0) return;
1117
+ const tree = manifest.__tree;
1118
+
1119
+ // Reverse the manifest (dotPath -> element) once so each removed root resolves
1120
+ // to its path. An element can be registered at more than one path (a component
1121
+ // wrapper appears at both its own node and an inlined child slot); keep the
1122
+ // SHORTEST so the path scopes the whole row subtree, not an inner slot.
1123
+ // __live / __tree are non-enumerable, so for-in skips them.
1124
+ const pathOf = new Map();
1125
+ for (const key in manifest) {
1126
+ const el = manifest[key];
1127
+ if (!el) continue;
1128
+ const existing = pathOf.get(el);
1129
+ if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
1130
+ }
1131
+
1132
+ const removedEls = new Set(); // top-level removed roots — tree prune cascades subtrees
1133
+ const removedPaths = new Set(); // their manifest paths — manifest prune is path-scoped
1134
+ for (let i = 0; i < removedRoots.length; i++) {
1135
+ const root = removedRoots[i];
1136
+ if (!root) continue;
1137
+ removedEls.add(root);
1138
+ const path = pathOf.get(root);
1139
+ if (path !== undefined) removedPaths.add(path);
1140
+ }
1141
+ if (removedPaths.size === 0 && removedEls.size === 0) return;
1142
+
1143
+ // Manifest: drop every entry at or under a removed root's path. Path scope (not
1144
+ // element identity) is what catches content hoisted out of the DOM — an inactive
1145
+ // conditional branch template sits in a detached container yet stays registered
1146
+ // under its row's path.
1147
+ if (removedPaths.size > 0) {
1148
+ for (const key in manifest) {
1149
+ if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
1150
+ }
1151
+ }
1152
+
1153
+ // Parsed tree: delete each removed root's node — its whole subtree (nested
1154
+ // conditionals/iterations and their branch templates) goes with it.
1155
+ if (tree) pruneTreeNodes(tree, removedEls);
1156
+ };
1157
+
1158
+ // True when `key` is, or is a descendant of, any path in `removedPaths`.
1159
+ const pathUnderRemoved = (key, removedPaths) => {
1160
+ if (removedPaths.has(key)) return true;
1161
+ for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
1162
+ if (removedPaths.has(key.slice(0, i))) return true;
1163
+ }
1164
+ return false;
1165
+ };
1166
+
1167
+ const pruneTreeNodes = (node, removedEls) => {
1168
+ const children = node.children;
1169
+ if (!children) return;
1170
+ for (const key in children) {
1171
+ const child = children[key];
1172
+ if (!child) continue;
1173
+ if (removedEls.has(child.element)) delete children[key];
1174
+ else pruneTreeNodes(child, removedEls);
1175
+ }
1176
+ };
1177
+
1138
1178
  // Bulk replacement: clear all DOM and re-render from scratch
1139
1179
  // Used when arrays share no common keys (avoids O(n²) LCS)
1140
1180
  const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1141
1181
  const { template, startComment, endComment } = iterationNode.meta;
1142
1182
  const parent = startComment.parentNode;
1143
1183
 
1144
- // Clear all existing DOM between comments using Range (single operation)
1184
+ // Clear all existing DOM between comments using Range (single operation),
1185
+ // then prune the removed subtrees from the manifest + tree. Collect the LIVE
1186
+ // nodes in the range rather than the instances' clonedNodes: a <component src>
1187
+ // row is replaced in place by component.js, so clonedNodes can point at the
1188
+ // stale original wrapper, not the processed content actually being removed.
1145
1189
  if (iterationNode.runtime.instances.length > 0) {
1190
+ const removedRoots = [];
1191
+ for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
1192
+ removedRoots.push(cur);
1193
+ }
1146
1194
  const range = document.createRange();
1147
1195
  range.setStartAfter(startComment);
1148
1196
  range.setEndBefore(endComment);
1149
1197
  range.deleteContents();
1198
+ releaseRemovedSubtrees(removedRoots, manifest);
1150
1199
  }
1151
1200
 
1152
1201
  if (newArray.length === 0) {
@@ -1165,26 +1214,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1165
1214
  return;
1166
1215
  }
1167
1216
 
1168
- // Complex templates: 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);
1217
+ // Complex templates: one shared clone+hydrate render loop.
1218
+ renderInstances(iterationNode, newArray, state, manifest, parentScope);
1188
1219
  };
1189
1220
 
1190
1221
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -1232,6 +1263,10 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1232
1263
  const anchor = findInstanceAnchor(instance, parent);
1233
1264
  const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
1234
1265
 
1266
+ // Return the live nodes actually removed so callers can prune them from the
1267
+ // manifest + tree (clonedNodes can be stale once a <component src> row is
1268
+ // replaced in place by component.js).
1269
+ const removed = [];
1235
1270
  if (anchor) {
1236
1271
  let cur = anchor;
1237
1272
  // endComment caps the walk even if nextAnchor ordering is ever
@@ -1239,6 +1274,7 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1239
1274
  while (cur && cur !== nextAnchor && cur !== endComment) {
1240
1275
  const nextSibling = cur.nextSibling;
1241
1276
  parent.removeChild(cur);
1277
+ removed.push(cur);
1242
1278
  cur = nextSibling;
1243
1279
  }
1244
1280
  }
@@ -1246,8 +1282,12 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1246
1282
  const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
1247
1283
  for (let i = 0; i < nodes.length; i++) {
1248
1284
  const n = nodes[i];
1249
- if (n && n.parentNode && n.parentNode !== parent) n.parentNode.removeChild(n);
1285
+ if (n && n.parentNode && n.parentNode !== parent) {
1286
+ n.parentNode.removeChild(n);
1287
+ removed.push(n);
1288
+ }
1250
1289
  }
1290
+ return removed;
1251
1291
  };
1252
1292
 
1253
1293
  // Build a fresh instance's DOM + tree + scope from the iteration template.
@@ -1285,6 +1325,31 @@ const finalizeInstance = (built, manifest, parentScope) => {
1285
1325
  }
1286
1326
  };
1287
1327
 
1328
+ // Build, finalize, and commit a fresh set of instances for `array` in one
1329
+ // batched DOM insertion, replacing iterationNode.runtime.instances. The single
1330
+ // clone+hydrate render loop shared by initial render (renderIteration) and full
1331
+ // rebuild (bulkReplace) — they differ only in their preamble, not this loop.
1332
+ const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
1333
+ const { startComment, endComment } = iterationNode.meta;
1334
+ const parent = startComment.parentNode;
1335
+ const instances = [];
1336
+ const frag = document.createDocumentFragment();
1337
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
1338
+ for (let i = 0; i < array.length; i++) {
1339
+ const built = buildInstance(iterationNode, array[i], i, state, parentScope, liveItemAt(liveArray, i, array[i]));
1340
+ for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
1341
+ finalizeInstance(built, manifest, parentScope);
1342
+ instances.push({
1343
+ element: built.element, tree: built.tree, item: array[i], liveItem: built.liveItem, index: i,
1344
+ clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1345
+ });
1346
+ }
1347
+ parent.insertBefore(frag, endComment);
1348
+ iterationNode.runtime.instances = instances;
1349
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1350
+ stampScopes(iterationNode, manifest, parentScope);
1351
+ };
1352
+
1288
1353
  const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
1289
1354
  const parent = iterationNode.meta.startComment.parentNode;
1290
1355
  const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
@@ -1297,10 +1362,11 @@ const addInstance = (iterationNode, item, index, state, manifest, parentScope) =
1297
1362
  });
1298
1363
  };
1299
1364
 
1300
- const removeInstance = (iterationNode, index) => {
1365
+ const removeInstance = (iterationNode, index, manifest) => {
1301
1366
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1302
1367
  const parent = iterationNode.meta.startComment.parentNode;
1303
- if (parent) detachInstanceDom(iterationNode, index, parent);
1368
+ const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
1369
+ releaseRemovedSubtrees(removed, manifest);
1304
1370
  iterationNode.runtime.instances.splice(index, 1);
1305
1371
  };
1306
1372
 
@@ -1337,7 +1403,12 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1337
1403
 
1338
1404
  const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1339
1405
  const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
1340
- detachInstanceDom(iterationNode, index, parent);
1406
+ // Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
1407
+ // so the replaced subtree is released (same removal contract as removeInstance).
1408
+ // Order matters: prune the detached old nodes before finalizeInstance registers
1409
+ // the new ones, so the new entries are never touched.
1410
+ const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
1411
+ releaseRemovedSubtrees(removed, manifest);
1341
1412
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
1342
1413
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
1343
1414
  finalizeInstance(built, manifest, parentScope);
@@ -1371,19 +1442,25 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
1371
1442
  }
1372
1443
 
1373
1444
  const { itemAlias, indexAlias } = iterationNode.meta;
1374
- const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
1375
1445
  const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
1376
- const oldScopedState = createScopedState(oldState, oldLocalVars, parentScope);
1446
+ // The "old" scoped state is exactly last cycle's "new" one — same global
1447
+ // snapshot (this cycle's oldState) and same item/index — already stored on the
1448
+ // instance. Reuse it instead of allocating a second proxy per row per frame
1449
+ // (createScopedState is a combat hot spot). Falls back on the first update
1450
+ // after an add, before scopedState has been recorded.
1451
+ const oldScopedState =
1452
+ instance.scopedState ||
1453
+ createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);
1377
1454
  const newScopedState = createScopedState(newState, newLocalVars, parentScope);
1378
1455
 
1379
- refreshIterationComponentProps(instance.clonedNodes, newScopedState);
1456
+ const changedSlots = refreshIterationComponentProps(instance.clonedNodes, newScopedState);
1380
1457
 
1381
1458
  const affectedList = affected(instance.tree, oldScopedState, newScopedState);
1382
1459
  if (affectedList.length > 0) {
1383
1460
  hydrate(affectedList, newScopedState, manifest, oldScopedState);
1384
1461
  }
1385
1462
 
1386
- hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
1463
+ hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest, changedSlots);
1387
1464
 
1388
1465
  instance.item = newItem;
1389
1466
  instance.scopedState = newScopedState;
@@ -5,7 +5,6 @@
5
5
  * Uses pre-compiled batch functions from manifest generated by the compiler.
6
6
  *
7
7
  * This is the production implementation that runs compiled code generated at build time.
8
- * Based on the prototype in _vibe-compiled-iteration-batch.js
9
8
  */
10
9
 
11
10
  import { stampInstanceScopes } from './loop-scope.js';
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