@ape-egg/vibe 4.0.0 → 4.1.1

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.
@@ -1,7 +1,7 @@
1
1
  import parse from './parse.js';
2
2
  import affected, { nodeSubscriberOf } from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
- import { resolvePath, getItemKey, computeDiff, iterPropWrappersOf, bumpIterPropGeneration } from './iteration-utils.js';
4
+ import { resolvePath, getItemKey, computeDiff, iterPropWrappersOf, bumpIterPropGeneration, markBoundValues } from './iteration-utils.js';
5
5
  import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys, overlayKeysOf } from './utils.js';
6
6
  import { recordRead, beginTracking, endTracking, pruneDisconnected, markAlways } from './tracking.js';
7
7
  import { managedNodes, extractDependencies } from './conditionals.js';
@@ -15,25 +15,12 @@ import {
15
15
  THIS_PROP_REGEX,
16
16
  } from './constants.js';
17
17
 
18
- // Pre-compiled iteration optimization (production)
19
18
  import * as compiled from './pre-compiled-iterations.js';
20
19
 
21
20
  import { generateComponentId, executeCompiledComponentScriptsIn } from './component.js';
22
21
 
23
- // Runtime batch-render helpers for full-replacement of simple templates.
24
- // Build an HTML string via template-literal compilation, then parse once —
25
- // avoids per-item clone/parse/hydrate in the hot path.
26
- // Only used for templates without nested <!-- each --> / <!-- if -->.
27
- //
28
- // Invariant: batch produces identical DOM to the clone+hydrate path for any
29
- // input. New binding forms must be supported in both paths simultaneously,
30
- // with equivalence tests under tests/e2e/batch-vs-clone-equivalence.spec.js
31
- // — the test harness flips a force-clone flag and compares region-by-region,
32
- // so silent divergence between the two paths fails loudly.
33
22
  const batchParseTemplate = document.createElement('template');
34
23
 
35
- // innerHTML serialization encodes <, >, &, ", ' inside attribute values.
36
- // Decode them back before wrapping @[expr] in ${...} for the template literal.
37
24
  const decodeEntities = (s) => s
38
25
  .replace(/&lt;/g, '<')
39
26
  .replace(/&gt;/g, '>')
@@ -52,27 +39,12 @@ const hasNestedStructures = (tree) => {
52
39
  return false;
53
40
  };
54
41
 
55
- // Batch render template-literal-interpolates `@[expr]` as `${expr}` — that
56
- // stringifies object/array values, which breaks `<component src>` props that
57
- // rely on resolveIterationComponentProps to stash non-primitives in the
58
- // registry. Templates carrying any `<component src>` go through the
59
- // clone+hydrate path instead.
60
42
  const hasComponentSrc = (templateEl) =>
61
43
  !!templateEl.querySelector?.('component[src], div.component[src]');
62
44
 
63
- // A compiled inlined component with its own setup script carries component-local
64
- // state (`component({...})` → `$._cN`). Batch render emits one shared HTML string
65
- // per row, which would duplicate the baked `_cN` id across rows and collapse
66
- // their state into one bucket. Route these through the clone path, where
67
- // initializeBlock isolates each row's component ids.
68
45
  const hasInlinedComponentScript = (templateEl) =>
69
46
  !!templateEl.querySelector?.('script[type="vibe-module"]');
70
47
 
71
- // `__vibe.forceClonePath` is a debug/test escape hatch — set it to
72
- // route every iteration through the clone+hydrate path, even templates that
73
- // would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
74
- // test harness so the same templates can be rendered through both paths and
75
- // compared. Not part of the public API.
76
48
  const canUseBatchRender = (template) =>
77
49
  !globalThis.__vibe?.forceClonePath &&
78
50
  !hasNestedStructures(template) &&
@@ -80,17 +52,7 @@ const canUseBatchRender = (template) =>
80
52
  !hasComponentSrc(template.element) &&
81
53
  !hasInlinedComponentScript(template.element);
82
54
 
83
- // Patterns used by compileBatchFn to recognize bindings in attribute-name and
84
- // attribute-value positions. The inner alternation mirrors BINDING_INNER from
85
- // constants.js — it has to allow nested brackets and quoted strings inside the
86
- // expression so things like `items[0]` or `x.replace(',', '')` round-trip
87
- // safely. Kept inline rather than re-exported because they live entirely
88
- // within this compile-time string transformation.
89
55
  const BATCH_BINDING_INNER = String.raw`(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+`;
90
- // Note: lookahead allows `=` because innerHTML serializes a bare attribute
91
- // name (no value) as `name=""`, so a name-binding written `<icon @[attrName]>`
92
- // arrives here as `@[attrname]=""`. The leading `\s` keeps this from matching
93
- // attribute-value bindings like `attr="@[x]"` (preceded by `"`, not whitespace).
94
56
  const BATCH_NAME_BINDING_REGEX = new RegExp(
95
57
  String.raw`(\s)@\[(${BATCH_BINDING_INNER})\](?:="")?(?=[\s/>])`,
96
58
  'g',
@@ -100,10 +62,6 @@ const BATCH_ATTR_BINDING_REGEX = new RegExp(
100
62
  'g',
101
63
  );
102
64
 
103
- // Apply fn to tag spans only (`<el ...>`, quote-aware so a `>` inside an
104
- // attribute value doesn't end the span), leaving text spans and comments
105
- // untouched. Used to scope the name-binding rewrite to positions where a
106
- // name binding can actually occur.
107
65
  const mapTagSpans = (html, fn) => {
108
66
  let out = '';
109
67
  let i = 0;
@@ -134,58 +92,22 @@ const mapTagSpans = (html, fn) => {
134
92
  return out;
135
93
  };
136
94
 
137
- // Whether a hydrate'd attribute is a "value-style" string attr (kept verbatim)
138
- // rather than a boolean-coerced attr (added/removed by truthiness). Mirrors
139
- // the predicate hydrate.js uses, so batch and clone classify identically.
140
95
  const isValueStyleAttr = (attrName) =>
141
96
  VALUE_ATTRS.includes(attrName) ||
142
97
  attrName.startsWith('data-') ||
143
98
  attrName.startsWith('aria-') ||
144
99
  attrName.startsWith('on');
145
100
 
146
- // Compile the iteration template into a single function that emits the full
147
- // HTML for any input array. The function produced here must match the DOM
148
- // shape of the clone+hydrate path for any binding form — gaps documented in
149
- // the prior iteration of `do-this-job.md` were closed here:
150
- //
151
- // 1. `this.X` rewriting: pre-rewritten at compile time using the iteration's
152
- // anchor element to resolve the owning componentId. No per-item cost.
153
- // 2. Boolean-coerced attributes: emitted as conditional template literal
154
- // expressions so the attribute is absent when the binding evaluates falsy
155
- // and present (with empty string value, matching hydrate.js) when truthy.
156
- // 3. DOM properties (value/checked/selected): emitted as attributes for
157
- // runtime DOM accuracy AND collected into `domPropertyWrites` so
158
- // renderBatch can run a small post-stamp loop that sets the actual DOM
159
- // property on each instance — the attribute alone isn't enough.
160
- // 4. Name bindings (`<el @[expr]>`): emitted as conditional template literal
161
- // expressions that produce ` resolvedName=""` when truthy and nothing
162
- // when falsy.
163
- //
164
- // Anything the clone path renders correctly, the function returned here has
165
- // to render identically. New binding forms must update both paths in lockstep
166
- // or the equivalence harness in tests/e2e/batch-vs-clone-equivalence.spec.js
167
- // will catch the divergence.
168
- // Root-position `_cN` component references inside a binding expression —
169
- // string literals are consumed whole and left untouched (an `_cN` inside a
170
- // quoted string is content, not a reference — same discipline as
171
- // compileExpression's alternation in utils.js; the two renderers are
172
- // lockstep-equivalent by contract). Already-canonical `$['_cN']` forms and
173
- // property accesses stay untouched via the lookbehind.
174
101
  const CID_ROOT_REGEX =
175
102
  /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])_c(\d+)\b/g;
176
103
 
177
104
  export const canonicalizeComponentIds = (expr) =>
