@ape-egg/vibe 1.8.0 → 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,17 +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';
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
+ 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
+
16
124
  /**
17
125
  * Find a comment node with matching text content in the given nodes.
18
126
  */
@@ -159,6 +267,18 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
159
267
  }
160
268
  }
161
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
+
162
282
  if (useCachedTree) {
163
283
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
164
284
  tree = cloneTreeWithElements(cachedTree, parseContainer);
@@ -313,10 +433,12 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
313
433
 
314
434
  const parent = startComment.parentNode;
315
435
 
316
- // Handle this.property for component-scoped arrays
317
- const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
318
-
319
- 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);
320
442
  if (!Array.isArray(array) || array.length === 0) {
321
443
  iterationNode.runtime.instances = [];
322
444
  return;
@@ -347,6 +469,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
347
469
  // Clone, parse, hydrate
348
470
  const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
349
471
 
472
+ // Pre-resolve <component src> binding props against iteration scope (see helper comment)
473
+ resolveIterationComponentProps(clonedNodes, scopedState);
474
+
350
475
  // Collect nodes in DocumentFragment (single DOM insertion at end)
351
476
  for (let j = 0; j < clonedNodes.length; j++) {
352
477
  frag.appendChild(clonedNodes[j]);
@@ -381,11 +506,10 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
381
506
 
382
507
  const { arrayPath, template, startComment, endComment } = iterationNode.meta;
383
508
 
384
- // Handle this.property for component-scoped arrays
385
- const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
509
+ const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
386
510
 
387
- const stateOldArray = resolvePath(oldState, resolvedArrayPath) || [];
388
- 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) ?? [];
389
513
 
390
514
  // Use instances (what's actually rendered) as ground truth for old array
391
515
  // when oldState disagrees with the rendered count.
@@ -457,7 +581,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
457
581
  // Bulk replacement: clear all DOM and re-render from scratch
458
582
  // Used when arrays share no common keys (avoids O(n²) LCS)
459
583
  const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
460
- const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
584
+ const { template, startComment, endComment } = iterationNode.meta;
461
585
  const parent = startComment.parentNode;
462
586
 
463
587
  // Clear all existing DOM between comments using Range (single operation)
@@ -475,8 +599,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
475
599
 
476
600
  // For simple templates (no nested iterations/conditionals, single root element),
477
601
  // 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);
602
+ if (canUseBatchRender(template)) {
603
+ renderBatch(iterationNode, newArray, state, parent, endComment);
480
604
  const instances = iterationNode.runtime.instances;
481
605
  for (let i = 0; i < instances.length; i++) {
482
606
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -484,135 +608,173 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
484
608
  return;
485
609
  }
486
610
 
487
- // Complex templates: clone, parse, hydrate each item with DocumentFragment batching
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.
488
614
  const instances = [];
489
- const templateNodes = template.element.childNodes;
490
615
  const frag = document.createDocumentFragment();
491
-
492
616
  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 });
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
+ });
510
624
  }
511
-
512
625
  parent.insertBefore(frag, endComment);
513
626
  iterationNode.runtime.instances = instances;
514
627
  };
515
628
 
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;
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
+ };
519
646
 
520
- const localVars = { [itemAlias]: item, [indexAlias]: index };
521
- const scopedState = createScopedState(state, localVars, parentScope);
522
- const templateNodes = [...template.element.childNodes];
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
+ };
523
659
 
524
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
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
+ }
525
684
 
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;
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
+ };
532
691
 
533
- // Insert cloned nodes
534
- 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
+ };
535
703
 
536
- // 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
+ }
537
714
  if (tree) {
538
715
  const nestedScope = { ...parentScope, ...localVars };
539
716
  renderAllIterations(tree, scopedState, manifest, nestedScope);
540
717
  _renderAllConditionals(tree, scopedState, manifest, nestedScope);
541
718
  }
719
+ };
542
720
 
543
- 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
+ });
544
730
  };
545
731
 
546
- // Remove an instance at the specified index
547
732
  const removeInstance = (iterationNode, index) => {
548
733
  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
-
734
+ const parent = iterationNode.meta.startComment.parentNode;
735
+ if (parent) detachInstanceDom(iterationNode, index, parent);
557
736
  iterationNode.runtime.instances.splice(index, 1);
558
737
  };
559
738
 
