@ape-egg/vibe 1.9.0 → 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.
@@ -35,18 +48,206 @@ const hasNestedStructures = (tree) => {
35
48
  return false;
36
49
  };
37
50
 
51
+ // Batch render template-literal-interpolates `@[expr]` as `${expr}` — that
52
+ // stringifies object/array values, which breaks `<component src>` props that
53
+ // rely on resolveIterationComponentProps to stash non-primitives in the
54
+ // registry. Templates carrying any `<component src>` go through the
55
+ // clone+hydrate path instead.
56
+ const hasComponentSrc = (templateEl) =>
57
+ !!templateEl.querySelector?.('component[src], div.component[src]');
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.
38
64
  const canUseBatchRender = (template) =>
39
- !hasNestedStructures(template) && template.element.children.length <= 1;
65
+ !globalThis.__vibeForceClonePath &&
66
+ !hasNestedStructures(template) &&
67
+ template.element.children.length <= 1 &&
68
+ !hasComponentSrc(template.element);
69
+
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
+ }
40
160
 
41
- const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
42
- const templateHtml = template.element.innerHTML.trim();
43
- const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
44
- const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
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
+ });
45
220
 
46
- return new Function(
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(
47
246
  'arr',
48
247
  ...stateKeys,
248
+ '$',
49
249
  `
250
+ ${ciWalkerSrc}
50
251
  let html = '';
51
252
  const len = arr.length;
52
253
  for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
@@ -56,17 +257,55 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
56
257
  return html;
57
258
  `,
58
259
  );
260
+
261
+ return { fn, domPropertyWrites };
262
+ };
263
+
264
+ // Registry for non-primitive iteration prop snapshots. Lives on `window` (not
265
+ // on `$`) so it doesn't pollute user-visible state enumeration, but is still
266
+ // reachable from binding expressions because `window` is in evalInScope's
267
+ // known-globals list. Each entry is freed when the owning component element
268
+ // is detached (see releaseOrphanedIterationProps).
269
+ let __vibeIterPropCounter = 0;
270
+ const ensureIterPropsRegistry = () => {
271
+ if (!window.__vibeIterProps) window.__vibeIterProps = {};
272
+ return window.__vibeIterProps;
273
+ };
274
+
275
+ // Walk a removed subtree and free any iteration-prop registry slots stashed
276
+ // on `<component>` elements inside it. Called from the mutation-observer
277
+ // cleanup path after DOM detachment.
278
+ export const releaseOrphanedIterationProps = (nodes) => {
279
+ if (!window.__vibeIterProps) return;
280
+ for (const node of nodes) {
281
+ if (node.nodeType !== 1) continue;
282
+ const free = (el) => {
283
+ const ids = el._vibeIterPropIds;
284
+ if (!ids) return;
285
+ for (const id of ids) delete window.__vibeIterProps[id];
286
+ el._vibeIterPropIds = null;
287
+ };
288
+ free(node);
289
+ node.querySelectorAll?.('[data-vibe-iter-prop]').forEach(free);
290
+ }
59
291
  };
60
292
 
61
293
  // For <component src> elements inside an iteration instance, evaluate any
