@ape-egg/vibe 4.0.1 → 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,12 +314,6 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
493
314
  }
494
315
  };
495
316
 
496
- // Substitute the loop-scoped `@[expr]` parts of a component's src with their
497
- // values from this block's scope. Only exprs whose dependencies include a loop
498
- // alias are resolved — everything else keeps its binding form (see the caller
499
- // in initializeBlock for why). An expr that evaluates to undefined stays raw
500
- // too, so the unresolved-src invariant keeps the wrapper unfetchable instead
501
- // of composing a garbage URL.
502
317
  const resolveScopedSrc = (el, scopedState, scopeKeys) => {
503
318
  const src = el.getAttribute('src');
504
319
  BINDING_REGEX.lastIndex = 0;
@@ -511,21 +326,6 @@ const resolveScopedSrc = (el, scopedState, scopeKeys) => {
511
326
  if (resolved !== src) el.setAttribute('src', resolved);
512
327
  };
513
328
 
514
- // For <component src> elements inside an iteration instance, evaluate any
515
- // `@[expr]` attribute bindings against the iteration's scoped state and route
516
- // every resolved value through the global iteration-prop registry. The prop
517
- // attribute becomes `@[window.__vibe.iterProps._pN]` — a live binding into the
518
- // registry slot — for both primitives and objects. The original expression is
519
- // stashed on the element so the iteration's update path can re-evaluate it
520
- // against the new scope and refresh the slot, propagating the change into the
521
- // inlined component's bindings without rebuilding the row's DOM.
522
- //
523
- // Component[src] attributes intentionally bypass hydrate (parse.js) so they
524
- // reach processComponent as bindings — but bindings that depend on
525
- // iteration-local vars (item, index) can't resolve later when processComponent
526
- // inlines the component, since by then iteration scope is gone. Only called
527
- // from iteration code paths; conditionals don't need this because their branch
528
- // content is registered in the global manifest and reacts to state updates.
529
329
  export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
530
330
  for (let n = 0; n < nodes.length; n++) {
531
331
  const node = nodes[n];
@@ -542,18 +342,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
542
342
  const match = attr.value.match(/^@\[(.+)\]$/);
543
343
  if (!match) continue;
544
344
  const expr = match[1];
545
- // The registry snapshot only exists to carry iteration-local values
546
- // (item/index/outer aliases, or component-local `this.X`) past the point
547
- // where processComponent inlines the component and that scope is gone.
548
- // A prop whose expression references ONLY globals doesn't need it — and
549
- // routing it through the snapshot would freeze it, since the slot is
550
- // refreshed solely on array diffs (the Brawling-loader freeze: a prop
551
- // bound to `elapsedMilliseconds` in a row whose array never changes).
552
- // Leave such a binding raw so the normal reactive path tracks the
553
- // global, but still tag the wrapper: `_vibeIterPropExprs` makes index.js
554
- // stamp `_vibeIterTree`, which is what lets affected.js's
555
- // walkInlinedComponentTrees descend in and re-hydrate the raw binding on
556
- // a global-state change.
557
345
  const usesLocalScope =
558
346
  /\bthis\b/.test(expr) ||
559
347
  (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
@@ -575,7 +363,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
575
363
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
576
364
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
577
365
  } catch {
578
- // Leave binding raw — processComponent will handle it as a binding
579
366
  }
580
367
  }
581
368
  resolveSlotContentBindings(el, scopedState, aliases);
@@ -583,13 +370,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
583
370
  }
584
371
  };
585
372
 
586
- // Walk a row's live cloned nodes and invoke `fn` for every inlined-component
587
- // wrapper carrying `marker` — the resolved node itself plus any
588
- // `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
589
- // inlined-hydrate passes are identical except for this marker and the per-wrapper
590
- // work, so they share this walk. (`[data-vibe-iter-prop]` always implies
591
- // `_vibeIterPropExprs`, set together at mount, so filtering descendants by the
592
- // marker matches the historical "take all, skip those without exprs" behavior.)
593
373
  const forEachIterWrapper = (clonedNodes, marker, fn) => {
594
374
  for (let n = 0; n < clonedNodes.length; n++) {
595
375
  const node = liveNode(clonedNodes[n]);
@@ -602,17 +382,6 @@ const forEachIterWrapper = (clonedNodes, marker, fn) => {
602
382
  }
603
383
  };
604
384
 
605
- // Walk a row's clones for any element tagged as iteration-prop owner —
606
- // pre-process `<component src>` (still has the src attribute) and post-process
607
- // `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
608
- // expression, re-evaluate against the row's new scoped state and write into
609
- // the registry slot the inlined bindings already reference. Idempotent: same
610
- // scoped state → same value → no-op write.
611
- // Returns the set of registry slot ids whose value actually changed this
612
- // refresh. A slot holding the same reference (e.g. a static ability array on a
613
- // combatant that only moved) is a no-op, so forceRegistryBackedIterationUpdates
614
- // can skip re-diffing the nested iteration it feeds — the dominant cost when a
615
- // row carries large static nested iterations.
616
385
  const refreshIterationComponentProps = (clonedNodes, scopedState) => {
617
386
  const changed = new Set();
618
387
  const registry = ensureIterPropsRegistry();
@@ -620,54 +389,24 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
620
389
  for (const { id, expr } of el._vibeIterPropExprs) {
621
390
  try {
622
391
  const value = evalInScope(expr, scopedState, el);
623
- // Don't clobber a slot with undefined — mirrors the mount-time guard in
624
- // resolveIterationComponentProps. forEachIterWrapper reaches every
625
- // [data-vibe-iter-prop] descendant, including components owned by a
626
- // DEEPER iteration (e.g. a cell component inside a nested each). Their
627
- // prop expressions reference the inner each's alias, which isn't in this
628
- // (outer) row's scope, so they evaluate to undefined here. Skipping keeps
629
- // the value the inner iteration's own update already set with the correct
630
- // scope, instead of wiping it to undefined and leaving raw @[...] bindings.
631
392
  if (value === undefined) continue;
632
393
  if (registry[id] !== value) {
633
394
  registry[id] = value;
634
395
  changed.add(id);
635
396
  }
636
397
  } catch {
637
- // Leave previous registry value in place — same fail-safe as
638
- // resolveIterationComponentProps's mount-time path.
639
398
  }
640
399
  }
641
400
  });
642
401
  return changed;
643
402
  };
644
403
 
