@ape-egg/vibe 2.3.0 → 3.0.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.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
package/runtime/index.js CHANGED
@@ -1,12 +1,12 @@
1
- import state from './state.js';
1
+ import state, { silentSet } from './state.js';
2
2
  import parse from './parse.js';
3
- import createManifest from './manifest.js';
3
+ import createManifest, { setManifestEntry, manifestPathOf, removeManifestSubtree } from './manifest.js';
4
4
  import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
6
  import { deepMerge, hash, setRootState } from './utils.js';
7
7
  import { unsafe } from './raw-html.js';
8
8
  import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
9
- import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
9
+ import { renderAllConditionals, branchNodeRegistry, managedNodes , settleConditionals} from './conditionals.js';
10
10
  import { installScopeResolver } from './loop-scope.js';
11
11
  import {
12
12
  NON_REACTIVE_ELEMENTS,
@@ -26,6 +26,8 @@ import {
26
26
  import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
27
27
  import { configureComponentCache, clearComponentCache } from './component-cache.js';
28
28
  import { debugLog } from './debug.js';
29
+ import { pruneDisconnected, beginFlush } from './tracking.js';
30
+ import dispatchFlush from './dispatch.js';
29
31
  import { shouldCleanup, cleanup } from './cleanup.js';
30
32
  import { reconcile } from './reconcile.js';
31
33
  import {
@@ -136,7 +138,7 @@ const ensureNode = (tree, path) => {
136
138
 
137
139
  // Recursively add parsed tree nodes to manifest (like createManifest does)
138
140
  const addToManifest = (tree, manifest, dotPath) => {
139
- manifest[dotPath] = tree.element;
141
+ setManifestEntry(manifest, dotPath, tree.element);
140
142
 
141
143
  if (tree.children && Object.keys(tree.children).length > 0) {
142
144
  Object.keys(tree.children).forEach((childKey) => {
@@ -372,7 +374,7 @@ let previousState = {};
372
374
  const main = (s, config = {}, stringSelector = '') => {
373
375
  const debug = !!config?.debug;
374
376
  const verbose = !!config?.verbose;
375
- globalThis.__vibeDebug = debug;
377
+ ((globalThis.__vibe ??= {}).debug = debug);
376
378
 
377
379
  // Enable/disable the component template cache from config (`{ noCache }`).
378
380
  configureComponentCache(config);
@@ -382,7 +384,7 @@ const main = (s, config = {}, stringSelector = '') => {
382
384
 
383
385
  // Detect if running in compiler's headless browser
384
386
  // When true: skip cleanup to preserve [vibe] attribute in compiled HTML
385
- const isCompiling = typeof window !== 'undefined' && window.__vibeCompiling === true;
387
+ const isCompiling = typeof window !== 'undefined' && window.__vibe?.compiling === true;
386
388
 
387
389
  // Reset previous state for each new instance
388
390
  previousState = {};
@@ -614,6 +616,47 @@ const main = (s, config = {}, stringSelector = '') => {
614
616
 
615
617
  // Observer callback will be defined below (already declared above before processComponent)
616
618
 
619
+ // Pause the observer around an engine patch phase (its DOM writes are the
620
+ // engine's own), then replay anything that queued while paused and resolve
621
+ // fresh <component src> mounts. Shared by the walk flush and the
622
+ // subscription dispatch flush.
623
+ const patchWithObserverPaused = (patch) => {
624
+ // Capture pending mutations before disconnecting (takeRecords clears the queue)
625
+ let pendingMutations = [];
626
+ if (observer) {
627
+ pendingMutations = observer.takeRecords();
628
+ observer.disconnect();
629
+ }
630
+
631
+ patch();
632
+
633
+ if (observer) {
634
+ observer.observe(rootElement, {
635
+ attributes: false,
636
+ characterData: false,
637
+ childList: true,
638
+ subtree: true,
639
+ });
640
+
641
+ // Process mutations that were pending before we disconnected
642
+ if (pendingMutations.length > 0 && processMutations) {
643
+ processMutations(pendingMutations);
644
+ }
645
+ }
646
+
647
+ // The patch may have mounted new DOM while the observer was disconnected.
648
+ // Scan for unresolved <component src=""> elements that need fetching.
649
+ if (componentProcessingStarted) {
650
+ const componentConfig = {
651
+ ...config,
652
+ _forceSync: true,
653
+ _observer: observer,
654
+ _processMutations: processMutations,
655
+ };
656
+ processComponent(rootElement, null, componentConfig);
657
+ }
658
+ };
659
+
617
660
  const $ = state(s, (changedProps) => {
618
661
  // Selective extraction: only extract changed props, preserve references for unchanged.
619
662
  // This ensures affected() correctly skips iterations whose arrays didn't change,
@@ -633,52 +676,17 @@ const main = (s, config = {}, stringSelector = '') => {
633
676
  }
634
677
  }
635
678
 
636
- // Find what changed (compare previousState vs currentState)
637
- const affectedElements = affected(parsedTree, previousState, currentState);
638
-
639
- if (affectedElements.length > 0) {
679
+ // THE update path: the flush is served from the subscription reverse
680
+ // index a write notifies exactly its subscribers, O(change). The walk
681
+ // (`affected()`) exists only as the MOUNT path now: first hydration,
682
+ // processMutations, fresh branches and rows, where it registers new
683
+ // subscribers via their first evaluation.
684
+ const dirty = beginFlush(changedProps);
685
+ if (dirty.size > 0) {
640
686
  debugLog(PHASE_UPDATE, 'state changed', debug);
641
-
642
- // Capture pending mutations before disconnecting (takeRecords clears the queue)
643
- let pendingMutations = [];
644
- if (observer) {
645
- pendingMutations = observer.takeRecords();
646
- observer.disconnect();
647
- }
648
-
649
- // Run core loop on the full tree (hydrate will use affected list)
650
- // isNewNode = false because this is a state update, not a DOM mutation
651
- hydrate(affectedElements, currentState, manifest, previousState);
652
-
653
- // After hydrate, check for conditional/iteration changes
654
- const conditionalCount = renderAllConditionals(parsedTree, currentState, manifest);
655
- const iterationCount = renderAllIterations(parsedTree, currentState, manifest);
656
-
657
- if (observer) {
658
- observer.observe(rootElement, {
659
- attributes: false,
660
- characterData: false,
661
- childList: true,
662
- subtree: true,
663
- });
664
-
665
- // Process mutations that were pending before we disconnected
666
- if (pendingMutations.length > 0 && processMutations) {
667
- processMutations(pendingMutations);
668
- }
669
- }
670
-
671
- // Conditionals/iterations may have mounted new DOM while observer was disconnected.
672
- // Scan for unresolved <component src=""> elements that need fetching.
673
- if (componentProcessingStarted) {
674
- const componentConfig = {
675
- ...config,
676
- _forceSync: true,
677
- _observer: observer,
678
- _processMutations: processMutations,
679
- };
680
- processComponent(rootElement, null, componentConfig);
681
- }
687
+ patchWithObserverPaused(() => {
688
+ dispatchFlush(dirty, currentState, previousState, manifest);
689
+ });
682
690
  }
683
691
 
684
692
  // Store previous state for hooks (currentState is already plain, no need to clone)
@@ -693,15 +701,48 @@ const main = (s, config = {}, stringSelector = '') => {
693
701
  // — the Proxy invariant rejects overriding non-configurable + non-writable
694
702
  // data properties. Enumerable stays false so `on` doesn't leak into state
695
703
  // snapshots or Object.keys($).
704
+ // Late ready — a `$.on('ready')` AFTER boot (a fetched fragment's script,
705
+ // an SPA mount's app-boot module) defers until the registering mount
706
+ // SETTLES: content inserted, attributes hydrated, staging committed, fouc
707
+ // gates released. That makes `$.on('ready')` ≡ `await $.ready` genuinely
708
+ // true — DOM-touching ready work (querySelector, scroll-spy) sees the
709
+ // mounted subtree in both forms. With nothing in flight the microtask
710
+ // flush fires on the same tick `await $.ready` would resume on.
711
+ const lateReady = [];
712
+ let lateReadyWatch = null;
713
+ // Settlement is judged against the MANAGED root, same as boot ready —
714
+ // content outside a config.target root is never scanned, so its literal
715
+ // @[...] text must not starve the late-ready queue.
716
+ const mountsSettled = () =>
717
+ !document.querySelector('[vibe-staged], [vibe-fouc]') && shouldCleanup(rootElement);
718
+ const flushLateReady = () => {
719
+ if (lateReady.length === 0 || !mountsSettled()) return;
720
+ if (lateReadyWatch) {
721
+ lateReadyWatch();
722
+ lateReadyWatch = null;
723
+ }
724
+ for (const cb of lateReady.splice(0)) {
725
+ try {
726
+ cb();
727
+ } catch (error) {
728
+ console.error('[vibe] Error in ready hook:', error);
729
+ }
730
+ }
731
+ };
732
+ const queueLateReady = (callback) => {
733
+ lateReady.push(callback);
734
+ lateReadyWatch ??= $.on('afterDomMutation', flushLateReady);
735
+ Promise.resolve().then(flushLateReady);
736
+ return () => {
737
+ const i = lateReady.indexOf(callback);
738
+ if (i >= 0) lateReady.splice(i, 1);
739
+ };
740
+ };
741
+
696
742
  Object.defineProperty($, 'on', {
697
743
  value: (event, callback) => {
698
744
  if (event === 'ready' && readyFired) {
699
- try {
700
- callback();
701
- } catch (error) {
702
- console.error('[vibe] Error in ready hook:', error);
703
- }
704
- return () => {};
745
+ return queueLateReady(callback);
705
746
  }
706
747
  if (hooks[event]) {
707
748
  hooks[event].push(callback);
@@ -728,6 +769,37 @@ const main = (s, config = {}, stringSelector = '') => {
728
769
  enumerable: false,
729
770
  });
730
771
 
772
+ // Register a component's state bucket under its id (the fetched-mount path).
773
+ // A FRESH id is a key nothing in the live tree can bind yet — its subtree
774
+ // enters the reactive tree only after this write — so the write lands
775
+ // silently (raw target, no global flush) and previousState is deliberately
776
+ // NOT seeded: the mount's grouped notify must see undefined → state as a
777
+ // real diff (see the inline comment below — the load-bearing invariant).
778
+ // Re-registering an EXISTING id (an HMR re-run) is a real value change for
779
+ // live bindings and flushes normally.
780
+ Object.defineProperty($, '_register', {
781
+ value: (componentId, componentState) => {
782
+ if (componentId in $) {
783
+ $[componentId] = componentState;
784
+ return componentId;
785
+ }
786
+ // Raw write only — previousState is deliberately NOT seeded. The mount
787
+ // commits all its registrations in one notifyChanged batch when its
788
+ // scripts settle, and that flush must see each fresh id as a real
789
+ // change (undefined -> state): the subtree may have hydrated BEFORE the
790
+ // script ran (scripts execute in module order behind earlier imports),
791
+ // so this correction flush is what renders bindings, conditionals and
792
+ // iterations that read the component's state. A seeded previousState
793
+ // made the diff vacuous whenever the script filled its state object
794
+ // before calling component() — the game's fill-then-register pages
795
+ // stayed frozen at their pre-registration (empty) render.
796
+ silentSet($, componentId, componentState);
797
+ return componentId;
798
+ },
799
+ enumerable: false,
800
+ configurable: true,
801
+ });
802
+
731
803
  // Mark a trusted string as raw HTML. A binding that is the sole content of
732
804
  // its element — `<p>@[$.unsafe(desc)]</p>` — sets innerHTML from the string
733
805
  // instead of escaping it via textContent. Trusted input only (no sanitizing,
@@ -743,7 +815,7 @@ const main = (s, config = {}, stringSelector = '') => {
743
815
  // processed HTML string the plugin's HMR handler can hand to $.reconcile.
744
816
  // Scripts are NOT executed — callers use this only when they've verified
745
817
  // script contents haven't changed (so registered state is still valid).
746
- Object.defineProperty($, 'renderComponent', {
818
+ Object.defineProperty($, '_renderComponent', {
747
819
  value: renderComponentTemplate,
748
820
  enumerable: false,
749
821
  });
@@ -866,7 +938,7 @@ const main = (s, config = {}, stringSelector = '') => {
866
938
  // Early exit if no mutations to process (common case)
867
939
  if (mutations.length === 0) return;
868
940
 
869
- const manifestSizeBefore = Object.keys(manifest).length;
941
+ const manifestSizeBefore = debug ? Object.keys(manifest).length : 0;
870
942
  let hadChanges = false;
871
943
  let parsedParents = null; // Lazy init - only create Set when needed
872
944
  let addedElements = 0;
@@ -900,8 +972,14 @@ const main = (s, config = {}, stringSelector = '') => {
900
972
  // conditional's tracked reference to point to the replacement node.
901
973
  const branchRef = branchNodeRegistry.get(node);
902
974
  if (branchRef) {
903
- // Find the replacement: an added node in the same mutation at the same parent
904
- const replacement = Array.from(addedNodesList).find(n => n.parentNode === target);
975
+ // Find the replacement: an added node in the same mutation at the same
976
+ // parent. A STAGED remount splits add and remove into different
977
+ // batches (the incoming wrapper is inserted early, the outgoing one
978
+ // removed at commit) — the wrapper's replacement back-pointer covers
979
+ // that case.
980
+ const replacement =
981
+ Array.from(addedNodesList).find(n => n.parentNode === target) ||
982
+ (node._vibeReplacedBy?.isConnected ? node._vibeReplacedBy : null);
905
983
  if (replacement) {
906
984
  branchRef.nodes[branchRef.index] = replacement;
907
985
  branchNodeRegistry.set(replacement, branchRef);
@@ -909,13 +987,16 @@ const main = (s, config = {}, stringSelector = '') => {
909
987
  branchNodeRegistry.delete(node);
910
988
  }
911
989
 
912
- const entry = Object.entries(manifest).find(([_, element]) => element === node);
990
+ const dotAnnotation = manifestPathOf(manifest, node);
913
991
 
914
992
  // Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
915
- if (!entry) return;
993
+ if (dotAnnotation === null) return;
916
994
 
917
- const [dotAnnotation] = entry;
918
- delete manifest[dotAnnotation];
995
+ // Prune the node's ENTIRE manifest scope — root entry plus every
996
+ // descendant and branch-alias path under it. Deleting just the root
997
+ // entry leaked the rest, pinning each swapped-out page's detached
998
+ // DOM forever (the SPA navigation leak).
999
+ removeManifestSubtree(manifest, dotAnnotation);
919
1000
 
920
1001
  const dotPath = dotAnnotation.split('.');
921
1002
  const name = dotPath.pop();
@@ -969,7 +1050,7 @@ const main = (s, config = {}, stringSelector = '') => {
969
1050
  });
970
1051
  }
971
1052
 
972
- const entry = Object.entries(manifest).find(([_, element]) => element === target);
1053
+ const dotAnnotation = manifestPathOf(manifest, target);
973
1054
 
974
1055
  // Parse the newly added node
975
1056
  const parsedNode = parse(node);
@@ -983,8 +1064,7 @@ const main = (s, config = {}, stringSelector = '') => {
983
1064
  // (If not — e.g. mutations inside an iteration instance whose rows aren't in the
984
1065
  // global manifest — we still hydrate the node below; we just skip tree/manifest
985
1066
  // registration since there's no tree branch to attach to.)
986
- if (entry) {
987
- const [dotAnnotation] = entry;
1067
+ if (dotAnnotation !== null) {
988
1068
  // navigateTree walks `children` only; when the parent lives in a
989
1069
  // conditional branch's slot-projected content its manifest path isn't
990
1070
  // navigable that way (see findNodeByElement). Fall back to an identity
@@ -1054,9 +1134,14 @@ const main = (s, config = {}, stringSelector = '') => {
1054
1134
 
1055
1135
  // CLEANUP OF CURRENT STATE
1056
1136
  releaseOrphanedComponentState(removedComponentIds);
1137
+ let hadRemovals = false;
1057
1138
  mutations.forEach(({ removedNodes: removedNodesList }) => {
1139
+ if (removedNodesList.length > 0) hadRemovals = true;
1058
1140
  releaseOrphanedIterationProps(removedNodesList);
1059
1141
  });
1142
+ // Subscribers anchored in the removed subtrees are dead — drop them from
1143
+ // the reverse index (observed removals: page swaps, reconcile, app code).
1144
+ if (hadRemovals) pruneDisconnected();
1060
1145
 
1061
1146
  // Fire hooks once after all mutations are processed (not per-node)
1062
1147
  if (hadChanges) {
@@ -1109,8 +1194,10 @@ const main = (s, config = {}, stringSelector = '') => {
1109
1194
  debugLog(PHASE_MUTATE, '', debug, 0, topmostParent || addedElementsList[0]);
1110
1195
  }
1111
1196
 
1112
- // Log parsed elements after mutation
1113
- const manifestSizeAfter = Object.keys(manifest).length;
1197
+ // Log parsed elements after mutation — debug-only: the entries
1198
+ // snapshot allocates the full manifest per batch, so skip it entirely
1199
+ // when nothing will be printed.
1200
+ const manifestSizeAfter = debug ? Object.keys(manifest).length : manifestSizeBefore;
1114
1201
  const totalNodes = manifestSizeAfter - manifestSizeBefore;
1115
1202
 
1116
1203
  if (totalNodes > 0) {
@@ -1227,6 +1314,7 @@ const main = (s, config = {}, stringSelector = '') => {
1227
1314
  // Track cleanup state
1228
1315
  let componentProcessingStarted = false;
1229
1316
  let cleanupExecuted = false;
1317
+ let bootSettleDone = false;
1230
1318
 
1231
1319
  // Check if cleanup should run
1232
1320
  const checkCleanup = () => {
@@ -1243,6 +1331,19 @@ const main = (s, config = {}, stringSelector = '') => {
1243
1331
 
1244
1332
  // Check if all processing is complete
1245
1333
  if (shouldCleanup(rootElement)) {
1334
+ // One-shot settle before ready: directives whose expressions read
1335
+ // globals provided by component scripts (window helpers) carry no
1336
+ // reactive dependency for those globals, so a directive that rendered
1337
+ // before the defining script settled — boot renders race async imports,
1338
+ // runtime fetch mounts land after initial hydration — would stay empty
1339
+ // forever. All scripts and mounts have settled here; re-render once
1340
+ // against the fully-scripted world. Idempotent: rendered iterations
1341
+ // early-return, value-unchanged conditionals are a no-op.
1342
+ if (!bootSettleDone) {
1343
+ bootSettleDone = true;
1344
+ renderAllIterations(parsedTree, extractPlainValue($), manifest);
1345
+ settleConditionals(parsedTree, $, manifest);
1346
+ }
1246
1347
  cleanup(rootElement, debug);
1247
1348
  cleanupExecuted = true;
1248
1349
 
@@ -1268,6 +1369,14 @@ const main = (s, config = {}, stringSelector = '') => {
1268
1369
  if (compiledScriptsPending) {
1269
1370
  compiledScriptsPending.then(() => {
1270
1371
  compiledScriptsDone = true;
1372
+ // The initial conditional pass ran before these module scripts settled
1373
+ // — native MPA ordering had page modules evaluate BEFORE vibe booted,
1374
+ // so a gate on a module-provided global (`<!-- if window.isDev -->`)
1375
+ // saw the booted world. A gate like that carries no reactive
1376
+ // dependency to re-check it later, so settle conditionals once against
1377
+ // the post-module world before ready fires. Value-unchanged branches
1378
+ // are a no-op.
1379
+ settleConditionals(parsedTree, $, manifest);
1271
1380
  checkCleanup();
1272
1381
  });
1273
1382
  }
@@ -1296,7 +1405,7 @@ const main = (s, config = {}, stringSelector = '') => {
1296
1405
  // Use pre-compiled manifest if available (has compiledBatchFn), otherwise runtime-generated
1297
1406
  if (typeof window !== 'undefined') {
1298
1407
  const manifestToExport = hyperspeedTree || hyperspeedManifestData;
1299
- window.__vibeManifest = manifestToExport;
1408
+ (window.__vibe ??= {}).manifest = manifestToExport;
1300
1409
  }
1301
1410
 
1302
1411
  return $;