62
- // `@[expr]` attribute bindings against the iteration's scoped state and replace
63
- // them with the resolved literal value. Component[src] attributes intentionally
64
- // bypass hydrate (parse.js) so they reach processComponent as bindings — but
65
- // bindings that depend on iteration-local vars (item, index) can't resolve later
66
- // when processComponent inlines the component, since by then iteration scope is gone.
67
- // Only called from iteration code paths; conditionals don't need this because their
68
- // branch content is registered in the global manifest and reacts to state updates.
69
- const resolveIterationComponentProps = (nodes, scopedState) => {
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.
301
+ //
302
+ // Component[src] attributes intentionally bypass hydrate (parse.js) so they
303
+ // reach processComponent as bindings — but bindings that depend on
304
+ // iteration-local vars (item, index) can't resolve later when processComponent
305
+ // inlines the component, since by then iteration scope is gone. Only called
306
+ // from iteration code paths; conditionals don't need this because their branch
307
+ // content is registered in the global manifest and reacts to state updates.
308
+ export const resolveIterationComponentProps = (nodes, scopedState) => {
70
309
  for (let n = 0; n < nodes.length; n++) {
71
310
  const node = nodes[n];
72
311
  if (node.nodeType !== 1) continue;
@@ -81,11 +320,17 @@ const resolveIterationComponentProps = (nodes, scopedState) => {
81
320
  if (attr.name === 'src') continue;
82
321
  const match = attr.value.match(/^@\[(.+)\]$/);
83
322
  if (!match) continue;
323
+ const expr = match[1];
84
324
  try {
85
- const value = evalInScope(match[1], scopedState, el);
86
- if (value !== undefined) {
87
- el.setAttribute(attr.name, String(value));
88
- }
325
+ const value = evalInScope(expr, scopedState, el);
326
+ if (value === undefined) continue;
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 });
89
334
  } catch {
90
335
  // Leave binding raw — processComponent will handle it as a binding
91
336
  }
@@ -94,18 +339,160 @@ const resolveIterationComponentProps = (nodes, scopedState) => {
94
339
  }
95
340
  };
96
341
 
97
- 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 = {}) => {
98
480
  const { itemAlias, indexAlias, template } = iterationNode.meta;
99
481
 
100
482
  if (!iterationNode.runtime.batchFn) {
101
483
  const stateKeys = Object.keys(state);
102
- 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;
103
487
  iterationNode.runtime.stateKeys = stateKeys;
104
488
  }
105
489
 
106
- const { batchFn, stateKeys } = iterationNode.runtime;
490
+ const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
107
491
  const stateValues = stateKeys.map((k) => state[k]);
108
- 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;
109
496
 
110
497
  batchParseTemplate.innerHTML = html;
111
498
  const frag = batchParseTemplate.content;
@@ -119,6 +506,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment) => {
119
506
 
120
507
  parent.insertBefore(frag, endComment);
121
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);
122
516
  };
123
517
 
124
518
  /**
@@ -240,9 +634,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
240
634
  * @param {NodeList} templateNodes - Template nodes to clone
241
635
  * @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
242
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.
243
641
  * @returns {Object} { element, tree, clonedNodes }
244
642
  */
245
- export const initializeBlock = (templateNodes, scopedState, cachedTree = null) => {
643
+ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined) => {
246
644
  let tree;
247
645
  let clonedNodes = [];
248
646
  let firstElement = null;
@@ -257,6 +655,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
257
655
 
258
656
  // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
259
657
  const parseContainer = document.createElement('div');
658
+ if (componentId) parseContainer._vibeComponentId = componentId;
260
659
 
261
660
  // Clone template nodes into container
262
661
  for (let i = 0; i < templateNodes.length; i++) {
@@ -283,8 +682,10 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
283
682
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
284
683
  tree = cloneTreeWithElements(cachedTree, parseContainer);
285
684
  } else {
286
- // Full parse: walk DOM, extract bindings, build tree from scratch
287
- 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);
288
689
  }
289
690
 
290
691
  // Extract the cloned nodes from the container (these are the same nodes the tree references)
@@ -405,19 +806,19 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
405
806
  return;
406
807
  }
407
808
 
408
- // Fallback check: If markers are lost (e.g., comment nodes replaced by component loading),
409
- // check actual DOM state between comments for hydrated nodes
410
- let currentNode = startComment.nextSibling;
411
- while (currentNode && currentNode !== endComment) {
412
- if (currentNode.nodeType === 1) {
413
- // Element node
414
- const html = currentNode.outerHTML || '';
415
- // If node doesn't have any @[...] syntax, it's been hydrated
416
- if (!html.includes('@[')) {
417
- 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
418
819
  }
820
+ currentNode = currentNode.nextSibling;
419
821
  }
420
- currentNode = currentNode.nextSibling;
421
822
  }
422
823
 
423
824
  // Remove template nodes from DOM on first render
@@ -460,6 +861,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
460
861
  const instances = [];
461
862
  const templateNodes = template.element.childNodes;
462
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);
463
868
 
464
869
  for (let i = 0; i < array.length; i++) {
465
870
  const item = array[i];
@@ -467,7 +872,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
467
872
  const scopedState = createScopedState(state, localVars, parentScope);
468
873
 
469
874
  // Clone, parse, hydrate
470
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
875
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
471
876
 
472
877
  // Pre-resolve <component src> binding props against iteration scope (see helper comment)
473
878
  resolveIterationComponentProps(clonedNodes, scopedState);
@@ -492,6 +897,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
492
897
  parent.insertBefore(frag, endComment);
493
898
  iterationNode.runtime.instances = instances;
494
899
 
900
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
901
+ stampInstanceScopes(iterationNode, parentScope);
902
+
495
903
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
496
904
  // Also store runtime data on the DOM node so it persists across re-parses
497
905
  // @ts-ignore - adding custom property to comment node
@@ -500,6 +908,34 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
500
908
  startComment.__vibeIterationRuntime = iterationNode.runtime;
501
909
  };
