@ape-egg/vibe 2.3.0 → 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 (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
@@ -7,12 +7,33 @@ import {
7
7
  BINDING_REGEX,
8
8
  THIS_PROP_REGEX,
9
9
  STATE_THIS_PROP_REGEX,
10
+ CONDITIONAL_START_REGEX,
11
+ ITERATION_START_REGEX,
10
12
  } from './constants.js';
11
13
  import { evalInScope } from './utils.js';
14
+ import { bumpIterPropGeneration } from './iteration-utils.js';
12
15
  import { fetchComponentTemplate, isComponentCached } from './component-cache.js';
16
+ import { notifyChanged } from './state.js';
17
+ import {
18
+ liveNode,
19
+ activeOutgoingRoots,
20
+ markOutgoing,
21
+ releaseOutgoing,
22
+ stageIncoming,
23
+ commitStaged,
24
+ abandonStaged,
25
+ isComponentWrapper,
26
+ } from './staging.js';
27
+
28
+ export { activeOutgoingRoots, isComponentWrapper };
13
29
 
14
30
  // Deterministic component counter
15
- let componentCounter = 0;
31
+ // Runtime-minted ids live in their own range, far above every build-tagged
32
+ // id (SPA units get 1000 ids each — 1M covers a thousand pages). Without the
33
+ // floor, a long session's runtime counter could grow into a not-yet-mounted
34
+ // fragment's build range and claim its state. advanceComponentCounterPastIds
35
+ // still bumps past anything larger it encounters in the document.
36
+ let componentCounter = 1000000;
16
37
 
17
38
  /**
18
39
  * Generate unique component ID
@@ -41,7 +62,7 @@ export const releaseOrphanedComponentState = (collectedIds) => {
41
62
  if (!collectedIds || collectedIds.size === 0) return;
42
63
  for (const id of collectedIds) {
43
64
  if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
44
- delete window.__vibeComponents?.[id];
65
+ delete window.__vibe?.components?.[id];
45
66
  // CLEANUP OF CURRENT STATE
46
67
  delete window.$[id];
47
68
  runComponentCleanups(id);
@@ -54,10 +75,20 @@ export const releaseOrphanedComponentState = (collectedIds) => {
54
75
  // unmounting the component fires the callbacks so the previous evaluation's
55
76
  // listeners don't accumulate alongside fresh registrations.
56
77
  export const runComponentCleanups = (componentId) => {
57
- const cleanups = window.__vibeComponentCleanups?.[componentId];
78
+ const cleanups = window.__vibe?.cleanups?.[componentId];
58
79
  if (!cleanups) return;
59
- for (let i = 0; i < cleanups.length; i++) cleanups[i]();
60
- delete window.__vibeComponentCleanups[componentId];
80
+ // Deregister BEFORE running: a throwing callback must neither re-throw on
81
+ // every later cleanup of this id nor take the remaining callbacks down
82
+ // with it — and never escape into the script chain, whose links have no
83
+ // rejection path. User callbacks fail loudly, the engine keeps going.
84
+ delete window.__vibe.cleanups[componentId];
85
+ for (let i = 0; i < cleanups.length; i++) {
86
+ try {
87
+ cleanups[i]();
88
+ } catch (e) {
89
+ console.error('[vibe] Error in unmount cleanup:', e);
90
+ }
91
+ }
61
92
  };
62
93
 
63
94
  // Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
@@ -81,8 +112,8 @@ const createScopedDollar = (componentId) => {
81
112
  // leaking them past its lifetime. At page level the root's `on`
82
113
  // resolves the same event name to pagehide instead.
83
114
  if (event === 'unmount') {
84
- if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
85
- const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
115
+ const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
116
+ const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
86
117
  slot.push(callback);
87
118
  return () => {
88
119
  const i = slot.indexOf(callback);
@@ -90,8 +121,8 @@ const createScopedDollar = (componentId) => {
90
121
  };
91
122
  }
92
123
  const unsub = target.on(event, callback);
93
- if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
94
- const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
124
+ const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
125
+ const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
95
126
  slot.push(unsub);
96
127
  return unsub;
97
128
  };
@@ -173,8 +204,16 @@ const transformScriptContent = (rawContent) => {
173
204
  //
174
205
  // Returns a Promise when any script is async (has imports) — the caller
175
206
  // gates `ready` on it — or null when everything ran synchronously.
207
+ //
208
+ // Collection is span-aware, not a blind document query: manifest adoption has
209
+ // already swapped every directive's stamped content back to its pre-stamp
210
+ // template (declaration form), so at sweep time ALL content between
211
+ // `<!-- if -->`/`<!-- each -->` markers is unrendered template. Those scripts
212
+ // run when their branch/row mounts (mountBranch / renderIteration call back
213
+ // into this pipeline); only directive-free scripts are the boot pass's own.
176
214
  export const executeCompiledComponentScripts = () => {
177
- const scripts = document.querySelectorAll('script[type="vibe-module"]');
215
+ const scripts = [];
216
+ collectMountedModuleScripts(document.body ? [document.body] : [], scripts);
178
217
  if (!scripts.length) return null;
179
218
  advanceComponentCounterPastIds(document);
180
219
  return runVibeModuleScripts(scripts);
@@ -189,16 +228,43 @@ export const executeCompiledComponentScripts = () => {
189
228
  // conditional restores its markup but its `<!-- each _cN.x -->` reads state that
190
229
  // was released on unmount (the AccountProgression overlay rendering blank on
191
230
  // second open).
192
- export const executeCompiledComponentScriptsIn = (nodes) => {
231
+ //
232
+ // `silent` routes fresh-id registrations through $.register (no global flush)
233
+ // — correct ONLY when the subtree's own hydration runs after these scripts
234
+ // against live `$` (the fetched-mount path). Branch/row mounts render against
235
+ // a flush snapshot that predates the registration, so they rely on the
236
+ // registration's own flush to fill `_cN` bindings in — they must stay loud.
237
+ export const executeCompiledComponentScriptsIn = (nodes, { silent = false } = {}) => {
193
238
  const scripts = [];
194
- for (const node of nodes) {
195
- if (node.nodeType !== 1) continue;
196
- if (node.matches?.('script[type="vibe-module"]')) scripts.push(node);
197
- node.querySelectorAll?.('script[type="vibe-module"]').forEach((s) => scripts.push(s));
198
- }
239
+ collectMountedModuleScripts(nodes, scripts);
199
240
  if (!scripts.length) return null;
200
241
  advanceComponentCounterPastIds(document);
201
- return runVibeModuleScripts(scripts);
242
+ return runVibeModuleScripts(scripts, silent);
243
+ };
244
+
245
+ // Collect the vibe-module scripts a fresh mount actually OWNS. Content between
246
+ // nested `<!-- if -->`/`<!-- each -->` markers in a just-cloned subtree is an
247
+ // unrendered template — its scripts belong to the nested directive and run when
248
+ // THAT directive mounts its branch/rows (mountBranch and renderIteration each
249
+ // call back into this pass). Running them here would execute component copies
250
+ // this mount never shows: the compiler inlines a slotted component once per
251
+ // conditional branch, so a mount that owns both branch templates would register
252
+ // ghost state buckets whose `$.on` hooks shadow the mounted copy's writes
253
+ // forever. Directive spans are sibling-scoped, so each child list scans with
254
+ // its own depth counter (same walk as reconcile's opaque-region skip).
255
+ const collectMountedModuleScripts = (nodes, out) => {
256
+ let depth = 0;
257
+ for (const node of nodes) {
258
+ if (node.nodeType === 8) {
259
+ const text = node.textContent.trim();
260
+ if (CONDITIONAL_START_REGEX.test(text) || ITERATION_START_REGEX.test(text)) depth++;
261
+ else if (text === '/if' || text === '/each') depth--;
262
+ continue;
263
+ }
264
+ if (depth > 0 || node.nodeType !== 1) continue;
265
+ if (node.matches?.('script[type="vibe-module"]')) out.push(node);
266
+ else collectMountedModuleScripts(node.childNodes, out);
267
+ }
202
268
  };
203
269
 
204
270
  // Build-time tagging already assigned _cN ids to wrappers; advance the runtime
@@ -210,48 +276,131 @@ const advanceComponentCounterPastIds = (root) => {
210
276
  });
211
277
  };
212
278
 
213
- const runVibeModuleScripts = (scripts) => {
279
+ // Shared registration: the component registry always, plus the live `$` when
280
+ // booted. `silent` prefers $.register — a fresh id lands without its own
281
+ // global flush; the MOUNT commits all its registrations in one notifyChanged
282
+ // batch when its scripts settle. Re-registrations (HMR) still flush
283
+ // immediately, and pre-boot placeholders keep the plain reactive write.
284
+ const registerComponentState = (componentId, state, silent) => {
285
+ ((window.__vibe ??= {}).components ??= {})[componentId] = state;
286
+ if (window.$) {
287
+ if (silent && window.$._register) window.$._register(componentId, state);
288
+ else window.$[componentId] = state;
289
+ }
290
+ };
291
+
292
+ // Native `<script type="module">` semantics: module scripts evaluate strictly
293
+ // in document order — script N+1 never starts before script N finished,
294
+ // imports included. A sync script may therefore rely on globals an earlier
295
+ // script's import produced (the game's Layout imports boot.js; chrome
296
+ // components read its globals at their top level). Eval'd vibe-module scripts
297
+ // lose that for free — an async function returns at its first `await`, so the
298
+ // executor's loop would start every script "concurrently" and sync scripts
299
+ // would run before earlier imports landed. One global chain restores the
300
+ // contract across ALL execution sites (boot batch, mounted subtrees, fetched
301
+ // components): while a script is pending, later scripts — sync ones too —
302
+ // queue behind it. An idle chain runs sync scripts synchronously, so
303
+ // boot timing is unchanged until the first async script appears.
304
+ let scriptChain = null;
305
+
306
+ const enqueueScriptExecution = (run) => {
307
+ if (scriptChain) {
308
+ const link = scriptChain.then(run);
309
+ const tail = link.then(() => {
310
+ if (scriptChain === tail) scriptChain = null;
311
+ });
312
+ scriptChain = tail;
313
+ return link;
314
+ }
315
+ const result = run();
316
+ if (!result) return null;
317
+ const tail = result.then(() => {
318
+ if (scriptChain === tail) scriptChain = null;
319
+ });
320
+ scriptChain = tail;
321
+ return result;
322
+ };
323
+
324
+ // Cleanups, scoping and evaluation for one script — the unit the chain
325
+ // executes. Never throws and never rejects: a crashed script is warned and
326
+ // released, later scripts still run (a failed native module doesn't stop
327
+ // subsequent script elements either).
328
+ const executeScriptUnit = (componentId, content, hasImports, scopedDollar, componentFn) => () => {
329
+ runComponentCleanups(componentId);
330
+ try {
331
+ if (hasImports) {
332
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
333
+ return new AsyncFunction('$', 'component', content)(scopedDollar, componentFn).catch((e) => {
334
+ console.warn('[vibe] Failed to execute component script:', e);
335
+ });
336
+ }
337
+ new Function('$', 'component', content)(scopedDollar, componentFn);
338
+ } catch (e) {
339
+ console.warn('[vibe] Failed to execute component script:', e);
340
+ }
341
+ };
342
+
343
+ // The wrapper's own setup script: the first vibe-module script in the wrapper's
344
+ // subtree not owned by a nested component wrapper. Only THIS script may claim
345
+ // the wrapper's build-tagged id — the id the stamped bindings reference — and
346
+ // the rule must be deterministic across batches, not "first in this batch": a
347
+ // later partial batch (a nested branch mount re-running one auxiliary script)
348
+ // claiming the component's id would tear down the component's live `$.on` hooks
349
+ // through executeScriptUnit's pre-run cleanup and never re-register them.
350
+ const wrapperSetupScript = (wrapper) => {
351
+ for (const s of wrapper.querySelectorAll('script[type="vibe-module"]')) {
352
+ if (s.closest('[data-vibe-component-id]') === wrapper) return s;
353
+ }
354
+ return null;
355
+ };
356
+
357
+ const runVibeModuleScripts = (scripts, silent = false) => {
214
358
  const asyncTasks = [];
215
- const claimed = new Set();
359
+ const registeredIds = [];
216
360
 
217
361
  for (const script of scripts) {
362
+ // One script node, one execution — native `<script>` semantics. Remounts
363
+ // clone fresh nodes from the restoration template (clones don't carry the
364
+ // marker), so they run; the same DOM node reached by overlapping passes
365
+ // (boot's document sweep after a pre-boot branch mount already ran it)
366
+ // does not run twice.
367
+ if (script.__vibeExecuted) continue;
368
+
218
369
  // Parity with the fetch path: dehydrated components never execute
219
370
  if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
220
371
 
221
372
  const rawContent = script.textContent?.trim() || '';
222
373
  if (!rawContent) continue;
374
+ script.__vibeExecuted = true;
223
375
 
224
- // The build tagged each component wrapper with its deterministic id —
225
- // the same id the stamped bindings reference. First script in a wrapper
226
- // claims it; additional scripts get fresh ids (mirrors the per-script
227
- // ids of the fetch path).
228
376
  const wrapper = script.closest('[data-vibe-component-id]');
229
- let componentId = wrapper?.getAttribute('data-vibe-component-id');
230
- if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
231
- claimed.add(componentId);
377
+ const componentId =
378
+ wrapper && wrapperSetupScript(wrapper) === script
379
+ ? wrapper.getAttribute('data-vibe-component-id')
380
+ : generateComponentId();
232
381
 
233
382
  const { content, hasImports } = transformScriptContent(rawContent);
234
383
 
235
384
  const componentFn = (state) => {
236
- if (!window.__vibeComponents) window.__vibeComponents = {};
237
- window.__vibeComponents[componentId] = state;
238
- if (window.$) window.$[componentId] = state;
385
+ registerComponentState(componentId, state, silent);
386
+ registeredIds.push(componentId);
239
387
  return componentId;
240
388
  };
241
389
 
242
- runComponentCleanups(componentId);
243
- const scopedDollar = createScopedDollar(componentId);
390
+ const task = enqueueScriptExecution(
391
+ executeScriptUnit(componentId, content, hasImports, createScopedDollar(componentId), componentFn),
392
+ );
393
+ if (task) asyncTasks.push(task);
394
+ }
244
395
 
245
- try {
246
- if (hasImports) {
247
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
248
- asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
249
- } else {
250
- new Function('$', 'component', content)(scopedDollar, componentFn);
251
- }
252
- } catch (e) {
253
- console.warn('[vibe] Failed to execute component script:', e);
396
+ // Silent mode: one grouped commit once every script has registered, so
397
+ // whole-`$` observers see all fresh keys with a single flush.
398
+ if (silent) {
399
+ if (asyncTasks.length) {
400
+ return Promise.all(asyncTasks).then(() => notifyChanged(registeredIds));
254
401
  }
402
+ notifyChanged(registeredIds);
403
+ return null;
255
404
  }
256
405
 
257
406
  return asyncTasks.length ? Promise.all(asyncTasks) : null;
@@ -279,21 +428,10 @@ export const abortComponentFetch = (element) => {
279
428
  }
280
429
  };
281
430
 
282
- // A fetched-component host: `<component>` or `<div class="component">`.
283
- export const isComponentWrapper = (el) =>
284
- el.nodeName === 'COMPONENT' ||
285
- (el.nodeName === 'DIV' && el.classList?.contains('component'));
286
-
287
431
  // The live element a reactive src binding acts on. The manifest tree keeps the
288
- // ORIGINAL element, but every (re)mount replaces the wrapper (finalize's
289
- // replaceWith), leaving a `_vibeReplacedBy` link behind. Follow the chain and
290
- // compress it so intermediate detached wrappers stay collectable.
291
- export const liveComponentWrapper = (element) => {
292
- let live = element;
293
- while (live._vibeReplacedBy) live = live._vibeReplacedBy;
294
- if (live !== element) element._vibeReplacedBy = live;
295
- return live;
296
- };
432
+ // ORIGINAL element, but every (re)mount replaces the wrapper — staging.js's
433
+ // chain resolver is the one implementation.
434
+ export const liveComponentWrapper = liveNode;
297
435
 
298
436
  // (Re)mount a component for a reactive src binding (`src="@[page.src]"`).
299
437
  // Three phases of a wrapper's life, one entry point:
@@ -322,6 +460,13 @@ export const remountComponent = (el, src, debug = false) => {
322
460
  // attribute) was invisible to that pass, so hydration owns its first
323
461
  // fetch too — the observer doesn't watch attributes.
324
462
  if (el._vibeMountedSrc === undefined && !wasFetching && hadSrcAttr) return;
463
+ // The mounted content is now OUTGOING: it will be replaced wholesale when
464
+ // the new src lands, so re-rendering it against post-navigation state is
465
+ // pure waste — and is what made outgoing pages visually collapse the
466
+ // moment route state flipped. Both engines skip beneath a flagged wrapper
467
+ // (the wrapper's own src/key bindings stay live so a rapid next navigation
468
+ // still re-triggers). The flag dies with the wrapper at swap.
469
+ markOutgoing(el);
325
470
  processSingle(el, debug);
326
471
  };
327
472
 
@@ -333,6 +478,9 @@ export const remountComponent = (el, src, debug = false) => {
333
478
  export const forceRemount = (el, debug = false) => {
334
479
  if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
335
480
  el.setAttribute('src', el._vibeMountedSrc);
481
+ // Same outgoing freeze as remountComponent — a keyed remount replaces the
482
+ // mounted content just the same.
483
+ markOutgoing(el);
336
484
  processSingle(el, debug);
337
485
  };
338
486
 
@@ -396,8 +544,15 @@ const tagScriptSiblings = (script, componentId) => {
396
544
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
397
545
  break;
398
546
  }
399
- sibling.setAttribute('data-vibe-component-id', componentId);
400
- rewriteThisBindings(sibling, componentId);
547
+ // A sibling already carrying an id is a build-tagged compiled wrapper
548
+ // that owns its scope — its `this.` bindings were rewritten to that id
549
+ // at compile time. Overwriting it re-scopes the wrapper to THIS script's
550
+ // id and orphans every `@[_cN.x]` binding inside it (the game's
551
+ // top-level create-teamplay modal losing its state).
552
+ if (!sibling.hasAttribute('data-vibe-component-id')) {
553
+ sibling.setAttribute('data-vibe-component-id', componentId);
554
+ rewriteThisBindings(sibling, componentId);
555
+ }
401
556
  sibling = sibling.nextElementSibling;
402
557
  }
403
558
  };
@@ -579,6 +734,13 @@ const processSingle = (el, debug) => {
579
734
 
580
735
  return fetchComponentTemplate(src, controller.signal)
581
736
  .then((html) => {
737
+ // Superseded before the template arrived: the cache path resolves
738
+ // regardless of the abort signal (aborting a cache hit is meaningless
739
+ // network-wise), so the supersede check lives here. A newer remount
740
+ // owns the wrapper — running this mount's scripts or finalize would
741
+ // land the stale fragment and discard the new one.
742
+ if (controller.signal.aborted) return;
743
+
582
744
  // Parse HTML in temporary container to process component scripts
583
745
  const temp = createDetached('div');
584
746
  temp.innerHTML = html;
@@ -620,9 +782,10 @@ const processSingle = (el, debug) => {
620
782
  // Sibling tagging is handled below (before script.remove()) so it works
621
783
  // for both sync and async scripts.
622
784
  const componentFn = (state) => {
623
- if (!window.__vibeComponents) window.__vibeComponents = {};
624
- window.__vibeComponents[componentId] = state;
625
- if (window.$) window.$[componentId] = state;
785
+ // Fetched-mount registration is silent: the fetched subtree's
786
+ // hydration runs after replaceWith against live `$`, so a fresh id
787
+ // needs no flush (see registerComponentState / $.register).
788
+ registerComponentState(componentId, state, true);
626
789
  if (!registeredComponentIds.includes(componentId)) {
627
790
  registeredComponentIds.push(componentId);
628
791
  }
@@ -633,34 +796,17 @@ const processSingle = (el, debug) => {
633
796
  return componentId;
634
797
  };
635
798
 
636
- // Re-running the script for a reused componentId (HMR remount) must
637
- // tear down listeners from the previous evaluation before the fresh
638
- // script registers new ones. Without this, every cycle stacks another
639
- // listener on top of the stale closures and a single reactive tick
640
- // fires N callbacks instead of one.
641
- runComponentCleanups(componentId);
642
-
643
- // Provide a per-script `$` whose `.on(...)` registers cleanups under
644
- // this componentId. Bare `$` in the script body resolves to this
645
- // Proxy (the function parameter shadows the global), so listener
646
- // registrations are auto-tracked across `await` boundaries via the
647
- // closure — no opt-in required.
648
- const scopedDollar = createScopedDollar(componentId);
649
-
650
- // Execute script with component() function in scope
651
- try {
652
- if (hasImports) {
653
- // Async execution for scripts with imports
654
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
655
- asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
656
- } else {
657
- // Synchronous execution for scripts without imports (preserves boot timing)
658
- const executeFn = new Function('$', 'component', scriptContent);
659
- executeFn(scopedDollar, componentFn);
660
- }
661
- } catch (e) {
662
- console.warn('[vibe] Failed to execute component script:', e);
663
- }
799
+ // Execute through the global script chain (see enqueueScriptExecution):
800
+ // cleanups for a reused componentId (HMR remount) tear down the prior
801
+ // evaluation's listeners right before the fresh run, the scoped `$`
802
+ // tracks this script's `.on(...)` registrations, and document-order
803
+ // execution holds even against scripts from other batches (a fetched
804
+ // component's script never runs while the shell's boot import is
805
+ // still in flight).
806
+ const task = enqueueScriptExecution(
807
+ executeScriptUnit(componentId, scriptContent, hasImports, createScopedDollar(componentId), componentFn),
808
+ );
809
+ if (task) asyncTasks.push(task);
664
810
 
665
811
  // Tag siblings + rewrite this. bindings using shared helper. Runs
666
812
  // BEFORE script.remove() so nextElementSibling is valid.
@@ -670,8 +816,27 @@ const processSingle = (el, debug) => {
670
816
  script.remove();
671
817
  }
672
818
 
819
+ // Evict everything this mount's scripts registered — state buckets AND
820
+ // their $.on listeners/unmount side effects. The scripts already RAN
821
+ // (execution precedes finalize), so skipping the cleanups here leaks
822
+ // live global listeners for DOM that never mounts.
823
+ const releaseRegisteredIds = () => {
824
+ for (const id of registeredComponentIds) {
825
+ delete window.__vibe?.components?.[id];
826
+ if (window.$) delete window.$[id];
827
+ runComponentCleanups(id);
828
+ }
829
+ };
830
+
673
831
  // Finalize: props, slots, DOM replacement
674
832
  const finalize = () => {
833
+ // Superseded DURING script execution (an async import held this mount
834
+ // while a newer remount aborted it): the newer fetch owns the wrapper.
835
+ if (controller.signal.aborted) {
836
+ releaseRegisteredIds();
837
+ return;
838
+ }
839
+
675
840
  // Delegate prop substitution + slot inlining to shared helper.
676
841
  const transformedHtml = renderPropsAndSlot(temp, props, children);
677
842
 
@@ -726,7 +891,7 @@ const processSingle = (el, debug) => {
726
891
  // detached `<component src>` to the new wrapper. The detached element
727
892
  // would otherwise trigger releaseOrphanedIterationProps and free the
728
893
  // registry slots that the inlined template's bindings still reference,
729
- // causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
894
+ // causing every `@[window.__vibe.iterProps._pN]` to resolve to undefined
730
895
  // on the next hydrate.
731
896
  if (el._vibeIterPropIds) {
732
897
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
@@ -740,6 +905,7 @@ const processSingle = (el, debug) => {
740
905
  if (el.hasAttribute('data-vibe-iter-prop')) {
741
906
  newWrapper.setAttribute('data-vibe-iter-prop', '');
742
907
  el.removeAttribute('data-vibe-iter-prop');
908
+ bumpIterPropGeneration();
743
909
  }
744
910
  // Transfer the original prop expressions too, so the iteration's
745
911
  // update path can re-evaluate them against the row's new scope and
@@ -765,37 +931,77 @@ const processSingle = (el, debug) => {
765
931
  // that parsed and hydrated this subtree.
766
932
  newWrapper.setAttribute('vibe-fouc', '');
767
933
  el._vibeReplacedBy = newWrapper;
768
- el.replaceWith(newWrapper);
934
+ if (el._vibeOutgoing) {
935
+ // REMOUNT (route/key change): stage, don't swap — staging.js owns
936
+ // the transition (display:none sibling now, one-paint commit in
937
+ // tryReveal below).
938
+ stageIncoming(el, newWrapper);
939
+ } else {
940
+ el.replaceWith(newWrapper);
941
+ }
769
942
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
770
943
 
944
+ // The mount's own script registrations commit as one batch — the
945
+ // subtree hydrates via the observer against live `$`, so the only
946
+ // consumers needing a flush are whole-`$` observers elsewhere.
947
+ notifyChanged(registeredComponentIds);
948
+
771
949
  // Build-inlined child components (compiled SPA fragments) arrive
772
950
  // with tagged wrapper ids and vibe-module scripts — the compiled-
773
951
  // document form. A fetched mount is the fourth delivery mode after
774
952
  // boot, conditional branches, and iteration rows: run those
775
953
  // scripts now so each child's component({...}) state registers
776
- // under its build-tagged id and the _cN bindings hydrate.
777
- executeCompiledComponentScriptsIn([newWrapper]);
954
+ // under its build-tagged id and the _cN bindings hydrate. Silent:
955
+ // their registrations commit as one grouped notify when the
956
+ // scripts settle — per-script flushes would re-walk the page N
957
+ // times (the 26-script game fragments spent ~150ms/navigation on
958
+ // exactly that). The settle promise also gates the reveal below:
959
+ // a slow module import delays registration, and until it lands the
960
+ // subtree's `this.`-derived bindings hydrate to junk (NaN widths —
961
+ // the game's "Making potion" bar painting full/empty mid-mount).
962
+ const scriptsSettled = Promise.resolve(
963
+ executeCompiledComponentScriptsIn([newWrapper], { silent: true }),
964
+ );
778
965
 
779
966
  // MutationObserver handles parsing and hydrating the new content.
780
967
  // Branch nodes are registered in the manifest by mountBranch,
781
968
  // so the observer can find parents even inside conditional branches.
782
969
  // Hydration can span multiple batches (nested fetched components,
783
970
  // async scripts) with paints in between — reveal only when the
784
- // subtree has settled (same predicate the page-level ready uses).
971
+ // wrapper's scripts have settled AND the subtree carries no raw
972
+ // bindings (same predicate the page-level ready uses). The grouped
973
+ // registration notify queues its correction flush BEFORE the settle
974
+ // callback runs, so the recheck sees post-correction DOM in the
975
+ // same microtask drain — the first painted frame is the true one.
785
976
  // A wrapper unmounted mid-hydration releases the hook.
786
- const unfouc = window.$.on('afterDomMutation', () => {
787
- if (newWrapper.isConnected && !shouldCleanup(newWrapper)) return;
977
+ let scriptsDone = false;
978
+ const tryReveal = () => {
979
+ if (!newWrapper.isConnected) {
980
+ abandonStaged(newWrapper);
981
+ newWrapper.removeAttribute('vibe-fouc');
982
+ unfouc();
983
+ return;
984
+ }
985
+ if (!scriptsDone || !shouldCleanup(newWrapper)) return;
986
+ // Atomic visual commit: old page out, parked styling-context
987
+ // bindings applied, new page revealed — one synchronous block,
988
+ // one paint. Until this moment the outgoing page was still the
989
+ // one on screen, fully styled.
990
+ commitStaged(newWrapper);
788
991
  newWrapper.removeAttribute('vibe-fouc');
789
992
  unfouc();
993
+ };
994
+ const unfouc = window.$.on('afterDomMutation', tryReveal);
995
+ scriptsSettled.then(() => {
996
+ scriptsDone = true;
997
+ tryReveal();
790
998
  });
791
999
  } else {
792
1000
  // Element was detached before finalize ran (conditional unmounted
793
1001
  // during fetch, parent removed, etc). Release any state component()
794
1002
  // calls registered — otherwise it leaks on `$` forever.
795
- for (const id of registeredComponentIds) {
796
- delete window.__vibeComponents?.[id];
797
- if (window.$) delete window.$[id];
798
- }
1003
+ releaseOutgoing(el);
1004
+ releaseRegisteredIds();
799
1005
  }
800
1006
  };
801
1007
 
@@ -810,12 +1016,19 @@ const processSingle = (el, debug) => {
810
1016
  // Clean up pending fetch tracker — ownership-guarded (see finalize)
811
1017
  if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
812
1018
 
813
- // If fetch was aborted (element removed or re-mounted), silently skip
1019
+ // Aborted fetch: a removal-abort (wrapper left the DOM mid-remount)
1020
+ // has no commit left to release its outgoing freeze — free the root
1021
+ // here or the detached page subtree stays pinned in
1022
+ // activeOutgoingRoots for the session. A supersede-abort (newer
1023
+ // remount on the same, still-connected wrapper) keeps it: the new
1024
+ // fetch owns the freeze.
814
1025
  if (error.name === 'AbortError') {
1026
+ if (!el.isConnected) releaseOutgoing(el);
815
1027
  return;
816
1028
  }
817
1029
 
818
1030
  console.error('[vibe] Failed to load:', src, error);
1031
+ releaseOutgoing(el);
819
1032
  if (el.parentNode) {
820
1033
  el.remove();
821
1034
  }