@ape-egg/vibe 1.7.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +28 -0
- package/boot.js +5 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +85 -496
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +3 -11
- package/compiler/src/parser/html.rs +15 -15
- package/index.js +15 -0
- package/package.json +2 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +54 -16
- package/runtime/component.js +287 -110
- package/runtime/conditionals.js +99 -7
- package/runtime/constants.js +10 -6
- package/runtime/index.js +142 -47
- package/runtime/iterate.js +364 -142
- package/runtime/iteration-utils.js +5 -1
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +67 -80
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +26 -7
- package/runtime/utils.js +97 -5
- package/vibe.css +3 -1
package/runtime/constants.js
CHANGED
|
@@ -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
|
|
206
|
-
|
|
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
|
|
212
|
-
//
|
|
213
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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))
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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, (
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
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,
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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
|
|