560
- // Move an instance from one position to another
561
739
  const moveInstance = (iterationNode, fromIndex, toIndex) => {
562
740
  if (fromIndex === toIndex) return;
563
- if (fromIndex < 0 || fromIndex >= iterationNode.runtime.instances.length) return;
564
- if (toIndex < 0 || toIndex >= iterationNode.runtime.instances.length) return;
565
-
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);
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;
572
745
 
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;
746
+ const instance = instances[fromIndex];
747
+ instances.splice(fromIndex, 1);
748
+ instances.splice(toIndex, 0, instance);
578
749
 
579
- // Move all nodes
580
- 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
+ }
581
759
  };
582
760
 
583
- // Update an instance with new item data
584
761
  const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
585
762
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
586
-
587
- const { itemAlias, indexAlias, template } = iterationNode.meta;
763
+ const parent = iterationNode.meta.startComment.parentNode;
588
764
  const instance = iterationNode.runtime.instances[index];
589
765
 
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
- }
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);
610
773
 
611
- // Update instance
612
- instance.element = element;
613
- instance.tree = tree;
774
+ instance.element = built.element;
775
+ instance.tree = built.tree;
614
776
  instance.item = newItem;
615
- instance.clonedNodes = clonedNodes;
777
+ instance.clonedNodes = built.clonedNodes;
616
778
  };
617
779
 
618
780
  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) => {
package/runtime/parse.js CHANGED
@@ -8,6 +8,66 @@ import {
8
8
  DEHYDRATE_CLASS_OR_ATTR,
9
9
  } from './constants.js';
10
10
 
11
+ // Walks up the DOM for the nearest component wrapper tagged by component.js.
12
+ // Used to rewrite `this.property` in event handlers to the component's state path.
13
+ const findComponentIdForElement = (element) => {
14
+ if (!element?.closest) return null;
15
+ const wrapper = element.closest('[data-vibe-component-id]');
16
+ return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
17
+ };
18
+
19
+ // Single source of truth for reading attribute/name bindings off an element.
20
+ // Called from both the root handler and recursive() so they can't drift. Any
21
+ // element classified as a fetched component (`<component src>` or
22
+ // `<div class="component" src>`) returns nulls — its attributes are props
23
+ // owned by processComponent and must stay raw; hydrating them would coerce
24
+ // objects to "[object Object]" or strip boolean-like attrs to empty.
25
+ const captureAttributeBindings = (element) => {
26
+ const nodeName = element.nodeName;
27
+ const isFetchedComponent =
28
+ (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
29
+ element.hasAttribute?.('src');
30
+
31
+ if (isFetchedComponent || !element.attributes || element.attributes.length === 0) {
32
+ return { attributes: null, nameBindings: null };
33
+ }
34
+
35
+ const attributes = {};
36
+ const nameBindings = [];
37
+
38
+ for (let j = 0; j < element.attributes.length; j++) {
39
+ const attr = element.attributes[j];
40
+ BINDING_REGEX.lastIndex = 0;
41
+
42
+ // Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
43
+ if (BINDING_REGEX.test(attr.name)) {
44
+ nameBindings.push(attr.name);
45
+ continue;
46
+ }
47
+
48
+ // Rewrite `this.property` inside event handlers to the component's state path.
49
+ if (attr.name.startsWith('on') && attr.value.includes('this.')) {
50
+ const componentId = findComponentIdForElement(element);
51
+ if (componentId) {
52
+ const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
53
+ return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
54
+ });
55
+ element.setAttribute(attr.name, rewritten);
56
+ }
57
+ }
58
+
59
+ BINDING_REGEX.lastIndex = 0;
60
+ if (BINDING_REGEX.test(attr.value)) {
61
+ attributes[attr.name] = attr.value;
62
+ }
63
+ }
64
+
65
+ return {
66
+ attributes: Object.keys(attributes).length > 0 ? attributes : null,
67
+ nameBindings: nameBindings.length > 0 ? nameBindings : null,
68
+ };
69
+ };
70
+
11
71
  const parseHTML = (children, rootKey = undefined) =>