178
105
  expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['_c${id}']`));
179
106
 
180
- // Name-binding emission: resolve the expression under the `_e` guard, emit
181
- // ` value=""` when truthy, nothing otherwise.
182
107
  const nameBindingEmit = (exprSrc) =>
183
108
  '${(()=>{const _v=_e(()=>(' + exprSrc + '));return _v?\' \'+_v+\'=""\':\'\';})()}';
184
109
 
185
110
  export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) => {
186
- // Walk a clone of the template DOM so we can stash a transient
187
- // `data-vibe-batch` marker on every element with DOM-property bindings
188
- // without polluting the cached template that subsequent renders read.
189
111
  const tplClone = template.element.cloneNode(true);
190
112
  const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
191
113
  const domPropertyWrites = [];
@@ -212,20 +134,12 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
212
134
 
213
135
  let templateHtml = tplClone.innerHTML.trim();
214
136
 
215
- // A name-binding the compiler relocated into `data-vibe-namebind` (its expression has
216
- // whitespace, so it can't be an HTML attribute name) — restore the `@[expr]=""` form
217
- // the name-binding pass below understands. Safe in this template STRING: only the
218
- // resolved value (no whitespace) ever reaches innerHTML. Mirrors the clone path, which
219
- // reads the same binding from the manifest's nameBindings.
220
137
  templateHtml = templateHtml.replace(
221
138
  /\sdata-vibe-namebind="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"/g,
222
139
  (_, bindings) =>
223
140
  bindings.replace(/@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]/g, (b) => ' ' + b + '=""'),
224
141
  );
225
142
 
226
- // Rewrite `this.X` only inside @[…] expressions so literal occurrences in
227
- // text content (e.g. a code example explaining `this.foo`) aren't mangled.
228
- // Mirrors evalInScope's behavior in the clone path.
229
143
  if (componentId) {
230
144
  BINDING_REGEX.lastIndex = 0;
231
145
  templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
@@ -233,12 +147,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
233
147
  );
234
148
  }
235
149
 
236
- // Compiler-stamped component references (`_cN.x` in SPA fragments) resolve
237
- // through `$` at call time — the mirror of evalInScope's id canonicalization.
238
- // Baked as bare identifiers they'd be a ReferenceError whenever the id is
239
- // missing from the compile-time state-key snapshot (a fragment iteration can
240
- // hydrate before its component script registers), and a state key that
241
- // arrives later would never be picked up by the cached batch function.
242
150
  BINDING_REGEX.lastIndex = 0;
243
151
  templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
244
152
  '@[' + canonicalizeComponentIds(expr) + ']',
@@ -251,25 +159,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
251
159
 
252
160
  let code = escaped;
253
161
 
254
- // Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
255
- // emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
256
- // bindings from attribute-value-position bindings (which are followed by `=`)
257
- // — but a TEXT-position binding on its own line is also whitespace-bounded,
258
- // so the pass runs only over tag spans (mapTagSpans): name bindings can only
259
- // exist inside a tag.
260
- //
261
- // HTML parses attribute names lowercase, so a binding like
262
- // `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
263
- // `<icon @[obj.iconName]>` arrives as `@[obj.iconname]`. We recover the
264
- // proper case in two places:
265
- // - Single identifier → resolve against `stateKeys` at compile time and
266
- // emit a direct identifier reference.
267
- // - Dotted path → split off the first segment (resolved at compile time
268
- // against `stateKeys` + iteration aliases), then emit a runtime call to
269
- // `_walkCi` (injected into the function body below) for the remaining
270
- // segments. Mirrors `resolveCaseInsensitivePath` from utils.js.
271
- // Bracket / call expressions pass through unchanged — they need a real
272
- // evaluator and aren't worth special-casing here.
273
162
  let needsCiWalker = false;
274
163
  code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
275
164
  let decExpr = decodeEntities(expr);
@@ -291,11 +180,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
291
180
  return nameBindingEmit('_walkCi(' + ciHead + ',' + tailJSON + ')');
292
181
  }));
293
182
 
294
- // Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
295
- // DOM property → emit attribute (post-stamp also writes the property)
296
- // value-style → emit attribute as-is
297
- // boolean-coerced → emit conditional ` attr=""` so the attribute is
298
- // absent when expr is falsy
299
183
  code = code.replace(BATCH_ATTR_BINDING_REGEX, (_, _ws, attrName, expr) => {
300
184
  const decExpr = decodeEntities(expr);
301
185
  if (DOM_PROPERTIES.includes(attrName) || isValueStyleAttr(attrName)) {
@@ -304,21 +188,8 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
304
188
  return '${_e(()=>(' + decExpr + ')) ? \' ' + attrName + '=""\' : \'\'}';
305
189
  });
306
190
 
307
- // Remaining @[…] markers — text content and partial-binding attribute
308
- // values like `href="/items/@[id]/edit"`.
309
191
  code = code.replace(BINDING_REGEX, (_, expr) => '${_e(()=>(' + decodeEntities(expr) + '))}');
310
192
 
311
- // `$` is supplied as the last parameter so component-scoped expressions like
312
- // `$['_c0'].chosen` (produced by the this.X rewrite above) can resolve
313
- // against the live state. Mirrors evalInScope, which also exposes `$`.
314
- // `_walkCi` is only emitted when at least one dotted-path name binding was
315
- // rewritten above — keeps the function body free of dead code for the
316
- // common no-name-binding case.
317
- // Per-expression failure contract, mirroring evalInScope: an expression
318
- // that can't evaluate yet (component state not registered, alias mid-swap)
319
- // renders '' and the next flush corrects it — it must never throw out of
320
- // the batch loop and abort every row after it. Non-throwing values pass
321
- // through untouched, so template-literal coercion stays identical.
322
193
  const evalGuardSrc = "const _e = (f) => { try { return f(); } catch { return ''; } };";
323
194
  const ciWalkerSrc = needsCiWalker
324
195
  ? `const _walkCi = (cur, segs) => {
@@ -354,27 +225,11 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
354
225
  return { fn, domPropertyWrites };
355
226
  };
356
227
 
357
- // Registry for non-primitive iteration prop snapshots. Lives on `window` (not
358
- // on `$`) so it doesn't pollute user-visible state enumeration, but is still
359
- // reachable from binding expressions because `window` is in evalInScope's
360
- // known-globals list. Each entry is freed when the owning component element
361
- // is detached (see releaseOrphanedIterationProps).
362
- //
363
- // The name is intentionally all-lowercase. resolveIterationComponentProps
364
- // injects `@[window.__vibe.iterProps._pN]` into the component's bindings, and
365
- // prop substitution carries that accessor into the template's own bindings —
366
- // including name-bindings (`<icon @[props.element]>`). HTML lowercases
367
- // attribute names, so a camelCase accessor would arrive at hydrate as
368
- // `window.__vibe.iterProps` and resolve to undefined, silently dropping the
369
- // attribute. Keeping the global lowercase makes it survive that normalization.
370
228
  let __vibeIterPropCounter = 0;
371
229
  const ensureIterPropsRegistry = () => {
372
230
  return ((window.__vibe ??= {}).iterProps ??= {});
373
231
  };
374
232
 
375
- // Walk a removed subtree and free any iteration-prop registry slots stashed
376
- // on `<component>` elements inside it. Called from the mutation-observer
377
- // cleanup path after DOM detachment.
378
233
  export const releaseOrphanedIterationProps = (nodes) => {
379
234
  if (!window.__vibe?.iterProps) return;
380
235
  for (const node of nodes) {
@@ -390,22 +245,8 @@ export const releaseOrphanedIterationProps = (nodes) => {
390
245
  }
391
246
  };
392
247
 
393
- // Slot content projected into a <component src> is captured raw (`_vibeSlotContent`)
394
- // and inlined only when processComponent runs — by which point the row's iteration
395
- // scope is gone. Any `@[...]` in that content rooted in a loop alias (item/index/
396
- // outer) or `this` therefore can't resolve later: value bindings render undefined
397
- // and name-bindings (`<icon @[row.icon]>`) never set their attribute. Pre-resolve
398
- // those into registry-backed global refs here — the same snapshot mechanism the
399
- // component's own prop attributes use — so the inlined slot hydrates against the
400
- // right values and the row's update path refreshes them in place (the wrapper
401
- // inherits `_vibeIterPropExprs`/`data-vibe-iter-prop`, so refreshIterationComponentProps
402
- // re-evaluates them on each item change). Globals-only bindings are left raw; they
403
- // resolve through the normal reactive path against the inlined component's scope.
404
248
  const SLOT_DIRECTIVE_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
405
249
 
406
- // Substitute an identifier only outside string literals — the same contract as
407
- // component.js's prop substitution: an alias that also appears inside a quoted
408
- // string ('materials' containing the alias name) must be left intact.
409
250
  const STRING_LITERAL_REGEX = /(['"`])(?:\\.|(?!\1)[^\\])*\1/g;
