@ape-egg/vibe 2.1.22 → 2.3.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.
@@ -426,6 +426,39 @@ fn exit_when_orphaned(lock_path: PathBuf) {
426
426
  #[cfg(not(unix))]
427
427
  fn exit_when_orphaned(_lock_path: PathBuf) {}
428
428
 
429
+ /// A config change invalidates every piece of watcher state — dependency
430
+ /// graph, compiler, parser, watch roots, output lock. The cleanest
431
+ /// expression is a fresh start: release the lock and exec the same binary
432
+ /// with the same argv, so CLI flag overrides re-apply with perfect parity.
433
+ /// Same pid — a parent process watching this child never sees an exit.
434
+ fn restart_with_fresh_config(lock: WatchLock) -> ! {
435
+ println!(
436
+ "{} package.json changed — restarting with fresh config",
437
+ "[watch]".cyan()
438
+ );
439
+ drop(lock);
440
+ let exe = std::env::current_exe()
441
+ .unwrap_or_else(|_| PathBuf::from(std::env::args().next().unwrap_or_default()));
442
+ let args: Vec<String> = std::env::args().skip(1).collect();
443
+ #[cfg(unix)]
444
+ {
445
+ use std::os::unix::process::CommandExt;
446
+ let err = std::process::Command::new(&exe).args(&args).exec();
447
+ eprintln!("{}: watcher restart failed: {}", "Error".red(), err);
448
+ std::process::exit(1);
449
+ }
450
+ #[cfg(not(unix))]
451
+ {
452
+ let code = std::process::Command::new(&exe)
453
+ .args(&args)
454
+ .status()
455
+ .ok()
456
+ .and_then(|status| status.code())
457
+ .unwrap_or(1);
458
+ std::process::exit(code);
459
+ }
460
+ }
461
+
429
462
  /// Start watching for file changes
430
463
  pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
431
464
  // Held for the watcher's whole lifetime; a second watcher on the same
@@ -544,6 +577,12 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
544
577
  // cache keys against the same base.
545
578
  let canonical_source = config.source.canonicalize()
546
579
  .unwrap_or_else(|_| config.source.clone());
580
+ // Canonical pages root: notify events carry absolute paths, so a relative
581
+ // --cwd would never prefix-match without canonicalizing the base.
582
+ let canonical_pages = {
583
+ let pages = config.source.join(&config.pages);
584
+ pages.canonicalize().unwrap_or(pages)
585
+ };
547
586
 
548
587
  // Keep compiler and parser alive to reuse component cache across incremental compilations
549
588
  let mut watch_compiler = Compiler::new(config.clone(), false);
@@ -565,6 +604,19 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
565
604
 
566
605
  debouncer.watcher().watch(&config.source, RecursiveMode::Recursive)?;
567
606
 
607
+ // Hot config: package.json is the compiler's config file — a change to
608
+ // any vibe-compiler option (spa, minify, paths) takes effect live via a
609
+ // watcher self-restart. Watched explicitly: it can sit outside the source
610
+ // tree, and skip_files (which routinely lists package.json for copying)
611
+ // must not silence it. The raw baseline is compared on each event so
612
+ // formatting-only writes don't restart anything.
613
+ let config_file = config.working_dir.join("package.json");
614
+ let canonical_config_file = config_file.canonicalize().unwrap_or_else(|_| config_file.clone());
615
+ let config_baseline = Config::load(config.working_dir.clone());
616
+ if config_file.exists() {
617
+ debouncer.watcher().watch(&config_file, RecursiveMode::NonRecursive)?;
618
+ }
619
+
568
620
  loop {
569
621
  match rx.recv() {
570
622
  Ok(result) => {
@@ -575,6 +627,20 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
575
627
 
576
628
  for event in events {
577
629
  for path in &event.paths {
630
+ // Config file first, before any filter: a changed
631
+ // vibe-compiler section restarts the watcher in
632
+ // place with the fresh config.
633
+ let is_config_file = path
634
+ .canonicalize()
635
+ .map(|p| p == canonical_config_file)
636
+ .unwrap_or(*path == config_file);
637
+ if is_config_file {
638
+ if Config::load(config.working_dir.clone()) != config_baseline {
639
+ restart_with_fresh_config(_watch_lock);
640
+ }
641
+ continue;
642
+ }
643
+
578
644
  // Skip blacklisted files/directories (check entire path, not just filename)
579
645
  if is_path_blacklisted(path, &config.source, &config.skip_files) {
580
646
  continue;
@@ -603,6 +669,11 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
603
669
  // so it must track every edit/deletion even when no page
604
670
  // inlines the component (zero graph dependents).
605
671
  let mut components_to_mirror: Vec<PathBuf> = Vec::new();
672
+ // SPA mode: any pages-tree change (edit, add, delete) or a
673
+ // component edit with page dependents re-runs the whole SPA
674
+ // pass after this batch — fragments re-transform, the shell
675
+ // recomposes, routes resync, orphans prune.
676
+ let mut spa_pages_changed = false;
606
677
 
607
678
  for path in &changed_paths {
608
679
  // Skip files in output directory (avoid infinite loop)
@@ -619,6 +690,22 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
619
690
  let relative_path = path.strip_prefix(&config.source)
620
691
  .unwrap_or(path);
621
692
 
693
+ // SPA pages never take the MPA per-page path — the
694
+ // batch-level SPA pass owns them. Deleted files can't
695
+ // canonicalize; their event path is already absolute.
696
+ if config.spa
697
+ && path.extension().and_then(|e| e.to_str()) == Some("html")
698
+ && canonical_path.starts_with(&canonical_pages)
699
+ {
700
+ if path.exists() {
701
+ println!("{} {} changed", "[watch]".cyan(), relative_path.display());
702
+ } else {
703
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
704
+ }
705
+ spa_pages_changed = true;
706
+ continue;
707
+ }
708
+
622
709
  // Check if it's a component
623
710
  if path.starts_with(&config.source.join(&config.components)) {
624
711
  // Component changed - recompile all transitively dependent pages
@@ -655,13 +742,29 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
655
742
  }
656
743
  }
657
744
 
658
- let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
659
- if !dependent_pages.is_empty() {
745
+ let mut dependent_pages = graph.get_all_dependent_pages(&path_canonical);
746
+ // SPA pages re-transform through the SPA pass, not
747
+ // the MPA per-page compile (graph paths are
748
+ // canonical — compare against the canonical root).
749
+ let mut has_spa_dependents = false;
750
+ if config.spa {
751
+ let (spa_pages, mpa_pages): (HashSet<_>, HashSet<_>) = dependent_pages
752
+ .into_iter()
753
+ .partition(|page| page.starts_with(&canonical_pages));
754
+ if !spa_pages.is_empty() {
755
+ spa_pages_changed = true;
756
+ has_spa_dependents = true;
757
+ }
758
+ dependent_pages = mpa_pages;
759
+ }
760
+ if !dependent_pages.is_empty() || has_spa_dependents {
660
761
  // Invalidate only the edited component and the
661
762
  // components whose cached inlined content embeds it
662
763
  // (its ancestors). Every other component stays cached,
663
764
  // so each affected page re-expands just the changed
664
- // subtree instead of its whole component tree.
765
+ // subtree instead of its whole component tree. The
766
+ // SPA pass re-expands through the same cache, so
767
+ // spa-dependent pages need this too.
665
768
  for stale in graph.get_all_dependent_components(&path_canonical) {
666
769
  if let Some(key) = component_cache_key(&stale, &canonical_source) {
667
770
  stale_component_keys.insert(key);
@@ -776,7 +879,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
776
879
  }
777
880
  }
778
881
 
779
- if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() {
882
+ if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() && !spa_pages_changed {
780
883
  continue;
781
884
  }
782
885
 
@@ -803,22 +906,24 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
803
906
  let mut manifest_stats_result = None;
804
907
  let mut had_errors = false;
805
908
 
909
+ // Drop only the stale component caches (edited components +
910
+ // their inlining ancestors); unchanged components are reused.
911
+ // A page-only edit invalidates nothing here — its components
912
+ // are still valid — so the whole cache is reused as-is. Runs
913
+ // before BOTH the per-page compile and the SPA pass: either
914
+ // may re-expand through this cache.
915
+ let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
916
+ if invalidated > 0 {
917
+ println!("{} {} component{} re-expanded, {} reused from cache",
918
+ "↻".cyan(),
919
+ invalidated,
920
+ if invalidated == 1 { "" } else { "s" },
921
+ watch_compiler.cached_component_count(),
922
+ );
923
+ }
924
+
806
925
  // Compile HTML files
807
926
  if !html_files.is_empty() {
808
- // Drop only the stale component caches (edited components +
809
- // their inlining ancestors); unchanged components are reused.
810
- // A page-only edit invalidates nothing here — its components
811
- // are still valid — so the whole cache is reused as-is.
812
- let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
813
- if invalidated > 0 {
814
- println!("{} {} component{} re-expanded, {} reused from cache",
815
- "↻".cyan(),
816
- invalidated,
817
- if invalidated == 1 { "" } else { "s" },
818
- watch_compiler.cached_component_count(),
819
- );
820
- }
821
-
822
927
  match watch_compiler.compile_specific_html_files(&html_files, &parser) {
823
928
  Ok(stats) => {
824
929
  total_stats.files_compiled = stats.files_compiled;
@@ -882,6 +987,32 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
882
987
  }
883
988
  }
884
989
 
990
+ // SPA pass: re-transform fragments, recompose the shell,
991
+ // resync the route table, prune orphans, refresh the
992
+ // shell's manifest.
993
+ if spa_pages_changed && !had_errors {
994
+ match watch_compiler.compile_spa(&parser) {
995
+ Ok(count) => {
996
+ if !config.runtime_as_is {
997
+ if let Err(e) = watch_compiler.generate_shell_manifest() {
998
+ eprintln!("{}: Shell manifest failed: {}", "Warning".yellow(), e);
999
+ }
1000
+ }
1001
+ println!("{} Recompiled SPA ({} fragment{} + shell)",
1002
+ "✓".green(),
1003
+ count - 1,
1004
+ if count == 2 { "" } else { "s" }
1005
+ );
1006
+ }
1007
+ Err(e) => {
1008
+ eprintln!("{}: {}", "Error".red(), e);
1009
+ eprintln!("Fix the errors and save to retry.");
1010
+ println!();
1011
+ had_errors = true;
1012
+ }
1013
+ }
1014
+ }
1015
+
885
1016
  if !had_errors {
886
1017
  // Show what was updated
887
1018
  if total_stats.files_compiled > 0 {
@@ -48,6 +48,19 @@ pub struct VibeCompilerConfig {
48
48
  pub no_clean: bool,
49
49
  #[serde(default)]
50
50
  pub fouc_as_is: bool,
51
+ #[serde(default, deserialize_with = "deserialize_spa")]
52
+ pub spa: bool,
53
+ }
54
+
55
+ // "spa": true is a boolean today; the future per-page selection form is an
56
+ // object. Parse tolerantly so a newer config never silently resets the whole
57
+ // vibe-compiler section to defaults on an older compiler.
58
+ fn deserialize_spa<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
59
+ Ok(match serde_json::Value::deserialize(deserializer)? {
60
+ serde_json::Value::Bool(enabled) => enabled,
61
+ serde_json::Value::Object(_) => true,
62
+ _ => false,
63
+ })
51
64
  }
52
65
 
53
66
  fn default_source() -> String { "./".to_string() }
@@ -105,11 +118,12 @@ impl Default for VibeCompilerConfig {
105
118
  iterations_as_is: false,
106
119
  no_clean: false,
107
120
  fouc_as_is: false,
121
+ spa: false,
108
122
  }
109
123
  }
110
124
  }
111
125
 
112
- #[derive(Debug, Clone)]
126
+ #[derive(Debug, Clone, PartialEq)]
113
127
  pub struct Config {
114
128
  pub source: PathBuf,
115
129
  pub output: PathBuf,
@@ -130,12 +144,19 @@ pub struct Config {
130
144
  pub iterations_as_is: bool,
131
145
  pub no_clean: bool,
132
146
  pub fouc_as_is: bool,
147
+ pub spa: bool,
133
148
  pub working_dir: PathBuf,
134
149
  }
135
150
 
136
151
  impl Config {
137
152
  /// Load config from package.json, or use defaults if not found
138
153
  pub fn load(working_dir: PathBuf) -> Self {
154
+ // A relative --cwd must become absolute HERE: every derived path
155
+ // (source, output, pages) joins from working_dir, and downstream
156
+ // strip_prefix against filesystem-event paths (always absolute)
157
+ // otherwise fails — in watch mode that once routed a manifest write
158
+ // and a stamped page INTO the source tree.
159
+ let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
139
160
  let package_path = working_dir.join("package.json");
140
161
 
141
162
  let config = if package_path.exists() {
@@ -185,6 +206,7 @@ impl Config {
185
206
  iterations_as_is: config.iterations_as_is,
186
207
  no_clean: config.no_clean,
187
208
  fouc_as_is: config.fouc_as_is,
209
+ spa: config.spa,
188
210
  working_dir,
189
211
  }
190
212
  }
@@ -202,6 +224,24 @@ impl Config {
202
224
  }
203
225
  }
204
226
 
227
+ #[cfg(test)]
228
+ mod tests {
229
+ use super::*;
230
+
231
+ fn parse(json: &str) -> VibeCompilerConfig {
232
+ serde_json::from_str(json).unwrap()
233
+ }
234
+
235
+ #[test]
236
+ fn spa_parses_bool_and_tolerates_the_future_object_form() {
237
+ assert!(!parse(r#"{}"#).spa);
238
+ assert!(parse(r#"{"spa": true}"#).spa);
239
+ assert!(!parse(r#"{"spa": false}"#).spa);
240
+ // Future per-page selection form must not break older compilers.
241
+ assert!(parse(r#"{"spa": {"pages": ["docs"]}}"#).spa);
242
+ }
243
+ }
244
+
205
245
  pub fn init_config(working_dir: &PathBuf) -> Result<(), ConfigError> {
206
246
  let package_path = working_dir.join("package.json");
207
247
 
@@ -19,6 +19,7 @@ struct ConfigOverrides {
19
19
  components_as_is: bool,
20
20
  runtime_as_is: bool,
21
21
  iterations_as_is: bool,
22
+ spa: bool,
22
23
  }
23
24
 
24
25
  /// Vibe Compiler - Compiles Vibe source files into optimized output
@@ -87,6 +88,10 @@ struct Args {
87
88
  /// Keep FOUC prevention class/attribute in compiled output
88
89
  #[arg(long, name = "fouc-as-is")]
89
90
  fouc_as_is: bool,
91
+
92
+ /// Compile the pages tree to SPA output: fragments + route table + shell
93
+ #[arg(long)]
94
+ spa: bool,
90
95
  }
91
96
 
92
97
  fn main() {
@@ -127,6 +132,7 @@ fn main() {
127
132
  let original_components_as_is = config.components_as_is;
128
133
  let original_runtime_as_is = config.runtime_as_is;
129
134
  let original_iterations_as_is = config.iterations_as_is;
135
+ let original_spa = config.spa;
130
136
 
131
137
  // Track overrides for verbose output
132
138
  let mut overrides = ConfigOverrides::default();
@@ -165,6 +171,10 @@ fn main() {
165
171
  if args.fouc_as_is {
166
172
  config.fouc_as_is = true;
167
173
  }
174
+ if args.spa {
175
+ overrides.spa = !original_spa;
176
+ config.spa = true;
177
+ }
168
178
 
169
179
  if args.verbose {
170
180
  println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan().bold());
@@ -228,6 +238,7 @@ fn main() {
228
238
  println!(" {}: {}", format!("{:<16}", "skipFiles").cyan(), format_value_no_flag(format_list_preview(&config.skip_files)));
229
239
  println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
230
240
  println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
241
+ println!(" {}: {}", format!("{:<16}", "spa").cyan(), format_bool_with_flag(config.spa, overrides.spa, original_spa));
231
242
  println!();
232
243
  } else {
233
244
  // Show version in non-verbose mode
package/index.js CHANGED
@@ -31,10 +31,24 @@ const createVibeInstance = () => ({
31
31
  }
32
32
  });
33
33
 
34
+ // Shallow, key-level defaults: set only keys `target` does not have yet.
35
+ // This is what "initial state, declared again" means once the app is live —
36
+ // a re-mounted SPA page fragment's vibe({ ... }) seeds on first mount and
37
+ // never clobbers live state after (vibe() state is app-lifetime; per-visit
38
+ // state belongs in a component()).
39
+ export const applyDefaults = (target, state) => {
40
+ for (const key in state) {
41
+ if (!(key in target)) target[key] = state[key];
42
+ }
43
+ return target;
44
+ };
45
+
34
46
  const vibe = (state = {}, config, targetSelector) => {
35
47
  if (isBooted()) {
36
- // Already booted - merge state into live proxy
37
- Object.assign(window.$, state);
48
+ // Already booted: initial state declared late seeds missing keys only —
49
+ // on a fresh document load this branch never runs, so MPA behavior is
50
+ // byte-identical.
51
+ applyDefaults(window.$, state);
38
52
  return window.$;
39
53
  }
40
54
 
package/llms.txt CHANGED
@@ -214,10 +214,39 @@ This makes vibe usable as a "sprinkle of reactivity" library: paste a snippet in
214
214
  $.on('ready', () => {}); // once, after initial parse + hydrate + components mounted
215
215
  $.on('afterUpdate', (cur, prev) => {}); // every state change (batched per microtask)
216
216
  $.on('afterDomMutation', () => {}); // after every MutationObserver batch
217
+ $.on('unmount', () => {}); // scope-resolved teardown: in a component script, fires when
218
+ // THAT component unmounts; at page level, fires on pagehide
217
219
  ```
218
220
 
219
221
  Also: `await $.ready` resolves after boot, useful when calling code captured `window.$` before vibe finished initializing.
220
222
 
223
+ ## SPA Routing (`@ape-egg/vibe/spa`)
224
+
225
+ A standalone router maintaining `$.page = { path, route, params, src }`; a reactive component src is the route outlet:
226
+
227
+ ```html
228
+ <script type="module">
229
+ import vibe from '@ape-egg/vibe';
230
+ import { setupSpa, resolve } from '@ape-egg/vibe/spa';
231
+
232
+ const routes = [
233
+ { route: '/brawlers/:index', src: '/components/brawler.html', title: 'Brawler' }, // :param = one segment
234
+ { route: '/docs/:rest*', src: '/components/docs.html' }, // trailing :name* = zero or more
235
+ { route: '/', src: '/components/home.html', title: 'Home' },
236
+ { route: '*', src: '/components/lost.html' }, // no-match fallback (deep links/popstate only)
237
+ ];
238
+
239
+ window.$ = vibe({ page: resolve(location, routes) ?? {} });
240
+ setupSpa({ routes }); // returns { navigate, dispose }
241
+ </script>
242
+
243
+ <component src="@[page.src]"></component>
244
+ ```
245
+
246
+ Rules: tables are pre-sorted most-specific-first, first match wins. Clicks are claimed only for same-origin, unmodified, untargeted links whose pathname matches a real route (`'*'` never claims) — everything else navigates natively, so mixed MPA/SPA output works. Route titles swap `document.title`. `resolve(location, routes)` is pure. A custom `onNavigate` makes it a pure router without Vibe.
247
+
248
+ State semantics under SPA: `vibe()` state is app-lifetime, `component()` state is mount-lifetime (resets per visit). `@ape-egg/vibe/defaults` is the vibe entry with defaults semantics — already booted, it sets only keys that don't exist yet on `$`, so re-running page scripts never clobber live state. The compiler's SPA mode (`"spa": true` in `vibe-compiler` config, or `--spa`) compiles an MPA `pages/` tree into page fragments under `/components/vibe-spa/`, a generated route table, and a composed `/index.html` shell wired to this router — deploy with one rewrite: every route → `/index.html`.
249
+
221
250
  ## Special Attributes
222
251
 
223
252
  ### vibe-fouc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.22",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -10,6 +10,7 @@
10
10
  "./boot": "./boot.js",
11
11
  "./component": "./component.js",
12
12
  "./hot-module-refresh": "./hot-module-refresh.js",
13
+ "./spa": "./spa.js",
13
14
  "./runtime": "./runtime/index.js",
14
15
  "./compiler": "./compiler/bin/vibe-compile.js"
15
16
  },
@@ -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,
@@ -305,6 +306,7 @@ export const liveComponentWrapper = (element) => {
305
306
  // the removal pass when replaceWith drops the old wrapper.
306
307
  export const remountComponent = (el, src, debug = false) => {
307
308
  const wasFetching = pendingFetches.has(el);
309
+ const hadSrcAttr = el.hasAttribute('src');
308
310
  // Compare against the LATEST requested src: with a fetch in flight the src
309
311
  // attribute holds it (rapid navigation A→B→A must abort B, not no-op on A);
310
312
  // mounted and idle, the finalize-stashed value does.
@@ -314,9 +316,23 @@ export const remountComponent = (el, src, debug = false) => {
314
316
  if (src === current) return;
315
317
  abortComponentFetch(el);
316
318
  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;
319
+ // Pre-fetch element awaiting its initial processing pass: that pass reads
320
+ // the new value — nothing to redo. A declaration-form wrapper (bound src
321
+ // that resolved to nothing, carried on data-vibe-src with no src
322
+ // attribute) was invisible to that pass, so hydration owns its first
323
+ // fetch too — the observer doesn't watch attributes.
324
+ if (el._vibeMountedSrc === undefined && !wasFetching && hadSrcAttr) return;
325
+ processSingle(el, debug);
326
+ };
327
+
328
+ // Key-change remount (`key="@[page.path]"`): mount the CURRENT src fresh even
329
+ // though it is unchanged. Only a mounted, idle wrapper has anything to redo —
330
+ // with a fetch pending the incoming mount is already fresh (a src change in
331
+ // the same flush started it), and an unmounted wrapper's first mount is owned
332
+ // by the normal processing pass.
333
+ export const forceRemount = (el, debug = false) => {
334
+ if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
335
+ el.setAttribute('src', el._vibeMountedSrc);
320
336
  processSingle(el, debug);
321
337
  };
322
338
 
@@ -542,8 +558,11 @@ const processSingle = (el, debug) => {
542
558
  let props = el._vibeRemountProps;
543
559
  if (!props) {
544
560
  props = {};
561
+ // `src` and `key` are the wrapper's own contract (what to mount / when to
562
+ // remount), and data-vibe-* attributes are runtime transport — none of
563
+ // them are authored props for the component.
545
564
  Array.from(el.attributes).forEach((attr) => {
546
- if (attr.name !== 'src') {
565
+ if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
547
566
  props[attr.name] = attr.value;
548
567
  }
549
568
  });
@@ -697,6 +716,12 @@ const processSingle = (el, debug) => {
697
716
  // attribute — the DOM alone carries the knowledge across swaps.
698
717
  const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
699
718
  if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
719
+ // The key binding and its last resolved value ride along the same
720
+ // way, so a later key change still finds what to compare against
721
+ // on the replacement wrapper.
722
+ const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
723
+ if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
724
+ if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
700
725
  // Transfer iteration-prop registry ownership from the soon-to-be-
701
726
  // detached `<component src>` to the new wrapper. The detached element
702
727
  // would otherwise trigger releaseOrphanedIterationProps and free the
@@ -732,13 +757,37 @@ const processSingle = (el, debug) => {
732
757
  // renderAllConditionals/Iterations populated runtime data) is what
733
758
  // iterate.js's update path uses to re-evaluate inlined bindings on
734
759
  // each row update.
760
+ // The observer hydrates the inserted subtree in its NEXT batch —
761
+ // until then, selectors keyed on hydrated attributes (a name-bound
762
+ // <page @[page.name]> → page[pvp] rules) don't match and the
763
+ // content paints unstyled. Cover the gap with the same fouc
764
+ // contract pages use: hidden at insertion, revealed by the batch
765
+ // that parsed and hydrated this subtree.
766
+ newWrapper.setAttribute('vibe-fouc', '');
735
767
  el._vibeReplacedBy = newWrapper;
736
768
  el.replaceWith(newWrapper);
737
769
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
738
770
 
771
+ // Build-inlined child components (compiled SPA fragments) arrive
772
+ // with tagged wrapper ids and vibe-module scripts — the compiled-
773
+ // document form. A fetched mount is the fourth delivery mode after
774
+ // boot, conditional branches, and iteration rows: run those
775
+ // scripts now so each child's component({...}) state registers
776
+ // under its build-tagged id and the _cN bindings hydrate.
777
+ executeCompiledComponentScriptsIn([newWrapper]);
778
+
739
779
  // MutationObserver handles parsing and hydrating the new content.
740
780
  // Branch nodes are registered in the manifest by mountBranch,
741
781
  // so the observer can find parents even inside conditional branches.
782
+ // Hydration can span multiple batches (nested fetched components,
783
+ // async scripts) with paints in between — reveal only when the
784
+ // subtree has settled (same predicate the page-level ready uses).
785
+ // A wrapper unmounted mid-hydration releases the hook.
786
+ const unfouc = window.$.on('afterDomMutation', () => {
787
+ if (newWrapper.isConnected && !shouldCleanup(newWrapper)) return;
788
+ newWrapper.removeAttribute('vibe-fouc');
789
+ unfouc();
790
+ });
742
791
  } else {
743
792
  // Element was detached before finalize ran (conditional unmounted
744
793
  // during fetch, parent removed, etc). Release any state component()
@@ -1,6 +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
+ import { isComponentWrapper, liveComponentWrapper, remountComponent, forceRemount } from './component.js';
4
4
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
5
5
  import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
6
6
  import { RawHtml } from './raw-html.js';
@@ -91,13 +91,40 @@ export default (affected, state, manifest = {}, oldState = {}) => {
91
91
  // reparse rebuilds the knowledge from that attribute. A state change
92
92
  // landing inside the swap window still resolves through the
93
93
  // replacement chain.
94
+ // Keyed component (`<component src="@[page.src]" key="@[page.path]">`):
95
+ // a key change is a declared identity change — remount the mounted
96
+ // component even when the src is unchanged (param→param navigation on
97
+ // the same route). The first resolution just records the initial key;
98
+ // the mount itself is owned by the normal component pass.
99
+ if (attrName === 'key' && isComponentWrapper(element)) {
100
+ const live = liveComponentWrapper(element);
101
+ const newKey = attrValue.replace(BINDING_REGEX, (_, expr) =>
102
+ evalInScope(expr, effectiveState, live) ?? '',
103
+ );
104
+ live._vibeKeyBinding = attrValue;
105
+ const prevKey = live._vibeMountedKey;
106
+ live._vibeMountedKey = newKey;
107
+ if (prevKey !== undefined && newKey !== prevKey) forceRemount(live);
108
+ return;
109
+ }
110
+
94
111
  if (attrName === 'src' && isComponentWrapper(element)) {
95
112
  const live = liveComponentWrapper(element);
96
113
  const newSrc = attrValue.replace(BINDING_REGEX, (_, expr) =>
97
- evalInScope(expr, effectiveState, live),
114
+ evalInScope(expr, effectiveState, live) ?? '',
98
115
  );
99
116
  live._vibeSrcBinding = attrValue;
100
- remountComponent(live, newSrc);
117
+ if (newSrc) {
118
+ remountComponent(live, newSrc);
119
+ } else if (live.hasAttribute('src')) {
120
+ // Unresolved src mounts nothing (a no-match deep link leaves
121
+ // $.page.src unset): move the binding onto data-vibe-src — the
122
+ // established transport parse.js already reads — and drop the
123
+ // fetchable src, so component processing and cleanup treat the
124
+ // outlet as settled instead of fetching a stringified binding.
125
+ live.setAttribute('data-vibe-src', attrValue);
126
+ live.removeAttribute('src');
127
+ }
101
128
  return;
102
129
  }
103
130
 
package/runtime/index.js CHANGED
@@ -571,6 +571,12 @@ const main = (s, config = {}, stringSelector = '') => {
571
571
  unmount: [],
572
572
  };
573
573
 
574
+ // The ready phase happens once. A listener registered after it fires
575
+ // immediately (parity with the late-safe $.ready promise) — late
576
+ // registration is the SPA norm, where fragment scripts run on mount,
577
+ // long after the shell booted.
578
+ let readyFired = false;
579
+
574
580
  // Page-scope 'unmount': the visitor actually leaving — pagehide (navigation
575
581
  // away, tab close). Deliberately NOT visibilitychange: a tab switch is not
576
582
  // an unmount, the visitor comes back. Inside a component script the same
@@ -689,6 +695,14 @@ const main = (s, config = {}, stringSelector = '') => {
689
695
  // snapshots or Object.keys($).
690
696
  Object.defineProperty($, 'on', {
691
697
  value: (event, callback) => {
698
+ if (event === 'ready' && readyFired) {
699
+ try {
700
+ callback();
701
+ } catch (error) {
702
+ console.error('[vibe] Error in ready hook:', error);
703
+ }
704
+ return () => {};
705
+ }
692
706
  if (hooks[event]) {
693
707
  hooks[event].push(callback);
694
708
  }
@@ -1233,6 +1247,7 @@ const main = (s, config = {}, stringSelector = '') => {
1233
1247
  cleanupExecuted = true;
1234
1248
 
1235
1249
  // Fire ready hook after cleanup completes
1250
+ readyFired = true;
1236
1251
  hooks.ready.forEach((callback) => {
1237
1252
  try {
1238
1253
  callback();