@ape-egg/vibe 1.9.1 → 1.9.5

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
+ }
55
160
 
56
- return new Function(
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
+ });
207
+
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,160 @@ 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
+ const renderBatch = (iterationNode, array, state, parent, endComment, parentScope = {}) => {
151
480
  const { itemAlias, indexAlias, template } = iterationNode.meta;
152
481
 
153
482
  if (!iterationNode.runtime.batchFn) {
154
483
  const stateKeys = Object.keys(state);
155
- iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
484
+ const compiled = compileBatchFn(template, itemAlias, indexAlias, stateKeys, parent);
485
+ iterationNode.runtime.batchFn = compiled.fn;
486
+ iterationNode.runtime.domPropertyWrites = compiled.domPropertyWrites;
156
487
  iterationNode.runtime.stateKeys = stateKeys;
157
488
  }
158
489
 
159
- const { batchFn, stateKeys } = iterationNode.runtime;
490
+ const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
160
491
  const stateValues = stateKeys.map((k) => state[k]);
161
- const html = batchFn(array, ...stateValues);
492
+ const html = batchFn(array, ...stateValues, state);
493
+ // Remember the rendered output so a later update can skip re-rendering when an
494
+ // unrelated state change produces identical HTML (see updateIteration).
495
+ iterationNode.runtime.lastBatchHtml = html;
162
496
 
163
497
  batchParseTemplate.innerHTML = html;
164
498
  const frag = batchParseTemplate.content;
@@ -172,6 +506,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment) => {
172
506
 
173
507
  parent.insertBefore(frag, endComment);
174
508
  iterationNode.runtime.instances = instances;
509
+
510
+ if (domPropertyWrites.length > 0) {
511
+ applyDomPropertyWrites(instances, array, state, itemAlias, indexAlias, domPropertyWrites);
512
+ }
513
+
514
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
515
+ stampInstanceScopes(iterationNode, parentScope);
175
516
  };
176
517
 
177
518
  /**
@@ -293,9 +634,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
293
634
  * @param {NodeList} templateNodes - Template nodes to clone
294
635
  * @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
295
636
  * @param {Object} cachedTree - Optional cached parsed tree from template
637
+ * @param {string|null} componentId - Component the row belongs to, when the
638
+ * iteration sits inside a component. Stashed on the parseContainer so
639
+ * `findComponentIdForElement` can resolve `this.X` during the hydrate pass
640
+ * that runs while clones are still detached.
296
641
  * @returns {Object} { element, tree, clonedNodes }
297
642
  */
298
- export const initializeBlock = (templateNodes, scopedState, cachedTree = null) => {
643
+ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined) => {
299
644
  let tree;
300
645
  let clonedNodes = [];
301
646
  let firstElement = null;
@@ -310,6 +655,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
310
655
 
311
656
  // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
312
657
  const parseContainer = document.createElement('div');
658
+ if (componentId) parseContainer._vibeComponentId = componentId;
313
659
 
314
660
  // Clone template nodes into container
315
661
  for (let i = 0; i < templateNodes.length; i++) {
@@ -336,8 +682,10 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
336
682
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
337
683
  tree = cloneTreeWithElements(cachedTree, parseContainer);
338
684
  } else {
339
- // Full parse: walk DOM, extract bindings, build tree from scratch
340
- tree = parse(parseContainer);
685
+ // Full parse: walk DOM, extract bindings, build tree from scratch. Pass this
686
+ // loop's aliases so loop-scoped `on*` handlers (and nested ones, via the
687
+ // parser's child-alias accumulation) rewrite to `$scope(this,'alias')`.
688
+ tree = parse(parseContainer, undefined, aliasSet);
341
689
  }
342
690
 
343
691
  // Extract the cloned nodes from the container (these are the same nodes the tree references)
@@ -458,19 +806,19 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
458
806
  return;
459
807
  }
460
808
 
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
809
+ // Recovery for lost comment markers: a <component src> item re-processes its node
810
+ // out of managedNodes, dropping startComment.__vibeRendered. templateRemoved lives
811
+ // on runtime (not the DOM), so it survives — a true value means a prior render
812
+ // already cleared the template, so if content is still present between the
813
+ // comments, it's rendered; don't rebuild. Falls through only if content was lost.
814
+ if (iterationNode.runtime.templateRemoved) {
815
+ let currentNode = startComment.nextSibling;
816
+ while (currentNode && currentNode !== endComment) {
817
+ if (currentNode.nodeType === 1) {
818
+ return; // Already rendered — content still present
471
819
  }
820
+ currentNode = currentNode.nextSibling;
472
821
  }
473
- currentNode = currentNode.nextSibling;
474
822
  }
475
823
 
