@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.
@@ -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
- // Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
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(/&lt;/g, '<')
22
+ .replace(/&gt;/g, '>')
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/&#39;/g, "'")
25
+ .replace(/&amp;/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
- // TEMPORARY: Disable fast path to test if it's causing duplication
142
- let canUseFastPath = false;
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
- if (canUseFastPath) {
148
- // Fast path: clone template nodes and map to cached tree structure
149
- for (let i = 0; i < templateNodes.length; i++) {
150
- const cloned = templateNodes[i].cloneNode(true);
151
- parseContainer.appendChild(cloned);
152
- if (!firstElement && cloned.nodeType === 1) {
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
- // Slow path: clone and parse from scratch
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
- // 1. Check local scope first (item, index, array)
200
- if (prop in localVars) {
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(target) {
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
- // Handle this.property for component-scoped arrays
327
- const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
328
-
329
- const array = resolvePath(state, resolvedArrayPath);
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 = [...template.element.childNodes];
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
- // Insert cloned nodes
366
- clonedNodes.forEach((node) => parent.insertBefore(node, endComment));
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
- // Handle this.property for component-scoped arrays
395
- const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
509
+ const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
396
510
 
397
- const oldArray = resolvePath(oldState, resolvedArrayPath) || [];
398
- const newArray = resolvePath(newState, resolvedArrayPath) || [];
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
- // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
419
- // Use for bulk operations (large arrays or empty→full transitions)
420
- if (
421
- window.__VIBE_FAST_ITERATION__ &&
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
- // Add a new instance at the specified index
457
- const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
458
- const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
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
- const localVars = { [itemAlias]: item, [indexAlias]: index };
461
- const scopedState = createScopedState(state, localVars, parentScope);
462
- const templateNodes = [...template.element.childNodes];
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
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
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
- // Find insertion point
467
- const insertBefore =
468
- index < iterationNode.runtime.instances.length
469
- ? iterationNode.runtime.instances[index].clonedNodes?.[0] ||
470
- iterationNode.runtime.instances[index].element
471
- : endComment;
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
- // Insert cloned nodes
474
- clonedNodes.forEach((node) => startComment.parentNode.insertBefore(node, insertBefore));
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
- // Recursively render nested iterations and conditionals
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
- iterationNode.runtime.instances.splice(index, 0, { element, tree, item, index, clonedNodes });
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
- const instance = iterationNode.runtime.instances[index];
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
- if (fromIndex < 0 || fromIndex >= iterationNode.runtime.instances.length) return;
504
- if (toIndex < 0 || toIndex >= iterationNode.runtime.instances.length) return;
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 = iterationNode.runtime.instances[fromIndex];
507
- const nodes = instance.clonedNodes || [instance.element];
508
- const parent = nodes[0]?.parentNode;
746
+ const instance = instances[fromIndex];
747
+ instances.splice(fromIndex, 1);
748
+ instances.splice(toIndex, 0, instance);
509
749
 
510
- iterationNode.runtime.instances.splice(fromIndex, 1);
511
- iterationNode.runtime.instances.splice(toIndex, 0, instance);
512
-
513
- // Find new insertion point
514
- const nextInstance = iterationNode.runtime.instances[toIndex + 1];
515
- const insertBefore = nextInstance
516
- ? nextInstance.clonedNodes?.[0] || nextInstance.element
517
- : iterationNode.meta.endComment;
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
- const localVars = { [itemAlias]: newItem, [indexAlias]: index };
531
- const scopedState = createScopedState(state, localVars, parentScope);
532
- const templateNodes = [...template.element.childNodes];
533
-
534
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
535
-
536
- // Remove old nodes, insert new ones in same position
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
- // Update instance
552
- instance.element = element;
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
- return path.split('.').reduce((acc, part) => acc?.[part], obj);
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