502
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
+
503
939
  // Update an iteration block when array changes
504
940
  export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
505
941
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
@@ -512,11 +948,19 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
512
948
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
513
949
 
514
950
  // Use instances (what's actually rendered) as ground truth for old array
515
- // 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.
516
959
  const instances = iterationNode.runtime.instances;
517
- const oldArray = instances.length === stateOldArray.length
518
- ? stateOldArray
519
- : instances.map(inst => inst.item);
960
+ const oldArray =
961
+ stateOldArray !== newArray && instances.length === stateOldArray.length
962
+ ? stateOldArray
963
+ : instances.map((inst) => inst.item);
520
964
 
521
965
  // Compiled path: Use pre-compiled batch function when available
522
966
  if (compiled.canUseCompiled(iterationNode)) {
@@ -543,8 +987,42 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
543
987
  return;
544
988
  }
545
989
 
546
- const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
547
- 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
+ }
548
1026
 
549
1027
  // O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
550
1028
  const oldKeySet = new Set(oldKeys);
@@ -556,6 +1034,11 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
556
1034
  // Standard diff-based updates (arrays share some common items)
557
1035
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
558
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();
559
1042
  operations.forEach((op) => {
560
1043
  switch (op.type) {
561
1044
  case 'REMOVE':
@@ -563,19 +1046,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
563
1046
  break;
564
1047
  case 'ADD':
565
1048
  addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
1049
+ refreshedByOps.add(iterationNode.runtime.instances[op.index]);
566
1050
  break;
567
1051
  case 'MOVE':
568
1052
  moveInstance(iterationNode, op.from, op.to);
569
1053
  break;
570
1054
  case 'UPDATE':
571
- 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]);
572
1057
  break;
573
1058
  }
574
1059
  });
575
1060
 
576
- iterationNode.runtime.instances.forEach((inst, i) => {
577
- inst.index = i;
578
- });
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);
579
1075
  };
580
1076
 
581
1077
  // Bulk replacement: clear all DOM and re-render from scratch
@@ -600,7 +1096,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
600
1096
  // For simple templates (no nested iterations/conditionals, single root element),
601
1097
  // use batch string rendering: one string concatenation loop + one innerHTML parse
602
1098
  if (canUseBatchRender(template)) {
603
- renderBatch(iterationNode, newArray, state, parent, endComment);
1099
+ renderBatch(iterationNode, newArray, state, parent, endComment, parentScope);
604
1100
  const instances = iterationNode.runtime.instances;
605
1101
  for (let i = 0; i < instances.length; i++) {
606
1102
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -624,6 +1120,9 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
624
1120
  }
625
1121
  parent.insertBefore(frag, endComment);
626
1122
  iterationNode.runtime.instances = instances;
1123
+
1124
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1125
+ stampInstanceScopes(iterationNode, parentScope);
627
1126
  };
628
1127
 
629
1128
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -693,10 +1192,11 @@ const detachInstanceDom = (iterationNode, index, parent) => {
693
1192
  // Pure function — no DOM insertion, no side effects on iteration state.
694
1193
  // Callers decide where the clones go (iteration parent, DocumentFragment).
695
1194
  const buildInstance = (iterationNode, item, index, state, parentScope) => {
696
- const { itemAlias, indexAlias, template } = iterationNode.meta;
1195
+ const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
697
1196
  const localVars = { [itemAlias]: item, [indexAlias]: index };
698
1197
  const scopedState = createScopedState(state, localVars, parentScope);
699
- 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));
700
1200
  resolveIterationComponentProps(built.clonedNodes, scopedState);
701
1201
  return { ...built, scopedState, localVars };
702
1202
  };
@@ -758,13 +1258,15 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
758
1258
  }
759
1259
  };
760
1260
 
761
- const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
762
- 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) => {
763
1267
  const parent = iterationNode.meta.startComment.parentNode;
764
1268
  const instance = iterationNode.runtime.instances[index];
765
1269
 
766
- // Build fresh first, then detach old — keeps the old DOM as a stable
767
- // anchor reference until we know how the new nodes are shaped.
768
1270
  const built = buildInstance(iterationNode, newItem, index, state, parentScope);
769
1271
  detachInstanceDom(iterationNode, index, parent);
770
1272
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
@@ -775,6 +1277,46 @@ const updateInstance = (iterationNode, index, newItem, state, manifest, parentSc
775
1277
  instance.tree = built.tree;
776
1278
  instance.item = newItem;
777
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;
778
1320
  };
779
1321
 
780
1322
  export default {