@ape-egg/vibe 2.1.21 → 2.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.22] - 2026-07-02
4
+
5
+ ### Added
6
+
7
+ - **`$.on('unmount', callback)` — scope-resolved teardown event** (`runtime/component.js`, `runtime/index.js`, `index.js`) — one event name, one concept ("this context is going away"), resolved by where you subscribe. **In a component `<script>`**: the callback runs when THAT component unmounts (conditional toggle, iteration removal, reactive-src swap) and before an HMR re-run of the same component id — the scoped-`$` proxy intercepts the event name and rides the exact registry `$.on(...)` unsubscribes already use (`__vibeComponentCleanups`, drained by `releaseOrphanedComponentState`/`runComponentCleanups`). No new lifecycle machinery. **At page level**: the same subscription fires on `pagehide` — the visitor navigating away or closing the tab. Deliberately NOT `visibilitychange`: a tab switch is not an unmount, the visitor comes back (separate `hide`/`show` visibility events can be added later without touching this). Closes the SPA-mode gap where a component-owned side effect (`setInterval`, `addEventListener`, a socket) outlived its component because nothing could reach the handle: `const t = setInterval(…); $.on('unmount', () => clearInterval(t))` is the whole story. Both scopes return an unsubscribe like every other `$.on`; the pre-boot placeholder queues `unmount` subscriptions like the other events (`index.js` `_pendingListeners`), so page-level registrations made before boot aren't dropped. Works identically in runtime and compiled modes (compiled `vibe-module` scripts run through the same scoped-`$` path). Tests: `e2e-runtime/component-unmount.html` + `tests/e2e/component-unmount.spec.js` (both modes: callback fires exactly once per component unmount, interval verifiably stops, remount registers fresh, page-level fires on pagehide via localStorage trace, unsubscribe cancels).
8
+ - **Reactive component src — `<component src="@[page.src]">`** (`runtime/parse.js`, `runtime/hydrate.js`, `runtime/component.js`) — the SPA routing primitive: one wrapper that fetches whatever the bound state resolves to and **re-mounts when it changes**, replacing the `<!-- if -->`-ladder-per-route pattern. `parse.js` captures ONLY the `src` binding on fetched components (every other attribute is still a raw prop owned by processComponent); initial hydration resolves the binding *before* the fetch scan, so the first mount rides the normal pipeline unchanged. On a state change, hydrate routes the update to `remountComponent`, which aborts any in-flight fetch (`pendingFetches` deletes are now ownership-guarded so an aborted fetch's cleanup can't wipe a newer fetch's controller), re-fetches, and re-mounts; the authored props and slot content ride a remount context finalize stashes on each wrapper (`_vibeMountedSrc`/`_vibeRemountProps`/`_vibeSlotContent`), and the outgoing component's state is evicted by the existing removal pass. The subtle part: every mount **replaces** the wrapper, and the observer's removal handling prunes the replaced element's tree node — taking the binding knowledge with it. So the authored binding travels ON the wrapper as `data-vibe-src` (same transport idea as `data-vibe-namebind`): the replacement's reparse recaptures it from the DOM alone, keeping the knowledge alive across arbitrarily many navigations — DOM-first, no tree surgery. A state change landing inside the swap window still resolves through the `_vibeReplacedBy` chain (now path-compressed so detached intermediates stay collectable). Compiled mode works through the same runtime path with zero compiler changes: inlining structurally skips a bound src (the target is unknowable at build time), the stamper resolves the initial value, and the manifest carries the binding — locked in by `tests/compiler/component-src-binding/`. Tests: `tests/unit/parse.test.js` (bound-src + data-vibe-src capture), `e2e-runtime/component-src-binding.html` + `tests/e2e/component-src-binding.spec.js` (both modes: initial mount, swap, prop/slot survival across re-mounts, state eviction, same-src no-op, post-swap interactivity).
9
+
10
+ ### Fixed
11
+
12
+ - **Deep-link reloads on catch-all routes hydrated blank — manifest resolution didn't understand `:name*`** (`runtime/pre-compiled-manifest.js`) — the route-aware manifest fast path tokenizes `:param` positions of `window.__ROUTE__` *by segment index*, which is meaningless for a trailing catch-all that matches zero-or-more segments: for `/x/fighter/7` under route `/x/:route*` it produced `/vibe-hyperspeed/x/$/7.html.manifest.js` (and the literal fallbacks 404'd too), so a compiled catch-all page (`pages/x/$$route.html`, the SPA-shell convention) went blank on every reload/deep link while client-side navigation worked. The compiler collapses `$$name.html` to the same `$` manifest token as a single `$param`, so there is exactly ONE manifest for every depth — `buildManifestCandidatePaths` now detects a trailing `:name*` and emits `<static-prefix>/$.html.manifest.js` (prefix `:param`s still tokenized), built from the raw pathname so the zero-extra-segments case (`/x` itself) resolves identically. One direct-hit request, no 404 probing. Tests: `tests/unit/pre-compiled-manifest.test.js` (locks single-param and mid-path-param behavior, catch-all at three depths, catch-all after a mid-path param); verified live: reload on `/…/fighter/7`, `/…/timer`, and the bare base all hydrate with a single 200 manifest fetch.
13
+ - **Watch mode dropped edits to runtime-fetched components** (`compiler/src/compiler/compile.rs`, `compiler/src/compiler/watcher.rs`, compiler 2.0.2 → 2.0.3) — the full build always mirrors `components/` verbatim into the output, because runtime-fetched components (iter-prop each-roots, `components_as_is`, and now `<component src="@[page.src]">` targets) are served from that mirror at request time — but the watch loop never honored that contract. A changed component only triggered recompiles of its *dependent pages* (refreshing their inlined copies); the mirror itself was never rewritten, and a component that **no page inlines** — exactly what every reactive-src routing target is — mapped to zero dependents and was silently dropped: no log line, no output write, stale bytes served until the next full build (observed in Battle Brawlers: SPA-demo components edited under `dev:compiled` never reached the browser, while page edits landed fine). The watch loop now re-mirrors every changed `components/` file into the output via the new `mirror_component_files` (atomic tmp+rename, same torn-read discipline as compiled pages, parent dirs created for components born mid-session), removes deleted components from the mirror, and logs the change even with zero dependents. Tests: `compile.rs` (`changed_component_is_remirrored_to_output`); verified end-to-end against a live `--watch` process (edit → mirror updates, delete → mirror entry removed).
14
+
3
15
  ## [2.1.21] - 2026-07-01