476
824
  // Remove template nodes from DOM on first render
@@ -513,6 +861,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
513
861
  const instances = [];
514
862
  const templateNodes = template.element.childNodes;
515
863
  const frag = document.createDocumentFragment();
864
+ // Resolve the iteration's owning component once — `this.X` bindings inside
865
+ // row content resolve against this id while the cloned subtree is still
866
+ // detached during hydrate (see findComponentIdForElement's detached fallback).
867
+ const componentId = findComponentIdForElement(startComment.parentElement);
516
868
 
517
869
  for (let i = 0; i < array.length; i++) {
518
870
  const item = array[i];
@@ -520,7 +872,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
520
872
  const scopedState = createScopedState(state, localVars, parentScope);
521
873
 
522
874
  // Clone, parse, hydrate
523
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
875
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
524
876
 
525
877
  // Pre-resolve <component src> binding props against iteration scope (see helper comment)
526
878
  resolveIterationComponentProps(clonedNodes, scopedState);
@@ -545,6 +897,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
545
897
  parent.insertBefore(frag, endComment);
546
898
  iterationNode.runtime.instances = instances;
547
899
 
900
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
901
+ stampInstanceScopes(iterationNode, parentScope);
902
+
548
903
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
549
904
  // Also store runtime data on the DOM node so it persists across re-parses
550
905
  // @ts-ignore - adding custom property to comment node
@@ -553,6 +908,34 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
553
908
  startComment.__vibeIterationRuntime = iterationNode.runtime;
554
909
  };
555
910
 
911
+ // Evaluate the iteration's optional key expression for one item.
912
+ // Returns undefined when no keyExpr is declared, falling back to the default
913
+ // heuristic in getItemKey.
914
+ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
915
+ const { keyExpr, itemAlias, indexAlias } = iterationNode.meta;
916
+ if (!keyExpr) return undefined;
917
+ const localVars = { [itemAlias]: item, [indexAlias]: index };
918
+ const scopedState = createScopedState(state, localVars, parentScope);
919
+ try {
920
+ return evalInScope(keyExpr, scopedState, iterationNode.meta.startComment.parentElement);
921
+ } catch {
922
+ return undefined;
923
+ }
924
+ };
925
+
926
+ // One-shot warning when an unkeyed iteration produces index-coupled keys —
927
+ // i.e. the fallback `hash_..._<index>` path. Only emits in debug mode and only
928
+ // once per iteration block, so console doesn't drown.
929
+ const warnIndexCoupledKey = (iterationNode) => {
930
+ if (iterationNode.runtime.warnedIndexCoupled) return;
931
+ if (!globalThis.__vibeDebug) return;
932
+ iterationNode.runtime.warnedIndexCoupled = true;
933
+ const arrayPath = iterationNode.meta.arrayPath;
934
+ console.warn(
935
+ `[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) -->`,
936
+ );
937
+ };
938
+
556
939
  // Update an iteration block when array changes
557
940
  export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
558
941
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
@@ -565,11 +948,19 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
565
948
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
566
949
 
567
950
  // Use instances (what's actually rendered) as ground truth for old array
568
- // when oldState disagrees with the rendered count.
951
+ // when oldState disagrees with the rendered count, OR when stateOldArray
952
+ // and newArray are the same reference. The latter happens for
953
+ // registry-backed iterations: when the iteration's arrayPath resolves to
954
+ // `window.__vibeIterProps._pN`, refreshIterationComponentProps updates the
955
+ // slot in place, so both reads return the same NEW array. Without this
956
+ // fallback the diff would compare the new array against itself and report
957
+ // no changes — leaving the inlined iteration frozen on previously rendered
958
+ // items.
569
959
  const instances = iterationNode.runtime.instances;
570
- const oldArray = instances.length === stateOldArray.length
571
- ? stateOldArray
572
- : instances.map(inst => inst.item);
960
+ const oldArray =
961
+ stateOldArray !== newArray && instances.length === stateOldArray.length
962
+ ? stateOldArray
963
+ : instances.map((inst) => inst.item);
573
964
 
574
965
  // Compiled path: Use pre-compiled batch function when available
575
966
  if (compiled.canUseCompiled(iterationNode)) {
@@ -596,8 +987,42 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
596
987
  return;
597
988
  }
598
989
 
