@ape-egg/vibe 1.8.0 → 1.9.1

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,17 +2,178 @@ 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';
5
+ import { resolveThisPath, evalInScope } from './utils.js';
6
6
  import { managedNodes } from './conditionals.js';
7
-
8
- // Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
9
- // See: _vibe-compiled-iteration-batch.js for implementation details
10
- // This is a preview of what Vibe Compiled (Phase 2) will do automatically
11
- import * as fastPath from './_vibe-compiled-iteration-batch.js';
7
+ import { BINDING_REGEX } from './constants.js';
12
8
 
13
9
  // Pre-compiled iteration optimization (production)
14
10
  import * as compiled from './pre-compiled-iterations.js';
15
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
+ // Batch render template-literal-interpolates `@[expr]` as `${expr}` — that
39
+ // stringifies object/array values, which breaks `<component src>` props that
40
+ // rely on resolveIterationComponentProps to stash non-primitives in the
41
+ // registry. Templates carrying any `<component src>` go through the
42
+ // clone+hydrate path instead.
43
+ const hasComponentSrc = (templateEl) =>
44
+ !!templateEl.querySelector?.('component[src], div.component[src]');
45
+
46
+ const canUseBatchRender = (template) =>
47
+ !hasNestedStructures(template) &&
48
+ template.element.children.length <= 1 &&
49
+ !hasComponentSrc(template.element);
50
+
51
+ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
52
+ const templateHtml = template.element.innerHTML.trim();
53
+ const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
54
+ const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
55
+
56
+ return new Function(
57
+ 'arr',
58
+ ...stateKeys,
59
+ `
60
+ let html = '';
61
+ const len = arr.length;
62
+ for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
63
+ const ${itemAlias} = arr[${indexAlias}];
64
+ html += \`${code}\`;
65
+ }
66
+ return html;
67
+ `,
68
+ );
69
+ };
70
+
71
+ // Registry for non-primitive iteration prop snapshots. Lives on `window` (not
72
+ // on `$`) so it doesn't pollute user-visible state enumeration, but is still
73
+ // reachable from binding expressions because `window` is in evalInScope's
74
+ // known-globals list. Each entry is freed when the owning component element
75
+ // is detached (see releaseOrphanedIterationProps).
76
+ let __vibeIterPropCounter = 0;
77
+ const ensureIterPropsRegistry = () => {
78
+ if (!window.__vibeIterProps) window.__vibeIterProps = {};
79
+ return window.__vibeIterProps;
80
+ };
81
+
82
+ // Walk a removed subtree and free any iteration-prop registry slots stashed
83
+ // on `<component>` elements inside it. Called from the mutation-observer
84
+ // cleanup path after DOM detachment.
85
+ export const releaseOrphanedIterationProps = (nodes) => {
86
+ if (!window.__vibeIterProps) return;
87
+ for (const node of nodes) {
88
+ if (node.nodeType !== 1) continue;
89
+ const free = (el) => {
90
+ const ids = el._vibeIterPropIds;
91
+ if (!ids) return;
92
+ for (const id of ids) delete window.__vibeIterProps[id];
93
+ el._vibeIterPropIds = null;
94
+ };
95
+ free(node);
96
+ node.querySelectorAll?.('[data-vibe-iter-prop]').forEach(free);
97
+ }
98
+ };
99
+
100
+ // For <component src> elements inside an iteration instance, evaluate any
101
+ // `@[expr]` attribute bindings against the iteration's scoped state and replace
102
+ // them with the resolved value. Primitives stringify into the attribute as
103
+ // before. Non-primitives (objects, arrays) snapshot into the registry and the
104
+ // attribute becomes a binding into that slot — preserving live object/array
105
+ // access for the child component's template (`@[prop.x]`,
106
+ // `<!-- each prop as item -->`, etc.).
107
+ //
108
+ // Component[src] attributes intentionally bypass hydrate (parse.js) so they
109
+ // reach processComponent as bindings — but bindings that depend on
110
+ // iteration-local vars (item, index) can't resolve later when processComponent
111
+ // inlines the component, since by then iteration scope is gone. Only called
112
+ // from iteration code paths; conditionals don't need this because their branch
113
+ // content is registered in the global manifest and reacts to state updates.
114
+ const resolveIterationComponentProps = (nodes, scopedState) => {
115
+ for (let n = 0; n < nodes.length; n++) {
116
+ const node = nodes[n];
117
+ if (node.nodeType !== 1) continue;
118
+ const components = node.matches?.('component[src], div.component[src]')
119
+ ? [node, ...node.querySelectorAll('component[src], div.component[src]')]
120
+ : [...node.querySelectorAll('component[src], div.component[src]')];
121
+ for (let i = 0; i < components.length; i++) {
122
+ const el = components[i];
123
+ const attrs = el.attributes;
124
+ for (let j = 0; j < attrs.length; j++) {
125
+ const attr = attrs[j];
126
+ if (attr.name === 'src') continue;
127
+ const match = attr.value.match(/^@\[(.+)\]$/);
128
+ if (!match) continue;
129
+ try {
130
+ const value = evalInScope(match[1], scopedState, el);
131
+ if (value === undefined) continue;
132
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
133
+ el.setAttribute(attr.name, String(value));
134
+ } else {
135
+ const registry = ensureIterPropsRegistry();
136
+ const id = `_p${__vibeIterPropCounter++}`;
137
+ registry[id] = value;
138
+ el.setAttribute(attr.name, `@[window.__vibeIterProps.${id}]`);
139
+ el.setAttribute('data-vibe-iter-prop', '');
140
+ (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
141
+ }
142
+ } catch {
143
+ // Leave binding raw — processComponent will handle it as a binding
144
+ }
145
+ }
146
+ }
147
+ }
148
+ };
149
+
150
+ const renderBatch = (iterationNode, array, state, parent, endComment) => {
151
+ const { itemAlias, indexAlias, template } = iterationNode.meta;
152
+
153
+ if (!iterationNode.runtime.batchFn) {
154
+ const stateKeys = Object.keys(state);
155
+ iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
156
+ iterationNode.runtime.stateKeys = stateKeys;
157
+ }
158
+
159
+ const { batchFn, stateKeys } = iterationNode.runtime;
160
+ const stateValues = stateKeys.map((k) => state[k]);
161
+ const html = batchFn(array, ...stateValues);
162
+
163
+ batchParseTemplate.innerHTML = html;
164
+ const frag = batchParseTemplate.content;
165
+ const kids = frag.children;
166
+
167
+ const arrayLen = array.length;
168
+ const instances = new Array(arrayLen);
169
+ for (let i = 0; i < arrayLen; i++) {
170
+ instances[i] = { element: kids[i], item: array[i], index: i };
171
+ }
172
+
173
+ parent.insertBefore(frag, endComment);
174
+ iterationNode.runtime.instances = instances;
175
+ };
176
+
16
177
  /**
17
178
  * Find a comment node with matching text content in the given nodes.
18
179
  */