4
16
 
5
17
  ### Fixed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "2.0.2"
1602
+ version = "2.0.3"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "2.0.2"
3
+ version = "2.0.3"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -1493,6 +1493,47 @@ impl Compiler {
1493
1493
  Ok(())
1494
1494
  }
1495
1495
 
1496
+ /// Re-mirror specific source files under the components directory into the
1497
+ /// output. The full build always mirrors components/ verbatim (see
1498
+ /// process_directory_assets_only): runtime-fetched components —
1499
+ /// `<component src="@[page.src]">` targets, iter-prop each-roots,
1500
+ /// components_as_is — are served from that mirror at request time. Watch
1501
+ /// mode calls this for every changed component so the mirror tracks edits
1502
+ /// and deletions even when no page inlines the component (zero graph
1503
+ /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1504
+ /// runtime fetch mid-copy never sees a torn file. Returns how many files
1505
+ /// were copied.
1506
+ pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1507
+ let mut copied = 0;
1508
+ for path in files {
1509
+ let relative = path.strip_prefix(&self.config.source).unwrap_or(path);
1510
+ let output_path = self.config.output.join(relative);
1511
+ if path.exists() {
1512
+ if let Some(parent) = output_path.parent() {
1513
+ fs::create_dir_all(parent).map_err(|e| CompileError::WriteError {
1514
+ path: parent.display().to_string(),
1515
+ source: e,
1516
+ })?;
1517
+ }
1518
+ let contents = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1519
+ path: path.display().to_string(),
1520
+ source: e,
1521
+ })?;
1522
+ atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1523
+ path: output_path.display().to_string(),
1524
+ source: e,
1525
+ })?;
1526
+ copied += 1;
1527
+ } else if output_path.exists() {
1528
+ fs::remove_file(&output_path).map_err(|e| CompileError::WriteError {
1529
+ path: output_path.display().to_string(),
1530
+ source: e,
1531
+ })?;
1532
+ }
1533
+ }
1534
+ Ok(copied)
1535
+ }
1536
+
1496
1537
  /// Fetch components only for specific files (used in incremental compilation)
