@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.
@@ -202,15 +202,19 @@ export const DOM_ELEMENT_PROPERTIES = new Set([
202
202
  ]);
203
203
 
204
204
  // Regex for matching reactive bindings (@[expression])
205
- // Supports one level of nested brackets: @[items[0]] or @[obj[key]]
206
- export const BINDING_REGEX = /\@\[((?:[^\[\]]|\[[^\]]*\])+)\]/g;
205
+ // Supports nested brackets, single-quoted and double-quoted strings inside expressions:
206
+ // @[items[0]], @[obj[key]], @[x.replace('.png', '-mugshot.png')]
207
+ const BINDING_INNER = String.raw`(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+`;
208
+ export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g');
207
209
 
208
210
  // Regex for detecting a pure binding (entire value is just @[expression])
209
- export const PURE_BINDING_REGEX = /^\@\[((?:[^\[\]]|\[[^\]]*\])+)\]$/;
211
+ export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
210
212
 
211
- // Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
212
- // Supports nested paths like category.items
213
- export const ITERATION_REGEX = /^each\s+([\w.]+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
213
+ // Regex for parsing iteration comment syntax (<!-- each expression as item, index -->)
214
+ // The array expression can be any JS: a state path (items), a window global
215
+ // (window.fights), a method call (items.filter(x => x.active)), or an inline
216
+ // array literal (['a', 'b']). Parsed via evalInScope at render time.
217
+ export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
214
218
 
215
219
  // Regex for detecting start of iteration comment
216
220
  export const ITERATION_START_REGEX = /^each\s+/;
package/runtime/index.js CHANGED
@@ -5,7 +5,7 @@ import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
6
  import { deepMerge, hash } from './utils.js';
7
7
  import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
8
- import { renderAllConditionals } from './conditionals.js';
8
+ import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
9
9
  import {
10
10
  NON_REACTIVE_ELEMENTS,
11
11
  PHASE_ATTACH,
@@ -21,9 +21,10 @@ import {
21
21
  PHASE_READY,
22
22
  DEHYDRATE_CLASS_OR_ATTR,
23
23
  } from './constants.js';
24
- import { processComponent, abortComponentFetch } from './component.js';
24
+ import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState } from './component.js';
25
25
  import { debugLog } from './debug.js';
26
26
  import { shouldCleanup, cleanup } from './cleanup.js';
27
+ import { reconcile } from './reconcile.js';
27
28
  import {
28
29
  buildHyperspeedManifest,
29
30
  hyperspeedManifest,
@@ -39,6 +40,9 @@ const shouldProcessNode = (node) => {
39
40
  // Only process element nodes
40
41
  if (node.nodeType !== 1) return false;
41
42
 
43
+ // Skip nodes already managed by mountBranch or renderIteration
44
+ if (managedNodes.has(node)) return false;
45
+
42
46
  // Fast check first: skip nodes without Vibe syntax (cheapest check)
43
47
  const html = node.outerHTML;
44
48
  if (
@@ -116,9 +120,15 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
116
120
  // 1. Parse - already done before calling core loop (in processMutations)
117
121
 
118
122
  // 2. Hydrate (replace @[...] bindings)
119
- // Use empty object as "previous state" for new nodes so all bindings are affected
123
+ // For new nodes, use {} so all bindings are found. But filter out iterations
124
+ // and conditionals — those should only go through renderAllIterations/renderAllConditionals
125
+ // (initial render path), not updateIteration/updateConditional (which would diff against
126
+ // stale oldState and produce false adds/removes).
120
127
  const oldStateForAffected = isNewNode ? {} : previousState;
121
- const affectedElements = affected(parsedNode, oldStateForAffected, state);
128
+ let affectedElements = affected(parsedNode, oldStateForAffected, state);
129
+ if (isNewNode) {
130
+ affectedElements = affectedElements.filter(a => a.type !== 'iteration' && a.type !== 'conditional');
131
+ }
122
132
  if (affectedElements.length > 0) {
123
133
  hydratedCount = affectedElements.length;
124
134
  hydrate(affectedElements, state, manifest, oldStateForAffected);
@@ -509,30 +519,45 @@ const main = (s, config = {}, stringSelector = '') => {
509
519
  };
510
520
 
511
521
  // Extract plain values from proxy (removes proxy wrappers)
522
+ // Optimized: indexed loops, Object.keys (no prototype walk), inline primitive check
512
523
  const extractPlainValue = (obj) => {
513
524
  if (obj === null || typeof obj !== 'object') return obj;
514
- if (Array.isArray(obj)) return obj.map(extractPlainValue);
515
- const plain = {};
516
- for (const key in obj) {
517
- if (obj.hasOwnProperty(key)) {
518
- plain[key] = extractPlainValue(obj[key]);
525
+ if (Array.isArray(obj)) {
526
+ const len = obj.length;
527
+ const arr = new Array(len);
528
+ for (let i = 0; i < len; i++) {
529
+ const v = obj[i];
530
+ arr[i] = (v !== null && typeof v === 'object') ? extractPlainValue(v) : v;
519
531
  }
532
+ return arr;
533
+ }
534
+ const keys = Object.keys(obj);
535
+ const len = keys.length;
536
+ const plain = {};
537
+ for (let i = 0; i < len; i++) {
538
+ const k = keys[i];
539
+ const v = obj[k];
540
+ plain[k] = (v !== null && typeof v === 'object') ? extractPlainValue(v) : v;
520
541
  }
521
542
  return plain;
522
543
  };
523
544
 
524
545
  // Observer callback will be defined below (already declared above before processComponent)
525
546
 
526
- const $ = state(s, (newState, oldState) => {
527
- // Extract current state (after mutation)
528
- const currentState = extractPlainValue($);
529
- const changedProp = Object.keys(newState)[0];
547
+ const $ = state(s, (changedProps) => {
548
+ // Selective extraction: only extract changed props, preserve references for unchanged.
549
+ // This ensures affected() correctly skips iterations whose arrays didn't change,
550
+ // while still detecting binding changes inside iteration instances.
551
+ const currentState = { ...previousState };
552
+ for (const prop of changedProps) {
553
+ currentState[prop] = extractPlainValue($[prop]);
554
+ }
530
555
 
531
556
  // Find what changed (compare previousState vs currentState)
532
557
  const affectedElements = affected(parsedTree, previousState, currentState);
533
558
 
534
559
  if (affectedElements.length > 0) {
535
- debugLog(PHASE_UPDATE, `state changed (${changedProp})`, debug);
560
+ debugLog(PHASE_UPDATE, 'state changed', debug);
536
561
 
537
562
  // Capture pending mutations before disconnecting (takeRecords clears the queue)
538
563
  let pendingMutations = [];
@@ -562,6 +587,18 @@ const main = (s, config = {}, stringSelector = '') => {
562
587
  processMutations(pendingMutations);
563
588
  }
564
589
  }
590
+
591
+ // Conditionals/iterations may have mounted new DOM while observer was disconnected.
592
+ // Scan for unresolved <component src=""> elements that need fetching.
593
+ if (componentProcessingStarted) {
594
+ const componentConfig = {
595
+ ...config,
596
+ _forceSync: true,
597
+ _observer: observer,
598
+ _processMutations: processMutations,
599
+ };
600
+ processComponent(rootElement, null, componentConfig);
601
+ }
565
602
  }
566
603
 
567
604
  // Store previous state for hooks (currentState is already plain, no need to clone)
@@ -581,6 +618,22 @@ const main = (s, config = {}, stringSelector = '') => {
581
618
  enumerable: false,
582
619
  });
583
620
 
621
+ // Promise that resolves after the ready hook fires and all ready callbacks
622
+ // have run. Lets late subscribers await readiness without missing the event:
623
+ // `await $.ready`. Non-enumerable so it won't leak into state snapshots.
624
+ let resolveReady;
625
+ Object.defineProperty($, 'ready', {
626
+ value: new Promise((resolve) => { resolveReady = resolve; }),
627
+ enumerable: false,
628
+ });
629
+
630
+ // Reconcile a managed subtree against new source HTML. Opt-in entry point;
631
+ // dormant unless called (so hot paths and benchmarks are unaffected).
632
+ Object.defineProperty($, 'reconcile', {
633
+ value: reconcile,
634
+ enumerable: false,
635
+ });
636
+
584
637
  // Initial hydration - pass plain values so iteration can do reference comparison
585
638
  const initialState = extractPlainValue($);
586
639
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -660,6 +713,14 @@ const main = (s, config = {}, stringSelector = '') => {
660
713
  let evaluatedCount = 0;
661
714
  let addedElementsList = []; // Track all added elements for verbose output
662
715
 
716
+ // Collect data-vibe-component-id values across ALL removed subtrees in this
717
+ // batch before doing any per-node work, so we can evict their state after
718
+ // the DOM mutations have been applied.
719
+ const removedComponentIds = new Set();
720
+ mutations.forEach(({ removedNodes: removedNodesList }) => {
721
+ removedNodesList.forEach((node) => collectComponentIds(node, removedComponentIds));
722
+ });
723
+
663
724
  mutations.forEach(({ addedNodes: addedNodesList, removedNodes: removedNodesList, target }) => {
664
725
  removedNodesList.forEach((node) => {
665
726
  // If this is a component element with pending fetch, abort it
@@ -667,6 +728,20 @@ const main = (s, config = {}, stringSelector = '') => {
667
728
  abortComponentFetch(node);
668
729
  }
669
730
 
731
+ // If this node is tracked by a conditional branch (e.g. a <component src>
732
+ // that was replaced by processComponent via el.replaceWith), update the
733
+ // conditional's tracked reference to point to the replacement node.
734
+ const branchRef = branchNodeRegistry.get(node);
735
+ if (branchRef) {
736
+ // Find the replacement: an added node in the same mutation at the same parent
737
+ const replacement = Array.from(addedNodesList).find(n => n.parentNode === target);
738
+ if (replacement) {
739
+ branchRef.nodes[branchRef.index] = replacement;
740
+ branchNodeRegistry.set(replacement, branchRef);
741
+ }
742
+ branchNodeRegistry.delete(node);
743
+ }
744
+
670
745
  const entry = Object.entries(manifest).find(([_, element]) => element === node);
671
746
 
672
747
  // Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
@@ -711,29 +786,20 @@ const main = (s, config = {}, stringSelector = '') => {
711
786
  return;
712
787
  }
713
788
 
714
- const entry = Object.entries(manifest).find(([_, element]) => element === target);
715
-
716
- // If parent isn't tracked, this node is outside the reactive scope
717
- if (!entry) return;
718
-
719
- const [dotAnnotation] = entry;
720
- const picked = navigateTree(parsedTree, dotAnnotation);
721
-
722
- // If we can't navigate to the parent in the tree, skip
723
- if (!picked) return;
724
-
725
- // If parent has no element reference, re-parse from the actual DOM element
726
- if (!picked.element) {
727
- picked.element = target;
789
+ // Capture raw slot content of nested <component src> elements BEFORE parse runs.
790
+ // Parse creates conditional nodes from <!-- if --> comments, and renderConditional
791
+ // later removes the template nodes between the comments. Without capturing slot
792
+ // content first, conditionals inside a component's slot content lose their branch
793
+ // templates, breaking reactive updates.
794
+ if (node.nodeType === 1) {
795
+ node.querySelectorAll('component[src], div.component[src]').forEach((el) => {
796
+ if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
797
+ });
728
798
  }
729
799
 
730
- // Parse the newly added node (use monotonically increasing counter for deterministic key)
731
- // Initialize counter if it doesn't exist
732
- if (!picked._nextChildIndex) {
733
- picked._nextChildIndex = Object.keys(picked.children).length;
734
- }
735
- const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
736
- picked._nextChildIndex++; // Always increment, never decrement
800
+ const entry = Object.entries(manifest).find(([_, element]) => element === target);
801
+
802
+ // Parse the newly added node
737
803
  const parsedNode = parse(node);
738
804
 
739
805
  // Accumulate skipped stats
@@ -741,19 +807,42 @@ const main = (s, config = {}, stringSelector = '') => {
741
807
  totalSkipped += parsedNode.stats.skipped;
742
808
  }
743
809
 
744
- // Update parent's parsed HTML (only once per parent)
745
- if (!parsedParents) parsedParents = new Set();
746
- if (!parsedParents.has(picked)) {
747
- const { parsed } = parse(picked.element);
748
- picked.parsed = parsed;
749
- parsedParents.add(picked);
750
- }
810
+ // If parent is tracked in the manifest, register this new node in the parsed tree.
811
+ // (If not e.g. mutations inside an iteration instance whose rows aren't in the
812
+ // global manifest — we still hydrate the node below; we just skip tree/manifest
813
+ // registration since there's no tree branch to attach to.)
814
+ if (entry) {
815
+ const [dotAnnotation] = entry;
816
+ const picked = navigateTree(parsedTree, dotAnnotation);
817
+
818
+ if (picked) {
819
+ if (!picked.element) {
820
+ picked.element = target;
821
+ }
751
822
 
752
- // Add the parsed node to parent's children
753
- picked.children[name] = parsedNode;
823
+ // Parse the newly added node (use monotonically increasing counter for deterministic key)
824
+ // Initialize counter if it doesn't exist
825
+ if (!picked._nextChildIndex) {
826
+ picked._nextChildIndex = Object.keys(picked.children).length;
827
+ }
828
+ const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
829
+ picked._nextChildIndex++; // Always increment, never decrement
830
+
831
+ // Update parent's parsed HTML (only once per parent)
832
+ if (!parsedParents) parsedParents = new Set();
833
+ if (!parsedParents.has(picked)) {
834
+ const { parsed } = parse(picked.element);
835
+ picked.parsed = parsed;
836
+ parsedParents.add(picked);
837
+ }
754
838
 
755
- // Recursively add node and all descendants to manifest
756
- addToManifest(parsedNode, manifest, `${dotAnnotation}.${name}`);
839
+ // Add the parsed node to parent's children
840
+ picked.children[name] = parsedNode;
841
+
842
+ // Recursively add node and all descendants to manifest
843
+ addToManifest(parsedNode, manifest, `${dotAnnotation}.${name}`);
844
+ }
845
+ }
757
846
 
758
847
  // Run core loop for the new node (parse already done, hydrate → conditionals → iterate)
759
848
  const counts = processCoreLoop(node, parsedNode, $, manifest, true, debug);
@@ -773,6 +862,9 @@ const main = (s, config = {}, stringSelector = '') => {
773
862
  });
774
863
  });
775
864
 
865
+ // CLEANUP OF CURRENT STATE
866
+ releaseOrphanedComponentState(removedComponentIds);
867
+
776
868
  // Fire hooks once after all mutations are processed (not per-node)
777
869
  if (hadChanges) {
778
870
  if (addedElements > 0 || addedNodes > 0 || removedElements > 0 || removedNodes > 0) {
@@ -907,7 +999,8 @@ const main = (s, config = {}, stringSelector = '') => {
907
999
 
908
1000
  // Check for new <component> elements after DOM mutations
909
1001
  // Process component elements if we've started (initial call happened)
910
- if (componentProcessingStarted && !cleanupExecuted) {
1002
+ // Note: Also process after cleanup — conditionals may reveal new components
1003
+ if (componentProcessingStarted) {
911
1004
  const componentConfig = {
912
1005
  ...config,
913
1006
  _forceSync: true,
@@ -968,6 +1061,8 @@ const main = (s, config = {}, stringSelector = '') => {
968
1061
  console.error('[vibe] Error in ready hook:', error);
969
1062
  }
970
1063
  });
1064
+ // Resolve $.ready promise after all ready callbacks have run
1065
+ resolveReady();
971
1066
  }
972
1067
  };
973
1068