@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.
- package/CHANGELOG.md +63 -0
- package/README.md +62 -111
- package/ROADMAP.md +6 -12
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +1 -1
- package/compiler/src/compiler/compile.rs +66 -6
- package/compiler/src/parser/html.rs +7 -1
- package/llms.txt +25 -0
- package/package.json +1 -1
- package/runtime/affected.js +141 -52
- package/runtime/component.js +125 -8
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +12 -5
- package/runtime/hydrate.js +8 -11
- package/runtime/index.js +42 -1
- package/runtime/iterate.js +617 -71
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +161 -0
- package/runtime/parse.js +94 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +45 -1
- package/runtime/utils.js +36 -1
package/runtime/iterate.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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(
|
|
325
|
+
const value = evalInScope(expr, scopedState, el);
|
|
131
326
|
if (value === undefined) continue;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (
|
|
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
|
|
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 =
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
600
|
-
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
820
|
-
|
|
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 {
|