1497
1538
  fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1498
1539
  for html_file in files {
@@ -2370,6 +2411,57 @@ mod tests {
2370
2411
  (config, page_src, page_out, manifest)
2371
2412
  }
2372
2413
 
2414
+ // The full build always mirrors components/ verbatim into the output —
2415
+ // runtime-fetched components (`<component src="@[page.src]">`, iter-prop
2416
+ // roots, components_as_is) are served from that mirror. Watch mode must
2417
+ // keep the same contract: an edited component reaches the mirror even when
2418
+ // NO page inlines it (zero dependents), and a deleted component leaves it.
2419
+ #[test]
2420
+ fn changed_component_is_remirrored_to_output() {
2421
+ let (config, _page_src, _page_out, _manifest) = manifest_test_project("component_mirror");
2422
+ let source_component = config.source.join("components").join("widget.html");
2423
+ let mirrored = config.output.join("components").join("widget.html");
2424
+ let nested_src = config.source.join("components").join("spa").join("pane.html");
2425
+ let nested_out = config.output.join("components").join("spa").join("pane.html");
2426
+ fs::write(&source_component, "<spa-widget>v1</spa-widget>\n").unwrap();
2427
+
2428
+ let mut compiler = Compiler::new(config, false);
2429
+ compiler.compile().expect("compile should succeed");
2430
+ assert_eq!(
2431
+ fs::read_to_string(&mirrored).unwrap(),
2432
+ "<spa-widget>v1</spa-widget>\n",
2433
+ "sanity: full build mirrors the component"
2434
+ );
2435
+
2436
+ // Edit the component — no page references it, so the dependency graph
2437
+ // maps it to zero pages; the mirror must still track the change.
2438
+ fs::write(&source_component, "<spa-widget>v2</spa-widget>\n").unwrap();
2439
+ let copied = compiler
2440
+ .mirror_component_files(&[source_component.clone()])
2441
+ .expect("mirroring should succeed");
2442
+ assert_eq!(copied, 1);
2443
+ assert_eq!(
2444
+ fs::read_to_string(&mirrored).unwrap(),
2445
+ "<spa-widget>v2</spa-widget>\n",
2446
+ "edited component did not reach the output mirror"
2447
+ );
2448
+
2449
+ // A component created mid-session (parent dirs may not exist yet).
2450
+ fs::create_dir_all(nested_src.parent().unwrap()).unwrap();
2451
+ fs::write(&nested_src, "<spa-pane>new</spa-pane>\n").unwrap();
2452
+ compiler
2453
+ .mirror_component_files(&[nested_src.clone()])
2454
+ .expect("mirroring a new nested component should succeed");
2455
+ assert_eq!(fs::read_to_string(&nested_out).unwrap(), "<spa-pane>new</spa-pane>\n");
2456
+
2457
+ // Deleting the source removes the mirrored copy.
2458
+ fs::remove_file(&source_component).unwrap();
2459
+ compiler
2460
+ .mirror_component_files(&[source_component])
2461
+ .expect("mirroring a deletion should succeed");
2462
+ assert!(!mirrored.exists(), "deleted component still present in the output mirror");
2463
+ }
2464
+
2373
2465
  // The watcher race: another writer truncates/rewrites a compiled page on
2374
2466
  // disk between our compile and our manifest pass. The manifest must be
2375
2467
  // built from the HTML this compiler just produced in memory — never from a
@@ -598,6 +598,11 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
598
598
  // component plus the ancestors that inline it. Everything
599
599
  // else stays cached and is reused.
600
600
  let mut stale_component_keys: HashSet<String> = HashSet::new();
601
+ // Changed component files to re-mirror into the output —
602
+ // runtime-fetched components are served from that mirror,
603
+ // so it must track every edit/deletion even when no page
604
+ // inlines the component (zero graph dependents).
605
+ let mut components_to_mirror: Vec<PathBuf> = Vec::new();
601
606
 
602
607
  for path in &changed_paths {
603
608
  // Skip files in output directory (avoid infinite loop)
@@ -620,6 +625,17 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
620
625
  // Canonicalize to match how dependencies were stored (handles case sensitivity)
621
626
  let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
622
627
 
628
+ // The output's components mirror tracks every change,
629
+ // dependents or not: a component nothing inlines is
630
+ // still fetched from the mirror at runtime
631
+ // (`<component src="@[page.src]">`, iter-prop roots).
632
+ if path.exists() {
633
+ println!("{} {} changed", "[watch]".cyan(), relative_path.display());
634
+ } else {
635
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
636
+ }
637
+ components_to_mirror.push(path.clone());
638
+
623
639
  // Refresh this component's own dependency edges so a
624
640
  // newly-added <component src> (e.g. a child file created
625
641
  // mid-session) is learned. Without this the new child maps
@@ -641,8 +657,6 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
641
657
 
642
658
  let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
643
659
  if !dependent_pages.is_empty() {
644
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
645
-
646
660
  // Invalidate only the edited component and the
647
661
  // components whose cached inlined content embeds it
648
662
  // (its ancestors). Every other component stays cached,
@@ -762,7 +776,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
762
776
  }
763
777
  }
764
778
 
765
- if html_files.is_empty() && asset_files.is_empty() {
779
+ if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() {
766
780
  continue;
767
781
  }
768
782
 
@@ -851,6 +865,23 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
851
865
  }
852
866
  }