410
251
  const substituteOutsideStrings = (expr, idRegex, replacement) => {
411
252
  let out = '';
@@ -424,9 +265,6 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
424
265
  if (!html || (!html.includes('@[') && !html.includes('<!--'))) return;
425
266
  const registry = ensureIterPropsRegistry();
426
267
  const idByExpr = new Map();
427
- // Snapshot one expression into a registry slot, reusing the slot for repeat
428
- // occurrences. Returns undefined (leave raw) when evaluation fails or yields
429
- // undefined — same fail-safe as resolveIterationComponentProps.
430
268
  const snapshot = (expr) => {
431
269
  let id = idByExpr.get(expr);
432
270
  if (id === undefined) {
@@ -453,25 +291,8 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
453
291
  const id = snapshot(expr);
454
292
  return id === undefined ? whole : `@[window.__vibe.iterProps.${id}]`;
455
293
  });
456
- // Directive comments in slot content — <!-- if -->, <!-- else if -->,
457
- // <!-- each --> — hit the same wall as @[...] bindings: after processComponent
458
- // inlines the template, the wrapper's subtree is re-processed against global
459
- // state, where the loop aliases don't resolve and every branch renders empty
460
- // (the battleborn rewards-window icons). Route each alias the expression
461
- // reads through its own registry slot (`drop.kind === 'materials'` →
462
- // `window.__vibe.iterProps._pN.kind === ...`), so the inlined directive
463
- // evaluates against the row's values in any scope. Updates need no extra
464
- // wiring: refreshIterationComponentProps re-snapshots the alias per row
465
- // update, the rewritten expression records no state read so its subscriber
466
- // re-dispatches from the tracking always-bucket on every flush, and the
467
- // affected() pass over the wrapper's tree stamps that subscriber's lastScope
468
- // with the row's merged scope — so a branch flip mounts with the loop
469
- // aliases reachable. A registry-backed <!-- each --> is driven by
470
- // forceRegistryBackedIterationUpdates like any other registry iteration.
471
294
  if (aliases?.size) {
472
295
  rewritten = rewritten.replace(SLOT_DIRECTIVE_REGEX, (whole, kw, expr) => {
473
- // An each's alias clause introduces fresh names — only its array
474
- // expression is rewritable.
475
296
  const asMatch = kw === 'each' && expr.match(/^([^]*?)\s+as\s+([^]*)$/);
476
297
  const original = asMatch ? asMatch[1] : expr;
477
298
  let code = original;
@@ -493,21 +314,18 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
493
314
  }
494
315
  };
495
316
 
496
- // For <component src> elements inside an iteration instance, evaluate any
497
- // `@[expr]` attribute bindings against the iteration's scoped state and route
498
- // every resolved value through the global iteration-prop registry. The prop
499
- // attribute becomes `@[window.__vibe.iterProps._pN]` — a live binding into the
500
- // registry slot — for both primitives and objects. The original expression is
501
- // stashed on the element so the iteration's update path can re-evaluate it
502
- // against the new scope and refresh the slot, propagating the change into the
503
- // inlined component's bindings without rebuilding the row's DOM.
504
- //
505
- // Component[src] attributes intentionally bypass hydrate (parse.js) so they
506
- // reach processComponent as bindings — but bindings that depend on
507
- // iteration-local vars (item, index) can't resolve later when processComponent
508
- // inlines the component, since by then iteration scope is gone. Only called
509
- // from iteration code paths; conditionals don't need this because their branch
510
- // content is registered in the global manifest and reacts to state updates.
317
+ const resolveScopedSrc = (el, scopedState, scopeKeys) => {
318
+ const src = el.getAttribute('src');
319
+ BINDING_REGEX.lastIndex = 0;
320
+ if (!BINDING_REGEX.test(src)) return;
321
+ const resolved = src.replace(BINDING_REGEX, (match, expr) =>
322
+ extractDependencies(expr).some((dep) => scopeKeys.has(dep))
323
+ ? (evalInScope(expr, scopedState, el) ?? match)
324
+ : match,
325
+ );
326
+ if (resolved !== src) el.setAttribute('src', resolved);
327
+ };
328
+
511
329
  export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
512
330
  for (let n = 0; n < nodes.length; n++) {
513
331
  const node = nodes[n];
@@ -524,18 +342,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
524
342
  const match = attr.value.match(/^@\[(.+)\]$/);
525
343
  if (!match) continue;
526
344
  const expr = match[1];
527
- // The registry snapshot only exists to carry iteration-local values
528
- // (item/index/outer aliases, or component-local `this.X`) past the point
529
- // where processComponent inlines the component and that scope is gone.
530
- // A prop whose expression references ONLY globals doesn't need it — and
531
- // routing it through the snapshot would freeze it, since the slot is
532
- // refreshed solely on array diffs (the Brawling-loader freeze: a prop
533
- // bound to `elapsedMilliseconds` in a row whose array never changes).
534
- // Leave such a binding raw so the normal reactive path tracks the
535
- // global, but still tag the wrapper: `_vibeIterPropExprs` makes index.js
536
- // stamp `_vibeIterTree`, which is what lets affected.js's
537
- // walkInlinedComponentTrees descend in and re-hydrate the raw binding on
538
- // a global-state change.
539
345
  const usesLocalScope =
540
346
  /\bthis\b/.test(expr) ||
541
347
  (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
@@ -557,7 +363,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
557
363
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
558
364
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
559
365
  } catch {
560
- // Leave binding raw — processComponent will handle it as a binding
561
366
  }
562
367
  }
563
368
  resolveSlotContentBindings(el, scopedState, aliases);
@@ -565,13 +370,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
565
370
  }
566
371
  };
567
372
 
568
- // Walk a row's live cloned nodes and invoke `fn` for every inlined-component
569
- // wrapper carrying `marker` — the resolved node itself plus any
570
- // `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
571
- // inlined-hydrate passes are identical except for this marker and the per-wrapper
572
- // work, so they share this walk. (`[data-vibe-iter-prop]` always implies
573
- // `_vibeIterPropExprs`, set together at mount, so filtering descendants by the
574
- // marker matches the historical "take all, skip those without exprs" behavior.)
575
373
  const forEachIterWrapper = (clonedNodes, marker, fn) => {
576
374
  for (let n = 0; n < clonedNodes.length; n++) {
577
375
  const node = liveNode(clonedNodes[n]);
@@ -584,17 +382,6 @@ const forEachIterWrapper = (clonedNodes, marker, fn) => {
584
382
  }
585
383
  };
586
384
 
587
- // Walk a row's clones for any element tagged as iteration-prop owner —
588
- // pre-process `<component src>` (still has the src attribute) and post-process
589
- // `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
590
- // expression, re-evaluate against the row's new scoped state and write into
591
- // the registry slot the inlined bindings already reference. Idempotent: same
592
- // scoped state → same value → no-op write.
593
- // Returns the set of registry slot ids whose value actually changed this
594
- // refresh. A slot holding the same reference (e.g. a static ability array on a
595
- // combatant that only moved) is a no-op, so forceRegistryBackedIterationUpdates
596
- // can skip re-diffing the nested iteration it feeds — the dominant cost when a
597
- // row carries large static nested iterations.
598
385
  const refreshIterationComponentProps = (clonedNodes, scopedState) => {
599
386
  const changed = new Set();
600
387
  const registry = ensureIterPropsRegistry();
@@ -602,54 +389,24 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
602
389
  for (const { id, expr } of el._vibeIterPropExprs) {
603
390
  try {
604
391
  const value = evalInScope(expr, scopedState, el);
605
- // Don't clobber a slot with undefined — mirrors the mount-time guard in
606
- // resolveIterationComponentProps. forEachIterWrapper reaches every
607
- // [data-vibe-iter-prop] descendant, including components owned by a
608
- // DEEPER iteration (e.g. a cell component inside a nested each). Their
609
- // prop expressions reference the inner each's alias, which isn't in this
610
- // (outer) row's scope, so they evaluate to undefined here. Skipping keeps
611
- // the value the inner iteration's own update already set with the correct
612
- // scope, instead of wiping it to undefined and leaving raw @[...] bindings.
613
392
  if (value === undefined) continue;
614
393
  if (registry[id] !== value) {
615
394
  registry[id] = value;
616
395
  changed.add(id);
617
396
  }
618
397
  } catch {
619
- // Leave previous registry value in place — same fail-safe as
620
- // resolveIterationComponentProps's mount-time path.
621
398
  }
622
399
  }
623
400
  });
624
401
  return changed;
625
402
  };
626
403
 
627
- // Walk an inlined component's parsed tree and force `updateIteration` on any
628
- // iteration node whose arrayPath resolves through the iteration-prop registry
629
- // (`window.__vibe.iterProps._pN`). The registry slot was just refreshed in
630
- // place by `refreshIterationComponentProps`, so `affected()` can't notice
631
- // the change — both old/new evaluations of the path read the same updated
632
- // value. `updateIteration` is the only place equipped to diff against
633
- // `iterationNode.runtime.instances` (which still hold the previously rendered
634
- // items) and emit ADD / REMOVE / UPDATE / MOVE ops to bring the inlined DOM
635
- // in sync. Without this, an `<inner-component>` whose template iterates over
636
- // an array prop stays frozen on its initial-render items when the prop's
637
- // contents change.
638
- // Registry-backed CONDITIONALS (rewritten by resolveSlotContentBindings) need
639
- // no counterpart here: their evaluation records no state read, so they sit in
640
- // the tracking always-bucket and re-dispatch on every flush — and the
641
- // affected() pass below stamps their subscriber's lastScope with this row's
642
- // merged scope, so the dispatch flips the branch with the loop aliases in
643
- // reach (letting the mounting branch hydrate alias bindings whose build-time
644
- // snapshot was undefined, e.g. drop.item.icon before an icon exists).
645
404
  const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
