@ape-egg/vibe 2.1.22 → 3.0.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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  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 +277 -110
  15. package/runtime/index.js +189 -65
  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 +81 -11
  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 +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  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 -2522
  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 -15
  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/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. 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 = {};
@@ -571,6 +573,12 @@ const main = (s, config = {}, stringSelector = '') => {
571
573
  unmount: [],
572
574
  };
573
575
 
576
+ // The ready phase happens once. A listener registered after it fires
577
+ // immediately (parity with the late-safe $.ready promise) — late
578
+ // registration is the SPA norm, where fragment scripts run on mount,
579
+ // long after the shell booted.
580
+ let readyFired = false;
581
+
574
582
  // Page-scope 'unmount': the visitor actually leaving — pagehide (navigation
575
583
  // away, tab close). Deliberately NOT visibilitychange: a tab switch is not
576
584
  // an unmount, the visitor comes back. Inside a component script the same
@@ -608,6 +616,47 @@ const main = (s, config = {}, stringSelector = '') => {
608
616
 
609
617
  // Observer callback will be defined below (already declared above before processComponent)
610
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
+
611
660
  const $ = state(s, (changedProps) => {
612
661
  // Selective extraction: only extract changed props, preserve references for unchanged.
613
662
  // This ensures affected() correctly skips iterations whose arrays didn't change,
@@ -627,52 +676,17 @@ const main = (s, config = {}, stringSelector = '') => {
627
676
  }
628
677
  }
629
678
 
630
- // Find what changed (compare previousState vs currentState)
631
- const affectedElements = affected(parsedTree, previousState, currentState);
632
-
633
- 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) {
634
686
  debugLog(PHASE_UPDATE, 'state changed', debug);
635
-
636
- // Capture pending mutations before disconnecting (takeRecords clears the queue)
637
- let pendingMutations = [];
638
- if (observer) {
639
- pendingMutations = observer.takeRecords();
640
- observer.disconnect();
641
- }
642
-
643
- // Run core loop on the full tree (hydrate will use affected list)
644
- // isNewNode = false because this is a state update, not a DOM mutation
645
- hydrate(affectedElements, currentState, manifest, previousState);
646
-
647
- // After hydrate, check for conditional/iteration changes
648
- const conditionalCount = renderAllConditionals(parsedTree, currentState, manifest);
649
- const iterationCount = renderAllIterations(parsedTree, currentState, manifest);
650
-
651
- if (observer) {
652
- observer.observe(rootElement, {
653
- attributes: false,
654
- characterData: false,
655
- childList: true,
656
- subtree: true,
657
- });
658
-
659
- // Process mutations that were pending before we disconnected
660
- if (pendingMutations.length > 0 && processMutations) {
661
- processMutations(pendingMutations);
662
- }
663
- }
664
-
665
- // Conditionals/iterations may have mounted new DOM while observer was disconnected.
666
- // Scan for unresolved <component src=""> elements that need fetching.
667
- if (componentProcessingStarted) {
668
- const componentConfig = {
669
- ...config,
670
- _forceSync: true,
671
- _observer: observer,
672
- _processMutations: processMutations,
673
- };
674
- processComponent(rootElement, null, componentConfig);
675
- }
687
+ patchWithObserverPaused(() => {
688
+ dispatchFlush(dirty, currentState, previousState, manifest);
689
+ });
676
690
  }
677
691
 
678
692
  // Store previous state for hooks (currentState is already plain, no need to clone)
@@ -687,8 +701,49 @@ const main = (s, config = {}, stringSelector = '') => {
687
701
  // — the Proxy invariant rejects overriding non-configurable + non-writable
688
702
  // data properties. Enumerable stays false so `on` doesn't leak into state
689
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
+
690
742
  Object.defineProperty($, 'on', {
691
743
  value: (event, callback) => {
744
+ if (event === 'ready' && readyFired) {
745
+ return queueLateReady(callback);
746
+ }
692
747
  if (hooks[event]) {
693
748
  hooks[event].push(callback);
694
749
  }
@@ -714,6 +769,37 @@ const main = (s, config = {}, stringSelector = '') => {
714
769
  enumerable: false,
715
770
  });
716
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
+
717
803
  // Mark a trusted string as raw HTML. A binding that is the sole content of
718
804
  // its element — `<p>@[$.unsafe(desc)]</p>` — sets innerHTML from the string
719
805
  // instead of escaping it via textContent. Trusted input only (no sanitizing,
@@ -729,7 +815,7 @@ const main = (s, config = {}, stringSelector = '') => {
729
815
  // processed HTML string the plugin's HMR handler can hand to $.reconcile.
730
816
  // Scripts are NOT executed — callers use this only when they've verified
731
817
  // script contents haven't changed (so registered state is still valid).
732
- Object.defineProperty($, 'renderComponent', {
818
+ Object.defineProperty($, '_renderComponent', {
733
819
  value: renderComponentTemplate,
734
820
  enumerable: false,
735
821
  });
@@ -852,7 +938,7 @@ const main = (s, config = {}, stringSelector = '') => {
852
938
  // Early exit if no mutations to process (common case)
853
939
  if (mutations.length === 0) return;
854
940
 
855
- const manifestSizeBefore = Object.keys(manifest).length;
941
+ const manifestSizeBefore = debug ? Object.keys(manifest).length : 0;
856
942
  let hadChanges = false;
857
943
  let parsedParents = null; // Lazy init - only create Set when needed
858
944
  let addedElements = 0;
@@ -886,8 +972,14 @@ const main = (s, config = {}, stringSelector = '') => {
886
972
  // conditional's tracked reference to point to the replacement node.
887
973
  const branchRef = branchNodeRegistry.get(node);
888
974
  if (branchRef) {
889
- // Find the replacement: an added node in the same mutation at the same parent
890
- 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);
891
983
  if (replacement) {
892
984
  branchRef.nodes[branchRef.index] = replacement;
893
985
  branchNodeRegistry.set(replacement, branchRef);
@@ -895,13 +987,16 @@ const main = (s, config = {}, stringSelector = '') => {
895
987
  branchNodeRegistry.delete(node);
896
988
  }
897
989
 
898
- const entry = Object.entries(manifest).find(([_, element]) => element === node);
990
+ const dotAnnotation = manifestPathOf(manifest, node);
899
991
 
900
992
  // Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
901
- if (!entry) return;
993
+ if (dotAnnotation === null) return;
902
994
 
903
- const [dotAnnotation] = entry;
904
- 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);
905
1000
 
906
1001
  const dotPath = dotAnnotation.split('.');
907
1002
  const name = dotPath.pop();
@@ -955,7 +1050,7 @@ const main = (s, config = {}, stringSelector = '') => {
955
1050
  });
956
1051
  }
957
1052
 
958
- const entry = Object.entries(manifest).find(([_, element]) => element === target);
1053
+ const dotAnnotation = manifestPathOf(manifest, target);
959
1054
 
960
1055
  // Parse the newly added node
961
1056
  const parsedNode = parse(node);
@@ -969,8 +1064,7 @@ const main = (s, config = {}, stringSelector = '') => {
969
1064
  // (If not — e.g. mutations inside an iteration instance whose rows aren't in the
970
1065
  // global manifest — we still hydrate the node below; we just skip tree/manifest
971
1066
  // registration since there's no tree branch to attach to.)
972
- if (entry) {
973
- const [dotAnnotation] = entry;
1067
+ if (dotAnnotation !== null) {
974
1068
  // navigateTree walks `children` only; when the parent lives in a
975
1069
  // conditional branch's slot-projected content its manifest path isn't
976
1070
  // navigable that way (see findNodeByElement). Fall back to an identity
@@ -1040,9 +1134,14 @@ const main = (s, config = {}, stringSelector = '') => {
1040
1134
 
1041
1135
  // CLEANUP OF CURRENT STATE
1042
1136
  releaseOrphanedComponentState(removedComponentIds);
1137
+ let hadRemovals = false;
1043
1138
  mutations.forEach(({ removedNodes: removedNodesList }) => {
1139
+ if (removedNodesList.length > 0) hadRemovals = true;
1044
1140
  releaseOrphanedIterationProps(removedNodesList);
1045
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();
1046
1145
 
1047
1146
  // Fire hooks once after all mutations are processed (not per-node)
1048
1147
  if (hadChanges) {
@@ -1095,8 +1194,10 @@ const main = (s, config = {}, stringSelector = '') => {
1095
1194
  debugLog(PHASE_MUTATE, '', debug, 0, topmostParent || addedElementsList[0]);
1096
1195
  }
1097
1196
 
1098
- // Log parsed elements after mutation
1099
- 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;
1100
1201
  const totalNodes = manifestSizeAfter - manifestSizeBefore;
1101
1202
 
1102
1203
  if (totalNodes > 0) {
@@ -1213,6 +1314,7 @@ const main = (s, config = {}, stringSelector = '') => {
1213
1314
  // Track cleanup state
1214
1315
  let componentProcessingStarted = false;
1215
1316
  let cleanupExecuted = false;
1317
+ let bootSettleDone = false;
1216
1318
 
1217
1319
  // Check if cleanup should run
1218
1320
  const checkCleanup = () => {
@@ -1229,10 +1331,24 @@ const main = (s, config = {}, stringSelector = '') => {
1229
1331
 
1230
1332
  // Check if all processing is complete
1231
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
+ }
1232
1347
  cleanup(rootElement, debug);
1233
1348
  cleanupExecuted = true;
1234
1349
 
1235
1350
  // Fire ready hook after cleanup completes
1351
+ readyFired = true;
1236
1352
  hooks.ready.forEach((callback) => {
1237
1353
  try {
1238
1354
  callback();
@@ -1253,6 +1369,14 @@ const main = (s, config = {}, stringSelector = '') => {
1253
1369
  if (compiledScriptsPending) {
1254
1370
  compiledScriptsPending.then(() => {
1255
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);
1256
1380
  checkCleanup();
1257
1381
  });
1258
1382
  }
@@ -1281,7 +1405,7 @@ const main = (s, config = {}, stringSelector = '') => {
1281
1405
  // Use pre-compiled manifest if available (has compiledBatchFn), otherwise runtime-generated
1282
1406
  if (typeof window !== 'undefined') {
1283
1407
  const manifestToExport = hyperspeedTree || hyperspeedManifestData;
1284
- window.__vibeManifest = manifestToExport;
1408
+ (window.__vibe ??= {}).manifest = manifestToExport;
1285
1409
  }
1286
1410
 
1287
1411
  return $;