853
867
 
868
+ // Keep the output's components mirror in sync — changed
869
+ // components reach it even with zero dependent pages, and
870
+ // deleted ones leave it (see mirror_component_files).
871
+ if !components_to_mirror.is_empty() && !had_errors {
872
+ match watch_compiler.mirror_component_files(&components_to_mirror) {
873
+ Ok(copied) => {
874
+ total_stats.files_copied += copied;
875
+ }
876
+ Err(e) => {
877
+ eprintln!("{}: {}", "Error".red(), e);
878
+ eprintln!("Fix the errors and save to retry.");
879
+ println!();
880
+ had_errors = true;
881
+ }
882
+ }
883
+ }
884
+
854
885
  if !had_errors {
855
886
  // Show what was updated
856
887
  if total_stats.files_compiled > 0 {
@@ -25,7 +25,7 @@ struct ConfigOverrides {
25
25
  #[derive(ClapParser, Debug)]
26
26
  #[command(name = "vibe-compile")]
27
27
  #[command(author = "Kim Korte")]
28
- #[command(version = "1.6.1")]
28
+ #[command(version)]
29
29
  #[command(about = "Compiles Vibe source files into optimized output")]
30
30
  struct Args {
31
31
  /// Working directory (defaults to current directory)
package/index.js CHANGED
@@ -8,7 +8,7 @@ let vibeInstance = null;
8
8
  let resolveInstanceReady = null;
9
9
 
10
10
  const createVibeInstance = () => ({
11
- _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
11
+ _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [], unmount: [] },
12
12
  // Promise that resolves when the real $.ready resolves post-boot. Lets
13
13
  // consumers holding the pre-boot placeholder (e.g. tests awaiting
14
14
  // `window.$.ready` before boot has replaced $ with the reactive proxy)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.21",
3
+ "version": "2.1.22",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -71,6 +71,23 @@ const createScopedDollar = (componentId) => {
71
71
  get(target, prop, receiver) {
72
72
  if (prop === 'on') {
73
73
  return (event, callback) => {
74
+ // 'unmount' is scope-resolved: in a component script it means THIS
75
+ // component's unmount (conditional toggle, iteration removal,
76
+ // reactive src swap — and before an HMR re-run of the same id).
77
+ // The callback rides the same per-component cleanup registry the
78
+ // global-event unsubscribes below ride, so a component owns
79
+ // arbitrary side effects (intervals, listeners, sockets) without
80
+ // leaking them past its lifetime. At page level the root's `on`
81
+ // resolves the same event name to pagehide instead.
82
+ if (event === 'unmount') {
83
+ if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
84
+ const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
85
+ slot.push(callback);
86
+ return () => {
87
+ const i = slot.indexOf(callback);
88
+ if (i >= 0) slot.splice(i, 1);
89
+ };
90
+ }
74
91
  const unsub = target.on(event, callback);
75
92
  if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
76
93
  const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
@@ -261,6 +278,48 @@ export const abortComponentFetch = (element) => {
261
278
  }
262
279
  };
263
280
 
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
+ // 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
+ };
296
+
297
+ // (Re)mount a component for a reactive src binding (`src="@[page.src]"`).
298
+ // Three phases of a wrapper's life, one entry point:
299
+ // - Unprocessed element (no fetch yet): write the resolved src — the pending
300
+ // boot/observer processComponent pass fetches it.
301
+ // - Fetch in flight: abort it and fetch the new src.
302
+ // - Mounted wrapper (src consumed by finalize): re-fetch and re-mount; the
303
+ // authored props + slot content re-apply via the remount context finalize
304
+ // stashed on the wrapper, and the outgoing component's state is evicted by
305
+ // the removal pass when replaceWith drops the old wrapper.
306
+ export const remountComponent = (el, src, debug = false) => {
307
+ const wasFetching = pendingFetches.has(el);
308
+ // Compare against the LATEST requested src: with a fetch in flight the src
309
+ // attribute holds it (rapid navigation A→B→A must abort B, not no-op on A);
310
+ // mounted and idle, the finalize-stashed value does.
311
+ const current = wasFetching
312
+ ? el.getAttribute('src')
313
+ : (el._vibeMountedSrc ?? el.getAttribute('src'));
314
+ if (src === current) return;
315
+ abortComponentFetch(el);
316
+ 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;
320
+ processSingle(el, debug);
321
+ };
322
+
264
323
  // Check if an element is nested inside another unprocessed component[src]
265
324
  const isNestedInUnprocessedComponent = (el, rootElement) => {
266
325
  let parent = el.parentElement;
@@ -473,16 +532,22 @@ const processSingle = (el, debug) => {
473
532
 
474
533
  const src = el.getAttribute('src');
475
534
 
476
- // Use pre-hydration slot content if available (saved by index.js before hydration ran),
477
- // otherwise fall back to current innerHTML (e.g. runtime-only usage without boot).
535
+ // Use pre-hydration slot content if available (saved by index.js before
536
+ // hydration ran, or re-stashed by finalize for reactive-src re-mounts),
537
+ // otherwise fall back to current innerHTML (e.g. runtime-only usage without
538
+ // boot). Kept on the element — a re-mount consumes the same authored slot.
478
539
  const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
479
- delete el._vibeSlotContent;
480
- const props = {};
481
- Array.from(el.attributes).forEach((attr) => {
482
- if (attr.name !== 'src') {
483
- props[attr.name] = attr.value;
484
- }
485
- });
540
+ // A re-mounted wrapper carries no prop attributes (finalize stripped them) —
541
+ // its authored props ride the remount context stashed at the previous mount.
542
+ let props = el._vibeRemountProps;
543
+ if (!props) {
544
+ props = {};
545
+ Array.from(el.attributes).forEach((attr) => {
546
+ if (attr.name !== 'src') {
547
+ props[attr.name] = attr.value;
548
+ }
549
+ });
550
+ }
486
551
 
487
552
  // Capture cache state before the fetch so the debug layer can tell a real
488
553
  // network fetch from a runtime-cache hit (the call below would make them
@@ -591,8 +656,10 @@ const processSingle = (el, debug) => {
591
656
  // Delegate prop substitution + slot inlining to shared helper.
592
657
  const transformedHtml = renderPropsAndSlot(temp, props, children);
593
658
 
594
- // Clean up pending fetch tracker
595
- pendingFetches.delete(el);
659
+ // Clean up pending fetch tracker — only if this fetch still owns the
660
+ // slot (a reactive-src re-mount may have aborted us and registered a
661
+ // newer controller for the same element).
662
+ if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
596
663
 
597
664
  // Replace with clean component wrapper (no src, no props)
598
665
  // Check if element still has a parent (might have been removed during fetch)
@@ -615,6 +682,21 @@ const processSingle = (el, debug) => {
615
682
  // the plugin would have nothing to compare against). Vibe itself
616
683
  // never reads this; it's purely for the plugin spy.
617
684
  newWrapper._vibeRawSource = html;
685
+ // Remount context for reactive src bindings (src="@[page.src]"):
686
+ // the mounted src (no-op detection), the authored props, and the
687
+ // authored slot content. Each re-mount consumes these and finalize
688
+ // stashes them onto the next wrapper — self-sustaining across
689
+ // arbitrarily many navigations.
690
+ newWrapper._vibeMountedSrc = src;
691
+ newWrapper._vibeRemountProps = props;
692
+ newWrapper._vibeSlotContent = children;
693
+ // The authored binding travels ON the wrapper (data-vibe-src, same
694
+ // transport idea as data-vibe-namebind): the original tree node is
695
+ // pruned when this replaceWith's removal mutation is processed, and
696
+ // the replacement's reparse recaptures the binding from this
697
+ // attribute — the DOM alone carries the knowledge across swaps.
698
+ const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
699
+ if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
618
700
  // Transfer iteration-prop registry ownership from the soon-to-be-
619
701
  // detached `<component src>` to the new wrapper. The detached element
620
702
  // would otherwise trigger releaseOrphanedIterationProps and free the
@@ -676,10 +758,10 @@ const processSingle = (el, debug) => {
676
758
  finalize();
677
759
  })
678
760
  .catch((error) => {
679
- // Clean up pending fetch tracker
680
- pendingFetches.delete(el);
761
+ // Clean up pending fetch tracker — ownership-guarded (see finalize)
762
+ if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
681
763
 
682
- // If fetch was aborted (element removed), silently skip
764
+ // If fetch was aborted (element removed or re-mounted), silently skip
683
765
  if (error.name === 'AbortError') {
684
766
  return;
685
767
  }
@@ -1,5 +1,6 @@
1
1
  import { updateIteration } from './iterate.js';
2
2
  import { updateConditional, managedNodes } from './conditionals.js';
3
+ import { isComponentWrapper, liveComponentWrapper, remountComponent } from './component.js';
3
4
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
5
  import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
5
6
  import { RawHtml } from './raw-html.js';
@@ -82,6 +83,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
82
83
  if (aff.type === 'attribute') {
83
84
  const { attrName, attrValue, element } = aff;
84
85
  try {
86
+ // Reactive component src (`<component src="@[page.src]">`): resolve
87
+ // the binding and (re)mount through component.js. The binding rides
88
+ // along so finalize can stamp it onto the replacement wrapper
89
+ // (data-vibe-src) — the tree node holding it is pruned when the
90
+ // wrapper swap's removal mutation lands, and the fresh wrapper's
91
+ // reparse rebuilds the knowledge from that attribute. A state change
92
+ // landing inside the swap window still resolves through the
93
+ // replacement chain.
94
+ if (attrName === 'src' && isComponentWrapper(element)) {
95
+ const live = liveComponentWrapper(element);
96
+ const newSrc = attrValue.replace(BINDING_REGEX, (_, expr) =>
97
+ evalInScope(expr, effectiveState, live),
98
+ );
99
+ live._vibeSrcBinding = attrValue;
100
+ remountComponent(live, newSrc);
101
+ return;
102
+ }
103
+
85
104
  // Check if this is a pure binding (e.g., value="@[inputValue]")
86
105
  const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
87
106
  const isDomProperty = DOM_PROPERTIES.includes(attrName);
package/runtime/index.js CHANGED
@@ -568,8 +568,20 @@ const main = (s, config = {}, stringSelector = '') => {
568
568
  afterUpdate: [],
569
569
  afterDomMutation: [],
570
570
  ready: [],
571
+ unmount: [],
571
572
  };
572
573
 
574
+ // Page-scope 'unmount': the visitor actually leaving — pagehide (navigation
575
+ // away, tab close). Deliberately NOT visibilitychange: a tab switch is not
576
+ // an unmount, the visitor comes back. Inside a component script the same
577
+ // event name resolves to that component's unmount instead (the scoped `$`
578
+ // proxy in component.js intercepts it before it reaches this hook).
579
+ if (typeof window !== 'undefined') {
580
+ window.addEventListener('pagehide', () => {
581
+ hooks.unmount.forEach((callback) => callback());
582
+ });
583
+ }
584
+
573
585
  // Extract plain values from proxy (removes proxy wrappers)
574
586
  // Optimized: indexed loops, Object.keys (no prototype walk), inline primitive check
575
587
  const extractPlainValue = (obj) => {
package/runtime/parse.js CHANGED
@@ -21,16 +21,30 @@ const findComponentIdForElement = (element) => {
21
21
  // Single source of truth for reading attribute/name bindings off an element.
22
22
  // Called from both the root handler and recursive() so they can't drift. Any
23
23
  // element classified as a fetched component (`<component src>` or
24
- // `<div class="component" src>`) returns nulls its attributes are props
25
- // owned by processComponent and must stay raw; hydrating them would coerce
26
- // objects to "[object Object]" or strip boolean-like attrs to empty.
24
+ // `<div class="component" src>`) captures ONLY a bound src (`src="@[page.src]"`
25
+ // resolved by hydration before the fetch, re-mounted on change); every other
26
+ // attribute is a prop owned by processComponent and must stay raw — hydrating
27
+ // them would coerce objects to "[object Object]" or strip boolean-like attrs
28
+ // to empty. A mounted wrapper carries the authored binding in data-vibe-src
29
+ // (stamped by finalize — the src attribute was consumed by the fetch), so the
30
+ // knowledge survives every wrapper replacement: reparsing the live DOM alone
31
+ // rebuilds it.
27
32
  const captureAttributeBindings = (element, aliasSet) => {
28
33
  const nodeName = element.nodeName;
29
34
  const isFetchedComponent =
30
35
  (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
31
- element.hasAttribute?.('src');
36
+ (element.hasAttribute?.('src') || element.hasAttribute?.('data-vibe-src'));
32
37
 
33
- if (isFetchedComponent || !element.attributes || element.attributes.length === 0) {
38
+ if (isFetchedComponent) {
39
+ const src = element.getAttribute?.('data-vibe-src') ?? element.getAttribute?.('src');
40
+ BINDING_REGEX.lastIndex = 0;
41
+ return {
42
+ attributes: BINDING_REGEX.test(src) ? { src } : null,
43
+ nameBindings: null,
44
+ };
45
+ }
46
+
47
+ if (!element.attributes || element.attributes.length === 0) {
34
48
  return { attributes: null, nameBindings: null };
35
49
  }
36
50
 
@@ -169,7 +169,24 @@ export const buildManifestCandidatePaths = (pathname, route) => {
169
169
  // sit mid-path, e.g. /a/:id/b) and try that manifest first — a direct hit, no
170
170
  // 404 probing. Skipped entirely when no route is declared.
171
171
  const routeSegments = route ? route.split("/").filter((s) => s) : null;
172
- if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
172
+ const isCatchAll =
173
+ routeSegments &&
174
+ routeSegments[routeSegments.length - 1]?.startsWith(":") &&
175
+ routeSegments[routeSegments.length - 1]?.endsWith("*");
176
+ if (isCatchAll) {
177
+ // A trailing `:name*` catch-all (pages/x/$$name.html) swallows every
178
+ // remaining URL segment — zero or more — so tokenizing by position is
179
+ // meaningless past the static prefix. The compiler collapses the whole
180
+ // `$$name.html` file to the same `$` token as a single `$param`, giving
181
+ // ONE manifest for every depth: <static-prefix>/$.html.manifest.js.
182
+ // Built from the raw pathname: with zero extra segments the .html
183
+ // normalization above has already mutated the prefix's last segment.
184
+ const rawSegments = pathname.split("/").filter((s) => s);
185
+ const prefix = rawSegments
186
+ .slice(0, routeSegments.length - 1)
187
+ .map((seg, i) => (routeSegments[i].startsWith(":") ? "$" : seg));
188
+ possiblePaths.push(`/vibe-hyperspeed/${[...prefix, "$"].join("/")}.html.manifest.js`);
189
+ } else if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
173
190
  const tokenized = pathSegments.map((seg, i) => {
174
191
  if (!routeSegments[i]?.startsWith(":")) return seg;
175
192
  const dot = seg.indexOf(".");