645
- // Walk an inlined component's parsed tree and force `updateIteration` on any
646
- // iteration node whose arrayPath resolves through the iteration-prop registry
647
- // (`window.__vibe.iterProps._pN`). The registry slot was just refreshed in
648
- // place by `refreshIterationComponentProps`, so `affected()` can't notice
649
- // the change — both old/new evaluations of the path read the same updated
650
- // value. `updateIteration` is the only place equipped to diff against
651
- // `iterationNode.runtime.instances` (which still hold the previously rendered
652
- // items) and emit ADD / REMOVE / UPDATE / MOVE ops to bring the inlined DOM
653
- // in sync. Without this, an `<inner-component>` whose template iterates over
654
- // an array prop stays frozen on its initial-render items when the prop's
655
- // contents change.
656
- // Registry-backed CONDITIONALS (rewritten by resolveSlotContentBindings) need
657
- // no counterpart here: their evaluation records no state read, so they sit in
658
- // the tracking always-bucket and re-dispatch on every flush — and the
659
- // affected() pass below stamps their subscriber's lastScope with this row's
660
- // merged scope, so the dispatch flips the branch with the loop aliases in
661
- // reach (letting the mounting branch hydrate alias bindings whose build-time
662
- // snapshot was undefined, e.g. drop.item.icon before an icon exists).
663
404
  const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
664
405
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
665
406
  if (!tree) return;
666
407
  if (tree.type === 'iteration') {
667
408
  const arrPath = tree.meta?.arrayPath;
668
409
  const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
669
- // Skip iterations whose backing slot didn't change this cycle. changedSlots
670
- // is undefined only on legacy/unguarded calls — fall back to always-update.
671
410
  if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
672
411
  updateIteration(tree, state, state, manifest, parentScope);
673
412
  }
@@ -679,12 +418,6 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope,
679
418
  }
680
419
  };
681
420
 
682
- // After registry slots are refreshed, re-hydrate the bindings inside each
683
- // inlined component wrapper so the new values reach the DOM. Each post-process
684
- // wrapper carries a `_vibeIterTree` snapshot captured at inline time (in
685
- // component.js) — that tree retains the original `@[...]` binding text even
686
- // after the wrapper's live DOM has been hydrated, so subsequent affected→
687
- // hydrate passes work the same way they would on initial render.
688
421
  const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
689
422
  forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
690
423
  const tree = wrapper._vibeIterTree;
@@ -692,20 +425,10 @@ const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, mani
692
425
  if (affectedList.length > 0) {
693
426
  hydrate(affectedList, newState, manifest, oldState);
694
427
  }
695
- // Bindings into the iteration-prop registry are visited above, but iteration
696
- // nodes whose arrayPath resolves through the registry need explicit driving:
697
- // the refreshed slot is a side effect `affected()` can't see. Update only the
698
- // iterations whose slot actually changed this cycle (changedSlots).
699
428
  forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
700
429
  });
701
430
  };
702
431
 
703
- // Apply DOM-property writes that compileBatchFn collected. Each batch row
704
- // gets a built tiny scope (item + index + every state key) which is then
705
- // passed to evalInScope — same evaluator the clone path uses, so identifier
706
- // resolution rules (this.X already pre-rewritten, undefined-tolerant lookups)
707
- // stay aligned. The transient `data-vibe-batch` marker is removed once its
708
- // expressions have been applied so it doesn't leak into the live DOM.
709
432
  const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias, domPropertyWrites) => {
710
433
  for (let i = 0; i < instances.length; i++) {
711
434
  const root = instances[i].element;
@@ -730,8 +453,6 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
730
453
  if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
731
454
  }
732
455
  } else if (value) {
733
- // checked/selected are boolean — presence/absence is the truthful
734
- // attribute form, matching the clone path in hydrate.js.
735
456
  if (el.getAttribute(prop) !== '') el.setAttribute(prop, '');
736
457
  } else if (el.hasAttribute(prop)) {
737
458
  el.removeAttribute(prop);
@@ -742,14 +463,6 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
742
463
  }
743
464
  };
744
465
 
745
- // Resolve the loop's array against the LIVE `$` proxy root (not the plain
746
- // diff-snapshot the pipeline renders against — see extractPlainValue in
747
- // index.js). The plain clones are never reference-identical to the proxy
748
- // elements the app sees through `$`, so a loop var that flows into an `on*`
749
- // handler must come from here for `item === $.arr[i]` to hold. Returns null
750
- // when unresolvable (no live root yet, a derivation that builds fresh objects,
751
- // or a nested loop whose source hangs off an outer plain item) — callers fall
752
- // back to the plain item, which is no worse than the pre-fix behavior.
753
466
  const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
754
467
  const liveRoot = (manifest && manifest.__live) || globalThis.$;
755
468
  if (!liveRoot) return null;
@@ -768,11 +481,6 @@ const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
768
481
  const liveItemAt = (liveArray, index, fallback) =>
769
482
  liveArray && index < liveArray.length ? liveArray[index] : fallback;
770
483
 
771
- // Refresh each instance's `liveItem` (the live `$`-proxy element handed to
772
- // loop-scoped `$scope` handlers, set when the instance was built) and stamp
773
- // scope. Re-resolving here keeps `liveItem` correct after the diff reorders or
774
- // updates instances. Diffing still keys off the plain `inst.item`; only the
775
- // handler-facing `$scope` value is live.
776
484
  const stampScopes = (iterationNode, manifest, parentScope = {}) => {
777
485
  const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
778
486
  const instances = iterationNode.runtime.instances;
@@ -797,17 +505,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
797
505
  const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
798
506
  const stateValues = stateKeys.map((k) => state[k]);
799
507
  const html = batchFn(array, ...stateValues, state);
800
- // Remember the rendered output so a later update can skip re-rendering when an
801
- // unrelated state change produces identical HTML (see updateIteration).
802
508
  iterationNode.runtime.lastBatchHtml = html;
803
509
 
804
- // Batch rows close over every state key (stateKeys = Object.keys(state) at
805
- // compile time) — "re-render on any change" is their honest dependency
806
- // set; the identical-HTML guard above absorbs the no-ops (walk parity).
807
510
  markAlways(nodeSubscriberOf(iterationNode, 'iteration'));
808
511
 
809
512
  batchParseTemplate.innerHTML = html;
810
513
  const frag = batchParseTemplate.content;
514
+ markBoundValues(frag, html);
811
515
  const kids = frag.children;
812
516
 
813
517
  const arrayLen = array.length;
@@ -823,13 +527,9 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
823
527
  applyDomPropertyWrites(instances, array, state, itemAlias, indexAlias, domPropertyWrites);
824
528
  }