646
405
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
647
406
  if (!tree) return;
648
407
  if (tree.type === 'iteration') {
649
408
  const arrPath = tree.meta?.arrayPath;
650
409
  const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
651
- // Skip iterations whose backing slot didn't change this cycle. changedSlots
652
- // is undefined only on legacy/unguarded calls — fall back to always-update.
653
410
  if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
654
411
  updateIteration(tree, state, state, manifest, parentScope);
655
412
  }
@@ -661,12 +418,6 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope,
661
418
  }
662
419
  };
663
420
 
664
- // After registry slots are refreshed, re-hydrate the bindings inside each
665
- // inlined component wrapper so the new values reach the DOM. Each post-process
666
- // wrapper carries a `_vibeIterTree` snapshot captured at inline time (in
667
- // component.js) — that tree retains the original `@[...]` binding text even
668
- // after the wrapper's live DOM has been hydrated, so subsequent affected→
669
- // hydrate passes work the same way they would on initial render.
670
421
  const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
671
422
  forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
672
423
  const tree = wrapper._vibeIterTree;
@@ -674,20 +425,10 @@ const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, mani
674
425
  if (affectedList.length > 0) {
675
426
  hydrate(affectedList, newState, manifest, oldState);
676
427
  }
677
- // Bindings into the iteration-prop registry are visited above, but iteration
678
- // nodes whose arrayPath resolves through the registry need explicit driving:
679
- // the refreshed slot is a side effect `affected()` can't see. Update only the
680
- // iterations whose slot actually changed this cycle (changedSlots).
681
428
  forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
682
429
  });
683
430
  };
684
431
 
685
- // Apply DOM-property writes that compileBatchFn collected. Each batch row
686
- // gets a built tiny scope (item + index + every state key) which is then
687
- // passed to evalInScope — same evaluator the clone path uses, so identifier
688
- // resolution rules (this.X already pre-rewritten, undefined-tolerant lookups)
689
- // stay aligned. The transient `data-vibe-batch` marker is removed once its
690
- // expressions have been applied so it doesn't leak into the live DOM.
691
432
  const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias, domPropertyWrites) => {
692
433
  for (let i = 0; i < instances.length; i++) {
693
434
  const root = instances[i].element;
@@ -712,8 +453,6 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
712
453
  if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
713
454
  }
714
455
  } else if (value) {
715
- // checked/selected are boolean — presence/absence is the truthful
716
- // attribute form, matching the clone path in hydrate.js.
717
456
  if (el.getAttribute(prop) !== '') el.setAttribute(prop, '');
718
457
  } else if (el.hasAttribute(prop)) {
719
458
  el.removeAttribute(prop);
@@ -724,14 +463,6 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
724
463
  }
725
464
  };
726
465
 
727
- // Resolve the loop's array against the LIVE `$` proxy root (not the plain
728
- // diff-snapshot the pipeline renders against — see extractPlainValue in
729
- // index.js). The plain clones are never reference-identical to the proxy
730
- // elements the app sees through `$`, so a loop var that flows into an `on*`
731
- // handler must come from here for `item === $.arr[i]` to hold. Returns null
732
- // when unresolvable (no live root yet, a derivation that builds fresh objects,
733
- // or a nested loop whose source hangs off an outer plain item) — callers fall
734
- // back to the plain item, which is no worse than the pre-fix behavior.
735
466
  const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
736
467
  const liveRoot = (manifest && manifest.__live) || globalThis.$;
737
468
  if (!liveRoot) return null;
@@ -750,11 +481,6 @@ const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
750
481
  const liveItemAt = (liveArray, index, fallback) =>
751
482
  liveArray && index < liveArray.length ? liveArray[index] : fallback;
752
483
 
753
- // Refresh each instance's `liveItem` (the live `$`-proxy element handed to
754
- // loop-scoped `$scope` handlers, set when the instance was built) and stamp
755
- // scope. Re-resolving here keeps `liveItem` correct after the diff reorders or
756
- // updates instances. Diffing still keys off the plain `inst.item`; only the
757
- // handler-facing `$scope` value is live.
758
484
  const stampScopes = (iterationNode, manifest, parentScope = {}) => {
759
485
  const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
760
486
  const instances = iterationNode.runtime.instances;
@@ -779,17 +505,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
779
505
  const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
780
506
  const stateValues = stateKeys.map((k) => state[k]);
781
507
  const html = batchFn(array, ...stateValues, state);
782
- // Remember the rendered output so a later update can skip re-rendering when an
783
- // unrelated state change produces identical HTML (see updateIteration).
784
508
  iterationNode.runtime.lastBatchHtml = html;
785
509
 
786
- // Batch rows close over every state key (stateKeys = Object.keys(state) at
787
- // compile time) — "re-render on any change" is their honest dependency
788
- // set; the identical-HTML guard above absorbs the no-ops (walk parity).
789
510
  markAlways(nodeSubscriberOf(iterationNode, 'iteration'));
790
511
 
791
512
  batchParseTemplate.innerHTML = html;
792
513
  const frag = batchParseTemplate.content;
514
+ markBoundValues(frag, html);
793
515
  const kids = frag.children;
794
516
 
795
517
  const arrayLen = array.length;
@@ -805,13 +527,9 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
805
527
  applyDomPropertyWrites(instances, array, state, itemAlias, indexAlias, domPropertyWrites);
806
528
  }
807
529
 
808
- // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
809
530
  stampScopes(iterationNode, manifest, parentScope);
810
531
  };
811
532
 
