@ape-egg/vibe 1.9.1 → 1.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -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 +120 -8
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +12 -5
- package/runtime/hydrate.js +8 -11
- package/runtime/index.js +35 -1
- package/runtime/iterate.js +550 -61
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +157 -0
- package/runtime/parse.js +94 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +18 -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
|
+
}
|
|
55
160
|
|
|
56
|
-
|
|
161
|
+
const escaped = templateHtml
|
|
162
|
+
.replace(/\\/g, '\\\\')
|
|
163
|
+
.replace(/`/g, '\\`')
|
|
164
|
+
.replace(/\$\{/g, '\\${');
|
|
165
|
+
|
|
166
|
+
let code = escaped;
|
|
167
|
+
|
|
168
|
+
// Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
|
|
169
|
+
// emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
|
|
170
|
+
// bindings from attribute-value-position bindings (which are followed by `=`).
|
|
171
|
+
//
|
|
172
|
+
// HTML parses attribute names lowercase, so a binding like
|
|
173
|
+
// `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
|
|
174
|
+
// `<icon @[obj.iconName]>` arrives as `@[obj.iconname]`. We recover the
|
|
175
|
+
// proper case in two places:
|
|
176
|
+
// - Single identifier → resolve against `stateKeys` at compile time and
|
|
177
|
+
// emit a direct identifier reference.
|
|
178
|
+
// - Dotted path → split off the first segment (resolved at compile time
|
|
179
|
+
// against `stateKeys` + iteration aliases), then emit a runtime call to
|
|
180
|
+
// `_walkCi` (injected into the function body below) for the remaining
|
|
181
|
+
// segments. Mirrors `resolveCaseInsensitivePath` from utils.js.
|
|
182
|
+
// Bracket / call expressions pass through unchanged — they need a real
|
|
183
|
+
// evaluator and aren't worth special-casing here.
|
|
184
|
+
let needsCiWalker = false;
|
|
185
|
+
code = code.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
186
|
+
let decExpr = decodeEntities(expr);
|
|
187
|
+
if (decExpr.includes('[') || decExpr.includes('(')) {
|
|
188
|
+
return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
|
|
189
|
+
}
|
|
190
|
+
const dot = decExpr.indexOf('.');
|
|
191
|
+
if (dot === -1) {
|
|
192
|
+
const ci = stateKeys.find((k) => k.toLowerCase() === decExpr.toLowerCase());
|
|
193
|
+
if (ci) decExpr = ci;
|
|
194
|
+
return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
|
|
195
|
+
}
|
|
196
|
+
const head = decExpr.slice(0, dot);
|
|
197
|
+
const tail = decExpr.slice(dot + 1).split('.');
|
|
198
|
+
const knownNames = [itemAlias, indexAlias, ...stateKeys];
|
|
199
|
+
const ciHead = knownNames.find((k) => k.toLowerCase() === head.toLowerCase()) || head;
|
|
200
|
+
const tailJSON = JSON.stringify(tail);
|
|
201
|
+
needsCiWalker = true;
|
|
202
|
+
return (
|
|
203
|
+
'${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
|
|
204
|
+
');return _v?\' \'+_v+\'=""\':\'\';})()}'
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
|
|
209
|
+
// DOM property → emit attribute (post-stamp also writes the property)
|
|
210
|
+
// value-style → emit attribute as-is
|
|
211
|
+
// boolean-coerced → emit conditional ` attr=""` so the attribute is
|
|
212
|
+
// absent when expr is falsy
|
|
213
|
+
code = code.replace(BATCH_ATTR_BINDING_REGEX, (_, _ws, attrName, expr) => {
|
|
214
|
+
const decExpr = decodeEntities(expr);
|
|
215
|
+
if (DOM_PROPERTIES.includes(attrName) || isValueStyleAttr(attrName)) {
|
|
216
|
+
return ' ' + attrName + '="${' + decExpr + '}"';
|
|
217
|
+
}
|
|
218
|
+
return '${(' + decExpr + ') ? \' ' + attrName + '=""\' : \'\'}';
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Remaining @[…] markers — text content and partial-binding attribute
|
|
222
|
+
// values like `href="/items/@[id]/edit"`.
|
|
223
|
+
code = code.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
|
|
224
|
+
|
|
225
|
+
// `$` is supplied as the last parameter so component-scoped expressions like
|
|
226
|
+
// `$['_c0'].chosen` (produced by the this.X rewrite above) can resolve
|
|
227
|
+
// against the live state. Mirrors evalInScope, which also exposes `$`.
|
|
228
|
+
// `_walkCi` is only emitted when at least one dotted-path name binding was
|
|
229
|
+
// rewritten above — keeps the function body free of dead code for the
|
|
230
|
+
// common no-name-binding case.
|
|
231
|
+
const ciWalkerSrc = needsCiWalker
|
|
232
|
+
? `const _walkCi = (cur, segs) => {
|
|
233
|
+
for (let i = 0; i < segs.length; i++) {
|
|
234
|
+
if (cur == null) return undefined;
|
|
235
|
+
const s = segs[i];
|
|
236
|
+
if (Reflect.has(Object(cur), s)) { cur = cur[s]; continue; }
|
|
237
|
+
if (typeof cur !== 'object') return undefined;
|
|
238
|
+
const k = Object.keys(cur).find((k) => k.toLowerCase() === s.toLowerCase());
|
|
239
|
+
if (!k) return undefined;
|
|
240
|
+
cur = cur[k];
|
|
241
|
+
}
|
|
242
|
+
return cur;
|
|
243
|
+
};`
|
|
244
|
+
: '';
|
|
245
|
+
const fn = new Function(
|
|
57
246
|
'arr',
|
|
58
247
|
...stateKeys,
|
|
248
|
+
'$',
|
|
59
249
|
`
|
|
250
|
+
${ciWalkerSrc}
|
|
60
251
|
let html = '';
|
|
61
252
|
const len = arr.length;
|
|
62
253
|
for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
|
|
@@ -66,6 +257,8 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
|
66
257
|
return html;
|
|
67
258
|
`,
|
|
68
259
|
);
|
|
260
|
+
|
|
261
|
+
return { fn, domPropertyWrites };
|
|
69
262
|
};
|
|
70
263
|
|
|
71
264
|
// Registry for non-primitive iteration prop snapshots. Lives on `window` (not
|
|
@@ -98,12 +291,13 @@ export const releaseOrphanedIterationProps = (nodes) => {
|
|
|
98
291
|
};
|
|
99
292
|
|
|
100
293
|
// For <component src> elements inside an iteration instance, evaluate any
|
|
101
|
-
// `@[expr]` attribute bindings against the iteration's scoped state and
|
|
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,160 @@ 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
|
+
const renderBatch = (iterationNode, array, state, parent, endComment, parentScope = {}) => {
|
|
151
480
|
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
152
481
|
|
|
153
482
|
if (!iterationNode.runtime.batchFn) {
|
|
154
483
|
const stateKeys = Object.keys(state);
|
|
155
|
-
|
|
484
|
+
const compiled = compileBatchFn(template, itemAlias, indexAlias, stateKeys, parent);
|
|
485
|
+
iterationNode.runtime.batchFn = compiled.fn;
|
|
486
|
+
iterationNode.runtime.domPropertyWrites = compiled.domPropertyWrites;
|
|
156
487
|
iterationNode.runtime.stateKeys = stateKeys;
|
|
157
488
|
}
|
|
158
489
|
|
|
159
|
-
const { batchFn, stateKeys } = iterationNode.runtime;
|
|
490
|
+
const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
|
|
160
491
|
const stateValues = stateKeys.map((k) => state[k]);
|
|
161
|
-
const html = batchFn(array, ...stateValues);
|
|
492
|
+
const html = batchFn(array, ...stateValues, state);
|
|
493
|
+
// Remember the rendered output so a later update can skip re-rendering when an
|
|
494
|
+
// unrelated state change produces identical HTML (see updateIteration).
|
|
495
|
+
iterationNode.runtime.lastBatchHtml = html;
|
|
162
496
|
|
|
163
497
|
batchParseTemplate.innerHTML = html;
|
|
164
498
|
const frag = batchParseTemplate.content;
|
|
@@ -172,6 +506,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment) => {
|
|
|
172
506
|
|
|
173
507
|
parent.insertBefore(frag, endComment);
|
|
174
508
|
iterationNode.runtime.instances = instances;
|
|
509
|
+
|
|
510
|
+
if (domPropertyWrites.length > 0) {
|
|
511
|
+
applyDomPropertyWrites(instances, array, state, itemAlias, indexAlias, domPropertyWrites);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
515
|
+
stampInstanceScopes(iterationNode, parentScope);
|
|
175
516
|
};
|
|
176
517
|
|
|
177
518
|
/**
|
|
@@ -293,9 +634,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
293
634
|
* @param {NodeList} templateNodes - Template nodes to clone
|
|
294
635
|
* @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
|
|
295
636
|
* @param {Object} cachedTree - Optional cached parsed tree from template
|
|
637
|
+
* @param {string|null} componentId - Component the row belongs to, when the
|
|
638
|
+
* iteration sits inside a component. Stashed on the parseContainer so
|
|
639
|
+
* `findComponentIdForElement` can resolve `this.X` during the hydrate pass
|
|
640
|
+
* that runs while clones are still detached.
|
|
296
641
|
* @returns {Object} { element, tree, clonedNodes }
|
|
297
642
|
*/
|
|
298
|
-
export const initializeBlock = (templateNodes, scopedState, cachedTree = null) => {
|
|
643
|
+
export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined) => {
|
|
299
644
|
let tree;
|
|
300
645
|
let clonedNodes = [];
|
|
301
646
|
let firstElement = null;
|
|
@@ -310,6 +655,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
|
|
|
310
655
|
|
|
311
656
|
// Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
|
|
312
657
|
const parseContainer = document.createElement('div');
|
|
658
|
+
if (componentId) parseContainer._vibeComponentId = componentId;
|
|
313
659
|
|
|
314
660
|
// Clone template nodes into container
|
|
315
661
|
for (let i = 0; i < templateNodes.length; i++) {
|
|
@@ -336,8 +682,10 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
|
|
|
336
682
|
// Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
|
|
337
683
|
tree = cloneTreeWithElements(cachedTree, parseContainer);
|
|
338
684
|
} else {
|
|
339
|
-
// Full parse: walk DOM, extract bindings, build tree from scratch
|
|
340
|
-
|
|
685
|
+
// Full parse: walk DOM, extract bindings, build tree from scratch. Pass this
|
|
686
|
+
// loop's aliases so loop-scoped `on*` handlers (and nested ones, via the
|
|
687
|
+
// parser's child-alias accumulation) rewrite to `$scope(this,'alias')`.
|
|
688
|
+
tree = parse(parseContainer, undefined, aliasSet);
|
|
341
689
|
}
|
|
342
690
|
|
|
343
691
|
// Extract the cloned nodes from the container (these are the same nodes the tree references)
|
|
@@ -458,19 +806,19 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
458
806
|
return;
|
|
459
807
|
}
|
|
460
808
|
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (
|
|
470
|
-
return; // Already rendered
|
|
809
|
+
// Recovery for lost comment markers: a <component src> item re-processes its node
|
|
810
|
+
// out of managedNodes, dropping startComment.__vibeRendered. templateRemoved lives
|
|
811
|
+
// on runtime (not the DOM), so it survives — a true value means a prior render
|
|
812
|
+
// already cleared the template, so if content is still present between the
|
|
813
|
+
// comments, it's rendered; don't rebuild. Falls through only if content was lost.
|
|
814
|
+
if (iterationNode.runtime.templateRemoved) {
|
|
815
|
+
let currentNode = startComment.nextSibling;
|
|
816
|
+
while (currentNode && currentNode !== endComment) {
|
|
817
|
+
if (currentNode.nodeType === 1) {
|
|
818
|
+
return; // Already rendered — content still present
|
|
471
819
|
}
|
|
820
|
+
currentNode = currentNode.nextSibling;
|
|
472
821
|
}
|
|
473
|
-
currentNode = currentNode.nextSibling;
|
|
474
822
|
}
|
|
475
823
|
|
|
476
824
|
// Remove template nodes from DOM on first render
|
|
@@ -513,6 +861,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
513
861
|
const instances = [];
|
|
514
862
|
const templateNodes = template.element.childNodes;
|
|
515
863
|
const frag = document.createDocumentFragment();
|
|
864
|
+
// Resolve the iteration's owning component once — `this.X` bindings inside
|
|
865
|
+
// row content resolve against this id while the cloned subtree is still
|
|
866
|
+
// detached during hydrate (see findComponentIdForElement's detached fallback).
|
|
867
|
+
const componentId = findComponentIdForElement(startComment.parentElement);
|
|
516
868
|
|
|
517
869
|
for (let i = 0; i < array.length; i++) {
|
|
518
870
|
const item = array[i];
|
|
@@ -520,7 +872,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
520
872
|
const scopedState = createScopedState(state, localVars, parentScope);
|
|
521
873
|
|
|
522
874
|
// Clone, parse, hydrate
|
|
523
|
-
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
|
|
875
|
+
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
|
|
524
876
|
|
|
525
877
|
// Pre-resolve <component src> binding props against iteration scope (see helper comment)
|
|
526
878
|
resolveIterationComponentProps(clonedNodes, scopedState);
|
|
@@ -545,6 +897,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
545
897
|
parent.insertBefore(frag, endComment);
|
|
546
898
|
iterationNode.runtime.instances = instances;
|
|
547
899
|
|
|
900
|
+
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
901
|
+
stampInstanceScopes(iterationNode, parentScope);
|
|
902
|
+
|
|
548
903
|
// Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
|
|
549
904
|
// Also store runtime data on the DOM node so it persists across re-parses
|
|
550
905
|
// @ts-ignore - adding custom property to comment node
|
|
@@ -553,6 +908,34 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
553
908
|
startComment.__vibeIterationRuntime = iterationNode.runtime;
|
|
554
909
|
};
|
|
555
910
|
|
|
911
|
+
// Evaluate the iteration's optional key expression for one item.
|
|
912
|
+
// Returns undefined when no keyExpr is declared, falling back to the default
|
|
913
|
+
// heuristic in getItemKey.
|
|
914
|
+
const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
|
|
915
|
+
const { keyExpr, itemAlias, indexAlias } = iterationNode.meta;
|
|
916
|
+
if (!keyExpr) return undefined;
|
|
917
|
+
const localVars = { [itemAlias]: item, [indexAlias]: index };
|
|
918
|
+
const scopedState = createScopedState(state, localVars, parentScope);
|
|
919
|
+
try {
|
|
920
|
+
return evalInScope(keyExpr, scopedState, iterationNode.meta.startComment.parentElement);
|
|
921
|
+
} catch {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// One-shot warning when an unkeyed iteration produces index-coupled keys —
|
|
927
|
+
// i.e. the fallback `hash_..._<index>` path. Only emits in debug mode and only
|
|
928
|
+
// once per iteration block, so console doesn't drown.
|
|
929
|
+
const warnIndexCoupledKey = (iterationNode) => {
|
|
930
|
+
if (iterationNode.runtime.warnedIndexCoupled) return;
|
|
931
|
+
if (!globalThis.__vibeDebug) return;
|
|
932
|
+
iterationNode.runtime.warnedIndexCoupled = true;
|
|
933
|
+
const arrayPath = iterationNode.meta.arrayPath;
|
|
934
|
+
console.warn(
|
|
935
|
+
`[vibe] iteration over '${arrayPath}' is using index-coupled keys. Removing earlier items will trigger a full re-render. Add an explicit key: <!-- each ${arrayPath} as item (item.id) -->`,
|
|
936
|
+
);
|
|
937
|
+
};
|
|
938
|
+
|
|
556
939
|
// Update an iteration block when array changes
|
|
557
940
|
export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
|
|
558
941
|
if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
|
|
@@ -565,11 +948,19 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
565
948
|
const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
|
|
566
949
|
|
|
567
950
|
// Use instances (what's actually rendered) as ground truth for old array
|
|
568
|
-
// when oldState disagrees with the rendered count
|
|
951
|
+
// when oldState disagrees with the rendered count, OR when stateOldArray
|
|
952
|
+
// and newArray are the same reference. The latter happens for
|
|
953
|
+
// registry-backed iterations: when the iteration's arrayPath resolves to
|
|
954
|
+
// `window.__vibeIterProps._pN`, refreshIterationComponentProps updates the
|
|
955
|
+
// slot in place, so both reads return the same NEW array. Without this
|
|
956
|
+
// fallback the diff would compare the new array against itself and report
|
|
957
|
+
// no changes — leaving the inlined iteration frozen on previously rendered
|
|
958
|
+
// items.
|
|
569
959
|
const instances = iterationNode.runtime.instances;
|
|
570
|
-
const oldArray =
|
|
571
|
-
|
|
572
|
-
|
|
960
|
+
const oldArray =
|
|
961
|
+
stateOldArray !== newArray && instances.length === stateOldArray.length
|
|
962
|
+
? stateOldArray
|
|
963
|
+
: instances.map((inst) => inst.item);
|
|
573
964
|
|
|
574
965
|
// Compiled path: Use pre-compiled batch function when available
|
|
575
966
|
if (compiled.canUseCompiled(iterationNode)) {
|
|
@@ -596,8 +987,42 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
596
987
|
return;
|
|
597
988
|
}
|
|
598
989
|
|
|
599
|
-
|
|
600
|
-
|
|
990
|
+
// Treeless rows (batch-rendered) can't be patched in place — they have no
|
|
991
|
+
// parsed tree for updateInstance to walk. When affected pushes them as
|
|
992
|
+
// iteration-affected (see the same predicate in affected.js), do a full
|
|
993
|
+
// re-batch so row bindings reflect current state. Clone-rendered iterations
|
|
994
|
+
// skip this — they take the diff path below.
|
|
995
|
+
const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
|
|
996
|
+
if (hasTreelessInstances && oldState !== newState) {
|
|
997
|
+
// affected.js conservatively flags a treeless (batch-rendered) iteration on
|
|
998
|
+
// ANY state change, since it can't walk per-row trees to see which bindings
|
|
999
|
+
// actually depend on what changed. Before tearing down and recreating every
|
|
1000
|
+
// row, re-run the batch: if it yields identical HTML, the rows don't depend
|
|
1001
|
+
// on what changed, so keep the existing DOM nodes — preserving their event
|
|
1002
|
+
// listeners (e.g. tooltip mouseleave) and any in-progress drag. Skip the
|
|
1003
|
+
// shortcut when the template has DOM-property writes (value/checked/etc.),
|
|
1004
|
+
// which aren't reflected in the HTML string.
|
|
1005
|
+
const rt = iterationNode.runtime;
|
|
1006
|
+
if (rt.batchFn && rt.lastBatchHtml !== undefined && (!rt.domPropertyWrites || rt.domPropertyWrites.length === 0)) {
|
|
1007
|
+
const stateValues = rt.stateKeys.map((k) => newState[k]);
|
|
1008
|
+
const newHtml = rt.batchFn(newArray, ...stateValues, newState);
|
|
1009
|
+
if (newHtml === rt.lastBatchHtml) return;
|
|
1010
|
+
}
|
|
1011
|
+
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const oldKeys = oldArray.map((item, i) =>
|
|
1016
|
+
getItemKey(item, i, evalKeyForItem(iterationNode, item, i, oldState, parentScope)),
|
|
1017
|
+
);
|
|
1018
|
+
const newKeys = newArray.map((item, i) =>
|
|
1019
|
+
getItemKey(item, i, evalKeyForItem(iterationNode, item, i, newState, parentScope)),
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
// Detect index-coupled fallback keys (debug-mode warning only).
|
|
1023
|
+
if (!iterationNode.meta.keyExpr && newKeys.some(k => k.startsWith('hash_') || k.startsWith('val_'))) {
|
|
1024
|
+
warnIndexCoupledKey(iterationNode);
|
|
1025
|
+
}
|
|
601
1026
|
|
|
602
1027
|
// O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
|
|
603
1028
|
const oldKeySet = new Set(oldKeys);
|
|
@@ -609,6 +1034,11 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
609
1034
|
// Standard diff-based updates (arrays share some common items)
|
|
610
1035
|
const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
|
|
611
1036
|
|
|
1037
|
+
// Apply array-shape operations and capture which instances were
|
|
1038
|
+
// bindings-refreshed by an op (ADD built a fresh tree with newState;
|
|
1039
|
+
// UPDATE patched bindings in place). Captured by reference, not index,
|
|
1040
|
+
// because indices shift during the loop.
|
|
1041
|
+
const refreshedByOps = new WeakSet();
|
|
612
1042
|
operations.forEach((op) => {
|
|
613
1043
|
switch (op.type) {
|
|
614
1044
|
case 'REMOVE':
|
|
@@ -616,19 +1046,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
616
1046
|
break;
|
|
617
1047
|
case 'ADD':
|
|
618
1048
|
addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
|
|
1049
|
+
refreshedByOps.add(iterationNode.runtime.instances[op.index]);
|
|
619
1050
|
break;
|
|
620
1051
|
case 'MOVE':
|
|
621
1052
|
moveInstance(iterationNode, op.from, op.to);
|
|
622
1053
|
break;
|
|
623
1054
|
case 'UPDATE':
|
|
624
|
-
updateInstance(iterationNode, op.index, op.item, newState, manifest, parentScope);
|
|
1055
|
+
updateInstance(iterationNode, op.index, op.item, oldState, newState, manifest, parentScope);
|
|
1056
|
+
refreshedByOps.add(iterationNode.runtime.instances[op.index]);
|
|
625
1057
|
break;
|
|
626
1058
|
}
|
|
627
1059
|
});
|
|
628
1060
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1061
|
+
// Refresh every row the ops didn't touch: moved rows (index changed,
|
|
1062
|
+
// bindings stale) and untouched rows whose outer-scope bindings depend
|
|
1063
|
+
// on state that changed in this update cycle. Each row goes through
|
|
1064
|
+
// updateInstance exactly once — via an op or here.
|
|
1065
|
+
const retained = iterationNode.runtime.instances;
|
|
1066
|
+
for (let i = 0; i < retained.length; i++) {
|
|
1067
|
+
retained[i].index = i;
|
|
1068
|
+
if (refreshedByOps.has(retained[i])) continue;
|
|
1069
|
+
updateInstance(iterationNode, i, retained[i].item, oldState, newState, manifest, parentScope);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Re-stamp after the diff settles: moved/updated instances now carry their
|
|
1073
|
+
// current item + index, so `$scope` handlers resolve correctly post-reorder.
|
|
1074
|
+
stampInstanceScopes(iterationNode, parentScope);
|
|
632
1075
|
};
|
|
633
1076
|
|
|
634
1077
|
// Bulk replacement: clear all DOM and re-render from scratch
|
|
@@ -653,7 +1096,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
653
1096
|
// For simple templates (no nested iterations/conditionals, single root element),
|
|
654
1097
|
// use batch string rendering: one string concatenation loop + one innerHTML parse
|
|
655
1098
|
if (canUseBatchRender(template)) {
|
|
656
|
-
renderBatch(iterationNode, newArray, state, parent, endComment);
|
|
1099
|
+
renderBatch(iterationNode, newArray, state, parent, endComment, parentScope);
|
|
657
1100
|
const instances = iterationNode.runtime.instances;
|
|
658
1101
|
for (let i = 0; i < instances.length; i++) {
|
|
659
1102
|
if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
|
|
@@ -677,6 +1120,9 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
677
1120
|
}
|
|
678
1121
|
parent.insertBefore(frag, endComment);
|
|
679
1122
|
iterationNode.runtime.instances = instances;
|
|
1123
|
+
|
|
1124
|
+
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
1125
|
+
stampInstanceScopes(iterationNode, parentScope);
|
|
680
1126
|
};
|
|
681
1127
|
|
|
682
1128
|
// Find an instance's canonical in-DOM anchor (the first of its cloned nodes
|
|
@@ -746,10 +1192,11 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
746
1192
|
// Pure function — no DOM insertion, no side effects on iteration state.
|
|
747
1193
|
// Callers decide where the clones go (iteration parent, DocumentFragment).
|
|
748
1194
|
const buildInstance = (iterationNode, item, index, state, parentScope) => {
|
|
749
|
-
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
1195
|
+
const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
|
|
750
1196
|
const localVars = { [itemAlias]: item, [indexAlias]: index };
|
|
751
1197
|
const scopedState = createScopedState(state, localVars, parentScope);
|
|
752
|
-
const
|
|
1198
|
+
const componentId = findComponentIdForElement(startComment.parentElement);
|
|
1199
|
+
const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
|
|
753
1200
|
resolveIterationComponentProps(built.clonedNodes, scopedState);
|
|
754
1201
|
return { ...built, scopedState, localVars };
|
|
755
1202
|
};
|
|
@@ -811,13 +1258,15 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
|
|
|
811
1258
|
}
|
|
812
1259
|
};
|
|
813
1260
|
|
|
814
|
-
|
|
815
|
-
|
|
1261
|
+
// Detach-and-rebuild fallback. Used when the row's parsed tree isn't
|
|
1262
|
+
// available (compiled iterations), or when the row template's top-level
|
|
1263
|
+
// shape itself depends on the item (a `<!-- if -->` directly under the
|
|
1264
|
+
// iteration with the item in its expression — flipping branches needs a
|
|
1265
|
+
// rebuild because the active root element type changes).
|
|
1266
|
+
const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, parentScope) => {
|
|
816
1267
|
const parent = iterationNode.meta.startComment.parentNode;
|
|
817
1268
|
const instance = iterationNode.runtime.instances[index];
|
|
818
1269
|
|
|
819
|
-
// Build fresh first, then detach old — keeps the old DOM as a stable
|
|
820
|
-
// anchor reference until we know how the new nodes are shaped.
|
|
821
1270
|
const built = buildInstance(iterationNode, newItem, index, state, parentScope);
|
|
822
1271
|
detachInstanceDom(iterationNode, index, parent);
|
|
823
1272
|
const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
@@ -828,6 +1277,46 @@ const updateInstance = (iterationNode, index, newItem, state, manifest, parentSc
|
|
|
828
1277
|
instance.tree = built.tree;
|
|
829
1278
|
instance.item = newItem;
|
|
830
1279
|
instance.clonedNodes = built.clonedNodes;
|
|
1280
|
+
instance.scopedState = built.scopedState;
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
// In-place refresh of a single row: rebuild scoped states from oldState +
|
|
1284
|
+
// newState (and possibly a new item), run affected→hydrate against the row's
|
|
1285
|
+
// parsed tree. Same pipeline as top-level state changes; the row's DOM nodes
|
|
1286
|
+
// survive, only changed bindings update.
|
|
1287
|
+
//
|
|
1288
|
+
// Called from two sites in updateIteration:
|
|
1289
|
+
// 1. UPDATE diff op — newItem differs from instance.item
|
|
1290
|
+
// 2. Post-diff outer-state propagation — newItem === instance.item
|
|
1291
|
+
// Both flow through here so iteration updates have one code path, not two.
|
|
1292
|
+
// Compiled / batch instances have no row tree to walk and fall back to a
|
|
1293
|
+
// full rebuild via updateInstanceRebuild.
|
|
1294
|
+
const updateInstance = (iterationNode, index, newItem, oldState, newState, manifest, parentScope = {}) => {
|
|
1295
|
+
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
1296
|
+
const instance = iterationNode.runtime.instances[index];
|
|
1297
|
+
|
|
1298
|
+
if (!instance.tree) {
|
|
1299
|
+
updateInstanceRebuild(iterationNode, index, newItem, newState, manifest, parentScope);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const { itemAlias, indexAlias } = iterationNode.meta;
|
|
1304
|
+
const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
|
|
1305
|
+
const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
|
|
1306
|
+
const oldScopedState = createScopedState(oldState, oldLocalVars, parentScope);
|
|
1307
|
+
const newScopedState = createScopedState(newState, newLocalVars, parentScope);
|
|
1308
|
+
|
|
1309
|
+
refreshIterationComponentProps(instance.clonedNodes, newScopedState);
|
|
1310
|
+
|
|
1311
|
+
const affectedList = affected(instance.tree, oldScopedState, newScopedState);
|
|
1312
|
+
if (affectedList.length > 0) {
|
|
1313
|
+
hydrate(affectedList, newScopedState, manifest, oldScopedState);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
|
|
1317
|
+
|
|
1318
|
+
instance.item = newItem;
|
|
1319
|
+
instance.scopedState = newScopedState;
|
|
831
1320
|
};
|
|
832
1321
|
|
|
833
1322
|
export default {
|