599
- const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
600
- const newKeys = newArray.map((item, i) => getItemKey(item, i));
990
+ // Treeless rows (batch-rendered) can't be patched in place — they have no
991
+ // parsed tree for updateInstance to walk. When affected pushes them as
992
+ // iteration-affected (see the same predicate in affected.js), do a full
993
+ // re-batch so row bindings reflect current state. Clone-rendered iterations
994
+ // skip this — they take the diff path below.
995
+ const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
996
+ if (hasTreelessInstances && oldState !== newState) {
997
+ // affected.js conservatively flags a treeless (batch-rendered) iteration on
998
+ // ANY state change, since it can't walk per-row trees to see which bindings
999
+ // actually depend on what changed. Before tearing down and recreating every
1000
+ // row, re-run the batch: if it yields identical HTML, the rows don't depend
1001
+ // on what changed, so keep the existing DOM nodes — preserving their event
1002
+ // listeners (e.g. tooltip mouseleave) and any in-progress drag. Skip the
1003
+ // shortcut when the template has DOM-property writes (value/checked/etc.),
1004
+ // which aren't reflected in the HTML string.
1005
+ const rt = iterationNode.runtime;
1006
+ if (rt.batchFn && rt.lastBatchHtml !== undefined && (!rt.domPropertyWrites || rt.domPropertyWrites.length === 0)) {
1007
+ const stateValues = rt.stateKeys.map((k) => newState[k]);
1008
+ const newHtml = rt.batchFn(newArray, ...stateValues, newState);
1009
+ if (newHtml === rt.lastBatchHtml) return;
1010
+ }
1011
+ bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1012
+ return;
1013
+ }
1014
+
1015
+ const oldKeys = oldArray.map((item, i) =>
1016
+ getItemKey(item, i, evalKeyForItem(iterationNode, item, i, oldState, parentScope)),
1017
+ );
1018
+ const newKeys = newArray.map((item, i) =>
1019
+ getItemKey(item, i, evalKeyForItem(iterationNode, item, i, newState, parentScope)),
1020
+ );
1021
+
1022
+ // Detect index-coupled fallback keys (debug-mode warning only).
1023
+ if (!iterationNode.meta.keyExpr && newKeys.some(k => k.startsWith('hash_') || k.startsWith('val_'))) {
1024
+ warnIndexCoupledKey(iterationNode);
1025
+ }
601
1026
 
602
1027
  // O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
603
1028
  const oldKeySet = new Set(oldKeys);
@@ -609,6 +1034,11 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
609
1034
  // Standard diff-based updates (arrays share some common items)
610
1035
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
611
1036
 
1037
+ // Apply array-shape operations and capture which instances were
1038
+ // bindings-refreshed by an op (ADD built a fresh tree with newState;
1039
+ // UPDATE patched bindings in place). Captured by reference, not index,
1040
+ // because indices shift during the loop.
1041
+ const refreshedByOps = new WeakSet();
612
1042
  operations.forEach((op) => {
613
1043
  switch (op.type) {
614
1044
  case 'REMOVE':
@@ -616,19 +1046,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
616
1046
  break;
617
1047
  case 'ADD':
618
1048
  addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
1049
+ refreshedByOps.add(iterationNode.runtime.instances[op.index]);
619
1050
  break;
620
1051
  case 'MOVE':
621
1052
  moveInstance(iterationNode, op.from, op.to);
622
1053
  break;
623
1054
  case 'UPDATE':
624
- updateInstance(iterationNode, op.index, op.item, newState, manifest, parentScope);
1055
+ updateInstance(iterationNode, op.index, op.item, oldState, newState, manifest, parentScope);
1056
+ refreshedByOps.add(iterationNode.runtime.instances[op.index]);
625
1057
  break;
626
1058
  }
627
1059
  });
628
1060
 
629
- iterationNode.runtime.instances.forEach((inst, i) => {
630
- inst.index = i;
631
- });
1061
+ // Refresh every row the ops didn't touch: moved rows (index changed,
1062
+ // bindings stale) and untouched rows whose outer-scope bindings depend
1063
+ // on state that changed in this update cycle. Each row goes through
1064
+ // updateInstance exactly once — via an op or here.
1065
+ const retained = iterationNode.runtime.instances;
1066
+ for (let i = 0; i < retained.length; i++) {
1067
+ retained[i].index = i;
1068
+ if (refreshedByOps.has(retained[i])) continue;
1069
+ updateInstance(iterationNode, i, retained[i].item, oldState, newState, manifest, parentScope);
1070
+ }
1071
+
1072
+ // Re-stamp after the diff settles: moved/updated instances now carry their
1073
+ // current item + index, so `$scope` handlers resolve correctly post-reorder.
1074
+ stampInstanceScopes(iterationNode, parentScope);
632
1075
  };
633
1076
 
634
1077
  // Bulk replacement: clear all DOM and re-render from scratch
@@ -653,7 +1096,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
653
1096
  // For simple templates (no nested iterations/conditionals, single root element),
654
1097
  // use batch string rendering: one string concatenation loop + one innerHTML parse
