@ape-egg/vibe 1.9.1 → 1.9.6

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.
@@ -2,9 +2,16 @@ import parse from './parse.js';
2
2
  import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
- import { resolveThisPath, evalInScope } from './utils.js';
5
+ import { resolveThisPath, evalInScope, findComponentIdForElement } from './utils.js';
6
6
  import { managedNodes } from './conditionals.js';
7
- import { BINDING_REGEX } from './constants.js';
7
+ import { stampInstanceScopes } from './loop-scope.js';
8
+ import {
9
+ BINDING_REGEX,
10
+ PURE_BINDING_REGEX,
11
+ VALUE_ATTRS,
12
+ DOM_PROPERTIES,
13
+ THIS_PROP_REGEX,
14
+ } from './constants.js';
8
15
 
9
16
  // Pre-compiled iteration optimization (production)
10
17
  import * as compiled from './pre-compiled-iterations.js';
@@ -13,6 +20,12 @@ import * as compiled from './pre-compiled-iterations.js';
13
20
  // Build an HTML string via template-literal compilation, then parse once —
14
21
  // avoids per-item clone/parse/hydrate in the hot path.
15
22
  // Only used for templates without nested <!-- each --> / <!-- if -->.
23
+ //
24
+ // Invariant: batch produces identical DOM to the clone+hydrate path for any
25
+ // input. New binding forms must be supported in both paths simultaneously,
26
+ // with equivalence tests under tests/e2e/batch-vs-clone-equivalence.spec.js
27
+ // — the test harness flips a force-clone flag and compares region-by-region,
28
+ // so silent divergence between the two paths fails loudly.
16
29
  const batchParseTemplate = document.createElement('template');
17
30
 
18
31
  // innerHTML serialization encodes <, >, &, ", ' inside attribute values.
@@ -43,20 +56,198 @@ const hasNestedStructures = (tree) => {
43
56
  const hasComponentSrc = (templateEl) =>
44
57
  !!templateEl.querySelector?.('component[src], div.component[src]');
45
58
 
59
+ // `__vibeForceClonePath` is a debug/test escape hatch — set it on globalThis to
60
+ // route every iteration through the clone+hydrate path, even templates that
61
+ // would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
62
+ // test harness so the same templates can be rendered through both paths and
63
+ // compared. Not part of the public API.
46
64
  const canUseBatchRender = (template) =>
65
+ !globalThis.__vibeForceClonePath &&
47
66
  !hasNestedStructures(template) &&
48
67
  template.element.children.length <= 1 &&
49
68
  !hasComponentSrc(template.element);
50
69
 
51
- const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
52
- const templateHtml = template.element.innerHTML.trim();
53
- const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
54
- const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
70
+ // Patterns used by compileBatchFn to recognize bindings in attribute-name and
71
+ // attribute-value positions. The inner alternation mirrors BINDING_INNER from
72
+ // constants.js it has to allow nested brackets and quoted strings inside the
73
+ // expression so things like `items[0]` or `x.replace(',', '')` round-trip
74
+ // safely. Kept inline rather than re-exported because they live entirely
75
+ // within this compile-time string transformation.
76
+ const BATCH_BINDING_INNER = String.raw`(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+`;
77
+ // Note: lookahead allows `=` because innerHTML serializes a bare attribute
78
+ // name (no value) as `name=""`, so a name-binding written `<icon @[attrName]>`
79
+ // arrives here as `@[attrname]=""`. The leading `\s` keeps this from matching
80
+ // attribute-value bindings like `attr="@[x]"` (preceded by `"`, not whitespace).
81
+ const BATCH_NAME_BINDING_REGEX = new RegExp(
82
+ String.raw`(\s)@\[(${BATCH_BINDING_INNER})\](?:="")?(?=[\s/>])`,
83
+ 'g',
84
+ );
85
+ const BATCH_ATTR_BINDING_REGEX = new RegExp(
86
+ String.raw`(\s)([\w-]+)="@\[(${BATCH_BINDING_INNER})\]"`,
87
+ 'g',
88
+ );
89
+
90
+ // Whether a hydrate'd attribute is a "value-style" string attr (kept verbatim)
91
+ // rather than a boolean-coerced attr (added/removed by truthiness). Mirrors
92
+ // the predicate hydrate.js uses, so batch and clone classify identically.
93
+ const isValueStyleAttr = (attrName) =>
94
+ VALUE_ATTRS.includes(attrName) ||
95
+ attrName.startsWith('data-') ||
96
+ attrName.startsWith('aria-') ||
97
+ attrName.startsWith('on');
98
+
99
+ // Compile the iteration template into a single function that emits the full
100
+ // HTML for any input array. The function produced here must match the DOM
101
+ // shape of the clone+hydrate path for any binding form — gaps documented in
102
+ // the prior iteration of `do-this-job.md` were closed here:
103
+ //
104
+ // 1. `this.X` rewriting: pre-rewritten at compile time using the iteration's
105
+ // anchor element to resolve the owning componentId. No per-item cost.
106
+ // 2. Boolean-coerced attributes: emitted as conditional template literal
107
+ // expressions so the attribute is absent when the binding evaluates falsy
108
+ // and present (with empty string value, matching hydrate.js) when truthy.
109
+ // 3. DOM properties (value/checked/selected): emitted as attributes for
110
+ // runtime DOM accuracy AND collected into `domPropertyWrites` so
111
+ // renderBatch can run a small post-stamp loop that sets the actual DOM
112
+ // property on each instance — the attribute alone isn't enough.
113
+ // 4. Name bindings (`<el @[expr]>`): emitted as conditional template literal
114
+ // expressions that produce ` resolvedName=""` when truthy and nothing
115
+ // when falsy.
116
+ //
117
+ // Anything the clone path renders correctly, the function returned here has
118
+ // to render identically. New binding forms must update both paths in lockstep
119
+ // or the equivalence harness in tests/e2e/batch-vs-clone-equivalence.spec.js
120
+ // will catch the divergence.
121
+ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) => {
122
+ // Walk a clone of the template DOM so we can stash a transient
123
+ // `data-vibe-batch` marker on every element with DOM-property bindings
124
+ // without polluting the cached template that subsequent renders read.
125
+ const tplClone = template.element.cloneNode(true);
126
+ const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
127
+ const domPropertyWrites = [];
128
+
129
+ const allEls = tplClone.querySelectorAll('*');
130
+ for (let n = 0; n < allEls.length; n++) {
131
+ const el = allEls[n];
132
+ const indexes = [];
133
+ const attrs = [...el.attributes];
134
+ for (let a = 0; a < attrs.length; a++) {
135
+ const attr = attrs[a];
136
+ if (!DOM_PROPERTIES.includes(attr.name)) continue;
137
+ const m = attr.value.match(PURE_BINDING_REGEX);
138
+ if (!m) continue;
139
+ let expr = m[1];
140
+ if (componentId) expr = expr.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`);
141
+ indexes.push(domPropertyWrites.length);
142
+ domPropertyWrites.push({ prop: attr.name, expr });
143
+ }
144
+ if (indexes.length > 0) {
145
+ el.setAttribute('data-vibe-batch', indexes.join(','));
146
+ }
147
+ }
148
+
149
+ let templateHtml = tplClone.innerHTML.trim();
150
+
151
+ // Rewrite `this.X` only inside @[…] expressions so literal occurrences in
152
+ // text content (e.g. a code example explaining `this.foo`) aren't mangled.
153
+ // Mirrors evalInScope's behavior in the clone path.
154
+ if (componentId) {
155
+ BINDING_REGEX.lastIndex = 0;
156
+ templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
157
+ '@[' + expr.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`) + ']',
158
+ );
159
+ }
160
+
161
+ const escaped = templateHtml
162
+ .replace(/\\/g, '\\\\')
163
+ .replace(/`/g, '\\`')
164
+ .replace(/\$\{/g, '\\${');
165
+
166
+ let code = escaped;
167
+
168
+ // Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
169
+ // emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
170
+ // bindings from attribute-value-position bindings (which are followed by `=`).
171
+ //
172
+ // HTML parses attribute names lowercase, so a binding like
173
+ // `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
174
+ // `<icon @[obj.iconName]>` arrives as `@[obj.iconname]`. We recover the
175
+ // proper case in two places:
176
+ // - Single identifier → resolve against `stateKeys` at compile time and
177
+ // emit a direct identifier reference.
178
+ // - Dotted path → split off the first segment (resolved at compile time
179
+ // against `stateKeys` + iteration aliases), then emit a runtime call to
180
+ // `_walkCi` (injected into the function body below) for the remaining
181
+ // segments. Mirrors `resolveCaseInsensitivePath` from utils.js.
182
+ // Bracket / call expressions pass through unchanged — they need a real
183
+ // evaluator and aren't worth special-casing here.
184
+ let needsCiWalker = false;
185
+ code = code.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
186
+ let decExpr = decodeEntities(expr);
187
+ if (decExpr.includes('[') || decExpr.includes('(')) {
188
+ return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
189
+ }
190
+ const dot = decExpr.indexOf('.');
191
+ if (dot === -1) {
192
+ const ci = stateKeys.find((k) => k.toLowerCase() === decExpr.toLowerCase());
193
+ if (ci) decExpr = ci;
194
+ return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
195
+ }
196
+ const head = decExpr.slice(0, dot);
197
+ const tail = decExpr.slice(dot + 1).split('.');
198
+ const knownNames = [itemAlias, indexAlias, ...stateKeys];
199
+ const ciHead = knownNames.find((k) => k.toLowerCase() === head.toLowerCase()) || head;
200
+ const tailJSON = JSON.stringify(tail);
201
+ needsCiWalker = true;
202
+ return (
203
+ '${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
204
+ ');return _v?\' \'+_v+\'=""\':\'\';})()}'
205
+ );
206
+ });
55
207
 