812
- /**
813
- * Find a comment node with matching text content in the given nodes.
814
- */
815
533
  const findComment = (nodes, text) => {
816
534
  const trimmedText = text.trim();
817
535
  for (let i = 0; i < nodes.length; i++) {
@@ -823,10 +541,6 @@ const findComment = (nodes, text) => {
823
541
  return null;
824
542
  };
825
543
 
826
- /**
827
- * Clone a parsed tree, mapping element references from original to cloned DOM.
828
- * Walks both trees in lockstep - no map building needed since structure is identical.
829
- */
830
544
  const cloneTreeWithElements = (originalTree, clonedRoot) => {
831
545
  if (!originalTree) return null;
832
546
 
@@ -836,7 +550,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
836
550
  children: {},
837
551
  };
838
552
 
839
- // Avoid spread operator for performance
840
553
  if (originalTree.attributes) {
841
554
  cloned.attributes = originalTree.attributes;
842
555
  }
@@ -848,16 +561,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
848
561
 
849
562
  const clonedChildNodes = clonedRoot?.childNodes;
850
563
 
851
- // Clone children recursively - use index from key to find cloned element
852
564
  for (const key in originalTree.children) {
853
565
  const child = originalTree.children[key];
854
566
  if (!child || typeof child !== 'object') continue;
855
567
 
856
- // Handle conditional nodes - need to update comment references
857
568
  if (child.type === 'conditional' && clonedChildNodes) {
858
569
  const { startComment, elseComment, endComment, branches } = child.meta;
859
570
 
860
- // Find corresponding comments in cloned DOM
861
571
  const clonedStart = findComment(clonedChildNodes, startComment.textContent);
862
572
  const clonedElse = elseComment
863
573
  ? findComment(clonedChildNodes, elseComment.textContent)
@@ -871,7 +581,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
871
581
  startComment: clonedStart || startComment,
872
582
  elseComment: clonedElse,
873
583
  endComment: clonedEnd || endComment,
874
- branches, // Branch templates are reused (they're cloned during mount)
584
+ branches,
875
585
  },
876
586
  runtime: {
877
587
  activeBranch: undefined,
@@ -883,11 +593,9 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
883
593
  continue;
884
594
  }
885
595
 
886
- // Handle iteration nodes - need to update comment references and fresh runtime
887
596
  if (child.type === 'iteration' && clonedChildNodes) {
888
597
  const { startComment, endComment, template } = child.meta;
889
598
 
890
- // Find corresponding comments in cloned DOM
891
599
  const clonedStart = findComment(clonedChildNodes, startComment.textContent);
892
600
  const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
893
601
 
@@ -899,7 +607,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
899
607
  indexAlias: child.meta.indexAlias,
900
608
  startComment: clonedStart || startComment,
901
609
  endComment: clonedEnd || endComment,
902
- template, // Template is reused (cloned during render)
610
+ template,
903
611
  },
904
612
  runtime: {
905
613
  instances: [],
@@ -910,7 +618,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
910
618
  continue;
911
619
  }
912
620
 
913
- // Extract index from key (format: "nodename_index") - avoid regex for speed
914
621
  const underscoreIdx = key.lastIndexOf('_');
915
622
  const childIndex = underscoreIdx >= 0 ? parseInt(key.slice(underscoreIdx + 1), 10) : -1;
916
623
  const clonedChild = childIndex >= 0 && clonedChildNodes ? clonedChildNodes[childIndex] : null;
@@ -921,28 +628,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
921
628
  return cloned;
922
629
  };
923
630
 
924
- /**
925
- * Initialize a block instance from template nodes.
926
- * Uses cached template tree when available for speed.
927
- *
928
- * @param {NodeList} templateNodes - Template nodes to clone
929
- * @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
930
- * @param {Object} cachedTree - Optional cached parsed tree from template
931
- * @param {string|null} componentId - Component the row belongs to, when the
932
- * iteration sits inside a component. Stashed on the parseContainer so
933
- * `findComponentIdForElement` can resolve `this.X` during the hydrate pass
934
- * that runs while clones are still detached.
935
- * @returns {Object} { element, tree, clonedNodes }
936
- */
937
- // Compiled mode bakes a fixed `data-vibe-component-id` into each inlined
938
- // component and stamps its `@[this.x]` bindings / `$.this.x` handlers to that
939
- // id. An iteration clones its template once per row, so every row would
940
- // otherwise share that one id — and thus one local-state bucket. (Two brawler
941
- // slots both resolving to `_c2.open` is why opening one ability drawer opened
942
- // them all.) Per row, remap every baked id in the clone to a fresh one and
943
- // rewrite the references to it; the caller then runs the row's component
944
- // scripts so each registers isolated state under its fresh id. Runtime mode has
945
- // no baked ids here (components are still `<component src>`), so this no-ops.
946
631
  const COMPONENT_ID = /^_c\d+$/;
947
632
 
948
633
  const isolateInlinedComponentIds = (container) => {
@@ -955,9 +640,6 @@ const isolateInlinedComponentIds = (container) => {
955
640
  }
956
641
  if (!remap.size) return false;
957
642
 
958
- // `_c2` must not match inside `_c20` or a longer identifier, so anchor on a
959
- // non-word/`$` boundary before and a non-digit/word after. Bindings always
960
- // read the id as `_cN.prop`, so the trailing `.` satisfies the lookahead.
961
643
  const refs = [...remap].map(([oldId, newId]) => [
962
644
  new RegExp(`(?<![\\w$])${oldId}(?![\\w\\d])`, 'g'),
963
645
  newId,
@@ -989,19 +671,11 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
989
671
  let clonedNodes = [];
990
672
  let firstElement = null;
991
673
 
992
- // Use cached tree when available: cloneTreeWithElements maps the existing parsed structure
993
- // onto cloned DOM nodes, avoiding a full parse() call per iteration item.
994
- // Only fall back to parse() when no cached tree exists (first parse of a new template).
995
- // cloneTreeWithElements has a mapping bug with compiled mode's tree structure.
996
- // Keep disabled until the root cause is fixed — the other optimizations
997
- // (bulk replacement, evalInScope caching, DocumentFragment) cover the hot paths.
998
674
  const useCachedTree = false;
999
675
 
1000
- // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
1001
676
  const parseContainer = document.createElement('div');
1002
677
  if (componentId) parseContainer._vibeComponentId = componentId;
1003
678
 
1004
- // Clone template nodes into container
1005
679
  for (let i = 0; i < templateNodes.length; i++) {
1006
680
  const cloned = templateNodes[i].cloneNode(true);
1007
681
  parseContainer.appendChild(cloned);
@@ -1010,44 +684,30 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
1010
684
  }
1011
685
  }
1012
686
 
1013
- // Preserve raw slot content of nested <component src> elements before renderAllConditionals /
1014
- // renderAllIterations runs on this branch/iteration instance — those paths strip <!-- if -->
1015
- // and <!-- each --> templates from the live DOM, so by the time processComponent reads
1016
- // el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
1017
- // would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
1018
- // _vibeSlotContent on every clone.
1019
687
  const components = parseContainer.querySelectorAll('component[src], div.component[src]');
688
+ const scopeKeys = overlayKeysOf(scopedState);
1020
689
  for (let i = 0; i < components.length; i++) {
1021
690
  const el = components[i];
1022
691
  if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
692
+ if (scopeKeys) resolveScopedSrc(el, scopedState, scopeKeys);
1023
693
  }
1024
694
 
1025
- // Give this row its own component ids (compiled mode) before parse() reads the
1026
- // bindings, then run the row's inlined setup scripts so each registers its own
1027
- // local state under the fresh id — mirroring the conditional-branch path.
1028
695
  if (isolateComponents && isolateInlinedComponentIds(parseContainer)) {
1029
696
  executeCompiledComponentScriptsIn([...parseContainer.childNodes]);
1030
697
  }
1031
698
 
1032
699
  if (useCachedTree) {
1033
- // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
1034
700
  tree = cloneTreeWithElements(cachedTree, parseContainer);
1035
701
  } else {
1036
- // Full parse: walk DOM, extract bindings, build tree from scratch. Pass this
1037
- // loop's aliases so loop-scoped `on*` handlers (and nested ones, via the
1038
- // parser's child-alias accumulation) rewrite to `$scope(this,'alias')`.
1039
702
  tree = parse(parseContainer, undefined, aliasSet);
1040
703
  }
1041
704
 
1042
- // Extract the cloned nodes from the container (these are the same nodes the tree references)
1043
- // Avoid Array.from for performance
1044
705
  const childNodes = parseContainer.childNodes;
1045
706
  clonedNodes = [];
1046
707
  for (let i = 0; i < childNodes.length; i++) {
1047
708
  clonedNodes.push(childNodes[i]);
1048
709
  }
1049
710
 
1050
- // If no firstElement found, use parseContainer as fallback
1051
711
  if (!firstElement) {
1052
712
  firstElement = parseContainer;
1053
713
  }
@@ -1064,19 +724,13 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
1064
724
  };
1065
725
  };
1066
726
 
1067
- // Create a proxied state with scoped variables (item, index, array)
1068
727
  export const createScopedState = (globalState, localVars, parentScope = {}) => {
1069
- // Pre-compute the combined key list once at creation time.
1070
- // Avoids rebuilding 3 arrays + Set on every Object.keys() call.
1071
728
  const cachedKeys = [...new Set([
1072
729
  ...Object.keys(localVars),
1073
730
  ...Object.keys(parentScope),
1074
731
  ...Reflect.ownKeys(globalState),
1075
732
  ])];
1076
733
 
1077
- // Overlay = the aliases this proxy resolves locally (localVars wins over
1078
- // parentScope, matching the get order below). affected's descent reuses it for
1079
- // a cheap plain merge instead of materializing the proxy.
1080
734
  const overlay =
1081
735
  Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
1082
736
 
@@ -1084,10 +738,6 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
1084
738
  get(target, prop) {
1085
739
  if (prop in localVars) return localVars[prop];
1086
740
  if (prop in parentScope) return parentScope[prop];
1087
- // Global fallthrough is a root-key read; alias hits above are the
1088
- // row's own scope (owned by its iteration's diff) and never record.
1089
- // When the target is the live root the proxy trap records too — the
1090
- // window's read Set dedupes.
1091
741
  recordRead(prop);
1092
742
  return Reflect.get(target, prop);
1093
743
  },
@@ -1124,19 +774,14 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
1124
774
  return rememberScopedKeys(proxy, cachedKeys, overlay);
1125
775
  };
1126
776
 
1127
- // Render all iterations in the parsed tree
1128
777
  export const renderAllIterations = (tree, state, manifest, parentScope = {}) => {
1129
778
  let count = 0;
1130
779
 
1131
- // If this is an iteration node, render it
1132
780
  if (tree.type === 'iteration') {
1133
781
  renderIteration(tree, state, manifest, parentScope);
1134
782
  return 1;
1135
783
  }
1136
784
 
1137
- // Recursively render iterations in child nodes — except under an outgoing
1138
- // wrapper (reactive-src remount in flight): its subtree is frozen until
1139
- // the swap replaces it.
1140
785
  if (tree.children && !isOutgoing(tree.element)) {
1141
786
  for (const key in tree.children) {
1142
787
  const child = tree.children[key];
@@ -1149,44 +794,32 @@ export const renderAllIterations = (tree, state, manifest, parentScope = {}) =>
1149
794
  return count;
1150
795
  };
1151
796
 
1152
- // Callback for rendering conditionals - set by conditionals.js to avoid circular import
1153
797
  let _renderAllConditionals = () => {};
1154
798
  export const setRenderAllConditionals = (fn) => {
1155
799
  _renderAllConditionals = fn;
1156
800
  };
1157
801
 
1158
- // Initial render of an iteration block
1159
802
  export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
1160
803
  const { arrayPath, startComment, endComment } = iterationNode.meta;
1161
804
 
1162
- // Already rendered - updates go through updateIteration
1163
805
  if (iterationNode.runtime.instances?.length > 0) {
1164
806
  return;
1165
807
  }
1166
808
 
1167
- // Check if this iteration has already been rendered
1168
- // We use a marker on the startComment node itself (survives re-parsing)
1169
- // @ts-ignore - adding custom property to comment node
1170
809
  if (startComment.__vibeRendered) {
1171
810
  return;
1172
811
  }
1173
812
 
1174
- // Recovery for lost comment markers: a <component src> item re-processes its node
1175
- // out of managedNodes, dropping startComment.__vibeRendered. templateRemoved lives
1176
- // on runtime (not the DOM), so it survives — a true value means a prior render
1177
- // already cleared the template, so if content is still present between the
1178
- // comments, it's rendered; don't rebuild. Falls through only if content was lost.
1179
813
  if (iterationNode.runtime.templateRemoved) {
1180
814
  let currentNode = startComment.nextSibling;
1181
815
  while (currentNode && currentNode !== endComment) {
1182
816
  if (currentNode.nodeType === 1) {
1183
- return; // Already rendered — content still present
817
+ return;
1184
818
  }
1185
819
  currentNode = currentNode.nextSibling;
1186
820
  }
1187
821
  }
1188
822
 
1189
- // Remove template nodes from DOM on first render
1190
823
  if (!iterationNode.runtime.templateRemoved) {
1191
824
  let node = startComment.nextSibling;
1192
825
  while (node && node !== endComment) {
@@ -1199,12 +832,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
1199
832
 
1200
833
  const parent = startComment.parentNode;
1201
834
 
1202
- // Evaluate the array expression — supports state paths (items),
1203
- // window globals (window.fights), method calls (items.filter(...)),
1204
- // and inline literals (['a', 'b']). Falls back to resolvePath for
1205
- // simple paths that evalInScope might miss in scoped contexts.
1206
835
  const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
1207
- // First render doubles as the iteration's subscription registration.
1208
836
  const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
1209
837
  trackSub.lastScope = state;
1210
838
  beginTracking(trackSub, overlayKeysOf(state));
@@ -1215,35 +843,22 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
1215
843
  return;
1216
844
  }
1217
845
 
1218
- // Compiled path: Check for pre-compiled batch function in manifest
1219
846
  if (compiled.canUseCompiled(iterationNode)) {
1220
847
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
1221
848
  if (compiled.renderCompiled(iterationNode, array, state, compiledMeta, parent, endComment)) {
1222
- // Compiled rows close over every state key — "any change" is their
1223
- // honest dependency set (walk parity for treeless instances).
1224
849
  markAlways(trackSub);
1225
- // Mark as rendered
1226
850
  startComment.__vibeRendered = true;
1227
851
  startComment.__vibeIterationRuntime = iterationNode.runtime;
1228
852
  return;
1229
853
  }
1230
- // Fall through to runtime path if compiled failed
1231
854
  }
1232
855
 
1233
- // Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
1234
856
  renderInstances(iterationNode, array, state, manifest, parentScope);
1235
857
 
1236
- // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
1237
- // Also store runtime data on the DOM node so it persists across re-parses
1238
- // @ts-ignore - adding custom property to comment node
1239
858
  startComment.__vibeRendered = true;
1240
- // @ts-ignore - adding custom property to comment node
1241
859
  startComment.__vibeIterationRuntime = iterationNode.runtime;
1242
860
  };
1243
861
 
1244
- // Evaluate the iteration's optional key expression for one item.
1245
- // Returns undefined when no keyExpr is declared, falling back to the default
1246
- // heuristic in getItemKey.
1247
862
  const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
1248
863
  const { keyExpr, itemAlias, indexAlias } = iterationNode.meta;
1249
864
  if (!keyExpr) return undefined;
@@ -1256,9 +871,6 @@ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
1256
871
  }
1257
872
  };
1258
873
 
1259
- // One-shot warning when an unkeyed iteration produces index-coupled keys —
1260
- // i.e. the fallback `hash_..._<index>` path. Only emits in debug mode and only
1261
- // once per iteration block, so console doesn't drown.
1262
874
  const warnIndexCoupledKey = (iterationNode) => {
1263
875
  if (iterationNode.runtime.warnedIndexCoupled) return;
1264
876
  if (!globalThis.__vibe?.debug) return;
@@ -1269,7 +881,6 @@ const warnIndexCoupledKey = (iterationNode) => {
1269
881
  );
1270
882
  };
1271
883
 
1272
- // Update an iteration block when array changes
1273
884
  export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
1274
885
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
1275
886
 
@@ -1278,32 +889,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1278
889
  const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
1279
890
 
1280
891
  const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
1281
- // New-side eval re-records the iteration's subscription (self-healing).
1282
892
  const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
1283
893
  trackSub.lastScope = newState;
1284
894
  beginTracking(trackSub, overlayKeysOf(newState));
1285
895
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
1286
896
  endTracking();
1287
897
 
1288
- // Use instances (what's actually rendered) as ground truth for old array
1289
- // whenever the only signal of change is a registry side-effect, or when
1290
- // the rendered count doesn't match the freshly evaluated state. Three
1291
- // cases collapse to "trust the rendered snapshot":
1292
- // 1. oldState === newState — forceRegistryBackedIterationUpdates calls
1293
- // updateIteration with the same state on both sides because the only
1294
- // mutation was a registry slot rewrite. The previously rendered
1295
- // items are the only honest record of what was there before.
1296
- // 2. stateOldArray === newArray — the iteration's arrayPath resolves
1297
- // directly to a registry slot (`window.__vibe.iterProps._pN`); the
1298
- // slot was swapped in place, so both reads return the same NEW
1299
- // array.
1300
- // 3. length mismatch — oldState predates the current render.
1301
- // Otherwise the freshly evaluated state is a trustworthy "old".
1302
- //
1303
- // Literal-wrap expressions like `[s]` would otherwise slip through this
1304
- // net: they build different array refs each eval but both contain the
1305
- // just-rewritten registry value, so a naive diff sees no change. Case 1
1306
- // catches them.
1307
898
  const instances = iterationNode.runtime.instances;
1308
899
  const useInstancesAsOld =
1309
900
  oldState === newState ||
@@ -1313,7 +904,6 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1313
904
  ? instances.map((inst) => inst.item)
1314
905
  : stateOldArray;
1315
906
 
1316
- // Compiled path: Use pre-compiled batch function when available
1317
907
  if (compiled.canUseCompiled(iterationNode)) {
1318
908
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
1319
909
  if (
@@ -1326,49 +916,25 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1326
916
  endComment,
1327
917
  )
1328
918
  ) {
1329
- // Still treeless — keep the honest any-change subscription.
1330
919
  markAlways(trackSub);
1331
920
  return;
1332
921
  }
1333
- // Fall through to runtime path if compiled failed
1334
922
  }
1335
923
 
1336
- // Bulk path: skip O(n²) LCS when arrays share no common items
1337
- // Handles empty→full, full→empty, and full replacement (no shared keys)
1338
924
  if (oldArray.length === 0 || newArray.length === 0) {
1339
925
  bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1340
926
  return;
1341
927
  }
1342
928
 
1343
- // Treeless rows (batch-rendered) can't be patched in place — they have no
1344
- // parsed tree for updateInstance to walk. When affected pushes them as
1345
- // iteration-affected (see the same predicate in affected.js), do a full
1346
- // re-batch so row bindings reflect current state. Clone-rendered iterations
1347
- // skip this — they take the diff path below.
1348
929
  const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
1349
930
  if (hasTreelessInstances && oldState !== newState) {
1350
- // Treeless rows stay on the any-change subscription. If bulkReplace
1351
- // below clone-renders trees instead, the next new-side eval re-records
1352
- // real deps (self-healing).
1353
931
  markAlways(trackSub);
1354
- // affected.js conservatively flags a treeless (batch-rendered) iteration on
1355
- // ANY state change, since it can't walk per-row trees to see which bindings
1356
- // actually depend on what changed. Before tearing down and recreating every
1357
- // row, re-run the batch: if it yields identical HTML, the rows don't depend
1358
- // on what changed, so keep the existing DOM nodes — preserving their event
1359
- // listeners (e.g. tooltip mouseleave) and any in-progress click on a row
1360
- // control. DOM-property writes (value/checked/etc.) aren't reflected in the
1361
- // HTML string, so they're re-applied against the kept rows — property
1362
- // assignment also wins over a user-dirtied checkbox, which an attribute
1363
- // rewrite wouldn't.
1364
932
  const rt = iterationNode.runtime;
1365
933
  if (rt.batchFn && rt.lastBatchHtml !== undefined) {
1366
934
  const stateValues = rt.stateKeys.map((k) => newState[k]);
1367
935
  const newHtml = rt.batchFn(newArray, ...stateValues, newState);
1368
936
  if (newHtml === rt.lastBatchHtml) {
1369
937
  if (rt.domPropertyWrites?.length) {
1370
- // Identical HTML implies identical row count — refresh item refs so
1371
- // $scope handlers and property writes read the live array.
1372
938
  for (let i = 0; i < instances.length; i++) instances[i].item = newArray[i];
1373
939
  applyDomPropertyWrites(
1374
940
  instances,
@@ -1393,25 +959,18 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1393
959
  getItemKey(item, i, evalKeyForItem(iterationNode, item, i, newState, parentScope)),
1394
960
  );
1395
961
 
1396
- // Detect index-coupled fallback keys (debug-mode warning only).
1397
962
  if (!iterationNode.meta.keyExpr && newKeys.some(k => k.startsWith('hash_') || k.startsWith('val_'))) {
1398
963
  warnIndexCoupledKey(iterationNode);
1399
964
  }
1400
965
 
1401
- // O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
1402
966
  const oldKeySet = new Set(oldKeys);
1403
967
  if (!newKeys.some(k => oldKeySet.has(k))) {
1404
968
  bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1405
969
  return;
1406
970
  }
1407
971
 
1408
- // Standard diff-based updates (arrays share some common items)
1409
972
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
1410
973
 
1411
- // Apply array-shape operations and capture which instances were
1412
- // bindings-refreshed by an op (ADD built a fresh tree with newState;
1413
- // UPDATE patched bindings in place). Captured by reference, not index,
1414
- // because indices shift during the loop.
1415
974
  const refreshedByOps = new WeakSet();
1416
975
  operations.forEach((op) => {
1417
976
  switch (op.type) {
@@ -1432,10 +991,6 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1432
991
  }
1433
992
  });
1434
993
 
1435
- // Refresh every row the ops didn't touch: moved rows (index changed,
1436
- // bindings stale) and untouched rows whose outer-scope bindings depend
1437
- // on state that changed in this update cycle. Each row goes through
1438
- // updateInstance exactly once — via an op or here.
1439
994
  const retained = iterationNode.runtime.instances;
1440
995
  for (let i = 0; i < retained.length; i++) {
1441
996
  retained[i].index = i;
@@ -1443,27 +998,13 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1443
998
  updateInstance(iterationNode, i, retained[i].item, oldState, newState, manifest, parentScope);
1444
999
  }
1445
1000
 
1446
- // Re-stamp after the diff settles: moved/updated instances now carry their
1447
- // current item + index, so `$scope` handlers resolve correctly post-reorder.
1448
1001
  stampScopes(iterationNode, manifest, parentScope);
1449
1002
  };
1450
1003
 
1451
- // Prune removed iteration rows from the global manifest (dotPath -> element)
1452
- // and parsed tree. Both views still reference rows that mounted globally-tracked
1453
- // content (e.g. a <component src> registers its subtree in the manifest). Vibe
1454
- // disconnects the page MutationObserver while it reconciles, so the removals
1455
- // below are never observed — left unpruned, the entries pin detached subtrees
1456
- // (memory leak) and bloat every later affected/hydrate walk (the
1457
- // combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
1458
1004
  const releaseRemovedSubtrees = (removedRoots, manifest) => {
1459
1005
  if (!manifest || removedRoots.length === 0) return;
1460
1006
  const tree = manifest.__tree;
1461
1007
 
1462
- // Reverse the manifest (dotPath -> element) once so each removed root resolves
1463
- // to its path. An element can be registered at more than one path (a component
1464
- // wrapper appears at both its own node and an inlined child slot); keep the
1465
- // SHORTEST so the path scopes the whole row subtree, not an inner slot.
1466
- // __live / __tree are non-enumerable, so for-in skips them.
1467
1008
  const pathOf = new Map();
1468
1009
  for (const key in manifest) {
1469
1010
  const el = manifest[key];
@@ -1472,8 +1013,8 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1472
1013
  if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
1473
1014
  }
1474
1015
 
1475
- const removedEls = new Set(); // top-level removed roots — tree prune cascades subtrees
1476
- const removedPaths = new Set(); // their manifest paths — manifest prune is path-scoped
1016
+ const removedEls = new Set();
1017
+ const removedPaths = new Set();
1477
1018
  for (let i = 0; i < removedRoots.length; i++) {
1478
1019
  const root = removedRoots[i];
1479
1020
  if (!root) continue;
@@ -1483,30 +1024,15 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1483
1024
  }
1484
1025
  if (removedPaths.size === 0 && removedEls.size === 0) return;
1485
1026
 
1486
- // Manifest: drop every entry at or under a removed root's path. Path scope (not
1487
- // element identity) is what catches content hoisted out of the DOM — an inactive
1488
- // conditional branch template sits in a detached container yet stays registered
1489
- // under its row's path.
1490
1027
  if (removedPaths.size > 0) {
1491
1028
  for (const key in manifest) {
1492
1029
  if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
1493
1030
  }
1494
1031
  }
1495
1032
 
1496
- // Parsed tree: delete each removed root's node — its whole subtree (nested
1497
- // conditionals/iterations and their branch templates) goes with it. Prune
1498
- // by CONTAINMENT, not just root identity: a row's inlined component trees
1499
- // are also linked under OTHER ancestors' children (the dual linkage the
1500
- // affected-walk dedupe documents), keyed by inner wrapper elements that are
1501
- // never themselves removed roots. Left in place, every later walk descends
1502
- // those detached trees — re-hydrating dead DOM and (since subscriptions)
1503
- // re-registering its subscribers after teardown pruned them, pinning the
1504
- // whole removed subtree forever.
1505
1033
  const roots = [...removedEls];
1506
1034
  if (tree) pruneTreeNodes(tree, removedEls, roots);
1507
1035
 
1508
- // The removed rows' subscribers (bindings, nested conditionals/iterations)
1509
- // are anchored to nodes that just left the document.
1510
1036
  pruneDisconnected();
1511
1037
  };
1512
1038
 
@@ -1517,7 +1043,6 @@ const underRemovedRoot = (el, roots) => {
1517
1043
  return false;
1518
1044
  };
1519
1045
 
1520
- // True when `key` is, or is a descendant of, any path in `removedPaths`.
1521
1046
  const pathUnderRemoved = (key, removedPaths) => {
1522
1047
  if (removedPaths.has(key)) return true;
1523
1048
  for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
@@ -1538,17 +1063,10 @@ const pruneTreeNodes = (node, removedEls, roots) => {
1538
1063
  }
1539
1064
  };
1540
1065
 
1541
- // Bulk replacement: clear all DOM and re-render from scratch
1542
- // Used when arrays share no common keys (avoids O(n²) LCS)
1543
1066
  const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1544
1067
  const { template, startComment, endComment } = iterationNode.meta;
1545
1068
  const parent = startComment.parentNode;
1546
1069
 
1547
- // Clear all existing DOM between comments using Range (single operation),
1548
- // then prune the removed subtrees from the manifest + tree. Collect the LIVE
1549
- // nodes in the range rather than the instances' clonedNodes: a <component src>
1550
- // row is replaced in place by component.js, so clonedNodes can point at the
1551
- // stale original wrapper, not the processed content actually being removed.
1552
1070
  if (iterationNode.runtime.instances.length > 0) {
1553
1071
  const removedRoots = [];
1554
1072
  for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
@@ -1566,8 +1084,6 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1566
1084
  return;
1567
1085
  }
1568
1086
 
1569
- // For simple templates (no nested iterations/conditionals, single root element),
1570
- // use batch string rendering: one string concatenation loop + one innerHTML parse
1571
1087
  if (canUseBatchRender(template)) {
1572
1088
  renderBatch(iterationNode, newArray, state, parent, endComment, parentScope, manifest);
1573
1089
  const instances = iterationNode.runtime.instances;
@@ -1577,20 +1093,9 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1577
1093
  return;
1578
1094
  }
1579
1095
 
1580
- // Complex templates: one shared clone+hydrate render loop.
1581
1096
  renderInstances(iterationNode, newArray, state, manifest, parentScope);
1582
1097
  };
1583
1098
 
1584
- // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
1585
- // that still lives directly under the iteration's parent). Nested primitives
1586
- // inside the iteration template — <!-- if -->, <!-- each -->, <component> —
1587
- // can move/replace cloned nodes between iteration renders (inactive branches
1588
- // get hoisted into template containers; component[src] wrappers get swapped
1589
- // for processed wrappers). Any of those mutations make `clonedNodes[0]` a
1590
- // stale reference to a node no longer under the iteration parent. Callers use
1591
- // this anchor instead of trusting `clonedNodes[0]` directly, so that
1592
- // insert-before / move operations always resolve against the iteration's real
1593
- // DOM slot.
1594
1099
  const findInstanceAnchor = (instance, iterationParent) => {
1595
1100
  const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
1596
1101
  for (let i = 0; i < nodes.length; i++) {
@@ -1599,10 +1104,6 @@ const findInstanceAnchor = (instance, iterationParent) => {
1599
1104
  return null;
1600
1105
  };
1601
1106
 
1602
- // Resolve the reference node for `parent.insertBefore(..., ref)` at a given
1603
- // logical iteration index. Walks later instances until it finds one with a
1604
- // live anchor under the iteration parent; falls back to `endComment` when no
1605
- // later instance has any node currently mounted in the iteration.
1606
1107
  const resolveInsertBefore = (iterationNode, index, parent) => {
1607
1108
  const { instances } = iterationNode.runtime;
1608
1109
  for (let i = index; i < instances.length; i++) {
@@ -1612,28 +1113,15 @@ const resolveInsertBefore = (iterationNode, index, parent) => {
1612
1113
  return iterationNode.meta.endComment;
1613
1114
  };
1614
1115
 
1615
- // Detach every DOM node belonging to a logical instance, including content
1616
- // mounted by nested primitives (conditional branches, nested each rows,
1617
- // fetched component wrappers) that isn't tracked in `instance.clonedNodes`.
1618
- // Walks iteration-parent siblings from this instance's anchor up to the next
1619
- // instance's anchor / endComment, so anything in between — clones, mounted
1620
- // branches, swapped-in component wrappers — all gets detached. Also sweeps
1621
- // any clonedNodes that were hoisted out of the iteration parent (e.g. into a
1622
- // sibling conditional's template container).
1623
1116
  const detachInstanceDom = (iterationNode, index, parent) => {
1624
1117
  const instance = iterationNode.runtime.instances[index];
1625
1118
  const { endComment } = iterationNode.meta;
1626
1119
  const anchor = findInstanceAnchor(instance, parent);
1627
1120
  const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
1628
1121
 
1629
- // Return the live nodes actually removed so callers can prune them from the
1630
- // manifest + tree (clonedNodes can be stale once a <component src> row is
1631
- // replaced in place by component.js).
1632
1122
  const removed = [];
1633
1123
  if (anchor) {
1634
1124
  let cur = anchor;
1635
- // endComment caps the walk even if nextAnchor ordering is ever
1636
- // corrupted — iteration DOM is bounded by startComment / endComment.
1637
1125
  while (cur && cur !== nextAnchor && cur !== endComment) {
1638
1126
  const nextSibling = cur.nextSibling;
1639
1127
  parent.removeChild(cur);
@@ -1653,14 +1141,6 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1653
1141
  return removed;
1654
1142
  };
1655
1143
 
1656
- // Build a fresh instance's DOM + tree + scope from the iteration template.
1657
- // Pure function — no DOM insertion, no side effects on iteration state.
1658
- // Callers decide where the clones go (iteration parent, DocumentFragment).
1659
- // `liveItem` (resolved by the caller via resolveLiveArray, so a derived-array
1660
- // loop evaluates the expression once per render, not once per row) is the live
1661
- // `$`-proxy element for this index. Building scope from it gives nested
1662
- // conditional stamps and `$scope` handlers the app-visible identity; `item`
1663
- // (plain snapshot) is still tracked for the diff.
1664
1144
  const buildInstance = (iterationNode, item, index, state, parentScope, liveItem) => {
1665
1145
  const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
1666
1146
  const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
@@ -1672,11 +1152,6 @@ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem)
1672
1152
  return { ...built, scopedState, localVars, liveItem };
1673
1153
  };
1674
1154
 
1675
- // After a built instance's clones are placed in the DOM (directly or via a
1676
- // fragment), mark element clones as managed so the page-level MutationObserver
1677
- // skips them in processMutations, then fire nested iteration/conditional
1678
- // renders. Without the managed mark those clones would be re-parsed + hydrated
1679
- // on top of the internal render, duplicating every nested branch.
1680
1155
  const finalizeInstance = (built, manifest, parentScope) => {
1681
1156
  const { clonedNodes, tree, scopedState, localVars } = built;
1682
1157
  for (let i = 0; i < clonedNodes.length; i++) {
@@ -1689,10 +1164,6 @@ const finalizeInstance = (built, manifest, parentScope) => {
1689
1164
  }
1690
1165
  };
1691
1166
 
1692
- // Build, finalize, and commit a fresh set of instances for `array` in one
1693
- // batched DOM insertion, replacing iterationNode.runtime.instances. The single
1694
- // clone+hydrate render loop shared by initial render (renderIteration) and full
1695
- // rebuild (bulkReplace) — they differ only in their preamble, not this loop.
1696
1167
  const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
1697
1168
  const { startComment, endComment } = iterationNode.meta;
1698
1169
  const parent = startComment.parentNode;
@@ -1710,7 +1181,6 @@ const renderInstances = (iterationNode, array, state, manifest, parentScope) =>
1710
1181
  }
1711
1182
  parent.insertBefore(frag, endComment);
1712
1183
  iterationNode.runtime.instances = instances;
1713
- // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1714
1184
  stampScopes(iterationNode, manifest, parentScope);
1715
1185
  };
1716
1186
 
@@ -1747,30 +1217,18 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
1747
1217
 
1748
1218
  const insertBefore = resolveInsertBefore(iterationNode, toIndex + 1, parent);
1749
1219
  const nodes = instance.clonedNodes || [instance.element];
1750
- // Re-insert only nodes currently under the iteration parent — those hoisted
1751
- // into nested-conditional template containers stay there so we don't
1752
- // double-count branch content.
1753
1220
  for (let i = 0; i < nodes.length; i++) {
1754
1221
  const n = nodes[i];
1755
1222
  if (n?.parentNode === parent) parent.insertBefore(n, insertBefore);
1756
1223
  }
1757
1224
  };
1758
1225
 
1759
- // Detach-and-rebuild fallback. Used when the row's parsed tree isn't
1760
- // available (compiled iterations), or when the row template's top-level
1761
- // shape itself depends on the item (a `<!-- if -->` directly under the
1762
- // iteration with the item in its expression — flipping branches needs a
1763
- // rebuild because the active root element type changes).
1764
1226
  const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, parentScope) => {
1765
1227
  const parent = iterationNode.meta.startComment.parentNode;
1766
1228
  const instance = iterationNode.runtime.instances[index];
1767
1229
 
1768
1230
  const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1769
1231
  const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
1770
- // Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
1771
- // so the replaced subtree is released (same removal contract as removeInstance).
1772
- // Order matters: prune the detached old nodes before finalizeInstance registers
1773
- // the new ones, so the new entries are never touched.
1774
1232
  const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
1775
1233
  releaseRemovedSubtrees(removed, manifest);
1776
1234
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
@@ -1785,17 +1243,6 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1785
1243
  instance.scopedState = built.scopedState;
1786
1244
  };
1787
1245
 
1788
- // In-place refresh of a single row: rebuild scoped states from oldState +
1789
- // newState (and possibly a new item), run affected→hydrate against the row's
1790
- // parsed tree. Same pipeline as top-level state changes; the row's DOM nodes
1791
- // survive, only changed bindings update.
1792
- //
1793
- // Called from two sites in updateIteration:
1794
- // 1. UPDATE diff op — newItem differs from instance.item
1795
- // 2. Post-diff outer-state propagation — newItem === instance.item
1796
- // Both flow through here so iteration updates have one code path, not two.
1797
- // Compiled / batch instances have no row tree to walk and fall back to a
1798
- // full rebuild via updateInstanceRebuild.
1799
1246
  const updateInstance = (iterationNode, index, newItem, oldState, newState, manifest, parentScope = {}) => {
1800
1247
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1801
1248
  const instance = iterationNode.runtime.instances[index];
@@ -1807,11 +1254,6 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
1807
1254
 
1808
1255
  const { itemAlias, indexAlias } = iterationNode.meta;
1809
1256
  const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
1810
- // The "old" scoped state is exactly last cycle's "new" one — same global
1811
- // snapshot (this cycle's oldState) and same item/index — already stored on the
1812
- // instance. Reuse it instead of allocating a second proxy per row per frame
1813
- // (createScopedState is a combat hot spot). Falls back on the first update
1814
- // after an add, before scopedState has been recorded.
1815
1257
  const oldScopedState =
1816
1258
  instance.scopedState ||
1817
1259
  createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);