@@ -159,6 +320,18 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
159
320
  }
160
321
  }
161
322
 
323
+ // Preserve raw slot content of nested <component src> elements before renderAllConditionals /
324
+ // renderAllIterations runs on this branch/iteration instance — those paths strip <!-- if -->
325
+ // and <!-- each --> templates from the live DOM, so by the time processComponent reads
326
+ // el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
327
+ // would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
328
+ // _vibeSlotContent on every clone.
329
+ const components = parseContainer.querySelectorAll('component[src], div.component[src]');
330
+ for (let i = 0; i < components.length; i++) {
331
+ const el = components[i];
332
+ if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
333
+ }
334
+
162
335
  if (useCachedTree) {
163
336
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
164
337
  tree = cloneTreeWithElements(cachedTree, parseContainer);
@@ -313,10 +486,12 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
313
486
 
314
487
  const parent = startComment.parentNode;
315
488
 
316
- // Handle this.property for component-scoped arrays
317
- const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
318
-
319
- const array = resolvePath(state, resolvedArrayPath);
489
+ // Evaluate the array expression — supports state paths (items),
490
+ // window globals (window.fights), method calls (items.filter(...)),
491
+ // and inline literals (['a', 'b']). Falls back to resolvePath for
492
+ // simple paths that evalInScope might miss in scoped contexts.
493
+ const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
494
+ const array = evalInScope(resolvedExpr, state, startComment.parentElement) ?? resolvePath(state, resolvedExpr);
320
495
  if (!Array.isArray(array) || array.length === 0) {
321
496
  iterationNode.runtime.instances = [];
322
497
  return;
@@ -347,6 +522,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
347
522
  // Clone, parse, hydrate
348
523
  const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
349
524
 
525
+ // Pre-resolve <component src> binding props against iteration scope (see helper comment)
526
+ resolveIterationComponentProps(clonedNodes, scopedState);
527
+
350
528
  // Collect nodes in DocumentFragment (single DOM insertion at end)
351
529
  for (let j = 0; j < clonedNodes.length; j++) {
352
530
  frag.appendChild(clonedNodes[j]);
@@ -381,11 +559,10 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
381
559
 
382
560
  const { arrayPath, template, startComment, endComment } = iterationNode.meta;
383
561
 
384
- // Handle this.property for component-scoped arrays
385
- const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
562
+ const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
386
563
 
387
- const stateOldArray = resolvePath(oldState, resolvedArrayPath) || [];
388
- const newArray = resolvePath(newState, resolvedArrayPath) || [];
564
+ const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
565
+ const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
389
566
 
390
567
  // Use instances (what's actually rendered) as ground truth for old array
391
568
  // when oldState disagrees with the rendered count.
@@ -457,7 +634,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
457
634
  // Bulk replacement: clear all DOM and re-render from scratch
458
635
  // Used when arrays share no common keys (avoids O(n²) LCS)
459
636
  const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
460
- const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
637
+ const { template, startComment, endComment } = iterationNode.meta;
461
638
  const parent = startComment.parentNode;
462
639
 
463
640
  // Clear all existing DOM between comments using Range (single operation)
@@ -475,8 +652,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
475
652
 
476
653
  // For simple templates (no nested iterations/conditionals, single root element),
477
654
  // use batch string rendering: one string concatenation loop + one innerHTML parse
478
- if (fastPath.canUseFastPath(template) && template.element.children.length <= 1) {
479
- fastPath.renderFast(iterationNode, newArray, state, parent, endComment);
655
+ if (canUseBatchRender(template)) {
656
+ renderBatch(iterationNode, newArray, state, parent, endComment);
480
657
  const instances = iterationNode.runtime.instances;
481
658
  for (let i = 0; i < instances.length; i++) {
482
659
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -484,135 +661,173 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
484
661
  return;
485
662
  }
486
663
 
487
- // Complex templates: clone, parse, hydrate each item with DocumentFragment batching
664
+ // Complex templates: build each instance, batch-append into a DocumentFragment,
665
+ // finalize (mark managed + render nested), then commit to the DOM in one
666
+ // parent.insertBefore call.
488
667
  const instances = [];
489
- const templateNodes = template.element.childNodes;
490
668
  const frag = document.createDocumentFragment();
491
-
492
669
  for (let i = 0; i < newArray.length; i++) {
493
- const item = newArray[i];
494
- const localVars = { [itemAlias]: item, [indexAlias]: i };
495
- const scopedState = createScopedState(state, localVars, parentScope);
496
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
497
-
498
- for (let j = 0; j < clonedNodes.length; j++) {
499
- frag.appendChild(clonedNodes[j]);
500
- if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
501
- }
502
-
503
- if (tree) {
504
- const nestedScope = { ...parentScope, ...localVars };
505
- renderAllIterations(tree, scopedState, manifest, nestedScope);
506
- _renderAllConditionals(tree, scopedState, manifest, nestedScope);
507
- }
508
-
509
- instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
670
+ const built = buildInstance(iterationNode, newArray[i], i, state, parentScope);
671
+ for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
672
+ finalizeInstance(built, manifest, parentScope);
673
+ instances.push({
674
+ element: built.element, tree: built.tree, item: newArray[i], index: i,
675
+ clonedNodes: built.clonedNodes, scopedState: built.scopedState,
676
+ });
510
677
  }
511
-
512
678
  parent.insertBefore(frag, endComment);
513
679
  iterationNode.runtime.instances = instances;
514
680
  };
515
681
 
516
- // Add a new instance at the specified index
517
- const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
518
- const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
682
+ // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
683
+ // that still lives directly under the iteration's parent). Nested primitives
684
+ // inside the iteration template <!-- if -->, <!-- each -->, <component> —
685
+ // can move/replace cloned nodes between iteration renders (inactive branches
686
+ // get hoisted into template containers; component[src] wrappers get swapped
687
+ // for processed wrappers). Any of those mutations make `clonedNodes[0]` a
688
+ // stale reference to a node no longer under the iteration parent. Callers use
689
+ // this anchor instead of trusting `clonedNodes[0]` directly, so that
690
+ // insert-before / move operations always resolve against the iteration's real
691
+ // DOM slot.
692
+ const findInstanceAnchor = (instance, iterationParent) => {
693
+ const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
694
+ for (let i = 0; i < nodes.length; i++) {
695
+ if (nodes[i]?.parentNode === iterationParent) return nodes[i];
696
+ }
697
+ return null;
698
+ };
519
699
 
520
- const localVars = { [itemAlias]: item, [indexAlias]: index };
521
- const scopedState = createScopedState(state, localVars, parentScope);
522
- const templateNodes = [...template.element.childNodes];
700
+ // Resolve the reference node for `parent.insertBefore(..., ref)` at a given
701
+ // logical iteration index. Walks later instances until it finds one with a
702
+ // live anchor under the iteration parent; falls back to `endComment` when no
703
+ // later instance has any node currently mounted in the iteration.
704
+ const resolveInsertBefore = (iterationNode, index, parent) => {
705
+ const { instances } = iterationNode.runtime;
706
+ for (let i = index; i < instances.length; i++) {
707
+ const anchor = findInstanceAnchor(instances[i], parent);
708
+ if (anchor) return anchor;
709
+ }
710
+ return iterationNode.meta.endComment;
711
+ };
523
712
 
524
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
713
+ // Detach every DOM node belonging to a logical instance, including content
714
+ // mounted by nested primitives (conditional branches, nested each rows,
715
+ // fetched component wrappers) that isn't tracked in `instance.clonedNodes`.
716
+ // Walks iteration-parent siblings from this instance's anchor up to the next
717
+ // instance's anchor / endComment, so anything in between — clones, mounted
718
+ // branches, swapped-in component wrappers — all gets detached. Also sweeps
719
+ // any clonedNodes that were hoisted out of the iteration parent (e.g. into a
720
+ // sibling conditional's template container).
721
+ const detachInstanceDom = (iterationNode, index, parent) => {
722
+ const instance = iterationNode.runtime.instances[index];
723
+ const { endComment } = iterationNode.meta;
724
+ const anchor = findInstanceAnchor(instance, parent);
725
+ const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
726
+
727
+ if (anchor) {
728
+ let cur = anchor;
729
+ // endComment caps the walk even if nextAnchor ordering is ever
730
+ // corrupted — iteration DOM is bounded by startComment / endComment.
731
+ while (cur && cur !== nextAnchor && cur !== endComment) {
732
+ const nextSibling = cur.nextSibling;
733
+ parent.removeChild(cur);
734
+ cur = nextSibling;
735
+ }
736
+ }
525
737
 
526
- // Find insertion point
527
- const insertBefore =
528
- index < iterationNode.runtime.instances.length
529
- ? iterationNode.runtime.instances[index].clonedNodes?.[0] ||
530
- iterationNode.runtime.instances[index].element
531
- : endComment;
738
+ const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
739
+ for (let i = 0; i < nodes.length; i++) {
740
+ const n = nodes[i];
741
+ if (n && n.parentNode && n.parentNode !== parent) n.parentNode.removeChild(n);
742
+ }
743
+ };
532
744
 
533
- // Insert cloned nodes
534
- clonedNodes.forEach((node) => startComment.parentNode.insertBefore(node, insertBefore));
745
+ // Build a fresh instance's DOM + tree + scope from the iteration template.
746
+ // Pure function — no DOM insertion, no side effects on iteration state.
747
+ // Callers decide where the clones go (iteration parent, DocumentFragment).
748
+ const buildInstance = (iterationNode, item, index, state, parentScope) => {
749
+ const { itemAlias, indexAlias, template } = iterationNode.meta;
750
+ const localVars = { [itemAlias]: item, [indexAlias]: index };
751
+ const scopedState = createScopedState(state, localVars, parentScope);
752
+ const built = initializeBlock([...template.element.childNodes], scopedState, template);
753
+ resolveIterationComponentProps(built.clonedNodes, scopedState);
754
+ return { ...built, scopedState, localVars };
755
+ };
535
756
 
536
- // Recursively render nested iterations and conditionals
757
+ // After a built instance's clones are placed in the DOM (directly or via a
758
+ // fragment), mark element clones as managed so the page-level MutationObserver
759
+ // skips them in processMutations, then fire nested iteration/conditional
760
+ // renders. Without the managed mark those clones would be re-parsed + hydrated
761
+ // on top of the internal render, duplicating every nested branch.
762
+ const finalizeInstance = (built, manifest, parentScope) => {
763
+ const { clonedNodes, tree, scopedState, localVars } = built;
764
+ for (let i = 0; i < clonedNodes.length; i++) {
765
+ if (clonedNodes[i].nodeType === 1) managedNodes.add(clonedNodes[i]);
766
+ }
537
767
  if (tree) {
538
768
  const nestedScope = { ...parentScope, ...localVars };
539
769
  renderAllIterations(tree, scopedState, manifest, nestedScope);
540
770
  _renderAllConditionals(tree, scopedState, manifest, nestedScope);
541
771
  }
772
+ };
542
773
 
543
- iterationNode.runtime.instances.splice(index, 0, { element, tree, item, index, clonedNodes });
774
+ const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
775
+ const parent = iterationNode.meta.startComment.parentNode;
776
+ const built = buildInstance(iterationNode, item, index, state, parentScope);
777
+ const insertBefore = resolveInsertBefore(iterationNode, index, parent);
778
+ for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
779
+ finalizeInstance(built, manifest, parentScope);
780
+ iterationNode.runtime.instances.splice(index, 0, {
781
+ element: built.element, tree: built.tree, item, index, clonedNodes: built.clonedNodes,
782
+ });
544
783
  };
545
784
 
546
- // Remove an instance at the specified index
547
785
  const removeInstance = (iterationNode, index) => {
548
786
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
549
-
550
- const instance = iterationNode.runtime.instances[index];
551
-
552
- // Remove all cloned nodes from DOM
553
- (instance.clonedNodes || [instance.element]).forEach((node) =>
554
- node?.parentNode?.removeChild(node),
555
- );
556
-
787
+ const parent = iterationNode.meta.startComment.parentNode;
788
+ if (parent) detachInstanceDom(iterationNode, index, parent);
557
789
  iterationNode.runtime.instances.splice(index, 1);
558
790
  };
559
791
 
560
- // Move an instance from one position to another
561
792
  const moveInstance = (iterationNode, fromIndex, toIndex) => {
562
793
  if (fromIndex === toIndex) return;
563
- if (fromIndex < 0 || fromIndex >= iterationNode.runtime.instances.length) return;
564
- if (toIndex < 0 || toIndex >= iterationNode.runtime.instances.length) return;
794
+ const { instances } = iterationNode.runtime;
795
+ if (fromIndex < 0 || fromIndex >= instances.length) return;
796
+ if (toIndex < 0 || toIndex >= instances.length) return;
797
+ const parent = iterationNode.meta.startComment.parentNode;
565
798
 
566
- const instance = iterationNode.runtime.instances[fromIndex];
567
- const nodes = instance.clonedNodes || [instance.element];
568
- const parent = nodes[0]?.parentNode;
569
-
570
- iterationNode.runtime.instances.splice(fromIndex, 1);
571
- iterationNode.runtime.instances.splice(toIndex, 0, instance);
572
-
573
- // Find new insertion point
574
- const nextInstance = iterationNode.runtime.instances[toIndex + 1];
575
- const insertBefore = nextInstance
576
- ? nextInstance.clonedNodes?.[0] || nextInstance.element
577
- : iterationNode.meta.endComment;
799
+ const instance = instances[fromIndex];
800
+ instances.splice(fromIndex, 1);
801
+ instances.splice(toIndex, 0, instance);
578
802
 
579
- // Move all nodes
580
- nodes.forEach((node) => parent.insertBefore(node, insertBefore));
803
+ const insertBefore = resolveInsertBefore(iterationNode, toIndex + 1, parent);
804
+ const nodes = instance.clonedNodes || [instance.element];
805
+ // Re-insert only nodes currently under the iteration parent — those hoisted
806
+ // into nested-conditional template containers stay there so we don't
807
+ // double-count branch content.
808
+ for (let i = 0; i < nodes.length; i++) {
809
+ const n = nodes[i];
810
+ if (n?.parentNode === parent) parent.insertBefore(n, insertBefore);
811
+ }
581
812
  };
582
813
 
583
- // Update an instance with new item data
584
814
  const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
585
815
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
586
-
587
- const { itemAlias, indexAlias, template } = iterationNode.meta;
816
+ const parent = iterationNode.meta.startComment.parentNode;
588
817
  const instance = iterationNode.runtime.instances[index];
589
818
 
590
- const localVars = { [itemAlias]: newItem, [indexAlias]: index };
591
- const scopedState = createScopedState(state, localVars, parentScope);
592
- const templateNodes = [...template.element.childNodes];
593
-
594
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
595
-
596
- // Remove old nodes, insert new ones in same position
597
- const oldNodes = instance.clonedNodes || [instance.element];
598
- const insertBefore = oldNodes[oldNodes.length - 1]?.nextSibling;
599
- const parent = oldNodes[0]?.parentNode;
600
-
601
- oldNodes.forEach((node) => node?.parentNode?.removeChild(node));
602
- clonedNodes.forEach((node) => parent.insertBefore(node, insertBefore));
603
-
604
- // Recursively render nested iterations and conditionals
605
- if (tree) {
606
- const nestedScope = { ...parentScope, ...localVars };
607
- renderAllIterations(tree, scopedState, manifest, nestedScope);
608
- _renderAllConditionals(tree, scopedState, manifest, nestedScope);
609
- }
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
+ const built = buildInstance(iterationNode, newItem, index, state, parentScope);
822
+ detachInstanceDom(iterationNode, index, parent);
823
+ const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
824
+ for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
825
+ finalizeInstance(built, manifest, parentScope);
610
826
 
611
- // Update instance
612
- instance.element = element;
613
- instance.tree = tree;
827
+ instance.element = built.element;
828
+ instance.tree = built.tree;
614
829
  instance.item = newItem;
615
- instance.clonedNodes = clonedNodes;
830
+ instance.clonedNodes = built.clonedNodes;
616
831
  };
617
832
 
618
833
  export default {
@@ -1,5 +1,6 @@
1
1
  const recursive = (tree, results, tagChain) => {
2
- results[tagChain.join('.')] = tree.element;
2
+ const path = tagChain.join('.');
3
+ results[path] = tree.element;
3
4
 
4
5
  if (tree.children && Object.keys(tree.children).length > 0) {
5
6
  Object.keys(tree.children).forEach((tag) => {