56
- return new Function(
208
+ // Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
209
+ // DOM property → emit attribute (post-stamp also writes the property)
210
+ // value-style → emit attribute as-is
211
+ // boolean-coerced → emit conditional ` attr=""` so the attribute is
212
+ // absent when expr is falsy
213
+ code = code.replace(BATCH_ATTR_BINDING_REGEX, (_, _ws, attrName, expr) => {
214
+ const decExpr = decodeEntities(expr);
215
+ if (DOM_PROPERTIES.includes(attrName) || isValueStyleAttr(attrName)) {
216
+ return ' ' + attrName + '="${' + decExpr + '}"';
217
+ }
218
+ return '${(' + decExpr + ') ? \' ' + attrName + '=""\' : \'\'}';
219
+ });
220
+
221
+ // Remaining @[…] markers — text content and partial-binding attribute
222
+ // values like `href="/items/@[id]/edit"`.
223
+ code = code.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
224
+
225
+ // `$` is supplied as the last parameter so component-scoped expressions like
226
+ // `$['_c0'].chosen` (produced by the this.X rewrite above) can resolve
227
+ // against the live state. Mirrors evalInScope, which also exposes `$`.
228
+ // `_walkCi` is only emitted when at least one dotted-path name binding was
229
+ // rewritten above — keeps the function body free of dead code for the
230
+ // common no-name-binding case.
231
+ const ciWalkerSrc = needsCiWalker
232
+ ? `const _walkCi = (cur, segs) => {
233
+ for (let i = 0; i < segs.length; i++) {
234
+ if (cur == null) return undefined;
235
+ const s = segs[i];
236
+ if (Reflect.has(Object(cur), s)) { cur = cur[s]; continue; }
237
+ if (typeof cur !== 'object') return undefined;
238
+ const k = Object.keys(cur).find((k) => k.toLowerCase() === s.toLowerCase());
239
+ if (!k) return undefined;
240
+ cur = cur[k];
241
+ }
242
+ return cur;
243
+ };`
244
+ : '';
245
+ const fn = new Function(
57
246
  'arr',
58
247
  ...stateKeys,
248
+ '$',
59
249
  `
250
+ ${ciWalkerSrc}
60
251
  let html = '';
61
252
  const len = arr.length;
62
253
  for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
@@ -66,6 +257,8 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
66
257
  return html;
67
258
  `,
68
259
  );
260
+
261
+ return { fn, domPropertyWrites };
69
262
  };