825
529
 
826
- // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
827
530
  stampScopes(iterationNode, manifest, parentScope);
828
531
  };
829
532
 
830
- /**
831
- * Find a comment node with matching text content in the given nodes.
832
- */
833
533
  const findComment = (nodes, text) => {
834
534
  const trimmedText = text.trim();
835
535
  for (let i = 0; i < nodes.length; i++) {
@@ -841,10 +541,6 @@ const findComment = (nodes, text) => {
841
541
  return null;
842
542
  };
843
543
 
844
- /**
845
- * Clone a parsed tree, mapping element references from original to cloned DOM.
846
- * Walks both trees in lockstep - no map building needed since structure is identical.
847
- */
848
544
  const cloneTreeWithElements = (originalTree, clonedRoot) => {
849
545
  if (!originalTree) return null;
850
546
 
@@ -854,7 +550,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
854
550
  children: {},
855
551
  };
856
552
 
857
- // Avoid spread operator for performance
858
553
  if (originalTree.attributes) {
859
554
  cloned.attributes = originalTree.attributes;
860
555
  }
@@ -866,16 +561,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
866
561
 
867
562
  const clonedChildNodes = clonedRoot?.childNodes;
868
563
 
869
- // Clone children recursively - use index from key to find cloned element
870
564
  for (const key in originalTree.children) {
871
565
  const child = originalTree.children[key];
872
566
  if (!child || typeof child !== 'object') continue;
873
567
 
874
- // Handle conditional nodes - need to update comment references
875
568
  if (child.type === 'conditional' && clonedChildNodes) {
876
569
  const { startComment, elseComment, endComment, branches } = child.meta;
877
570
 
878
- // Find corresponding comments in cloned DOM
879
571
  const clonedStart = findComment(clonedChildNodes, startComment.textContent);
880
572
  const clonedElse = elseComment
881
573
  ? findComment(clonedChildNodes, elseComment.textContent)
@@ -889,7 +581,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
889
581
  startComment: clonedStart || startComment,
890
582
  elseComment: clonedElse,
891
583
  endComment: clonedEnd || endComment,
892
- branches, // Branch templates are reused (they're cloned during mount)
584
+ branches,
893
585
  },
894
586
  runtime: {
895
587
  activeBranch: undefined,
@@ -901,11 +593,9 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
901
593
  continue;
902
594
  }
903
595
 
904
- // Handle iteration nodes - need to update comment references and fresh runtime
905
596
  if (child.type === 'iteration' && clonedChildNodes) {
906
597
  const { startComment, endComment, template } = child.meta;
907
598
 
908
- // Find corresponding comments in cloned DOM
909
599
  const clonedStart = findComment(clonedChildNodes, startComment.textContent);
910
600
  const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
911
601
 
@@ -917,7 +607,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
917
607
  indexAlias: child.meta.indexAlias,
918
608
  startComment: clonedStart || startComment,
919
609
  endComment: clonedEnd || endComment,
920
- template, // Template is reused (cloned during render)
610
+ template,
921
611
  },
922
612
  runtime: {
923
613
  instances: [],
@@ -928,7 +618,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
928
618
  continue;
929
619
  }
930
620
 
931
- // Extract index from key (format: "nodename_index") - avoid regex for speed
932
621
  const underscoreIdx = key.lastIndexOf('_');
933
622
  const childIndex = underscoreIdx >= 0 ? parseInt(key.slice(underscoreIdx + 1), 10) : -1;
934
623
  const clonedChild = childIndex >= 0 && clonedChildNodes ? clonedChildNodes[childIndex] : null;
@@ -939,28 +628,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
939
628
  return cloned;
940
629
  };
941
630
 
942
- /**
943
- * Initialize a block instance from template nodes.
944
- * Uses cached template tree when available for speed.
945
- *
946
- * @param {NodeList} templateNodes - Template nodes to clone
947
- * @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
948
- * @param {Object} cachedTree - Optional cached parsed tree from template
949
- * @param {string|null} componentId - Component the row belongs to, when the
950
- * iteration sits inside a component. Stashed on the parseContainer so
951
- * `findComponentIdForElement` can resolve `this.X` during the hydrate pass
952
- * that runs while clones are still detached.
953
- * @returns {Object} { element, tree, clonedNodes }
954
- */
955
- // Compiled mode bakes a fixed `data-vibe-component-id` into each inlined
956
- // component and stamps its `@[this.x]` bindings / `$.this.x` handlers to that
957
- // id. An iteration clones its template once per row, so every row would
958
- // otherwise share that one id — and thus one local-state bucket. (Two brawler
959
- // slots both resolving to `_c2.open` is why opening one ability drawer opened
960
- // them all.) Per row, remap every baked id in the clone to a fresh one and
961
- // rewrite the references to it; the caller then runs the row's component
962
- // scripts so each registers isolated state under its fresh id. Runtime mode has
963
- // no baked ids here (components are still `<component src>`), so this no-ops.
964
631
  const COMPONENT_ID = /^_c\d+$/;
965
632
 
966
633
  const isolateInlinedComponentIds = (container) => {
@@ -973,9 +640,6 @@ const isolateInlinedComponentIds = (container) => {
973
640
  }
974
641
  if (!remap.size) return false;
975
642
 
976
- // `_c2` must not match inside `_c20` or a longer identifier, so anchor on a
977
- // non-word/`$` boundary before and a non-digit/word after. Bindings always
978
- // read the id as `_cN.prop`, so the trailing `.` satisfies the lookahead.
979
643
  const refs = [...remap].map(([oldId, newId]) => [
980
644
  new RegExp(`(?<![\\w$])${oldId}(?![\\w\\d])`, 'g'),
981
645
  newId,
@@ -1007,19 +671,11 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
1007
671
  let clonedNodes = [];
1008
672
  let firstElement = null;
1009
673
 
1010
- // Use cached tree when available: cloneTreeWithElements maps the existing parsed structure
1011
- // onto cloned DOM nodes, avoiding a full parse() call per iteration item.
1012
- // Only fall back to parse() when no cached tree exists (first parse of a new template).
1013
- // cloneTreeWithElements has a mapping bug with compiled mode's tree structure.
1014
- // Keep disabled until the root cause is fixed — the other optimizations
1015
- // (bulk replacement, evalInScope caching, DocumentFragment) cover the hot paths.
1016
674
  const useCachedTree = false;
1017
675
 
1018
- // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
1019
676
  const parseContainer = document.createElement('div');
1020
677
  if (componentId) parseContainer._vibeComponentId = componentId;
1021
678
 
1022
- // Clone template nodes into container
1023
679
  for (let i = 0; i < templateNodes.length; i++) {
1024
680
  const cloned = templateNodes[i].cloneNode(true);
1025
681
  parseContainer.appendChild(cloned);
@@ -1028,22 +684,6 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
1028
684
  }
1029
685
  }
1030
686
 
1031
- // Preserve raw slot content of nested <component src> elements before renderAllConditionals /
1032
- // renderAllIterations runs on this branch/iteration instance — those paths strip <!-- if -->
1033
- // and <!-- each --> templates from the live DOM, so by the time processComponent reads
1034
- // el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
1035
- // would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
1036
- // _vibeSlotContent on every clone.
1037
- //
1038
- // Also resolve loop-scoped src bindings here, BEFORE parse() reads the
1039
- // attribute: the loop locals die with this render, so a src composed from
1040
- // them (`src="/x/@[item.slug].html"`) must become a literal URL now. Left as
1041
- // a binding it registers in this block's tree, and the remount transport
1042
- // (_vibeSrcBinding → data-vibe-src on the finalized wrapper) re-evaluates it
1043
- // against GLOBAL state on reparse — clobbering the mounted row with a fetch
1044
- // of a garbage URL. Exprs that read no loop alias stay raw: a reactive
1045
- // outlet (`src="@[page.src]"`) keeps its global binding, and an alias owned
1046
- // by a deeper loop resolves when that loop clones its own instances.
1047
687
  const components = parseContainer.querySelectorAll('component[src], div.component[src]');
1048
688
  const scopeKeys = overlayKeysOf(scopedState);
1049
689
  for (let i = 0; i < components.length; i++) {
@@ -1052,32 +692,22 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
1052
692
  if (scopeKeys) resolveScopedSrc(el, scopedState, scopeKeys);
1053
693
  }
1054
694
 
1055
- // Give this row its own component ids (compiled mode) before parse() reads the
1056
- // bindings, then run the row's inlined setup scripts so each registers its own
1057
- // local state under the fresh id — mirroring the conditional-branch path.
1058
695
  if (isolateComponents && isolateInlinedComponentIds(parseContainer)) {
1059
696
  executeCompiledComponentScriptsIn([...parseContainer.childNodes]);
1060
697
  }
1061
698
 
1062
699
  if (useCachedTree) {
1063
- // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
1064
700
  tree = cloneTreeWithElements(cachedTree, parseContainer);
1065
701
  } else {
1066
- // Full parse: walk DOM, extract bindings, build tree from scratch. Pass this
1067
- // loop's aliases so loop-scoped `on*` handlers (and nested ones, via the
1068
- // parser's child-alias accumulation) rewrite to `$scope(this,'alias')`.
1069
702
  tree = parse(parseContainer, undefined, aliasSet);
1070
703
  }
1071
704
 
1072
- // Extract the cloned nodes from the container (these are the same nodes the tree references)
1073
- // Avoid Array.from for performance
1074
705
  const childNodes = parseContainer.childNodes;
1075
706
  clonedNodes = [];
1076
707
  for (let i = 0; i < childNodes.length; i++) {
1077
708
  clonedNodes.push(childNodes[i]);
1078
709
  }
1079
710
 
1080
- // If no firstElement found, use parseContainer as fallback
1081
711
  if (!firstElement) {
1082
712
  firstElement = parseContainer;
1083
713
  }
@@ -1094,19 +724,13 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
1094
724
  };
1095
725
  };
1096
726
 
1097
- // Create a proxied state with scoped variables (item, index, array)
1098
727
  export const createScopedState = (globalState, localVars, parentScope = {}) => {
1099
- // Pre-compute the combined key list once at creation time.
1100
- // Avoids rebuilding 3 arrays + Set on every Object.keys() call.
1101
728
  const cachedKeys = [...new Set([
1102
729
  ...Object.keys(localVars),
1103
730
  ...Object.keys(parentScope),
1104
731
  ...Reflect.ownKeys(globalState),
1105
732
  ])];
1106
733
 
1107
- // Overlay = the aliases this proxy resolves locally (localVars wins over
1108
- // parentScope, matching the get order below). affected's descent reuses it for
1109
- // a cheap plain merge instead of materializing the proxy.
1110
734
  const overlay =
1111
735
  Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
1112
736
 
@@ -1114,10 +738,6 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
1114
738
  get(target, prop) {
1115
739
  if (prop in localVars) return localVars[prop];
1116
740
  if (prop in parentScope) return parentScope[prop];
1117
- // Global fallthrough is a root-key read; alias hits above are the
1118
- // row's own scope (owned by its iteration's diff) and never record.
1119
- // When the target is the live root the proxy trap records too — the
1120
- // window's read Set dedupes.
1121
741
  recordRead(prop);
1122
742
  return Reflect.get(target, prop);
1123
743
  },
@@ -1154,19 +774,14 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
1154
774
  return rememberScopedKeys(proxy, cachedKeys, overlay);
1155
775
  };
1156
776
 
1157
- // Render all iterations in the parsed tree
1158
777
  export const renderAllIterations = (tree, state, manifest, parentScope = {}) => {
1159
778
  let count = 0;
1160
779
 
1161
- // If this is an iteration node, render it
1162
780
  if (tree.type === 'iteration') {
1163
781
  renderIteration(tree, state, manifest, parentScope);
1164
782
  return 1;
1165
783
  }
1166
784
 
1167
- // Recursively render iterations in child nodes — except under an outgoing
1168
- // wrapper (reactive-src remount in flight): its subtree is frozen until
1169
- // the swap replaces it.
1170
785
  if (tree.children && !isOutgoing(tree.element)) {
1171
786
  for (const key in tree.children) {
1172
787
  const child = tree.children[key];
@@ -1179,44 +794,32 @@ export const renderAllIterations = (tree, state, manifest, parentScope = {}) =>
1179
794
  return count;
1180
795
  };
1181
796
 
1182
- // Callback for rendering conditionals - set by conditionals.js to avoid circular import
1183
797
  let _renderAllConditionals = () => {};
1184
798
  export const setRenderAllConditionals = (fn) => {
1185
799
  _renderAllConditionals = fn;
1186
800
  };
1187
801
 
1188
- // Initial render of an iteration block
1189
802
  export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
1190
803
  const { arrayPath, startComment, endComment } = iterationNode.meta;
1191
804
 
1192
- // Already rendered - updates go through updateIteration
1193
805
  if (iterationNode.runtime.instances?.length > 0) {
1194
806
  return;
1195
807
  }
1196
808
 
1197
- // Check if this iteration has already been rendered
1198
- // We use a marker on the startComment node itself (survives re-parsing)
1199
- // @ts-ignore - adding custom property to comment node
1200
809
  if (startComment.__vibeRendered) {
1201
810
  return;
1202
811
  }
1203
812
 
1204
- // Recovery for lost comment markers: a <component src> item re-processes its node
1205
- // out of managedNodes, dropping startComment.__vibeRendered. templateRemoved lives
1206
- // on runtime (not the DOM), so it survives — a true value means a prior render
1207
- // already cleared the template, so if content is still present between the
1208
- // comments, it's rendered; don't rebuild. Falls through only if content was lost.
1209
813
  if (iterationNode.runtime.templateRemoved) {
1210
814
  let currentNode = startComment.nextSibling;
1211
815
  while (currentNode && currentNode !== endComment) {
1212
816
  if (currentNode.nodeType === 1) {
1213
- return; // Already rendered — content still present
817
+ return;
1214
818
  }
1215
819
  currentNode = currentNode.nextSibling;
1216
820
  }
1217
821
  }
1218
822
 
1219
- // Remove template nodes from DOM on first render
1220
823
  if (!iterationNode.runtime.templateRemoved) {
1221
824
  let node = startComment.nextSibling;
1222
825
  while (node && node !== endComment) {
@@ -1229,12 +832,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
1229
832
 
1230
833
  const parent = startComment.parentNode;
1231
834
 
1232
- // Evaluate the array expression — supports state paths (items),
1233
- // window globals (window.fights), method calls (items.filter(...)),
1234
- // and inline literals (['a', 'b']). Falls back to resolvePath for
1235
- // simple paths that evalInScope might miss in scoped contexts.
1236
835
  const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
1237
- // First render doubles as the iteration's subscription registration.
1238
836
  const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
1239
837
  trackSub.lastScope = state;
1240
838
  beginTracking(trackSub, overlayKeysOf(state));
@@ -1245,35 +843,22 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
1245
843
  return;
1246
844
  }
1247
845
 
1248
- // Compiled path: Check for pre-compiled batch function in manifest
1249
846
  if (compiled.canUseCompiled(iterationNode)) {
1250
847
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
1251
848
  if (compiled.renderCompiled(iterationNode, array, state, compiledMeta, parent, endComment)) {
1252
- // Compiled rows close over every state key — "any change" is their
1253
- // honest dependency set (walk parity for treeless instances).
1254
849
  markAlways(trackSub);
1255
- // Mark as rendered
1256
850
  startComment.__vibeRendered = true;
1257
851
  startComment.__vibeIterationRuntime = iterationNode.runtime;
1258
852
  return;
1259
853
  }
1260
- // Fall through to runtime path if compiled failed
1261
854
  }
1262
855
 
1263
- // Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
1264
856
  renderInstances(iterationNode, array, state, manifest, parentScope);
1265
857
 
1266
- // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
1267
- // Also store runtime data on the DOM node so it persists across re-parses
1268
- // @ts-ignore - adding custom property to comment node
1269
858
  startComment.__vibeRendered = true;
1270
- // @ts-ignore - adding custom property to comment node
1271
859
  startComment.__vibeIterationRuntime = iterationNode.runtime;
1272
860
  };
1273
861
 
1274
- // Evaluate the iteration's optional key expression for one item.
1275
- // Returns undefined when no keyExpr is declared, falling back to the default
1276
- // heuristic in getItemKey.
1277
862
  const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
1278
863
  const { keyExpr, itemAlias, indexAlias } = iterationNode.meta;
1279
864
  if (!keyExpr) return undefined;
@@ -1286,9 +871,6 @@ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
1286
871
  }
1287
872
  };
1288
873
 
1289
- // One-shot warning when an unkeyed iteration produces index-coupled keys —
1290
- // i.e. the fallback `hash_..._<index>` path. Only emits in debug mode and only
1291
- // once per iteration block, so console doesn't drown.
1292
874
  const warnIndexCoupledKey = (iterationNode) => {
1293
875
  if (iterationNode.runtime.warnedIndexCoupled) return;
1294
876
  if (!globalThis.__vibe?.debug) return;
@@ -1299,7 +881,6 @@ const warnIndexCoupledKey = (iterationNode) => {
1299
881
  );
1300
882
  };
1301
883
 
1302
- // Update an iteration block when array changes
1303
884
  export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
1304
885
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
1305
886
 
@@ -1308,32 +889,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1308
889
  const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
1309
890
 
1310
891
  const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
1311
- // New-side eval re-records the iteration's subscription (self-healing).
1312
892
  const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
1313
893
  trackSub.lastScope = newState;
1314
894
  beginTracking(trackSub, overlayKeysOf(newState));
1315
895
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
1316
896
  endTracking();
1317
897
 
1318
- // Use instances (what's actually rendered) as ground truth for old array
1319
- // whenever the only signal of change is a registry side-effect, or when
1320
- // the rendered count doesn't match the freshly evaluated state. Three
1321
- // cases collapse to "trust the rendered snapshot":
1322
- // 1. oldState === newState — forceRegistryBackedIterationUpdates calls
1323
- // updateIteration with the same state on both sides because the only
1324
- // mutation was a registry slot rewrite. The previously rendered
1325
- // items are the only honest record of what was there before.
1326
- // 2. stateOldArray === newArray — the iteration's arrayPath resolves
1327
- // directly to a registry slot (`window.__vibe.iterProps._pN`); the
1328
- // slot was swapped in place, so both reads return the same NEW
1329
- // array.
1330
- // 3. length mismatch — oldState predates the current render.
1331
- // Otherwise the freshly evaluated state is a trustworthy "old".
1332
- //
1333
- // Literal-wrap expressions like `[s]` would otherwise slip through this
1334
- // net: they build different array refs each eval but both contain the
1335
- // just-rewritten registry value, so a naive diff sees no change. Case 1
1336
- // catches them.
1337
898
  const instances = iterationNode.runtime.instances;
1338
899
  const useInstancesAsOld =
1339
900
  oldState === newState ||
@@ -1343,7 +904,6 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1343
904
  ? instances.map((inst) => inst.item)
1344
905
  : stateOldArray;
1345
906
 
1346
- // Compiled path: Use pre-compiled batch function when available
1347
907
  if (compiled.canUseCompiled(iterationNode)) {
1348
908
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
1349
909
  if (
@@ -1356,49 +916,25 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1356
916
  endComment,
1357
917
  )
1358
918
  ) {
1359
- // Still treeless — keep the honest any-change subscription.
1360
919
  markAlways(trackSub);
1361
920
  return;
1362
921
  }
1363
- // Fall through to runtime path if compiled failed
1364
922
  }
1365
923
 
1366
- // Bulk path: skip O(n²) LCS when arrays share no common items
1367
- // Handles empty→full, full→empty, and full replacement (no shared keys)
1368
924
  if (oldArray.length === 0 || newArray.length === 0) {
1369
925
  bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1370
926
  return;
1371
927
  }
1372
928
 
1373
- // Treeless rows (batch-rendered) can't be patched in place — they have no
1374
- // parsed tree for updateInstance to walk. When affected pushes them as
1375
- // iteration-affected (see the same predicate in affected.js), do a full
1376
- // re-batch so row bindings reflect current state. Clone-rendered iterations
1377
- // skip this — they take the diff path below.
1378
929
  const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
1379
930
  if (hasTreelessInstances && oldState !== newState) {
1380
- // Treeless rows stay on the any-change subscription. If bulkReplace
1381
- // below clone-renders trees instead, the next new-side eval re-records
1382
- // real deps (self-healing).
1383
931
  markAlways(trackSub);
1384
- // affected.js conservatively flags a treeless (batch-rendered) iteration on
1385
- // ANY state change, since it can't walk per-row trees to see which bindings
1386
- // actually depend on what changed. Before tearing down and recreating every
1387
- // row, re-run the batch: if it yields identical HTML, the rows don't depend
1388
- // on what changed, so keep the existing DOM nodes — preserving their event
1389
- // listeners (e.g. tooltip mouseleave) and any in-progress click on a row
1390
- // control. DOM-property writes (value/checked/etc.) aren't reflected in the
1391
- // HTML string, so they're re-applied against the kept rows — property
1392
- // assignment also wins over a user-dirtied checkbox, which an attribute
1393
- // rewrite wouldn't.
1394
932
  const rt = iterationNode.runtime;
1395
933
  if (rt.batchFn && rt.lastBatchHtml !== undefined) {
1396
934
  const stateValues = rt.stateKeys.map((k) => newState[k]);
1397
935
  const newHtml = rt.batchFn(newArray, ...stateValues, newState);
1398
936
  if (newHtml === rt.lastBatchHtml) {
1399
937
  if (rt.domPropertyWrites?.length) {
1400
- // Identical HTML implies identical row count — refresh item refs so
1401
- // $scope handlers and property writes read the live array.
1402
938
  for (let i = 0; i < instances.length; i++) instances[i].item = newArray[i];
1403
939
  applyDomPropertyWrites(
1404
940
  instances,
@@ -1423,25 +959,18 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1423
959
  getItemKey(item, i, evalKeyForItem(iterationNode, item, i, newState, parentScope)),
1424
960
  );
1425
961
 
1426
- // Detect index-coupled fallback keys (debug-mode warning only).
1427
962
  if (!iterationNode.meta.keyExpr && newKeys.some(k => k.startsWith('hash_') || k.startsWith('val_'))) {
1428
963
  warnIndexCoupledKey(iterationNode);
1429
964
  }
1430
965
 
1431
- // O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
1432
966
  const oldKeySet = new Set(oldKeys);
1433
967
  if (!newKeys.some(k => oldKeySet.has(k))) {
1434
968
  bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1435
969
  return;
1436
970
  }
1437
971
 
1438
- // Standard diff-based updates (arrays share some common items)
1439
972
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
1440
973
 
1441
- // Apply array-shape operations and capture which instances were
1442
- // bindings-refreshed by an op (ADD built a fresh tree with newState;
1443
- // UPDATE patched bindings in place). Captured by reference, not index,
1444
- // because indices shift during the loop.
1445
974
  const refreshedByOps = new WeakSet();
1446
975
  operations.forEach((op) => {
1447
976
  switch (op.type) {
@@ -1462,10 +991,6 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1462
991
  }
1463
992
  });
1464
993
 
1465
- // Refresh every row the ops didn't touch: moved rows (index changed,
1466
- // bindings stale) and untouched rows whose outer-scope bindings depend
1467
- // on state that changed in this update cycle. Each row goes through
1468
- // updateInstance exactly once — via an op or here.
1469
994
  const retained = iterationNode.runtime.instances;
1470
995
  for (let i = 0; i < retained.length; i++) {
1471
996
  retained[i].index = i;
@@ -1473,27 +998,13 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1473
998
  updateInstance(iterationNode, i, retained[i].item, oldState, newState, manifest, parentScope);
1474
999
  }
1475
1000
 
1476
- // Re-stamp after the diff settles: moved/updated instances now carry their
1477
- // current item + index, so `$scope` handlers resolve correctly post-reorder.
1478
1001
  stampScopes(iterationNode, manifest, parentScope);
1479
1002
  };
1480
1003
 
1481
- // Prune removed iteration rows from the global manifest (dotPath -> element)
1482
- // and parsed tree. Both views still reference rows that mounted globally-tracked
1483
- // content (e.g. a <component src> registers its subtree in the manifest). Vibe
1484
- // disconnects the page MutationObserver while it reconciles, so the removals
1485
- // below are never observed — left unpruned, the entries pin detached subtrees
1486
- // (memory leak) and bloat every later affected/hydrate walk (the
1487
- // combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
1488
1004
  const releaseRemovedSubtrees = (removedRoots, manifest) => {
1489
1005
  if (!manifest || removedRoots.length === 0) return;
1490
1006
  const tree = manifest.__tree;
1491
1007
 
1492
- // Reverse the manifest (dotPath -> element) once so each removed root resolves
1493
- // to its path. An element can be registered at more than one path (a component
1494
- // wrapper appears at both its own node and an inlined child slot); keep the
1495
- // SHORTEST so the path scopes the whole row subtree, not an inner slot.
1496
- // __live / __tree are non-enumerable, so for-in skips them.
1497
1008
  const pathOf = new Map();
1498
1009
  for (const key in manifest) {
1499
1010
  const el = manifest[key];
@@ -1502,8 +1013,8 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1502
1013
  if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
1503
1014
  }
1504
1015
 
1505
- const removedEls = new Set(); // top-level removed roots — tree prune cascades subtrees
1506
- const removedPaths = new Set(); // their manifest paths — manifest prune is path-scoped
1016
+ const removedEls = new Set();
1017
+ const removedPaths = new Set();
1507
1018
  for (let i = 0; i < removedRoots.length; i++) {
1508
1019
  const root = removedRoots[i];
1509
1020
  if (!root) continue;
@@ -1513,30 +1024,15 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1513
1024
  }
1514
1025
  if (removedPaths.size === 0 && removedEls.size === 0) return;
1515
1026
 
1516
- // Manifest: drop every entry at or under a removed root's path. Path scope (not
1517
- // element identity) is what catches content hoisted out of the DOM — an inactive
1518
- // conditional branch template sits in a detached container yet stays registered
1519
- // under its row's path.
1520
1027
  if (removedPaths.size > 0) {
1521
1028
  for (const key in manifest) {
1522
1029
  if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
1523
1030
  }
1524
1031
  }
1525
1032
 
1526
- // Parsed tree: delete each removed root's node — its whole subtree (nested
1527
- // conditionals/iterations and their branch templates) goes with it. Prune
1528
- // by CONTAINMENT, not just root identity: a row's inlined component trees
1529
- // are also linked under OTHER ancestors' children (the dual linkage the
1530
- // affected-walk dedupe documents), keyed by inner wrapper elements that are
1531
- // never themselves removed roots. Left in place, every later walk descends
1532
- // those detached trees — re-hydrating dead DOM and (since subscriptions)
1533
- // re-registering its subscribers after teardown pruned them, pinning the
1534
- // whole removed subtree forever.
1535
1033
  const roots = [...removedEls];
1536
1034
  if (tree) pruneTreeNodes(tree, removedEls, roots);
1537
1035
 
1538
- // The removed rows' subscribers (bindings, nested conditionals/iterations)
1539
- // are anchored to nodes that just left the document.
1540
1036
  pruneDisconnected();
1541
1037
  };
1542
1038
 
@@ -1547,7 +1043,6 @@ const underRemovedRoot = (el, roots) => {
1547
1043
  return false;
1548
1044
  };
1549
1045
 
1550
- // True when `key` is, or is a descendant of, any path in `removedPaths`.
1551
1046
  const pathUnderRemoved = (key, removedPaths) => {
1552
1047
  if (removedPaths.has(key)) return true;
1553
1048
  for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
@@ -1568,17 +1063,10 @@ const pruneTreeNodes = (node, removedEls, roots) => {
1568
1063
  }
1569
1064
  };
1570
1065
 
1571
- // Bulk replacement: clear all DOM and re-render from scratch
1572
- // Used when arrays share no common keys (avoids O(n²) LCS)
1573
1066
  const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1574
1067
  const { template, startComment, endComment } = iterationNode.meta;
1575
1068
  const parent = startComment.parentNode;
1576
1069
 
1577
- // Clear all existing DOM between comments using Range (single operation),
1578
- // then prune the removed subtrees from the manifest + tree. Collect the LIVE
1579
- // nodes in the range rather than the instances' clonedNodes: a <component src>
1580
- // row is replaced in place by component.js, so clonedNodes can point at the
1581
- // stale original wrapper, not the processed content actually being removed.
1582
1070
  if (iterationNode.runtime.instances.length > 0) {
1583
1071
  const removedRoots = [];
1584
1072
  for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
@@ -1596,8 +1084,6 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1596
1084
  return;
1597
1085
  }
1598
1086
 
1599
- // For simple templates (no nested iterations/conditionals, single root element),
1600
- // use batch string rendering: one string concatenation loop + one innerHTML parse
1601
1087
  if (canUseBatchRender(template)) {
1602
1088
  renderBatch(iterationNode, newArray, state, parent, endComment, parentScope, manifest);
1603
1089
  const instances = iterationNode.runtime.instances;
@@ -1607,20 +1093,9 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1607
1093
  return;
1608
1094
  }
1609
1095
 
1610
- // Complex templates: one shared clone+hydrate render loop.
1611
1096
  renderInstances(iterationNode, newArray, state, manifest, parentScope);
1612
1097
  };
1613
1098
 
1614
- // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
1615
- // that still lives directly under the iteration's parent). Nested primitives
1616
- // inside the iteration template — <!-- if -->, <!-- each -->, <component> —
1617
- // can move/replace cloned nodes between iteration renders (inactive branches
1618
- // get hoisted into template containers; component[src] wrappers get swapped
1619
- // for processed wrappers). Any of those mutations make `clonedNodes[0]` a
1620
- // stale reference to a node no longer under the iteration parent. Callers use
1621
- // this anchor instead of trusting `clonedNodes[0]` directly, so that
1622
- // insert-before / move operations always resolve against the iteration's real
1623
- // DOM slot.
1624
1099
  const findInstanceAnchor = (instance, iterationParent) => {
1625
1100
  const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
1626
1101
  for (let i = 0; i < nodes.length; i++) {
@@ -1629,10 +1104,6 @@ const findInstanceAnchor = (instance, iterationParent) => {
1629
1104
  return null;
1630
1105
  };
1631
1106
 
1632
- // Resolve the reference node for `parent.insertBefore(..., ref)` at a given
1633
- // logical iteration index. Walks later instances until it finds one with a
1634
- // live anchor under the iteration parent; falls back to `endComment` when no
1635
- // later instance has any node currently mounted in the iteration.
1636
1107
  const resolveInsertBefore = (iterationNode, index, parent) => {
1637
1108
  const { instances } = iterationNode.runtime;
1638
1109
  for (let i = index; i < instances.length; i++) {
@@ -1642,28 +1113,15 @@ const resolveInsertBefore = (iterationNode, index, parent) => {
1642
1113
  return iterationNode.meta.endComment;
1643
1114
  };
1644
1115
 
1645
- // Detach every DOM node belonging to a logical instance, including content
1646
- // mounted by nested primitives (conditional branches, nested each rows,
1647
- // fetched component wrappers) that isn't tracked in `instance.clonedNodes`.
1648
- // Walks iteration-parent siblings from this instance's anchor up to the next
1649
- // instance's anchor / endComment, so anything in between — clones, mounted
1650
- // branches, swapped-in component wrappers — all gets detached. Also sweeps
1651
- // any clonedNodes that were hoisted out of the iteration parent (e.g. into a
1652
- // sibling conditional's template container).
1653
1116
  const detachInstanceDom = (iterationNode, index, parent) => {
1654
1117
  const instance = iterationNode.runtime.instances[index];
1655
1118
  const { endComment } = iterationNode.meta;
1656
1119
  const anchor = findInstanceAnchor(instance, parent);
1657
1120
  const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
1658
1121
 
1659
- // Return the live nodes actually removed so callers can prune them from the
1660
- // manifest + tree (clonedNodes can be stale once a <component src> row is
1661
- // replaced in place by component.js).
1662
1122
  const removed = [];
1663
1123
  if (anchor) {
1664
1124
  let cur = anchor;
1665
- // endComment caps the walk even if nextAnchor ordering is ever
1666
- // corrupted — iteration DOM is bounded by startComment / endComment.
1667
1125
  while (cur && cur !== nextAnchor && cur !== endComment) {
1668
1126
  const nextSibling = cur.nextSibling;
1669
1127
  parent.removeChild(cur);
@@ -1683,14 +1141,6 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1683
1141
  return removed;
1684
1142
  };
1685
1143
 
1686
- // Build a fresh instance's DOM + tree + scope from the iteration template.
1687
- // Pure function — no DOM insertion, no side effects on iteration state.
1688
- // Callers decide where the clones go (iteration parent, DocumentFragment).
1689
- // `liveItem` (resolved by the caller via resolveLiveArray, so a derived-array
1690
- // loop evaluates the expression once per render, not once per row) is the live
1691
- // `$`-proxy element for this index. Building scope from it gives nested
1692
- // conditional stamps and `$scope` handlers the app-visible identity; `item`
1693
- // (plain snapshot) is still tracked for the diff.
1694
1144
  const buildInstance = (iterationNode, item, index, state, parentScope, liveItem) => {
1695
1145
  const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
1696
1146
  const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
@@ -1702,11 +1152,6 @@ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem)
1702
1152
  return { ...built, scopedState, localVars, liveItem };
1703
1153
  };
1704
1154
 
1705
- // After a built instance's clones are placed in the DOM (directly or via a
1706
- // fragment), mark element clones as managed so the page-level MutationObserver
1707
- // skips them in processMutations, then fire nested iteration/conditional
1708
- // renders. Without the managed mark those clones would be re-parsed + hydrated
1709
- // on top of the internal render, duplicating every nested branch.
1710
1155
  const finalizeInstance = (built, manifest, parentScope) => {
1711
1156
  const { clonedNodes, tree, scopedState, localVars } = built;
1712
1157
  for (let i = 0; i < clonedNodes.length; i++) {
@@ -1719,10 +1164,6 @@ const finalizeInstance = (built, manifest, parentScope) => {
1719
1164
  }
1720
1165
  };
1721
1166
 
1722
- // Build, finalize, and commit a fresh set of instances for `array` in one
1723
- // batched DOM insertion, replacing iterationNode.runtime.instances. The single
1724
- // clone+hydrate render loop shared by initial render (renderIteration) and full
1725
- // rebuild (bulkReplace) — they differ only in their preamble, not this loop.
1726
1167
  const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
1727
1168
  const { startComment, endComment } = iterationNode.meta;
1728
1169
  const parent = startComment.parentNode;
@@ -1740,7 +1181,6 @@ const renderInstances = (iterationNode, array, state, manifest, parentScope) =>
1740
1181
  }
1741
1182
  parent.insertBefore(frag, endComment);
1742
1183
  iterationNode.runtime.instances = instances;
1743
- // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1744
1184
  stampScopes(iterationNode, manifest, parentScope);
1745
1185
  };
1746
1186
 
@@ -1777,30 +1217,18 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
1777
1217
 
1778
1218
  const insertBefore = resolveInsertBefore(iterationNode, toIndex + 1, parent);
1779
1219
  const nodes = instance.clonedNodes || [instance.element];
1780
- // Re-insert only nodes currently under the iteration parent — those hoisted
1781
- // into nested-conditional template containers stay there so we don't
1782
- // double-count branch content.
1783
1220
  for (let i = 0; i < nodes.length; i++) {
1784
1221
  const n = nodes[i];
1785
1222
  if (n?.parentNode === parent) parent.insertBefore(n, insertBefore);
1786
1223
  }
1787
1224
  };
1788
1225
 
1789
- // Detach-and-rebuild fallback. Used when the row's parsed tree isn't
1790
- // available (compiled iterations), or when the row template's top-level
1791
- // shape itself depends on the item (a `<!-- if -->` directly under the
1792
- // iteration with the item in its expression — flipping branches needs a
1793
- // rebuild because the active root element type changes).
1794
1226
  const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, parentScope) => {
1795
1227
  const parent = iterationNode.meta.startComment.parentNode;
1796
1228
  const instance = iterationNode.runtime.instances[index];
1797
1229
 
1798
1230
  const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1799
1231
  const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
1800
- // Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
1801
- // so the replaced subtree is released (same removal contract as removeInstance).
1802
- // Order matters: prune the detached old nodes before finalizeInstance registers
1803
- // the new ones, so the new entries are never touched.
1804
1232
  const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
1805
1233
  releaseRemovedSubtrees(removed, manifest);
1806
1234
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
@@ -1815,17 +1243,6 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1815
1243
  instance.scopedState = built.scopedState;
1816
1244
  };
1817
1245
 
1818
- // In-place refresh of a single row: rebuild scoped states from oldState +
1819
- // newState (and possibly a new item), run affected→hydrate against the row's
1820
- // parsed tree. Same pipeline as top-level state changes; the row's DOM nodes
1821
- // survive, only changed bindings update.
1822
- //
1823
- // Called from two sites in updateIteration:
1824
- // 1. UPDATE diff op — newItem differs from instance.item
1825
- // 2. Post-diff outer-state propagation — newItem === instance.item
1826
- // Both flow through here so iteration updates have one code path, not two.
1827
- // Compiled / batch instances have no row tree to walk and fall back to a
1828
- // full rebuild via updateInstanceRebuild.
1829
1246
  const updateInstance = (iterationNode, index, newItem, oldState, newState, manifest, parentScope = {}) => {
1830
1247
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1831
1248
  const instance = iterationNode.runtime.instances[index];
@@ -1837,11 +1254,6 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
1837
1254
 
1838
1255
  const { itemAlias, indexAlias } = iterationNode.meta;
1839
1256
  const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
1840
- // The "old" scoped state is exactly last cycle's "new" one — same global
1841
- // snapshot (this cycle's oldState) and same item/index — already stored on the
1842
- // instance. Reuse it instead of allocating a second proxy per row per frame
1843
- // (createScopedState is a combat hot spot). Falls back on the first update
1844
- // after an add, before scopedState has been recorded.
1845
1257
  const oldScopedState =
1846
1258
  instance.scopedState ||
1847
1259
  createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);