655
1098
  if (canUseBatchRender(template)) {
656
- renderBatch(iterationNode, newArray, state, parent, endComment);
1099
+ renderBatch(iterationNode, newArray, state, parent, endComment, parentScope);
657
1100
  const instances = iterationNode.runtime.instances;
658
1101
  for (let i = 0; i < instances.length; i++) {
659
1102
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -677,6 +1120,9 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
677
1120
  }
678
1121
  parent.insertBefore(frag, endComment);
679
1122
  iterationNode.runtime.instances = instances;
1123
+
1124
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1125
+ stampInstanceScopes(iterationNode, parentScope);
680
1126
  };
681
1127
 
682
1128
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -746,10 +1192,11 @@ const detachInstanceDom = (iterationNode, index, parent) => {
746
1192
  // Pure function — no DOM insertion, no side effects on iteration state.
747
1193
  // Callers decide where the clones go (iteration parent, DocumentFragment).
748
1194
  const buildInstance = (iterationNode, item, index, state, parentScope) => {
749
- const { itemAlias, indexAlias, template } = iterationNode.meta;
1195
+ const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
750
1196
  const localVars = { [itemAlias]: item, [indexAlias]: index };
751
1197
  const scopedState = createScopedState(state, localVars, parentScope);
752
- const built = initializeBlock([...template.element.childNodes], scopedState, template);
1198
+ const componentId = findComponentIdForElement(startComment.parentElement);
1199
+ const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
753
1200
  resolveIterationComponentProps(built.clonedNodes, scopedState);
754
1201
  return { ...built, scopedState, localVars };
755
1202
  };
@@ -811,13 +1258,15 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
811
1258
  }
812
1259
  };
813
1260
 
814
- const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
815
- if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1261
+ // Detach-and-rebuild fallback. Used when the row's parsed tree isn't
1262
+ // available (compiled iterations), or when the row template's top-level
1263
+ // shape itself depends on the item (a `<!-- if -->` directly under the
1264
+ // iteration with the item in its expression — flipping branches needs a
1265
+ // rebuild because the active root element type changes).
1266
+ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, parentScope) => {
816
1267
  const parent = iterationNode.meta.startComment.parentNode;
817
1268
  const instance = iterationNode.runtime.instances[index];
818
1269
 
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
1270
  const built = buildInstance(iterationNode, newItem, index, state, parentScope);
822
1271
  detachInstanceDom(iterationNode, index, parent);
823
1272
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
@@ -828,6 +1277,46 @@ const updateInstance = (iterationNode, index, newItem, state, manifest, parentSc
828
1277
  instance.tree = built.tree;
829
1278
  instance.item = newItem;
830
1279
  instance.clonedNodes = built.clonedNodes;
1280
+ instance.scopedState = built.scopedState;
1281
+ };
1282
+
1283
+ // In-place refresh of a single row: rebuild scoped states from oldState +
1284
+ // newState (and possibly a new item), run affected→hydrate against the row's
1285
+ // parsed tree. Same pipeline as top-level state changes; the row's DOM nodes
1286
+ // survive, only changed bindings update.
1287
+ //
1288
+ // Called from two sites in updateIteration:
1289
+ // 1. UPDATE diff op — newItem differs from instance.item
1290
+ // 2. Post-diff outer-state propagation — newItem === instance.item
1291
+ // Both flow through here so iteration updates have one code path, not two.
1292
+ // Compiled / batch instances have no row tree to walk and fall back to a
1293
+ // full rebuild via updateInstanceRebuild.
1294
+ const updateInstance = (iterationNode, index, newItem, oldState, newState, manifest, parentScope = {}) => {
1295
+ if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1296
+ const instance = iterationNode.runtime.instances[index];
1297
+
1298
+ if (!instance.tree) {
1299
+ updateInstanceRebuild(iterationNode, index, newItem, newState, manifest, parentScope);
1300
+ return;
1301
+ }
1302
+
1303
+ const { itemAlias, indexAlias } = iterationNode.meta;
1304
+ const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
1305
+ const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
1306
+ const oldScopedState = createScopedState(oldState, oldLocalVars, parentScope);
1307
+ const newScopedState = createScopedState(newState, newLocalVars, parentScope);
1308
+
1309
+ refreshIterationComponentProps(instance.clonedNodes, newScopedState);
1310
+
1311
+ const affectedList = affected(instance.tree, oldScopedState, newScopedState);
1312
+ if (affectedList.length > 0) {
1313
+ hydrate(affectedList, newScopedState, manifest, oldScopedState);
1314
+ }
1315
+
1316
+ hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
1317
+
1318
+ instance.item = newItem;
1319
+ instance.scopedState = newScopedState;
831
1320
  };
832
1321
 
833
1322
  export default {