70
263
 
71
264
  // Registry for non-primitive iteration prop snapshots. Lives on `window` (not
@@ -98,12 +291,13 @@ export const releaseOrphanedIterationProps = (nodes) => {
98
291
  };
99
292
 
100
293
  // For <component src> elements inside an iteration instance, evaluate any
101
- // `@[expr]` attribute bindings against the iteration's scoped state and replace
102
- // them with the resolved value. Primitives stringify into the attribute as
103
- // before. Non-primitives (objects, arrays) snapshot into the registry and the
104
- // attribute becomes a binding into that slot preserving live object/array
105
- // access for the child component's template (`@[prop.x]`,
106
- // `<!-- each prop as item -->`, etc.).
294
+ // `@[expr]` attribute bindings against the iteration's scoped state and route
295
+ // every resolved value through the global iteration-prop registry. The prop
296
+ // attribute becomes `@[window.__vibeIterProps._pN]` a live binding into the
297
+ // registry slot for both primitives and objects. The original expression is
298
+ // stashed on the element so the iteration's update path can re-evaluate it
299
+ // against the new scope and refresh the slot, propagating the change into the
300
+ // inlined component's bindings without rebuilding the row's DOM.
107
301
  //
108
302
  // Component[src] attributes intentionally bypass hydrate (parse.js) so they
109
303
  // reach processComponent as bindings — but bindings that depend on
