@ape-egg/vibe 1.7.2 → 1.9.0
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 +139 -0
- package/README.md +28 -0
- package/boot.js +5 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +85 -496
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +3 -11
- package/compiler/src/parser/html.rs +15 -15
- package/index.js +15 -0
- package/package.json +2 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +54 -16
- package/runtime/component.js +287 -110
- package/runtime/conditionals.js +99 -7
- package/runtime/constants.js +10 -6
- package/runtime/index.js +142 -47
- package/runtime/iterate.js +364 -142
- package/runtime/iteration-utils.js +5 -1
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +67 -80
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +26 -7
- package/runtime/utils.js +97 -5
- package/vibe.css +3 -1
package/runtime/iterate.js
CHANGED
|
@@ -2,16 +2,125 @@ 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 } from './utils.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// See: _vibe-compiled-iteration-batch.js for implementation details
|
|
9
|
-
// This is a preview of what Vibe Compiled (Phase 2) will do automatically
|
|
10
|
-
import * as fastPath from './_vibe-compiled-iteration-batch.js';
|
|
5
|
+
import { resolveThisPath, evalInScope } from './utils.js';
|
|
6
|
+
import { managedNodes } from './conditionals.js';
|
|
7
|
+
import { BINDING_REGEX } from './constants.js';
|
|
11
8
|
|
|
12
9
|
// Pre-compiled iteration optimization (production)
|
|
13
10
|
import * as compiled from './pre-compiled-iterations.js';
|
|
14
11
|
|
|
12
|
+
// Runtime batch-render helpers for full-replacement of simple templates.
|
|
13
|
+
// Build an HTML string via template-literal compilation, then parse once —
|
|
14
|
+
// avoids per-item clone/parse/hydrate in the hot path.
|
|
15
|
+
// Only used for templates without nested <!-- each --> / <!-- if -->.
|
|
16
|
+
const batchParseTemplate = document.createElement('template');
|
|
17
|
+
|
|
18
|
+
// innerHTML serialization encodes <, >, &, ", ' inside attribute values.
|
|
19
|
+
// Decode them back before wrapping @[expr] in ${...} for the template literal.
|
|
20
|
+
const decodeEntities = (s) => s
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'")
|
|
25
|
+
.replace(/&/g, '&');
|
|
26
|
+
|
|
27
|
+
const hasNestedStructures = (tree) => {
|
|
28
|
+
if (!tree || !tree.children) return false;
|
|
29
|
+
for (const key in tree.children) {
|
|
30
|
+
const child = tree.children[key];
|
|
31
|
+
if (!child) continue;
|
|
32
|
+
if (child.type === 'iteration' || child.type === 'conditional') return true;
|
|
33
|
+
if (hasNestedStructures(child)) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const canUseBatchRender = (template) =>
|
|
39
|
+
!hasNestedStructures(template) && template.element.children.length <= 1;
|
|
40
|
+
|
|
41
|
+
const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
42
|
+
const templateHtml = template.element.innerHTML.trim();
|
|
43
|
+
const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
44
|
+
const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
|
|
45
|
+
|
|
46
|
+
return new Function(
|
|
47
|
+
'arr',
|
|
48
|
+
...stateKeys,
|
|
49
|
+
`
|
|
50
|
+
let html = '';
|
|
51
|
+
const len = arr.length;
|
|
52
|
+
for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
|
|
53
|
+
const ${itemAlias} = arr[${indexAlias}];
|
|
54
|
+
html += \`${code}\`;
|
|
55
|
+
}
|
|
56
|
+
return html;
|
|
57
|
+
`,
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// For <component src> elements inside an iteration instance, evaluate any
|
|
62
|
+
// `@[expr]` attribute bindings against the iteration's scoped state and replace
|
|
63
|
+
// them with the resolved literal value. Component[src] attributes intentionally
|
|
64
|
+
// bypass hydrate (parse.js) so they reach processComponent as bindings — but
|
|
65
|
+
// bindings that depend on iteration-local vars (item, index) can't resolve later
|
|
66
|
+
// when processComponent inlines the component, since by then iteration scope is gone.
|
|
67
|
+
// Only called from iteration code paths; conditionals don't need this because their
|
|
68
|
+
// branch content is registered in the global manifest and reacts to state updates.
|
|
69
|
+
const resolveIterationComponentProps = (nodes, scopedState) => {
|
|
70
|
+
for (let n = 0; n < nodes.length; n++) {
|
|
71
|
+
const node = nodes[n];
|
|
72
|
+
if (node.nodeType !== 1) continue;
|
|
73
|
+
const components = node.matches?.('component[src], div.component[src]')
|
|
74
|
+
? [node, ...node.querySelectorAll('component[src], div.component[src]')]
|
|
75
|
+
: [...node.querySelectorAll('component[src], div.component[src]')];
|
|
76
|
+
for (let i = 0; i < components.length; i++) {
|
|
77
|
+
const el = components[i];
|
|
78
|
+
const attrs = el.attributes;
|
|
79
|
+
for (let j = 0; j < attrs.length; j++) {
|
|
80
|
+
const attr = attrs[j];
|
|
81
|
+
if (attr.name === 'src') continue;
|
|
82
|
+
const match = attr.value.match(/^@\[(.+)\]$/);
|
|
83
|
+
if (!match) continue;
|
|
84
|
+
try {
|
|
85
|
+
const value = evalInScope(match[1], scopedState, el);
|
|
86
|
+
if (value !== undefined) {
|
|
87
|
+
el.setAttribute(attr.name, String(value));
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Leave binding raw — processComponent will handle it as a binding
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const renderBatch = (iterationNode, array, state, parent, endComment) => {
|
|
98
|
+
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
99
|
+
|
|
100
|
+
if (!iterationNode.runtime.batchFn) {
|
|
101
|
+
const stateKeys = Object.keys(state);
|
|
102
|
+
iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
|
|
103
|
+
iterationNode.runtime.stateKeys = stateKeys;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { batchFn, stateKeys } = iterationNode.runtime;
|
|
107
|
+
const stateValues = stateKeys.map((k) => state[k]);
|
|
108
|
+
const html = batchFn(array, ...stateValues);
|
|
109
|
+
|
|
110
|
+
batchParseTemplate.innerHTML = html;
|
|
111
|
+
const frag = batchParseTemplate.content;
|
|
112
|
+
const kids = frag.children;
|
|
113
|
+
|
|
114
|
+
const arrayLen = array.length;
|
|
115
|
+
const instances = new Array(arrayLen);
|
|
116
|
+
for (let i = 0; i < arrayLen; i++) {
|
|
117
|
+
instances[i] = { element: kids[i], item: array[i], index: i };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
parent.insertBefore(frag, endComment);
|
|
121
|
+
iterationNode.runtime.instances = instances;
|
|
122
|
+
};
|
|
123
|
+
|
|
15
124
|
/**
|
|
16
125
|
* Find a comment node with matching text content in the given nodes.
|
|
17
126
|
*/
|
|
@@ -138,32 +247,43 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
|
|
|
138
247
|
let clonedNodes = [];
|
|
139
248
|
let firstElement = null;
|
|
140
249
|
|
|
141
|
-
//
|
|
142
|
-
|
|
250
|
+
// Use cached tree when available: cloneTreeWithElements maps the existing parsed structure
|
|
251
|
+
// onto cloned DOM nodes, avoiding a full parse() call per iteration item.
|
|
252
|
+
// Only fall back to parse() when no cached tree exists (first parse of a new template).
|
|
253
|
+
// cloneTreeWithElements has a mapping bug with compiled mode's tree structure.
|
|
254
|
+
// Keep disabled until the root cause is fixed — the other optimizations
|
|
255
|
+
// (bulk replacement, evalInScope caching, DocumentFragment) cover the hot paths.
|
|
256
|
+
const useCachedTree = false;
|
|
143
257
|
|
|
144
258
|
// Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
|
|
145
259
|
const parseContainer = document.createElement('div');
|
|
146
260
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
firstElement = cloned;
|
|
154
|
-
}
|
|
261
|
+
// Clone template nodes into container
|
|
262
|
+
for (let i = 0; i < templateNodes.length; i++) {
|
|
263
|
+
const cloned = templateNodes[i].cloneNode(true);
|
|
264
|
+
parseContainer.appendChild(cloned);
|
|
265
|
+
if (!firstElement && cloned.nodeType === 1) {
|
|
266
|
+
firstElement = cloned;
|
|
155
267
|
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Preserve raw slot content of nested <component src> elements before renderAllConditionals /
|
|
271
|
+
// renderAllIterations runs on this branch/iteration instance — those paths strip <!-- if -->
|
|
272
|
+
// and <!-- each --> templates from the live DOM, so by the time processComponent reads
|
|
273
|
+
// el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
|
|
274
|
+
// would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
|
|
275
|
+
// _vibeSlotContent on every clone.
|
|
276
|
+
const components = parseContainer.querySelectorAll('component[src], div.component[src]');
|
|
277
|
+
for (let i = 0; i < components.length; i++) {
|
|
278
|
+
const el = components[i];
|
|
279
|
+
if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (useCachedTree) {
|
|
283
|
+
// Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
|
|
156
284
|
tree = cloneTreeWithElements(cachedTree, parseContainer);
|
|
157
285
|
} else {
|
|
158
|
-
//
|
|
159
|
-
for (let i = 0; i < templateNodes.length; i++) {
|
|
160
|
-
const cloned = templateNodes[i].cloneNode(true);
|
|
161
|
-
parseContainer.appendChild(cloned);
|
|
162
|
-
if (!firstElement && cloned.nodeType === 1) {
|
|
163
|
-
firstElement = cloned;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Parse the entire container (includes all nodes + conditionals)
|
|
286
|
+
// Full parse: walk DOM, extract bindings, build tree from scratch
|
|
167
287
|
tree = parse(parseContainer);
|
|
168
288
|
}
|
|
169
289
|
|
|
@@ -194,24 +314,22 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
|
|
|
194
314
|
|
|
195
315
|
// Create a proxied state with scoped variables (item, index, array)
|
|
196
316
|
export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
317
|
+
// Pre-compute the combined key list once at creation time.
|
|
318
|
+
// Avoids rebuilding 3 arrays + Set on every Object.keys() call.
|
|
319
|
+
const cachedKeys = [...new Set([
|
|
320
|
+
...Object.keys(localVars),
|
|
321
|
+
...Object.keys(parentScope),
|
|
322
|
+
...Reflect.ownKeys(globalState),
|
|
323
|
+
])];
|
|
324
|
+
|
|
197
325
|
return new Proxy(globalState, {
|
|
198
326
|
get(target, prop) {
|
|
199
|
-
|
|
200
|
-
if (prop in
|
|
201
|
-
return localVars[prop];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 2. Check parent scope (for nested iterations)
|
|
205
|
-
if (prop in parentScope) {
|
|
206
|
-
return parentScope[prop];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 3. Fall back to global state
|
|
327
|
+
if (prop in localVars) return localVars[prop];
|
|
328
|
+
if (prop in parentScope) return parentScope[prop];
|
|
210
329
|
return Reflect.get(target, prop);
|
|
211
330
|
},
|
|
212
331
|
|
|
213
332
|
set(target, prop, value) {
|
|
214
|
-
// Only allow setting global state, not local vars
|
|
215
333
|
if (prop in localVars) {
|
|
216
334
|
console.warn(`Cannot modify iteration variable '${prop}'`);
|
|
217
335
|
return false;
|
|
@@ -223,21 +341,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
223
341
|
return Reflect.set(target, prop, value);
|
|
224
342
|
},
|
|
225
343
|
|
|
226
|
-
ownKeys(
|
|
227
|
-
// Return all keys: local vars, parent scope, and global state
|
|
228
|
-
const localKeys = Object.keys(localVars);
|
|
229
|
-
const parentKeys = Object.keys(parentScope);
|
|
230
|
-
const globalKeys = Reflect.ownKeys(target);
|
|
231
|
-
return [...new Set([...localKeys, ...parentKeys, ...globalKeys])];
|
|
232
|
-
},
|
|
344
|
+
ownKeys() { return cachedKeys; },
|
|
233
345
|
|
|
234
346
|
has(target, prop) {
|
|
235
|
-
// Check if property exists in local scope, parent scope, or global state
|
|
236
347
|
return prop in localVars || prop in parentScope || Reflect.has(target, prop);
|
|
237
348
|
},
|
|
238
349
|
|
|
239
350
|
getOwnPropertyDescriptor(target, prop) {
|
|
240
|
-
// Provide property descriptor for local vars and parent scope
|
|
241
351
|
if (prop in localVars) {
|
|
242
352
|
return { configurable: true, enumerable: true, value: localVars[prop] };
|
|
243
353
|
}
|
|
@@ -323,10 +433,12 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
323
433
|
|
|
324
434
|
const parent = startComment.parentNode;
|
|
325
435
|
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
436
|
+
// Evaluate the array expression — supports state paths (items),
|
|
437
|
+
// window globals (window.fights), method calls (items.filter(...)),
|
|
438
|
+
// and inline literals (['a', 'b']). Falls back to resolvePath for
|
|
439
|
+
// simple paths that evalInScope might miss in scoped contexts.
|
|
440
|
+
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
441
|
+
const array = evalInScope(resolvedExpr, state, startComment.parentElement) ?? resolvePath(state, resolvedExpr);
|
|
330
442
|
if (!Array.isArray(array) || array.length === 0) {
|
|
331
443
|
iterationNode.runtime.instances = [];
|
|
332
444
|
return;
|
|
@@ -344,15 +456,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
344
456
|
// Fall through to runtime path if compiled failed
|
|
345
457
|
}
|
|
346
458
|
|
|
347
|
-
// Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
|
|
348
|
-
if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template)) {
|
|
349
|
-
fastPath.renderFast(iterationNode, array, state, parent, endComment);
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
459
|
// Standard path: clone and hydrate each item (handles nested iterations/conditionals)
|
|
354
460
|
const instances = [];
|
|
355
|
-
const templateNodes =
|
|
461
|
+
const templateNodes = template.element.childNodes;
|
|
462
|
+
const frag = document.createDocumentFragment();
|
|
356
463
|
|
|
357
464
|
for (let i = 0; i < array.length; i++) {
|
|
358
465
|
const item = array[i];
|
|
@@ -362,8 +469,14 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
362
469
|
// Clone, parse, hydrate
|
|
363
470
|
const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
|
|
364
471
|
|
|
365
|
-
//
|
|
366
|
-
clonedNodes
|
|
472
|
+
// Pre-resolve <component src> binding props against iteration scope (see helper comment)
|
|
473
|
+
resolveIterationComponentProps(clonedNodes, scopedState);
|
|
474
|
+
|
|
475
|
+
// Collect nodes in DocumentFragment (single DOM insertion at end)
|
|
476
|
+
for (let j = 0; j < clonedNodes.length; j++) {
|
|
477
|
+
frag.appendChild(clonedNodes[j]);
|
|
478
|
+
if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
|
|
479
|
+
}
|
|
367
480
|
|
|
368
481
|
// Recursively render nested iterations and conditionals
|
|
369
482
|
if (tree) {
|
|
@@ -375,6 +488,8 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
375
488
|
instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
|
|
376
489
|
}
|
|
377
490
|
|
|
491
|
+
// Single DOM insertion for all items
|
|
492
|
+
parent.insertBefore(frag, endComment);
|
|
378
493
|
iterationNode.runtime.instances = instances;
|
|
379
494
|
|
|
380
495
|
// Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
|
|
@@ -391,11 +506,17 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
391
506
|
|
|
392
507
|
const { arrayPath, template, startComment, endComment } = iterationNode.meta;
|
|
393
508
|
|
|
394
|
-
|
|
395
|
-
const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
|
|
509
|
+
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
396
510
|
|
|
397
|
-
const
|
|
398
|
-
const newArray = resolvePath(newState,
|
|
511
|
+
const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
|
|
512
|
+
const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
|
|
513
|
+
|
|
514
|
+
// Use instances (what's actually rendered) as ground truth for old array
|
|
515
|
+
// when oldState disagrees with the rendered count.
|
|
516
|
+
const instances = iterationNode.runtime.instances;
|
|
517
|
+
const oldArray = instances.length === stateOldArray.length
|
|
518
|
+
? stateOldArray
|
|
519
|
+
: instances.map(inst => inst.item);
|
|
399
520
|
|
|
400
521
|
// Compiled path: Use pre-compiled batch function when available
|
|
401
522
|
if (compiled.canUseCompiled(iterationNode)) {
|
|
@@ -415,20 +536,24 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
415
536
|
// Fall through to runtime path if compiled failed
|
|
416
537
|
}
|
|
417
538
|
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
fastPath.canUseFastPath(template) &&
|
|
423
|
-
(isEmptyToFull || isFullToEmpty || isLargeArray)
|
|
424
|
-
) {
|
|
425
|
-
fastPath.updateFast(iterationNode, newArray, newState, startComment, endComment);
|
|
539
|
+
// Bulk path: skip O(n²) LCS when arrays share no common items
|
|
540
|
+
// Handles empty→full, full→empty, and full replacement (no shared keys)
|
|
541
|
+
if (oldArray.length === 0 || newArray.length === 0) {
|
|
542
|
+
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
426
543
|
return;
|
|
427
544
|
}
|
|
428
545
|
|
|
429
|
-
// Standard diff-based updates
|
|
430
546
|
const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
|
|
431
547
|
const newKeys = newArray.map((item, i) => getItemKey(item, i));
|
|
548
|
+
|
|
549
|
+
// O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
|
|
550
|
+
const oldKeySet = new Set(oldKeys);
|
|
551
|
+
if (!newKeys.some(k => oldKeySet.has(k))) {
|
|
552
|
+
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Standard diff-based updates (arrays share some common items)
|
|
432
557
|
const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
|
|
433
558
|
|
|
434
559
|
operations.forEach((op) => {
|
|
@@ -453,106 +578,203 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
453
578
|
});
|
|
454
579
|
};
|
|
455
580
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
581
|
+
// Bulk replacement: clear all DOM and re-render from scratch
|
|
582
|
+
// Used when arrays share no common keys (avoids O(n²) LCS)
|
|
583
|
+
const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
584
|
+
const { template, startComment, endComment } = iterationNode.meta;
|
|
585
|
+
const parent = startComment.parentNode;
|
|
459
586
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
587
|
+
// Clear all existing DOM between comments using Range (single operation)
|
|
588
|
+
if (iterationNode.runtime.instances.length > 0) {
|
|
589
|
+
const range = document.createRange();
|
|
590
|
+
range.setStartAfter(startComment);
|
|
591
|
+
range.setEndBefore(endComment);
|
|
592
|
+
range.deleteContents();
|
|
593
|
+
}
|
|
463
594
|
|
|
464
|
-
|
|
595
|
+
if (newArray.length === 0) {
|
|
596
|
+
iterationNode.runtime.instances = [];
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// For simple templates (no nested iterations/conditionals, single root element),
|
|
601
|
+
// use batch string rendering: one string concatenation loop + one innerHTML parse
|
|
602
|
+
if (canUseBatchRender(template)) {
|
|
603
|
+
renderBatch(iterationNode, newArray, state, parent, endComment);
|
|
604
|
+
const instances = iterationNode.runtime.instances;
|
|
605
|
+
for (let i = 0; i < instances.length; i++) {
|
|
606
|
+
if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Complex templates: build each instance, batch-append into a DocumentFragment,
|
|
612
|
+
// finalize (mark managed + render nested), then commit to the DOM in one
|
|
613
|
+
// parent.insertBefore call.
|
|
614
|
+
const instances = [];
|
|
615
|
+
const frag = document.createDocumentFragment();
|
|
616
|
+
for (let i = 0; i < newArray.length; i++) {
|
|
617
|
+
const built = buildInstance(iterationNode, newArray[i], i, state, parentScope);
|
|
618
|
+
for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
|
|
619
|
+
finalizeInstance(built, manifest, parentScope);
|
|
620
|
+
instances.push({
|
|
621
|
+
element: built.element, tree: built.tree, item: newArray[i], index: i,
|
|
622
|
+
clonedNodes: built.clonedNodes, scopedState: built.scopedState,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
parent.insertBefore(frag, endComment);
|
|
626
|
+
iterationNode.runtime.instances = instances;
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// Find an instance's canonical in-DOM anchor (the first of its cloned nodes
|
|
630
|
+
// that still lives directly under the iteration's parent). Nested primitives
|
|
631
|
+
// inside the iteration template — <!-- if -->, <!-- each -->, <component> —
|
|
632
|
+
// can move/replace cloned nodes between iteration renders (inactive branches
|
|
633
|
+
// get hoisted into template containers; component[src] wrappers get swapped
|
|
634
|
+
// for processed wrappers). Any of those mutations make `clonedNodes[0]` a
|
|
635
|
+
// stale reference to a node no longer under the iteration parent. Callers use
|
|
636
|
+
// this anchor instead of trusting `clonedNodes[0]` directly, so that
|
|
637
|
+
// insert-before / move operations always resolve against the iteration's real
|
|
638
|
+
// DOM slot.
|
|
639
|
+
const findInstanceAnchor = (instance, iterationParent) => {
|
|
640
|
+
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
641
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
642
|
+
if (nodes[i]?.parentNode === iterationParent) return nodes[i];
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// Resolve the reference node for `parent.insertBefore(..., ref)` at a given
|
|
648
|
+
// logical iteration index. Walks later instances until it finds one with a
|
|
649
|
+
// live anchor under the iteration parent; falls back to `endComment` when no
|
|
650
|
+
// later instance has any node currently mounted in the iteration.
|
|
651
|
+
const resolveInsertBefore = (iterationNode, index, parent) => {
|
|
652
|
+
const { instances } = iterationNode.runtime;
|
|
653
|
+
for (let i = index; i < instances.length; i++) {
|
|
654
|
+
const anchor = findInstanceAnchor(instances[i], parent);
|
|
655
|
+
if (anchor) return anchor;
|
|
656
|
+
}
|
|
657
|
+
return iterationNode.meta.endComment;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// Detach every DOM node belonging to a logical instance, including content
|
|
661
|
+
// mounted by nested primitives (conditional branches, nested each rows,
|
|
662
|
+
// fetched component wrappers) that isn't tracked in `instance.clonedNodes`.
|
|
663
|
+
// Walks iteration-parent siblings from this instance's anchor up to the next
|
|
664
|
+
// instance's anchor / endComment, so anything in between — clones, mounted
|
|
665
|
+
// branches, swapped-in component wrappers — all gets detached. Also sweeps
|
|
666
|
+
// any clonedNodes that were hoisted out of the iteration parent (e.g. into a
|
|
667
|
+
// sibling conditional's template container).
|
|
668
|
+
const detachInstanceDom = (iterationNode, index, parent) => {
|
|
669
|
+
const instance = iterationNode.runtime.instances[index];
|
|
670
|
+
const { endComment } = iterationNode.meta;
|
|
671
|
+
const anchor = findInstanceAnchor(instance, parent);
|
|
672
|
+
const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
673
|
+
|
|
674
|
+
if (anchor) {
|
|
675
|
+
let cur = anchor;
|
|
676
|
+
// endComment caps the walk even if nextAnchor ordering is ever
|
|
677
|
+
// corrupted — iteration DOM is bounded by startComment / endComment.
|
|
678
|
+
while (cur && cur !== nextAnchor && cur !== endComment) {
|
|
679
|
+
const nextSibling = cur.nextSibling;
|
|
680
|
+
parent.removeChild(cur);
|
|
681
|
+
cur = nextSibling;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
465
684
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
685
|
+
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
686
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
687
|
+
const n = nodes[i];
|
|
688
|
+
if (n && n.parentNode && n.parentNode !== parent) n.parentNode.removeChild(n);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
472
691
|
|
|
473
|
-
|
|
474
|
-
|
|
692
|
+
// Build a fresh instance's DOM + tree + scope from the iteration template.
|
|
693
|
+
// Pure function — no DOM insertion, no side effects on iteration state.
|
|
694
|
+
// Callers decide where the clones go (iteration parent, DocumentFragment).
|
|
695
|
+
const buildInstance = (iterationNode, item, index, state, parentScope) => {
|
|
696
|
+
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
697
|
+
const localVars = { [itemAlias]: item, [indexAlias]: index };
|
|
698
|
+
const scopedState = createScopedState(state, localVars, parentScope);
|
|
699
|
+
const built = initializeBlock([...template.element.childNodes], scopedState, template);
|
|
700
|
+
resolveIterationComponentProps(built.clonedNodes, scopedState);
|
|
701
|
+
return { ...built, scopedState, localVars };
|
|
702
|
+
};
|
|
475
703
|
|
|
476
|
-
|
|
704
|
+
// After a built instance's clones are placed in the DOM (directly or via a
|
|
705
|
+
// fragment), mark element clones as managed so the page-level MutationObserver
|
|
706
|
+
// skips them in processMutations, then fire nested iteration/conditional
|
|
707
|
+
// renders. Without the managed mark those clones would be re-parsed + hydrated
|
|
708
|
+
// on top of the internal render, duplicating every nested branch.
|
|
709
|
+
const finalizeInstance = (built, manifest, parentScope) => {
|
|
710
|
+
const { clonedNodes, tree, scopedState, localVars } = built;
|
|
711
|
+
for (let i = 0; i < clonedNodes.length; i++) {
|
|
712
|
+
if (clonedNodes[i].nodeType === 1) managedNodes.add(clonedNodes[i]);
|
|
713
|
+
}
|
|
477
714
|
if (tree) {
|
|
478
715
|
const nestedScope = { ...parentScope, ...localVars };
|
|
479
716
|
renderAllIterations(tree, scopedState, manifest, nestedScope);
|
|
480
717
|
_renderAllConditionals(tree, scopedState, manifest, nestedScope);
|
|
481
718
|
}
|
|
719
|
+
};
|
|
482
720
|
|
|
483
|
-
|
|
721
|
+
const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
|
|
722
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
723
|
+
const built = buildInstance(iterationNode, item, index, state, parentScope);
|
|
724
|
+
const insertBefore = resolveInsertBefore(iterationNode, index, parent);
|
|
725
|
+
for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
|
|
726
|
+
finalizeInstance(built, manifest, parentScope);
|
|
727
|
+
iterationNode.runtime.instances.splice(index, 0, {
|
|
728
|
+
element: built.element, tree: built.tree, item, index, clonedNodes: built.clonedNodes,
|
|
729
|
+
});
|
|
484
730
|
};
|
|
485
731
|
|
|
486
|
-
// Remove an instance at the specified index
|
|
487
732
|
const removeInstance = (iterationNode, index) => {
|
|
488
733
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
// Remove all cloned nodes from DOM
|
|
493
|
-
(instance.clonedNodes || [instance.element]).forEach((node) =>
|
|
494
|
-
node?.parentNode?.removeChild(node),
|
|
495
|
-
);
|
|
496
|
-
|
|
734
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
735
|
+
if (parent) detachInstanceDom(iterationNode, index, parent);
|
|
497
736
|
iterationNode.runtime.instances.splice(index, 1);
|
|
498
737
|
};
|
|
499
738
|
|
|
500
|
-
// Move an instance from one position to another
|
|
501
739
|
const moveInstance = (iterationNode, fromIndex, toIndex) => {
|
|
502
740
|
if (fromIndex === toIndex) return;
|
|
503
|
-
|
|
504
|
-
if (
|
|
741
|
+
const { instances } = iterationNode.runtime;
|
|
742
|
+
if (fromIndex < 0 || fromIndex >= instances.length) return;
|
|
743
|
+
if (toIndex < 0 || toIndex >= instances.length) return;
|
|
744
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
505
745
|
|
|
506
|
-
const instance =
|
|
507
|
-
|
|
508
|
-
|
|
746
|
+
const instance = instances[fromIndex];
|
|
747
|
+
instances.splice(fromIndex, 1);
|
|
748
|
+
instances.splice(toIndex, 0, instance);
|
|
509
749
|
|
|
510
|
-
iterationNode
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
// Move all nodes
|
|
520
|
-
nodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
|
750
|
+
const insertBefore = resolveInsertBefore(iterationNode, toIndex + 1, parent);
|
|
751
|
+
const nodes = instance.clonedNodes || [instance.element];
|
|
752
|
+
// Re-insert only nodes currently under the iteration parent — those hoisted
|
|
753
|
+
// into nested-conditional template containers stay there so we don't
|
|
754
|
+
// double-count branch content.
|
|
755
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
756
|
+
const n = nodes[i];
|
|
757
|
+
if (n?.parentNode === parent) parent.insertBefore(n, insertBefore);
|
|
758
|
+
}
|
|
521
759
|
};
|
|
522
760
|
|
|
523
|
-
// Update an instance with new item data
|
|
524
761
|
const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
|
|
525
762
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
526
|
-
|
|
527
|
-
const { itemAlias, indexAlias, template } = iterationNode.meta;
|
|
763
|
+
const parent = iterationNode.meta.startComment.parentNode;
|
|
528
764
|
const instance = iterationNode.runtime.instances[index];
|
|
529
765
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const oldNodes = instance.clonedNodes || [instance.element];
|
|
538
|
-
const insertBefore = oldNodes[oldNodes.length - 1]?.nextSibling;
|
|
539
|
-
const parent = oldNodes[0]?.parentNode;
|
|
540
|
-
|
|
541
|
-
oldNodes.forEach((node) => node?.parentNode?.removeChild(node));
|
|
542
|
-
clonedNodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
|
543
|
-
|
|
544
|
-
// Recursively render nested iterations and conditionals
|
|
545
|
-
if (tree) {
|
|
546
|
-
const nestedScope = { ...parentScope, ...localVars };
|
|
547
|
-
renderAllIterations(tree, scopedState, manifest, nestedScope);
|
|
548
|
-
_renderAllConditionals(tree, scopedState, manifest, nestedScope);
|
|
549
|
-
}
|
|
766
|
+
// Build fresh first, then detach old — keeps the old DOM as a stable
|
|
767
|
+
// anchor reference until we know how the new nodes are shaped.
|
|
768
|
+
const built = buildInstance(iterationNode, newItem, index, state, parentScope);
|
|
769
|
+
detachInstanceDom(iterationNode, index, parent);
|
|
770
|
+
const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
771
|
+
for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
|
|
772
|
+
finalizeInstance(built, manifest, parentScope);
|
|
550
773
|
|
|
551
|
-
|
|
552
|
-
instance.
|
|
553
|
-
instance.tree = tree;
|
|
774
|
+
instance.element = built.element;
|
|
775
|
+
instance.tree = built.tree;
|
|
554
776
|
instance.item = newItem;
|
|
555
|
-
instance.clonedNodes = clonedNodes;
|
|
777
|
+
instance.clonedNodes = built.clonedNodes;
|
|
556
778
|
};
|
|
557
779
|
|
|
558
780
|
export default {
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
|
|
3
3
|
|
|
4
4
|
// Resolve nested paths in state (e.g., "user.items" -> state.user.items)
|
|
5
|
+
// Supports bracket notation: "teams[0].combatants" -> state.teams[0].combatants
|
|
5
6
|
export const resolvePath = (obj, path) => {
|
|
6
7
|
if (!path || !obj) return undefined;
|
|
7
|
-
|
|
8
|
+
// Split on dots and brackets: "a[0].b[1].c" → ["a", "0", "b", "1", "c"]
|
|
9
|
+
const parts = path.match(/[^.\[\]]+/g);
|
|
10
|
+
if (!parts) return undefined;
|
|
11
|
+
return parts.reduce((acc, part) => acc?.[part], obj);
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
// Clone template element preserving structure
|