12
72
  children.reduce((s, element, i) => {
13
73
  const { nodeName, textContent } = element;
@@ -196,59 +256,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
196
256
  const elementForBindings = isTextNode ? element.parentElement : element;
197
257
  const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
198
258
 
199
- // Check for attribute bindings
200
- const attributes = {};
201
- const nameBindings = [];
202
-
203
- // Skip hydrating attributes on fetched components - they need to be passed raw
204
- const isFetchedComponent =
205
- (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
206
- element.hasAttribute('src');
207
-
208
- if (element.attributes && !isFetchedComponent) {
209
- for (let j = 0; j < element.attributes.length; j++) {
210
- const attr = element.attributes[j];
211
- // Reset lastIndex before test - BINDING_REGEX has 'g' flag which persists state
212
- BINDING_REGEX.lastIndex = 0;
213
-
214
- // Check if attribute name contains binding (e.g., @[section.icon])
215
- if (BINDING_REGEX.test(attr.name)) {
216
- nameBindings.push(attr.name);
217
- continue; // Don't process as regular attribute
218
- }
219
-
220
- // Rewrite event handlers with this. to use component state
221
- if (attr.name.startsWith('on') && attr.value.includes('this.')) {
222
- const componentId = findComponentIdForElement(element);
223
- if (componentId) {
224
- // Rewrite this.property to $['componentId'].property, but skip DOM properties
225
- const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
226
- return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
227
- });
228
- element.setAttribute(attr.name, rewritten);
229
- }
230
- }
231
-
232
- // Check if attribute value contains binding
233
- BINDING_REGEX.lastIndex = 0;
234
- if (BINDING_REGEX.test(attr.value)) {
235
- attributes[attr.name] = attr.value;
236
- }
237
- }
238
- }
239
-
240
- // Helper to find component ID for an element
241
- // Looks for nearest ancestor with data-vibe-component-id
242
- function findComponentIdForElement(element) {
243
- if (!element) return null;
244
-
245
- // Find nearest component wrapper (tagged by component.js)
246
- const wrapper = element.closest('[data-vibe-component-id]');
247
- return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
248
- }
249
- const hasAttributeBindings = Object.keys(attributes).length > 0;
250
- const hasNameBindings = nameBindings.length > 0;
251
-
259
+ const { attributes, nameBindings } = captureAttributeBindings(element);
252
260
  const hasChildren = childNodes.length;
253
261
 
254
262
  if (hasChildren) {
@@ -259,8 +267,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
259
267
  parsed,
260
268
  element: elementForBindings,
261
269
  children: recursive(iteratableChildren, undefined, new Set(), stats),
262
- ...(hasAttributeBindings && { attributes }),
263
- ...(hasNameBindings && { nameBindings }),
270
+ ...(attributes && { attributes }),
271
+ ...(nameBindings && { nameBindings }),
264
272
  ...(textNodeRef && { textNode: textNodeRef }),
265
273
  };
266
274
  } else {
@@ -268,8 +276,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
268
276
  parsed: innerHTML || textContent,
269
277
  element: elementForBindings,
270
278
  children: {},
271
- ...(hasAttributeBindings && { attributes }),
272
- ...(hasNameBindings && { nameBindings }),
279
+ ...(attributes && { attributes }),
280
+ ...(nameBindings && { nameBindings }),
273
281
  ...(textNodeRef && { textNode: textNodeRef }),
274
282
  };
275
283
  }
@@ -282,29 +290,7 @@ export default (root, rootKey = undefined) => {
282
290
  const { childNodes } = root;
283
291
  const stats = { skipped: 0 };
284
292
 
285
- // Check for attribute bindings on the root element itself (only if element has attributes)
286
- let attributes = null;
287
- let nameBindings = null;
288
- if (root.attributes && root.attributes.length > 0) {
289
- for (let j = 0; j < root.attributes.length; j++) {
290
- const attr = root.attributes[j];
291
- BINDING_REGEX.lastIndex = 0;
292
-
293
- // Check if attribute name contains binding
294
- if (BINDING_REGEX.test(attr.name)) {
295
- if (!nameBindings) nameBindings = [];
296
- nameBindings.push(attr.name);
297
- continue;
298
- }
299
-
300
- // Check if attribute value contains binding
301
- BINDING_REGEX.lastIndex = 0;
302
- if (BINDING_REGEX.test(attr.value)) {
303
- if (!attributes) attributes = {};
304
- attributes[attr.name] = attr.value;
305
- }
306
- }
307
- }
293
+ const { attributes, nameBindings } = captureAttributeBindings(root);
308
294
 
309
295
  return {
310
296
  // html: root.outerHTML,