@@ -111,7 +305,7 @@ export const releaseOrphanedIterationProps = (nodes) => {
111
305
  // inlines the component, since by then iteration scope is gone. Only called
112
306
  // from iteration code paths; conditionals don't need this because their branch
113
307
  // content is registered in the global manifest and reacts to state updates.
114
- const resolveIterationComponentProps = (nodes, scopedState) => {
308
+ export const resolveIterationComponentProps = (nodes, scopedState) => {
115
309
  for (let n = 0; n < nodes.length; n++) {
116
310
  const node = nodes[n];
117
311
  if (node.nodeType !== 1) continue;
@@ -126,19 +320,17 @@ const resolveIterationComponentProps = (nodes, scopedState) => {
126
320
  if (attr.name === 'src') continue;
127
321
  const match = attr.value.match(/^@\[(.+)\]$/);
128
322
  if (!match) continue;
323
+ const expr = match[1];
129
324
  try {
130
- const value = evalInScope(match[1], scopedState, el);
325
+ const value = evalInScope(expr, scopedState, el);
131
326
  if (value === undefined) continue;
132
- if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
133
- el.setAttribute(attr.name, String(value));
134
- } else {
135
- const registry = ensureIterPropsRegistry();
136
- const id = `_p${__vibeIterPropCounter++}`;
137
- registry[id] = value;
138
- el.setAttribute(attr.name, `@[window.__vibeIterProps.${id}]`);
139
- el.setAttribute('data-vibe-iter-prop', '');
140
- (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
141
- }
327
+ const registry = ensureIterPropsRegistry();
328
+ const id = `_p${__vibeIterPropCounter++}`;
329
+ registry[id] = value;
330
+ el.setAttribute(attr.name, `@[window.__vibeIterProps.${id}]`);
331
+ el.setAttribute('data-vibe-iter-prop', '');
332
+ (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
333
+ (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
142
334
  } catch {
143
335
  // Leave binding raw — processComponent will handle it as a binding
144
336
  }
@@ -147,18 +339,201 @@ const resolveIterationComponentProps = (nodes, scopedState) => {
147
339
  }
148
340
  };
149
341
 
150
- const renderBatch = (iterationNode, array, state, parent, endComment) => {
342
+ // Resolve a clonedNodes entry to its live counterpart. processComponent's
343
+ // `el.replaceWith(newWrapper)` detaches the original `<component src>` and
344
+ // installs a `<component>` post-process wrapper in its place — but the
345
+ // iteration's `instance.clonedNodes` still points at the original. The
346
+ // `_vibeReplacedBy` chain (set in component.js right before the swap) gives
347
+ // us a single pointer to the live wrapper.
348
+ const liveCloneNode = (node) => {
349
+ let cur = node;
350
+ while (cur && cur._vibeReplacedBy) cur = cur._vibeReplacedBy;
351
+ return cur;
352
+ };
353
+
354
+ // Walk a row's clones for any element tagged as iteration-prop owner —
355
+ // pre-process `<component src>` (still has the src attribute) and post-process
356
+ // `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
357
+ // expression, re-evaluate against the row's new scoped state and write into
358
+ // the registry slot the inlined bindings already reference. Idempotent: same
359
+ // scoped state → same value → no-op write.
360
+ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
361
+ for (let n = 0; n < clonedNodes.length; n++) {
362
+ const node = liveCloneNode(clonedNodes[n]);
363
+ if (!node || node.nodeType !== 1) continue;
364
+ const wrappers = [];
365
+ if (node._vibeIterPropExprs) wrappers.push(node);
366
+ const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
367
+ if (found) for (let i = 0; i < found.length; i++) wrappers.push(found[i]);
368
+ for (let w = 0; w < wrappers.length; w++) {
369
+ const el = wrappers[w];
370
+ const exprs = el._vibeIterPropExprs;
371
+ if (!exprs) continue;
372
+ const registry = ensureIterPropsRegistry();
373
+ for (let e = 0; e < exprs.length; e++) {
374
+ try {
375
+ const value = evalInScope(exprs[e].expr, scopedState, el);
376
+ registry[exprs[e].id] = value;
377
+ } catch {
378
+ // Leave previous registry value in place — same fail-safe as
379
+ // resolveIterationComponentProps's mount-time path.
380
+ }
381
+ }
382
+ }
383
+ }
384
+ };
385
+
386
+ // Walk an inlined component's parsed tree and force `updateIteration` on any
387
+ // iteration node whose arrayPath resolves through the iteration-prop registry
388
+ // (`window.__vibeIterProps._pN`). The registry slot was just refreshed in
389
+ // place by `refreshIterationComponentProps`, so `affected()` can't notice
390
+ // the change — both old/new evaluations of the path read the same updated
391
+ // value. `updateIteration` is the only place equipped to diff against
392
+ // `iterationNode.runtime.instances` (which still hold the previously rendered
393
+ // items) and emit ADD / REMOVE / UPDATE / MOVE ops to bring the inlined DOM
394
+ // in sync. Without this, an `<inner-component>` whose template iterates over
395
+ // an array prop stays frozen on its initial-render items when the prop's
396
+ // contents change.
397
+ const REGISTRY_PATH_REGEX = /__vibeIterProps\._p\d+/;
398
+ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope) => {
399
+ if (!tree) return;
400
+ if (tree.type === 'iteration') {
401
+ const arrPath = tree.meta?.arrayPath;
402
+ if (arrPath && REGISTRY_PATH_REGEX.test(arrPath)) {
403
+ updateIteration(tree, state, state, manifest, parentScope);
404
+ }
405
+ }
406
+ if (tree.children) {
407
+ for (const k in tree.children) {
408
+ forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope);
409
+ }
410
+ }
411
+ };
412
+
413
+ // After registry slots are refreshed, re-hydrate the bindings inside each
414
+ // inlined component wrapper so the new values reach the DOM. Each post-process
415
+ // wrapper carries a `_vibeIterTree` snapshot captured at inline time (in
416
+ // component.js) — that tree retains the original `@[...]` binding text even
417
+ // after the wrapper's live DOM has been hydrated, so subsequent affected→
418
+ // hydrate passes work the same way they would on initial render.
419
+ const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest) => {
420
+ for (let n = 0; n < clonedNodes.length; n++) {
421
+ const node = liveCloneNode(clonedNodes[n]);
422
+ if (!node || node.nodeType !== 1) continue;
423
+ const wrappers = [];
424
+ if (node._vibeIterTree) wrappers.push(node);
425
+ const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
426
+ if (found) for (let i = 0; i < found.length; i++) {
427
+ if (found[i]._vibeIterTree) wrappers.push(found[i]);
428
+ }
429
+ for (let w = 0; w < wrappers.length; w++) {
430
+ const wrapper = wrappers[w];
431
+ const tree = wrapper._vibeIterTree;
432
+ const affectedList = affected(tree, oldState, newState);
433
+ if (affectedList.length > 0) {
434
+ hydrate(affectedList, newState, manifest, oldState);
435
+ }
436
+ // Bindings into the iteration-prop registry are visited above, but
437
+ // iteration nodes whose arrayPath resolves through the registry need
438
+ // explicit driving: the refreshed slot is a side effect that
439
+ // `affected()` can't see. Walk the tree and update them directly.
440
+ forceRegistryBackedIterationUpdates(tree, newState, manifest, {});
441
+ }
442
+ }
443
+ };
444
+
445
+ // Apply DOM-property writes that compileBatchFn collected. Each batch row
446
+ // gets a built tiny scope (item + index + every state key) which is then
447
+ // passed to evalInScope — same evaluator the clone path uses, so identifier
448
+ // resolution rules (this.X already pre-rewritten, undefined-tolerant lookups)
449
+ // stay aligned. The transient `data-vibe-batch` marker is removed once its
450
+ // expressions have been applied so it doesn't leak into the live DOM.
451
+ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias, domPropertyWrites) => {
452
+ for (let i = 0; i < instances.length; i++) {
453
+ const root = instances[i].element;
454
+ if (!root || root.nodeType !== 1) continue;
455
+ const localState = { ...state, [itemAlias]: array[i], [indexAlias]: i };
456
+ const tagged = [];
457
+ if (root.matches?.('[data-vibe-batch]')) tagged.push(root);
458
+ const found = root.querySelectorAll?.('[data-vibe-batch]');
459
+ if (found) for (let f = 0; f < found.length; f++) tagged.push(found[f]);
460
+ for (let t = 0; t < tagged.length; t++) {
461
+ const el = tagged[t];
462
+ const indexes = el.getAttribute('data-vibe-batch').split(',');
463
+ for (let k = 0; k < indexes.length; k++) {
464
+ const { prop, expr } = domPropertyWrites[indexes[k] | 0];
465
+ const value = evalInScope(expr, localState, el);
466
+ if (el[prop] !== value) el[prop] = value;
467
+ if (value === undefined || value === null) {
468
+ if (el.hasAttribute(prop)) el.removeAttribute(prop);
469
+ } else {
470
+ const str = String(value);
471
+ if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
472
+ }
473
+ }
474
+ el.removeAttribute('data-vibe-batch');
475
+ }
476
+ }
477
+ };
478
+
479
+ // Resolve the loop's array against the LIVE `$` proxy root (not the plain
480
+ // diff-snapshot the pipeline renders against — see extractPlainValue in
481
+ // index.js). The plain clones are never reference-identical to the proxy
482
+ // elements the app sees through `$`, so a loop var that flows into an `on*`
483
+ // handler must come from here for `item === $.arr[i]` to hold. Returns null
484
+ // when unresolvable (no live root yet, a derivation that builds fresh objects,
485
+ // or a nested loop whose source hangs off an outer plain item) — callers fall
486
+ // back to the plain item, which is no worse than the pre-fix behavior.
487
+ const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
488
+ const liveRoot = (manifest && manifest.__live) || globalThis.$;
489
+ if (!liveRoot) return null;
490
+ try {
491
+ const { arrayPath, startComment } = iterationNode.meta;
492
+ const parentEl = startComment.parentElement;
493
+ const resolved = resolveThisPath(arrayPath, parentEl);
494
+ const liveScoped = createScopedState(liveRoot, {}, parentScope);
495
+ const a = evalInScope(resolved, liveScoped, parentEl) ?? resolvePath(liveScoped, resolved);
496
+ return Array.isArray(a) ? a : null;
497
+ } catch {
498
+ return null;
499
+ }
500
+ };
501
+
502
+ const liveItemAt = (liveArray, index, fallback) =>
503
+ liveArray && index < liveArray.length ? liveArray[index] : fallback;
504
+
505
+ // Refresh each instance's `liveItem` (the live `$`-proxy element handed to
506
+ // loop-scoped `$scope` handlers, set when the instance was built) and stamp
507
+ // scope. Re-resolving here keeps `liveItem` correct after the diff reorders or
508
+ // updates instances. Diffing still keys off the plain `inst.item`; only the
509
+ // handler-facing `$scope` value is live.
510
+ const stampScopes = (iterationNode, manifest, parentScope = {}) => {
511
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
512
+ const instances = iterationNode.runtime.instances;
513
+ for (let k = 0; k < instances.length; k++) {
514
+ const inst = instances[k];
515
+ inst.liveItem = liveItemAt(liveArray, inst.index, inst.item);
516
+ }
517
+ stampInstanceScopes(iterationNode, parentScope);
518
+ };
519
+
520
+ const renderBatch = (iterationNode, array, state, parent, endComment, parentScope = {}, manifest) => {
151
521
  const { itemAlias, indexAlias, template } = iterationNode.meta;
152
522
 
153
523
  if (!iterationNode.runtime.batchFn) {
154
524
  const stateKeys = Object.keys(state);
155
- iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
525
+ const compiled = compileBatchFn(template, itemAlias, indexAlias, stateKeys, parent);
526
+ iterationNode.runtime.batchFn = compiled.fn;
527
+ iterationNode.runtime.domPropertyWrites = compiled.domPropertyWrites;
156
528
  iterationNode.runtime.stateKeys = stateKeys;
157
529
  }
158
530
 
159
- const { batchFn, stateKeys } = iterationNode.runtime;
531
+ const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
160
532
  const stateValues = stateKeys.map((k) => state[k]);
161
- const html = batchFn(array, ...stateValues);
533
+ const html = batchFn(array, ...stateValues, state);
534
+ // Remember the rendered output so a later update can skip re-rendering when an
535
+ // unrelated state change produces identical HTML (see updateIteration).
536
+ iterationNode.runtime.lastBatchHtml = html;
162
537
 
163
538
  batchParseTemplate.innerHTML = html;
164
539
  const frag = batchParseTemplate.content;
@@ -172,6 +547,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment) => {
172
547
 
173
548
  parent.insertBefore(frag, endComment);
174
549
  iterationNode.runtime.instances = instances;
550
+
551
+ if (domPropertyWrites.length > 0) {
552
+ applyDomPropertyWrites(instances, array, state, itemAlias, indexAlias, domPropertyWrites);
553
+ }
554
+
555
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
556
+ stampScopes(iterationNode, manifest, parentScope);
175
557
  };
176
558
 
177
559
  /**
@@ -293,9 +675,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
293
675
  * @param {NodeList} templateNodes - Template nodes to clone
294
676
  * @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
295
677
  * @param {Object} cachedTree - Optional cached parsed tree from template
678
+ * @param {string|null} componentId - Component the row belongs to, when the
679
+ * iteration sits inside a component. Stashed on the parseContainer so
680
+ * `findComponentIdForElement` can resolve `this.X` during the hydrate pass
681
+ * that runs while clones are still detached.
296
682
  * @returns {Object} { element, tree, clonedNodes }
297
683
  */
298
- export const initializeBlock = (templateNodes, scopedState, cachedTree = null) => {
684
+ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined) => {
299
685
  let tree;
300
686
  let clonedNodes = [];
301
687
  let firstElement = null;
@@ -310,6 +696,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
310
696
 
311
697
  // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
312
698
  const parseContainer = document.createElement('div');
699
+ if (componentId) parseContainer._vibeComponentId = componentId;
313
700
 
314
701
  // Clone template nodes into container
315
702
  for (let i = 0; i < templateNodes.length; i++) {
@@ -336,8 +723,10 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
336
723
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
337
724
  tree = cloneTreeWithElements(cachedTree, parseContainer);
338
725
  } else {
339
- // Full parse: walk DOM, extract bindings, build tree from scratch
340
- tree = parse(parseContainer);
726
+ // Full parse: walk DOM, extract bindings, build tree from scratch. Pass this
727
+ // loop's aliases so loop-scoped `on*` handlers (and nested ones, via the
728
+ // parser's child-alias accumulation) rewrite to `$scope(this,'alias')`.
729
+ tree = parse(parseContainer, undefined, aliasSet);
341
730
  }
342
731
 
343
732
  // Extract the cloned nodes from the container (these are the same nodes the tree references)
@@ -458,19 +847,19 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
458
847
  return;
459
848
  }
460
849
 
461
- // Fallback check: If markers are lost (e.g., comment nodes replaced by component loading),
462
- // check actual DOM state between comments for hydrated nodes
463
- let currentNode = startComment.nextSibling;
464
- while (currentNode && currentNode !== endComment) {
465
- if (currentNode.nodeType === 1) {
466
- // Element node
467
- const html = currentNode.outerHTML || '';
468
- // If node doesn't have any @[...] syntax, it's been hydrated
469
- if (!html.includes('@[')) {
470
- return; // Already rendered
850
+ // Recovery for lost comment markers: a <component src> item re-processes its node
851
+ // out of managedNodes, dropping startComment.__vibeRendered. templateRemoved lives
852
+ // on runtime (not the DOM), so it survives — a true value means a prior render
853
+ // already cleared the template, so if content is still present between the
854
+ // comments, it's rendered; don't rebuild. Falls through only if content was lost.
855
+ if (iterationNode.runtime.templateRemoved) {
856
+ let currentNode = startComment.nextSibling;
857
+ while (currentNode && currentNode !== endComment) {
858
+ if (currentNode.nodeType === 1) {
859
+ return; // Already rendered — content still present
471
860
  }
861
+ currentNode = currentNode.nextSibling;
472
862
  }
473
- currentNode = currentNode.nextSibling;
474
863
  }
475
864
 
476
865
  // Remove template nodes from DOM on first render
@@ -513,14 +902,25 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
513
902
  const instances = [];
514
903
  const templateNodes = template.element.childNodes;
515
904
  const frag = document.createDocumentFragment();
905
+ // Resolve the iteration's owning component once — `this.X` bindings inside
906
+ // row content resolve against this id while the cloned subtree is still
907
+ // detached during hydrate (see findComponentIdForElement's detached fallback).
908
+ const componentId = findComponentIdForElement(startComment.parentElement);
909
+
910
+ // Live `$`-proxy elements for the loop alias, so nested conditionals (which
911
+ // stamp `__vibeScope` from this scoped state) and `$scope` handlers receive
912
+ // the same identity the app sees through `$`. `inst.item` stays the plain
913
+ // snapshot value for the diff.
914
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
516
915
 
517
916
  for (let i = 0; i < array.length; i++) {
518
917
  const item = array[i];
519
- const localVars = { [itemAlias]: item, [indexAlias]: i };
918
+ const liveItem = liveItemAt(liveArray, i, item);
919
+ const localVars = { [itemAlias]: liveItem, [indexAlias]: i };
520
920
  const scopedState = createScopedState(state, localVars, parentScope);
521
921
 
522
922
  // Clone, parse, hydrate
523
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
923
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
524
924
 
525
925
  // Pre-resolve <component src> binding props against iteration scope (see helper comment)
526
926
  resolveIterationComponentProps(clonedNodes, scopedState);
@@ -538,13 +938,16 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
538
938
  _renderAllConditionals(tree, scopedState, manifest, nestedScope);
539
939
  }
540
940
 
541
- instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
941
+ instances.push({ element, tree, item, liveItem, index: i, clonedNodes, scopedState });
542
942
  }
543
943
 
544
944
  // Single DOM insertion for all items
545
945
  parent.insertBefore(frag, endComment);
546
946
  iterationNode.runtime.instances = instances;
547
947
 
948
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
949
+ stampScopes(iterationNode, manifest, parentScope);
950
+
548
951
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
549
952
  // Also store runtime data on the DOM node so it persists across re-parses
550
953
  // @ts-ignore - adding custom property to comment node
@@ -553,6 +956,34 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
553
956
  startComment.__vibeIterationRuntime = iterationNode.runtime;
554
957
  };
555
958
 
959
+ // Evaluate the iteration's optional key expression for one item.
960
+ // Returns undefined when no keyExpr is declared, falling back to the default
961
+ // heuristic in getItemKey.
962
+ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
963
+ const { keyExpr, itemAlias, indexAlias } = iterationNode.meta;
964
+ if (!keyExpr) return undefined;
965
+ const localVars = { [itemAlias]: item, [indexAlias]: index };
966
+ const scopedState = createScopedState(state, localVars, parentScope);
967
+ try {
968
+ return evalInScope(keyExpr, scopedState, iterationNode.meta.startComment.parentElement);
969
+ } catch {
970
+ return undefined;
971
+ }
972
+ };
973
+
974
+ // One-shot warning when an unkeyed iteration produces index-coupled keys —
975
+ // i.e. the fallback `hash_..._<index>` path. Only emits in debug mode and only
976
+ // once per iteration block, so console doesn't drown.
977
+ const warnIndexCoupledKey = (iterationNode) => {
978
+ if (iterationNode.runtime.warnedIndexCoupled) return;
979
+ if (!globalThis.__vibeDebug) return;
980
+ iterationNode.runtime.warnedIndexCoupled = true;
981
+ const arrayPath = iterationNode.meta.arrayPath;
982
+ console.warn(
983
+ `[vibe] iteration over '${arrayPath}' is using index-coupled keys. Removing earlier items will trigger a full re-render. Add an explicit key: <!-- each ${arrayPath} as item (item.id) -->`,
984
+ );
985
+ };
986
+
556
987
  // Update an iteration block when array changes
557
988
  export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
558
989
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
@@ -565,11 +996,19 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
565
996
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
566
997
 
567
998
  // Use instances (what's actually rendered) as ground truth for old array
568
- // when oldState disagrees with the rendered count.
999
+ // when oldState disagrees with the rendered count, OR when stateOldArray
1000
+ // and newArray are the same reference. The latter happens for
1001
+ // registry-backed iterations: when the iteration's arrayPath resolves to
1002
+ // `window.__vibeIterProps._pN`, refreshIterationComponentProps updates the
1003
+ // slot in place, so both reads return the same NEW array. Without this
1004
+ // fallback the diff would compare the new array against itself and report
1005
+ // no changes — leaving the inlined iteration frozen on previously rendered
1006
+ // items.
569
1007
  const instances = iterationNode.runtime.instances;
570
- const oldArray = instances.length === stateOldArray.length
571
- ? stateOldArray
572
- : instances.map(inst => inst.item);
1008
+ const oldArray =
1009
+ stateOldArray !== newArray && instances.length === stateOldArray.length
1010
+ ? stateOldArray
1011
+ : instances.map((inst) => inst.item);
573
1012
 
574
1013
  // Compiled path: Use pre-compiled batch function when available
575
1014
  if (compiled.canUseCompiled(iterationNode)) {
@@ -596,8 +1035,42 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
596
1035
  return;
597
1036
  }
598
1037
 
599
- const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
600
- const newKeys = newArray.map((item, i) => getItemKey(item, i));
1038
+ // Treeless rows (batch-rendered) can't be patched in place — they have no
1039
+ // parsed tree for updateInstance to walk. When affected pushes them as
1040
+ // iteration-affected (see the same predicate in affected.js), do a full
1041
+ // re-batch so row bindings reflect current state. Clone-rendered iterations
1042
+ // skip this — they take the diff path below.
1043
+ const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
1044
+ if (hasTreelessInstances && oldState !== newState) {
1045
+ // affected.js conservatively flags a treeless (batch-rendered) iteration on
1046
+ // ANY state change, since it can't walk per-row trees to see which bindings
1047
+ // actually depend on what changed. Before tearing down and recreating every
1048
+ // row, re-run the batch: if it yields identical HTML, the rows don't depend
1049
+ // on what changed, so keep the existing DOM nodes — preserving their event
1050
+ // listeners (e.g. tooltip mouseleave) and any in-progress drag. Skip the
1051
+ // shortcut when the template has DOM-property writes (value/checked/etc.),
1052
+ // which aren't reflected in the HTML string.
1053
+ const rt = iterationNode.runtime;
1054
+ if (rt.batchFn && rt.lastBatchHtml !== undefined && (!rt.domPropertyWrites || rt.domPropertyWrites.length === 0)) {
1055
+ const stateValues = rt.stateKeys.map((k) => newState[k]);
1056
+ const newHtml = rt.batchFn(newArray, ...stateValues, newState);
1057
+ if (newHtml === rt.lastBatchHtml) return;
1058
+ }
1059
+ bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1060
+ return;
1061
+ }
1062
+
1063
+ const oldKeys = oldArray.map((item, i) =>
1064
+ getItemKey(item, i, evalKeyForItem(iterationNode, item, i, oldState, parentScope)),
1065
+ );
1066
+ const newKeys = newArray.map((item, i) =>
1067
+ getItemKey(item, i, evalKeyForItem(iterationNode, item, i, newState, parentScope)),
1068
+ );
1069
+
1070
+ // Detect index-coupled fallback keys (debug-mode warning only).
1071
+ if (!iterationNode.meta.keyExpr && newKeys.some(k => k.startsWith('hash_') || k.startsWith('val_'))) {
1072
+ warnIndexCoupledKey(iterationNode);
1073
+ }
601
1074
 
602
1075
  // O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
603
1076
  const oldKeySet = new Set(oldKeys);
@@ -609,6 +1082,11 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
609
1082
  // Standard diff-based updates (arrays share some common items)
610
1083
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
611
1084
 
1085
+ // Apply array-shape operations and capture which instances were
1086
+ // bindings-refreshed by an op (ADD built a fresh tree with newState;
1087
+ // UPDATE patched bindings in place). Captured by reference, not index,
1088
+ // because indices shift during the loop.
1089
+ const refreshedByOps = new WeakSet();
612
1090
  operations.forEach((op) => {
613
1091
  switch (op.type) {
614
1092
  case 'REMOVE':
@@ -616,19 +1094,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
616
1094
  break;
617
1095
  case 'ADD':
618
1096
  addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
1097
+ refreshedByOps.add(iterationNode.runtime.instances[op.index]);
619
1098
  break;
620
1099
  case 'MOVE':
621
1100
  moveInstance(iterationNode, op.from, op.to);
622
1101
  break;
623
1102
  case 'UPDATE':
624
- updateInstance(iterationNode, op.index, op.item, newState, manifest, parentScope);
1103
+ updateInstance(iterationNode, op.index, op.item, oldState, newState, manifest, parentScope);
1104
+ refreshedByOps.add(iterationNode.runtime.instances[op.index]);
625
1105
  break;
626
1106
  }
627
1107
  });
628
1108
 
629
- iterationNode.runtime.instances.forEach((inst, i) => {
630
- inst.index = i;
631
- });
1109
+ // Refresh every row the ops didn't touch: moved rows (index changed,
1110
+ // bindings stale) and untouched rows whose outer-scope bindings depend
1111
+ // on state that changed in this update cycle. Each row goes through
1112
+ // updateInstance exactly once — via an op or here.
1113
+ const retained = iterationNode.runtime.instances;
1114
+ for (let i = 0; i < retained.length; i++) {
1115
+ retained[i].index = i;
1116
+ if (refreshedByOps.has(retained[i])) continue;
1117
+ updateInstance(iterationNode, i, retained[i].item, oldState, newState, manifest, parentScope);
1118
+ }
1119
+
1120
+ // Re-stamp after the diff settles: moved/updated instances now carry their
1121
+ // current item + index, so `$scope` handlers resolve correctly post-reorder.
1122
+ stampScopes(iterationNode, manifest, parentScope);
632
1123
  };
