@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
@@ -1,4 +1,5 @@
1
1
  import { debugLog } from './debug.js';
2
+ import { shouldCleanup } from './cleanup.js';
2
3
  import {
3
4
  PHASE_FETCH,
4
5
  PHASE_FETCH_CACHED,
@@ -6,12 +7,33 @@ import {
6
7
  BINDING_REGEX,
7
8
  THIS_PROP_REGEX,
8
9
  STATE_THIS_PROP_REGEX,
10
+ CONDITIONAL_START_REGEX,
11
+ ITERATION_START_REGEX,
9
12
  } from './constants.js';
10
13
  import { evalInScope } from './utils.js';
14
+ import { bumpIterPropGeneration } from './iteration-utils.js';
11
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 };
12
29
 
13
30
  // Deterministic component counter
14
- 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;
15
37
 
16
38
  /**
17
39
  * Generate unique component ID
@@ -40,7 +62,7 @@ export const releaseOrphanedComponentState = (collectedIds) => {
40
62
  if (!collectedIds || collectedIds.size === 0) return;
41
63
  for (const id of collectedIds) {
42
64
  if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
43
- delete window.__vibeComponents?.[id];
65
+ delete window.__vibe?.components?.[id];
44
66
  // CLEANUP OF CURRENT STATE
45
67
  delete window.$[id];
46
68
  runComponentCleanups(id);
@@ -53,10 +75,20 @@ export const releaseOrphanedComponentState = (collectedIds) => {
53
75
  // unmounting the component fires the callbacks so the previous evaluation's
54
76
  // listeners don't accumulate alongside fresh registrations.
55
77
  export const runComponentCleanups = (componentId) => {
56
- const cleanups = window.__vibeComponentCleanups?.[componentId];
78
+ const cleanups = window.__vibe?.cleanups?.[componentId];
57
79
  if (!cleanups) return;
58
- for (let i = 0; i < cleanups.length; i++) cleanups[i]();
59
- 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
+ }
60
92
  };
61
93
 
62
94
  // Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
@@ -80,8 +112,8 @@ const createScopedDollar = (componentId) => {
80
112
  // leaking them past its lifetime. At page level the root's `on`
81
113
  // resolves the same event name to pagehide instead.
82
114
  if (event === 'unmount') {
83
- if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
84
- const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
115
+ const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
116
+ const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
85
117
  slot.push(callback);
86
118
  return () => {
87
119
  const i = slot.indexOf(callback);
@@ -89,8 +121,8 @@ const createScopedDollar = (componentId) => {
89
121
  };
90
122
  }
91
123
  const unsub = target.on(event, callback);
92
- if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
93
- const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
124
+ const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
125
+ const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
94
126
  slot.push(unsub);
95
127
  return unsub;
96
128
  };
@@ -172,8 +204,16 @@ const transformScriptContent = (rawContent) => {
172
204
  //
173
205
  // Returns a Promise when any script is async (has imports) — the caller
174
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.
175
214
  export const executeCompiledComponentScripts = () => {
176
- const scripts = document.querySelectorAll('script[type="vibe-module"]');
215
+ const scripts = [];
216
+ collectMountedModuleScripts(document.body ? [document.body] : [], scripts);
177
217
  if (!scripts.length) return null;
178
218
  advanceComponentCounterPastIds(document);
179
219
  return runVibeModuleScripts(scripts);
@@ -188,16 +228,43 @@ export const executeCompiledComponentScripts = () => {
188
228
  // conditional restores its markup but its `<!-- each _cN.x -->` reads state that
189
229
  // was released on unmount (the AccountProgression overlay rendering blank on
190
230
  // second open).
191
- 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 } = {}) => {
192
238
  const scripts = [];
193
- for (const node of nodes) {
194
- if (node.nodeType !== 1) continue;
195
- if (node.matches?.('script[type="vibe-module"]')) scripts.push(node);
196
- node.querySelectorAll?.('script[type="vibe-module"]').forEach((s) => scripts.push(s));
197
- }
239
+ collectMountedModuleScripts(nodes, scripts);
198
240
  if (!scripts.length) return null;
199
241
  advanceComponentCounterPastIds(document);
200
- 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
+ }
201
268
  };
202
269
 
203
270
  // Build-time tagging already assigned _cN ids to wrappers; advance the runtime
@@ -209,48 +276,131 @@ const advanceComponentCounterPastIds = (root) => {
209
276
  });
210
277
  };
211
278
 
212
- 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) => {
213
358
  const asyncTasks = [];
214
- const claimed = new Set();
359
+ const registeredIds = [];
215
360
 
216
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
+
217
369
  // Parity with the fetch path: dehydrated components never execute
218
370
  if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
219
371
 
220
372
  const rawContent = script.textContent?.trim() || '';
221
373
  if (!rawContent) continue;
374
+ script.__vibeExecuted = true;
222
375
 
223
- // The build tagged each component wrapper with its deterministic id —
224
- // the same id the stamped bindings reference. First script in a wrapper
225
- // claims it; additional scripts get fresh ids (mirrors the per-script
226
- // ids of the fetch path).
227
376
  const wrapper = script.closest('[data-vibe-component-id]');
228
- let componentId = wrapper?.getAttribute('data-vibe-component-id');
229
- if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
230
- claimed.add(componentId);
377
+ const componentId =
378
+ wrapper && wrapperSetupScript(wrapper) === script
379
+ ? wrapper.getAttribute('data-vibe-component-id')
380
+ : generateComponentId();
231
381
 
232
382
  const { content, hasImports } = transformScriptContent(rawContent);
233
383
 
234
384
  const componentFn = (state) => {
235
- if (!window.__vibeComponents) window.__vibeComponents = {};
236
- window.__vibeComponents[componentId] = state;
237
- if (window.$) window.$[componentId] = state;
385
+ registerComponentState(componentId, state, silent);
386
+ registeredIds.push(componentId);
238
387
  return componentId;
239
388
  };
240
389
 
241
- runComponentCleanups(componentId);
242
- const scopedDollar = createScopedDollar(componentId);
390
+ const task = enqueueScriptExecution(
391
+ executeScriptUnit(componentId, content, hasImports, createScopedDollar(componentId), componentFn),
392
+ );
393
+ if (task) asyncTasks.push(task);
394
+ }
243
395
 
244
- try {
245
- if (hasImports) {
246
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
247
- asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
248
- } else {
249
- new Function('$', 'component', content)(scopedDollar, componentFn);
250
- }
251
- } catch (e) {
252
- 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));
253
401
  }
402
+ notifyChanged(registeredIds);
403
+ return null;
254
404
  }
255
405
 
256
406
  return asyncTasks.length ? Promise.all(asyncTasks) : null;
@@ -278,21 +428,10 @@ export const abortComponentFetch = (element) => {
278
428
  }
279
429
  };
280
430
 
281
- // A fetched-component host: `<component>` or `<div class="component">`.
282
- export const isComponentWrapper = (el) =>
283
- el.nodeName === 'COMPONENT' ||
284
- (el.nodeName === 'DIV' && el.classList?.contains('component'));
285
-
286
431
  // The live element a reactive src binding acts on. The manifest tree keeps the
287
- // ORIGINAL element, but every (re)mount replaces the wrapper (finalize's
288
- // replaceWith), leaving a `_vibeReplacedBy` link behind. Follow the chain and
289
- // compress it so intermediate detached wrappers stay collectable.
290
- export const liveComponentWrapper = (element) => {
291
- let live = element;
292
- while (live._vibeReplacedBy) live = live._vibeReplacedBy;
293
- if (live !== element) element._vibeReplacedBy = live;
294
- return live;
295
- };
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;
296
435
 
297
436
  // (Re)mount a component for a reactive src binding (`src="@[page.src]"`).
298
437
  // Three phases of a wrapper's life, one entry point:
@@ -305,6 +444,7 @@ export const liveComponentWrapper = (element) => {
305
444
  // the removal pass when replaceWith drops the old wrapper.
306
445
  export const remountComponent = (el, src, debug = false) => {
307
446
  const wasFetching = pendingFetches.has(el);
447
+ const hadSrcAttr = el.hasAttribute('src');
308
448
  // Compare against the LATEST requested src: with a fetch in flight the src
309
449
  // attribute holds it (rapid navigation A→B→A must abort B, not no-op on A);
310
450
  // mounted and idle, the finalize-stashed value does.
@@ -314,9 +454,33 @@ export const remountComponent = (el, src, debug = false) => {
314
454
  if (src === current) return;
315
455
  abortComponentFetch(el);
316
456
  el.setAttribute('src', src);
317
- // Pre-fetch element: the boot/observer processComponent pass that hasn't
318
- // reached it yet will pick up the new value — nothing to redo.
319
- if (el._vibeMountedSrc === undefined && !wasFetching) return;
457
+ // Pre-fetch element awaiting its initial processing pass: that pass reads
458
+ // the new value — nothing to redo. A declaration-form wrapper (bound src
459
+ // that resolved to nothing, carried on data-vibe-src with no src
460
+ // attribute) was invisible to that pass, so hydration owns its first
461
+ // fetch too — the observer doesn't watch attributes.
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);
470
+ processSingle(el, debug);
471
+ };
472
+
473
+ // Key-change remount (`key="@[page.path]"`): mount the CURRENT src fresh even
474
+ // though it is unchanged. Only a mounted, idle wrapper has anything to redo —
475
+ // with a fetch pending the incoming mount is already fresh (a src change in
476
+ // the same flush started it), and an unmounted wrapper's first mount is owned
477
+ // by the normal processing pass.
478
+ export const forceRemount = (el, debug = false) => {
479
+ if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
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);
320
484
  processSingle(el, debug);
321
485
  };
322
486
 
@@ -380,8 +544,15 @@ const tagScriptSiblings = (script, componentId) => {
380
544
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
381
545
  break;
382
546
  }
383
- sibling.setAttribute('data-vibe-component-id', componentId);
384
- 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
+ }
385
556
  sibling = sibling.nextElementSibling;
386
557
  }
387
558
  };
@@ -542,8 +713,11 @@ const processSingle = (el, debug) => {
542
713
  let props = el._vibeRemountProps;
543
714
  if (!props) {
544
715
  props = {};
716
+ // `src` and `key` are the wrapper's own contract (what to mount / when to
717
+ // remount), and data-vibe-* attributes are runtime transport — none of
718
+ // them are authored props for the component.
545
719
  Array.from(el.attributes).forEach((attr) => {
546
- if (attr.name !== 'src') {
720
+ if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
547
721
  props[attr.name] = attr.value;
548
722
  }
549
723
  });
@@ -560,6 +734,13 @@ const processSingle = (el, debug) => {
560
734
 
561
735
  return fetchComponentTemplate(src, controller.signal)
562
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
+
563
744
  // Parse HTML in temporary container to process component scripts
564
745
  const temp = createDetached('div');
565
746
  temp.innerHTML = html;
@@ -601,9 +782,10 @@ const processSingle = (el, debug) => {
601
782
  // Sibling tagging is handled below (before script.remove()) so it works
602
783
  // for both sync and async scripts.
603
784
  const componentFn = (state) => {
604
- if (!window.__vibeComponents) window.__vibeComponents = {};
605
- window.__vibeComponents[componentId] = state;
606
- 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);
607
789
  if (!registeredComponentIds.includes(componentId)) {
608
790
  registeredComponentIds.push(componentId);
609
791
  }
@@ -614,34 +796,17 @@ const processSingle = (el, debug) => {
614
796
  return componentId;
615
797
  };
616
798
 
617
- // Re-running the script for a reused componentId (HMR remount) must
618
- // tear down listeners from the previous evaluation before the fresh
619
- // script registers new ones. Without this, every cycle stacks another
620
- // listener on top of the stale closures and a single reactive tick
621
- // fires N callbacks instead of one.
622
- runComponentCleanups(componentId);
623
-
624
- // Provide a per-script `$` whose `.on(...)` registers cleanups under
625
- // this componentId. Bare `$` in the script body resolves to this
626
- // Proxy (the function parameter shadows the global), so listener
627
- // registrations are auto-tracked across `await` boundaries via the
628
- // closure — no opt-in required.
629
- const scopedDollar = createScopedDollar(componentId);
630
-
631
- // Execute script with component() function in scope
632
- try {
633
- if (hasImports) {
634
- // Async execution for scripts with imports
635
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
636
- asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
637
- } else {
638
- // Synchronous execution for scripts without imports (preserves boot timing)
639
- const executeFn = new Function('$', 'component', scriptContent);
640
- executeFn(scopedDollar, componentFn);
641
- }
642
- } catch (e) {
643
- console.warn('[vibe] Failed to execute component script:', e);
644
- }
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);
645
810
 
646
811
  // Tag siblings + rewrite this. bindings using shared helper. Runs
647
812
  // BEFORE script.remove() so nextElementSibling is valid.
@@ -651,8 +816,27 @@ const processSingle = (el, debug) => {
651
816
  script.remove();
652
817
  }
653
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
+
654
831
  // Finalize: props, slots, DOM replacement
655
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
+
656
840
  // Delegate prop substitution + slot inlining to shared helper.
657
841
  const transformedHtml = renderPropsAndSlot(temp, props, children);
658
842
 
@@ -697,11 +881,17 @@ const processSingle = (el, debug) => {
697
881
  // attribute — the DOM alone carries the knowledge across swaps.
698
882
  const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
699
883
  if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
884
+ // The key binding and its last resolved value ride along the same
885
+ // way, so a later key change still finds what to compare against
886
+ // on the replacement wrapper.
887
+ const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
888
+ if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
889
+ if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
700
890
  // Transfer iteration-prop registry ownership from the soon-to-be-
701
891
  // detached `<component src>` to the new wrapper. The detached element
702
892
  // would otherwise trigger releaseOrphanedIterationProps and free the
703
893
  // registry slots that the inlined template's bindings still reference,
704
- // causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
894
+ // causing every `@[window.__vibe.iterProps._pN]` to resolve to undefined
705
895
  // on the next hydrate.
706
896
  if (el._vibeIterPropIds) {
707
897
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
@@ -715,6 +905,7 @@ const processSingle = (el, debug) => {
715
905
  if (el.hasAttribute('data-vibe-iter-prop')) {
716
906
  newWrapper.setAttribute('data-vibe-iter-prop', '');
717
907
  el.removeAttribute('data-vibe-iter-prop');
908
+ bumpIterPropGeneration();
718
909
  }
719
910
  // Transfer the original prop expressions too, so the iteration's
720
911
  // update path can re-evaluate them against the row's new scope and
@@ -732,21 +923,85 @@ const processSingle = (el, debug) => {
732
923
  // renderAllConditionals/Iterations populated runtime data) is what
733
924
  // iterate.js's update path uses to re-evaluate inlined bindings on
734
925
  // each row update.
926
+ // The observer hydrates the inserted subtree in its NEXT batch —
927
+ // until then, selectors keyed on hydrated attributes (a name-bound
928
+ // <page @[page.name]> → page[pvp] rules) don't match and the
929
+ // content paints unstyled. Cover the gap with the same fouc
930
+ // contract pages use: hidden at insertion, revealed by the batch
931
+ // that parsed and hydrated this subtree.
932
+ newWrapper.setAttribute('vibe-fouc', '');
735
933
  el._vibeReplacedBy = newWrapper;
736
- 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
+ }
737
942
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
738
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
+
949
+ // Build-inlined child components (compiled SPA fragments) arrive
950
+ // with tagged wrapper ids and vibe-module scripts — the compiled-
951
+ // document form. A fetched mount is the fourth delivery mode after
952
+ // boot, conditional branches, and iteration rows: run those
953
+ // scripts now so each child's component({...}) state registers
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
+ );
965
+
739
966
  // MutationObserver handles parsing and hydrating the new content.
740
967
  // Branch nodes are registered in the manifest by mountBranch,
741
968
  // so the observer can find parents even inside conditional branches.
969
+ // Hydration can span multiple batches (nested fetched components,
970
+ // async scripts) with paints in between — reveal only when the
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.
976
+ // A wrapper unmounted mid-hydration releases the hook.
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);
991
+ newWrapper.removeAttribute('vibe-fouc');
992
+ unfouc();
993
+ };
994
+ const unfouc = window.$.on('afterDomMutation', tryReveal);
995
+ scriptsSettled.then(() => {
996
+ scriptsDone = true;
997
+ tryReveal();
998
+ });
742
999
  } else {
743
1000
  // Element was detached before finalize ran (conditional unmounted
744
1001
  // during fetch, parent removed, etc). Release any state component()
745
1002
  // calls registered — otherwise it leaks on `$` forever.
746
- for (const id of registeredComponentIds) {
747
- delete window.__vibeComponents?.[id];
748
- if (window.$) delete window.$[id];
749
- }
1003
+ releaseOutgoing(el);
1004
+ releaseRegisteredIds();
750
1005
  }
751
1006
  };
752
1007
 
@@ -761,12 +1016,19 @@ const processSingle = (el, debug) => {
761
1016
  // Clean up pending fetch tracker — ownership-guarded (see finalize)
762
1017
  if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
763
1018
 
764
- // 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.
765
1025
  if (error.name === 'AbortError') {
1026
+ if (!el.isConnected) releaseOutgoing(el);
766
1027
  return;
767
1028
  }
768
1029
 
769
1030
  console.error('[vibe] Failed to load:', src, error);
1031
+ releaseOutgoing(el);
770
1032
  if (el.parentNode) {
771
1033
  el.remove();
772
1034
  }