633
1124
 
634
1125
  // Bulk replacement: clear all DOM and re-render from scratch
@@ -653,7 +1144,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
653
1144
  // For simple templates (no nested iterations/conditionals, single root element),
654
1145
  // use batch string rendering: one string concatenation loop + one innerHTML parse
655
1146
  if (canUseBatchRender(template)) {
656
- renderBatch(iterationNode, newArray, state, parent, endComment);
1147
+ renderBatch(iterationNode, newArray, state, parent, endComment, parentScope, manifest);
657
1148
  const instances = iterationNode.runtime.instances;
658
1149
  for (let i = 0; i < instances.length; i++) {
659
1150
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -666,17 +1157,21 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
666
1157
  // parent.insertBefore call.
667
1158
  const instances = [];
668
1159
  const frag = document.createDocumentFragment();
1160
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
669
1161
  for (let i = 0; i < newArray.length; i++) {
670
- const built = buildInstance(iterationNode, newArray[i], i, state, parentScope);
1162
+ const built = buildInstance(iterationNode, newArray[i], i, state, parentScope, liveItemAt(liveArray, i, newArray[i]));
671
1163
  for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
672
1164
  finalizeInstance(built, manifest, parentScope);
673
1165
  instances.push({
674
- element: built.element, tree: built.tree, item: newArray[i], index: i,
1166
+ element: built.element, tree: built.tree, item: newArray[i], liveItem: built.liveItem, index: i,
675
1167
  clonedNodes: built.clonedNodes, scopedState: built.scopedState,
676
1168
  });
677
1169
  }
678
1170
  parent.insertBefore(frag, endComment);
679
1171
  iterationNode.runtime.instances = instances;
1172
+
1173
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1174
+ stampScopes(iterationNode, manifest, parentScope);
680
1175
  };
681
1176
 
682
1177
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -745,13 +1240,19 @@ const detachInstanceDom = (iterationNode, index, parent) => {
745
1240
  // Build a fresh instance's DOM + tree + scope from the iteration template.
746
1241
  // Pure function — no DOM insertion, no side effects on iteration state.
747
1242
  // Callers decide where the clones go (iteration parent, DocumentFragment).
748
- const buildInstance = (iterationNode, item, index, state, parentScope) => {
749
- const { itemAlias, indexAlias, template } = iterationNode.meta;
750
- const localVars = { [itemAlias]: item, [indexAlias]: index };
1243
+ // `liveItem` (resolved by the caller via resolveLiveArray, so a derived-array
1244
+ // loop evaluates the expression once per render, not once per row) is the live
1245
+ // `$`-proxy element for this index. Building scope from it gives nested
1246
+ // conditional stamps and `$scope` handlers the app-visible identity; `item`
1247
+ // (plain snapshot) is still tracked for the diff.
1248
+ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem) => {
1249
+ const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
1250
+ const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
751
1251
  const scopedState = createScopedState(state, localVars, parentScope);
752
- const built = initializeBlock([...template.element.childNodes], scopedState, template);
1252
+ const componentId = findComponentIdForElement(startComment.parentElement);
1253
+ const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
753
1254
  resolveIterationComponentProps(built.clonedNodes, scopedState);
754
- return { ...built, scopedState, localVars };
1255
+ return { ...built, scopedState, localVars, liveItem };
755
1256
  };
756
1257
 
757
1258
  // After a built instance's clones are placed in the DOM (directly or via a
@@ -773,12 +1274,13 @@ const finalizeInstance = (built, manifest, parentScope) => {
773
1274
 
774
1275
  const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
775
1276
  const parent = iterationNode.meta.startComment.parentNode;
776
- const built = buildInstance(iterationNode, item, index, state, parentScope);
1277
+ const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
1278
+ const built = buildInstance(iterationNode, item, index, state, parentScope, liveItem);
777
1279
  const insertBefore = resolveInsertBefore(iterationNode, index, parent);
778
1280
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
779
1281
  finalizeInstance(built, manifest, parentScope);
780
1282
  iterationNode.runtime.instances.splice(index, 0, {
781
- element: built.element, tree: built.tree, item, index, clonedNodes: built.clonedNodes,
1283
+ element: built.element, tree: built.tree, item, liveItem: built.liveItem, index, clonedNodes: built.clonedNodes,
782
1284
  });
783
1285
  };
784
1286
 
@@ -811,14 +1313,17 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
811
1313
  }
812
1314
  };
813
1315
 
814
- const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
815
- if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1316
+ // Detach-and-rebuild fallback. Used when the row's parsed tree isn't
1317
+ // available (compiled iterations), or when the row template's top-level
1318
+ // shape itself depends on the item (a `<!-- if -->` directly under the
1319
+ // iteration with the item in its expression — flipping branches needs a
1320
+ // rebuild because the active root element type changes).
1321
+ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, parentScope) => {
816
1322
  const parent = iterationNode.meta.startComment.parentNode;
817
1323
  const instance = iterationNode.runtime.instances[index];
818
1324
 
819
- // Build fresh first, then detach old — keeps the old DOM as a stable
820
- // anchor reference until we know how the new nodes are shaped.
821
- const built = buildInstance(iterationNode, newItem, index, state, parentScope);
1325
+ const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1326
+ const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
822
1327
  detachInstanceDom(iterationNode, index, parent);
823
1328
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
824
1329
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
@@ -827,7 +1332,48 @@ const updateInstance = (iterationNode, index, newItem, state, manifest, parentSc
827
1332
  instance.element = built.element;
828
1333
  instance.tree = built.tree;
829
1334
  instance.item = newItem;
1335
+ instance.liveItem = built.liveItem;
830
1336
  instance.clonedNodes = built.clonedNodes;
1337
+ instance.scopedState = built.scopedState;
1338
+ };
1339
+
1340
+ // In-place refresh of a single row: rebuild scoped states from oldState +
1341
+ // newState (and possibly a new item), run affected→hydrate against the row's
1342
+ // parsed tree. Same pipeline as top-level state changes; the row's DOM nodes
1343
+ // survive, only changed bindings update.
1344
+ //
1345
+ // Called from two sites in updateIteration:
1346
+ // 1. UPDATE diff op — newItem differs from instance.item
1347
+ // 2. Post-diff outer-state propagation — newItem === instance.item
1348
+ // Both flow through here so iteration updates have one code path, not two.
1349
+ // Compiled / batch instances have no row tree to walk and fall back to a
1350
+ // full rebuild via updateInstanceRebuild.
1351
+ const updateInstance = (iterationNode, index, newItem, oldState, newState, manifest, parentScope = {}) => {
1352
+ if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1353
+ const instance = iterationNode.runtime.instances[index];
1354
+
1355
+ if (!instance.tree) {
1356
+ updateInstanceRebuild(iterationNode, index, newItem, newState, manifest, parentScope);
1357
+ return;
1358
+ }
1359
+
1360
+ const { itemAlias, indexAlias } = iterationNode.meta;
1361
+ const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
1362
+ const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
1363
+ const oldScopedState = createScopedState(oldState, oldLocalVars, parentScope);
1364
+ const newScopedState = createScopedState(newState, newLocalVars, parentScope);
1365
+
1366
+ refreshIterationComponentProps(instance.clonedNodes, newScopedState);
1367
+
1368
+ const affectedList = affected(instance.tree, oldScopedState, newScopedState);
1369
+ if (affectedList.length > 0) {
1370
+ hydrate(affectedList, newScopedState, manifest, oldScopedState);
1371
+ }
1372
+
1373
+ hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
1374
+
1375
+ instance.item = newItem;
1376
+ instance.scopedState = newScopedState;
831
1377
  };
832
1378
 
833
1379
  export default {