@ape-egg/vibe 2.1.6 → 2.1.8

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.8] - 2026-06-19
4
+
5
+ ### Changed
6
+
7
+ - **Compiler 1.9.3 → 1.9.4 — watch-mode recompiles are incremental at the component-cache level** (`compiler/src/compiler/watcher.rs`, `compile.rs`) — on a component edit the watcher previously cleared the *entire* component cache, so every affected page re-expanded its whole component tree even though one leaf changed. It now invalidates only the edited component plus the components whose cached inlined content embeds it — its transitive inlining ancestors, via `DependencyGraph::get_all_dependent_components` — while every unrelated component stays cached and is reused. Supporting changes: the watcher adopts the initial full-compile's warm cache (`adopt_component_cache`) so even the first edit is incremental; cache keys are resolved source-relative (`component_cache_key`, matching the `<component src>` form, with paths outside the source root excluded so external-URL components are never wrongly invalidated); and incremental manifest generation (`generate_manifests_for_files`) now runs in parallel with rayon like the full build, since per-page static analysis dominates watch latency. A page-only edit invalidates no component caches at all. Unit tests cover the stale-ancestor set and cache-key normalization (`watcher.rs` tests).
8
+
9
+ ## [2.1.7] - 2026-06-19
10
+
11
+ ### Fixed
12
+
13
+ - **Compiled component-local state was lost when a conditional re-mounted** (`runtime/component.js`, `runtime/conditionals.js`) — on compiled pages each component's setup script is inlined as `type="vibe-module"`, but the boot-time pass only runs the scripts present at boot. A `<component>` carrying its own `component({...})` state inside a `<!-- if -->` rendered correctly the first time, then went blank after the conditional was toggled off and back on: the branch markup was restored but its `<!-- each _cN.x -->` read component-local state that had been released on unmount. `mountBranch` now runs the freshly mounted subtree's `vibe-module` scripts via a new `executeCompiledComponentScriptsIn(nodes)` — before nested iterations/conditionals render, so the re-registered state is in place when `<!-- each _cN.x -->` evaluates. The `_cN` id-counter advance and the script runner are factored out so the scoped and boot-time passes share one path. Repro: `e2e-runtime/conditional-component-remount.html`, `tests/e2e/components-in-conditionals.spec.js`.
14
+
3
15
  ## [2.1.6] - 2026-06-19
4
16
 
5
17
  ### Added
package/ROADMAP.md CHANGED
@@ -323,6 +323,75 @@ Hand-roll the idle wait at the call site with `requestIdleCallback`. See `webdev
323
323
 
324
324
  ---
325
325
 
326
+ ## Proposed: Surgical HMR in Compiled (Hyperspeed) Mode
327
+
328
+ **Status**: Proposal
329
+ **Priority**: High (developer experience)
330
+ **Category**: Compiler + Core Runtime (orchestrated by vite-plugin-vibe)
331
+ **Discovered**: 2026-06-19 (battle-brawlers `dev:compiled` loop — a one-line edit to a leaf component recompiles every page that inlines it)
332
+
333
+ ### Problem
334
+
335
+ Runtime mode already has surgical component HMR: vite-plugin-vibe refetches the changed component's source, calls `$.renderComponent(...)` + `$.reconcile(...)`, and patches **only the live instances** of that component — no page-level work. State is preserved by reusing the component ids.
336
+
337
+ Compiled (hyperspeed) mode cannot do this today. The compiler **inlines components into each page's HTML and per-page manifest**, which *dissolves component identity*: the page manifest tree keeps the boundary as an anonymous positional node (`…→ component_1 → game-layout_0 → component_0 → …`) but nothing records *which source file produced it*. So the only invalidation the compiler can compute on a file save is the inverse graph — **component → dependent pages** — and it recompiles all of them.
338
+
339
+ Concrete cost: `AccountProgression.html` is included by `Sidebar` and `Overlay`, both of which live in `Layout`, which **every** page mounts. One leaf edit → all 27 pages + 27 manifests recompiled (~2.7s), versus runtime mode's ~instant single-boundary patch.
340
+
341
+ ### Key realization — this is *not* "teach the compiler the runtime engine"
342
+
343
+ The instinct is that surgical HMR means giving the Rust compiler the engine's runtime awareness (component instantiation, props, slots, live DOM) just to know what to recompile. It does **not**. Two things are already in place:
344
+
345
+ 1. **The runtime already ships the surgical engine** — `runtime/reconcile.js`, `runtime/component.js`, `runtime/component-cache.js`. Compiled pages boot the *same* runtime, so the patching machinery is already in the browser in compiled mode.
346
+ 2. **The compiler already builds the static include-graph** ("Building dependency graph…") and already knows component identity *during* inlining — it simply discards it in the output.
347
+
348
+ What's missing is purely **emission + addressing**, not awareness:
349
+
350
+ - The compiler needs to **stamp each boundary with its `src`** and (Option A) **emit each component's compiled subtree as its own small unit** that pages *reference* rather than absorb.
351
+ - The runtime needs a **public boundary-swap API** built on the existing `reconcile` + `component-cache`, taking a recompiled unit + the live boundary and re-rendering just that subtree (live props/state stay the runtime's job — the compiler never needs them).
352
+
353
+ The compiler and the engine have *converged on the same structural model from opposite ends* (both understand `<component src>` and the component tree). Surgical HMR **connects** those two seams through the tooling layer; it does not merge them, and it does not give the compiler runtime DOM awareness. The compiler's job stays build-time-static (include graph + boundary metadata); the runtime's job stays runtime-dynamic (props, state, DOM).
354
+
355
+ ### Layering constraint (non-negotiable)
356
+
357
+ `vite-plugin-vibe` is **sugar on top**. The dependency direction is one-way:
358
+
359
+ ```
360
+ vite-plugin-vibe ──imports──▶ Vibe (compiler output + runtime public APIs)
361
+ Vibe ──never───▶ vite-plugin-vibe
362
+ ```
363
+
364
+ - **Capabilities live in Vibe**, transport-agnostic:
365
+ - Compiler: emit boundary `src` identity; recompile a single component to its unit (`--watch` already incremental).
366
+ - Runtime: a public `swap(boundary, unit)` / `patchComponent(...)` API (reusing `reconcile`, `component-cache`, and component-id reuse for state preservation). Knows nothing about websockets or HMR.
367
+ - **Orchestration lives in the plugin**: watch the compiler's `--watch` output, carry the recompiled unit over the dev websocket, call the runtime's swap API. The plugin already sends a `vibe:component-update` event in runtime mode — the compiled path emits the *same* event with a compiled payload.
368
+
369
+ Neither the compiler nor the runtime gains a dependency on the plugin. The plugin remains optional sugar that delivers true HMR to **both** runtime dev and compiled dev.
370
+
371
+ ### Two implementation paths
372
+
373
+ **Option A — per-component compiled units (full).**
374
+ Compiler tags `component_N` boundaries with `src` and modularizes manifests so each component's subtree is a separately-emittable, separately-loadable module; pages reference units instead of inlining them. Repurpose the dep graph from *component→pages* to *component→its unit*. An edit recompiles one small unit. Runtime swaps boundaries from the new unit. **Manifest modularization is the bulk of the work** (today a page manifest is one ~440KB blob).
375
+
376
+ **Option B — hybrid, recommended first (small lift).**
377
+ In dev only, the compiler **keeps the boundary markers + the call-site props/slot** in the compiled output instead of fully dissolving them. On a component edit, the plugin treats *just that boundary* like a **runtime** component — refetch source → `renderComponent` + `reconcile` that one subtree — and leaves the rest of the page compiled. This reuses vite-plugin-vibe's existing runtime-HMR path almost verbatim; the **only** compiler change is "don't throw away boundary + props/slot metadata in a dev compiled build." Gets ~90% of the benefit; the edited boundary is re-expanded by the runtime engine rather than from a compiled unit (fine for dev).
378
+
379
+ ### Trade-offs
380
+
381
+ - **Dev/prod divergence.** Inlining *is* hyperspeed; a boundary-preserving dev build is structurally different from the inlined prod build, so compiled-specific bugs (cross-boundary manifest merging, minified inlined scripts) may not reproduce in surgical-dev. Every framework lives with this (dev HMR builds ≠ prod). Keep **both** modes — surgical dev for iteration, full-inline compile for fidelity passes — and pick per task.
382
+ - **Only internal edits go surgical.** Changing a *call site* (props/slot a page passes) still recompiles that page — same as runtime HMR re-mounting on a prop change.
383
+
384
+ ### Related (precedents — all do exactly this shape)
385
+
386
+ - **Svelte** (`svelte-hmr` / Vite plugin): each component compiles to a module with an HMR proxy; a changed component hot-swaps its instances, preserving state where it can.
387
+ - **Vue SFC HMR**: compiler stamps `__hmrId`; runtime `rerender`/`reload` patches just that component's instances.
388
+ - **React Fast Refresh**: components compile with register/signature metadata + module boundaries; the refresh runtime re-renders only affected components, preserving hook state.
389
+ - **Vite HMR boundaries**: `import.meta.hot.accept` — module-scoped patching instead of full reload.
390
+
391
+ All three compile components into **independently-addressable, hot-swappable units with HMR boundaries**, then patch via a runtime accept API. This proposal is the same pattern adapted to Vibe's manifest model, with the orchestration deliberately kept in the (one-way-dependent) plugin.
392
+
393
+ ---
394
+
326
395
  ## Future Proposals
327
396
 
328
397
  *This section reserved for additional feature proposals*
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.3"
1602
+ version = "1.9.4"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.9.3"
3
+ version = "1.9.4"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -558,9 +558,29 @@ impl Compiler {
558
558
  }
559
559
  }
560
560
 
561
- /// Clear all cached component content (forces re-fetch on next compile)
562
- pub fn clear_component_cache(&mut self) {
563
- self.component_cache.clear();
561
+ /// Drop only the named cache entries, forcing those components to be
562
+ /// re-read + re-inlined on the next compile while every other component is
563
+ /// reused from cache. `keys` are normalized component srcs (e.g.
564
+ /// `/components/Sidebar.html`). Returns how many entries were actually
565
+ /// removed. This is the incremental-watch path: invalidate the edited
566
+ /// component and its inlining ancestors, nothing else.
567
+ pub fn invalidate_components(&mut self, keys: &std::collections::HashSet<String>) -> usize {
568
+ let before = self.component_cache.len();
569
+ self.component_cache.retain(|key, _| !keys.contains(key));
570
+ before - self.component_cache.len()
571
+ }
572
+
573
+ /// Number of components currently held in the inlining cache.
574
+ pub fn cached_component_count(&self) -> usize {
575
+ self.component_cache.len()
576
+ }
577
+
578
+ /// Take over another compiler's warm component cache, leaving it empty.
579
+ /// The watch loop uses this to carry the initial full-compile cache into the
580
+ /// incremental compiler, so even the first edit reuses everything unchanged
581
+ /// instead of re-fetching the whole tree.
582
+ pub fn adopt_component_cache(&mut self, other: &mut Compiler) {
583
+ self.component_cache = std::mem::take(&mut other.component_cache);
564
584
  }
565
585
 
566
586
  pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
@@ -864,56 +884,63 @@ impl Compiler {
864
884
  /// Generate manifests for specific HTML files (incremental compilation for watch mode)
865
885
  pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
866
886
  let start = Instant::now();
867
- let mut pages_processed = 0;
868
- let mut pages_skipped = 0;
887
+
888
+ // Mirror generate_manifests: process each page's manifest in parallel.
889
+ // Manifest generation is the bulk of an incremental recompile (static
890
+ // analysis + serialization per page), so a sequential loop over the
891
+ // affected pages left most cores idle and dominated watch latency.
892
+ let output_dir = self.config.output.clone();
893
+ let source_root = self.config.source.clone();
894
+ let verbose = self.verbose;
895
+ let iterations_as_is = self.config.iterations_as_is;
896
+ let components_as_is = self.config.components_as_is;
897
+ let manifest_root = self.config.root.clone();
898
+ // Resolve constant global-state keys once, shared read-only across pages.
869
899
  let global_constants = self.compute_global_constants();
870
900
 
871
- for file_path in files {
872
- // Convert source path to output path
873
- let relative_path = file_path
874
- .strip_prefix(&self.config.source)
875
- .unwrap_or(file_path)
876
- .to_str()
877
- .unwrap();
901
+ let results: Vec<bool> = files
902
+ .par_iter()
903
+ .map(|file_path| {
904
+ let relative_path = match file_path.strip_prefix(&source_root).unwrap_or(file_path).to_str() {
905
+ Some(r) => r,
906
+ None => return false,
907
+ };
878
908
 
879
- let output_path = self.config.output.join(relative_path);
909
+ let output_path = output_dir.join(relative_path);
910
+ if !output_path.exists() {
911
+ return false;
912
+ }
880
913
 
881
- if !output_path.exists() {
882
- pages_skipped += 1;
883
- continue;
884
- }
914
+ let html = match fs::read_to_string(&output_path) {
915
+ Ok(h) => h,
916
+ Err(_) => return false,
917
+ };
885
918
 
886
- // Read compiled HTML from output
887
- let html = fs::read_to_string(&output_path)
888
- .map_err(|e| CompileError::ReadError {
889
- path: output_path.display().to_string(),
890
- source: e,
891
- })?;
892
-
893
- // Try to generate manifest for this file (skip on error)
894
- match Self::generate_file_manifest(
895
- &html,
896
- &output_path,
897
- &self.config.output,
898
- relative_path,
899
- self.verbose,
900
- self.config.iterations_as_is,
901
- self.config.components_as_is,
902
- &self.config.source,
903
- self.config.root.as_deref(),
904
- &global_constants,
905
- ) {
906
- Ok(()) => {
907
- pages_processed += 1;
908
- }
909
- Err(e) => {
910
- pages_skipped += 1;
911
- if self.verbose {
912
- println!(" Skipped ({}): {}", e, relative_path);
919
+ match Self::generate_file_manifest(
920
+ &html,
921
+ &output_path,
922
+ &output_dir,
923
+ relative_path,
924
+ verbose,
925
+ iterations_as_is,
926
+ components_as_is,
927
+ &source_root,
928
+ manifest_root.as_deref(),
929
+ &global_constants,
930
+ ) {
931
+ Ok(()) => true,
932
+ Err(e) => {
933
+ if verbose {
934
+ eprintln!(" Skipped ({}): {}", e, relative_path);
935
+ }
936
+ false
913
937
  }
914
938
  }
915
- }
916
- }
939
+ })
940
+ .collect();
941
+
942
+ let pages_processed = results.iter().filter(|&&ok| ok).count();
943
+ let pages_skipped = results.len() - pages_processed;
917
944
 
918
945
  let total_time_ms = start.elapsed().as_millis() as f64;
919
946
 
@@ -81,6 +81,30 @@ impl DependencyGraph {
81
81
  }
82
82
  }
83
83
  }
84
+
85
+ /// Get the edited component itself plus every component that transitively
86
+ /// inlines it. These are exactly the component caches that go stale on an
87
+ /// edit: a parent's cached content is the *fully inlined* child, so when the
88
+ /// child changes the parent's cache is stale too. Unrelated components keep
89
+ /// their cache. The returned set always includes `component` itself.
90
+ pub fn get_all_dependent_components(&self, component: &Path) -> HashSet<PathBuf> {
91
+ let mut all = HashSet::new();
92
+ self.collect_dependent_components(component, &mut all);
93
+ all
94
+ }
95
+
96
+ fn collect_dependent_components(&self, component: &Path, all: &mut HashSet<PathBuf>) {
97
+ // insert() == false → already seen: doubles as the cycle guard.
98
+ if !all.insert(component.to_path_buf()) {
99
+ return;
100
+ }
101
+
102
+ if let Some(users) = self.component_to_components.get(component) {
103
+ for user in users {
104
+ self.collect_dependent_components(user, all);
105
+ }
106
+ }
107
+ }
84
108
  }
85
109
 
86
110
  /// Extract component references from HTML content
@@ -126,6 +150,19 @@ fn to_kebab_case(s: &str) -> String {
126
150
  result
127
151
  }
128
152
 
153
+ /// Map a component's source path to the normalized key under which its inlined
154
+ /// content is cached (e.g. `/components/Sidebar.html`). The cache key is the
155
+ /// component's path relative to the source root, with a leading slash — the same
156
+ /// form `<component src>` resolves to. Returns None for paths outside the source
157
+ /// root (e.g. external URL components, which never go stale on a local edit).
158
+ fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<String> {
159
+ component
160
+ .strip_prefix(canonical_source)
161
+ .ok()
162
+ .and_then(|rel| rel.to_str())
163
+ .map(|rel| format!("/{}", rel.replace('\\', "/")))
164
+ }
165
+
129
166
  /// Check if a path should be blacklisted based on SKIP_FILES patterns
130
167
  /// This checks both the filename and all path components relative to source root
131
168
  fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
@@ -326,9 +363,16 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
326
363
  // Canonicalize output path for reliable comparison
327
364
  let canonical_output = config.output.canonicalize()
328
365
  .unwrap_or_else(|_| config.output.clone());
366
+ // Canonical source root: dependency-graph paths are canonical, so map them to
367
+ // cache keys against the same base.
368
+ let canonical_source = config.source.canonicalize()
369
+ .unwrap_or_else(|_| config.source.clone());
329
370
 
330
371
  // Keep compiler and parser alive to reuse component cache across incremental compilations
331
372
  let mut watch_compiler = Compiler::new(config.clone(), false);
373
+ // Carry the initial compile's warm component cache into the watcher so the
374
+ // first edit is already incremental (only the edited subtree re-expands).
375
+ watch_compiler.adopt_component_cache(&mut compiler);
332
376
  let mut parser = {
333
377
  use crate::parser::HtmlParser;
334
378
  let mut p = HtmlParser::new(config.components_path());
@@ -373,6 +417,10 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
373
417
 
374
418
  // Determine what needs recompiling
375
419
  let mut pages_to_recompile: HashSet<PathBuf> = HashSet::new();
420
+ // Component caches that go stale this batch: each edited
421
+ // component plus the ancestors that inline it. Everything
422
+ // else stays cached and is reused.
423
+ let mut stale_component_keys: HashSet<String> = HashSet::new();
376
424
 
377
425
  for path in &changed_paths {
378
426
  // Skip files in output directory (avoid infinite loop)
@@ -398,8 +446,16 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
398
446
  if !dependent_pages.is_empty() {
399
447
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
400
448
 
401
- // Invalidate component cache so stale content isn't used
402
- watch_compiler.clear_component_cache();
449
+ // Invalidate only the edited component and the
450
+ // components whose cached inlined content embeds it
451
+ // (its ancestors). Every other component stays cached,
452
+ // so each affected page re-expands just the changed
453
+ // subtree instead of its whole component tree.
454
+ for stale in graph.get_all_dependent_components(&path_canonical) {
455
+ if let Some(key) = component_cache_key(&stale, &canonical_source) {
456
+ stale_component_keys.insert(key);
457
+ }
458
+ }
403
459
 
404
460
  // Reload the changed component in the parser's element cache
405
461
  // (used for custom element syntax like <Layout>)
@@ -546,9 +602,19 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
546
602
 
547
603
  // Compile HTML files
548
604
  if !html_files.is_empty() {
549
- // Always clear component cache before recompiling so components
550
- // are read fresh from disk (prevents stale cache from previous triggers)
551
- watch_compiler.clear_component_cache();
605
+ // Drop only the stale component caches (edited components +
606
+ // their inlining ancestors); unchanged components are reused.
607
+ // A page-only edit invalidates nothing here — its components
608
+ // are still valid — so the whole cache is reused as-is.
609
+ let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
610
+ if invalidated > 0 {
611
+ println!("{} {} component{} re-expanded, {} reused from cache",
612
+ "↻".cyan(),
613
+ invalidated,
614
+ if invalidated == 1 { "" } else { "s" },
615
+ watch_compiler.cached_component_count(),
616
+ );
617
+ }
552
618
 
553
619
  match watch_compiler.compile_specific_html_files(&html_files, &parser) {
554
620
  Ok(stats) => {
@@ -645,3 +711,77 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
645
711
 
646
712
  Ok(())
647
713
  }
714
+
715
+ #[cfg(test)]
716
+ mod tests {
717
+ use super::*;
718
+
719
+ // leaf <- mid <- layout <- page, with `sibling` an unrelated component that
720
+ // layout also uses. Editing `leaf` invalidates the cached, fully-inlined
721
+ // content of leaf and every component whose cache embeds it (mid, layout) —
722
+ // but NOT `sibling` (its cache never contained leaf) and NOT pages (they are
723
+ // recompiled, not cache entries).
724
+ fn chain_graph() -> (DependencyGraph, [PathBuf; 5]) {
725
+ let leaf = PathBuf::from("/src/components/AccountProgression.html");
726
+ let mid = PathBuf::from("/src/components/Sidebar.html");
727
+ let layout = PathBuf::from("/src/components/Layout.html");
728
+ let sibling = PathBuf::from("/src/components/Topbar.html");
729
+ let page = PathBuf::from("/src/pages/index.html");
730
+
731
+ let mut graph = DependencyGraph::new();
732
+ graph.add_dependency(mid.clone(), leaf.clone(), true); // mid uses leaf
733
+ graph.add_dependency(layout.clone(), mid.clone(), true); // layout uses mid
734
+ graph.add_dependency(layout.clone(), sibling.clone(), true); // layout uses sibling
735
+ graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
736
+
737
+ (graph, [leaf, mid, layout, sibling, page])
738
+ }
739
+
740
+ #[test]
741
+ fn dependent_components_are_just_the_stale_ancestor_chain() {
742
+ let (graph, [leaf, mid, layout, sibling, page]) = chain_graph();
743
+
744
+ let stale = graph.get_all_dependent_components(&leaf);
745
+
746
+ // The edited component itself, plus every component that inlines it.
747
+ assert!(stale.contains(&leaf), "leaf itself must be invalidated");
748
+ assert!(stale.contains(&mid), "mid inlines leaf — stale");
749
+ assert!(stale.contains(&layout), "layout transitively inlines leaf — stale");
750
+ // A sibling that does not contain leaf keeps its cache.
751
+ assert!(!stale.contains(&sibling), "sibling never inlined leaf — must stay cached");
752
+ // Pages are recompiled, not component-cache entries.
753
+ assert!(!stale.contains(&page), "pages are not in the component set");
754
+ assert_eq!(stale.len(), 3, "exactly leaf + mid + layout go stale");
755
+ }
756
+
757
+ #[test]
758
+ fn editing_a_leaf_invalidates_far_fewer_than_the_dependent_pages() {
759
+ let (graph, [leaf, _mid, _layout, _sibling, _page]) = chain_graph();
760
+
761
+ // dependent-pages drives which output files recompile; dependent-
762
+ // components drives which caches to drop — the minimal stale set, which
763
+ // never includes unrelated siblings.
764
+ let stale_components = graph.get_all_dependent_components(&leaf);
765
+ let dependent_pages = graph.get_all_dependent_pages(&leaf);
766
+
767
+ assert_eq!(dependent_pages.len(), 1, "one page inlines leaf");
768
+ assert!(stale_components.iter().all(|c| c.starts_with("/src/components")));
769
+ }
770
+
771
+ #[test]
772
+ fn cache_key_is_source_relative_with_leading_slash() {
773
+ let source = PathBuf::from("/proj/src");
774
+ // Must match the key form fetch_component_recursive stores.
775
+ assert_eq!(
776
+ component_cache_key(&PathBuf::from("/proj/src/components/Sidebar.html"), &source)
777
+ .as_deref(),
778
+ Some("/components/Sidebar.html"),
779
+ );
780
+ // A path outside the source root (e.g. an external URL component's
781
+ // resolved path) yields no key, so it's never wrongly invalidated.
782
+ assert_eq!(
783
+ component_cache_key(&PathBuf::from("/elsewhere/x.html"), &source),
784
+ None,
785
+ );
786
+ }
787
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -158,14 +158,41 @@ const transformScriptContent = (rawContent) => {
158
158
  export const executeCompiledComponentScripts = () => {
159
159
  const scripts = document.querySelectorAll('script[type="vibe-module"]');
160
160
  if (!scripts.length) return null;
161
+ advanceComponentCounterPastIds(document);
162
+ return runVibeModuleScripts(scripts);
163
+ };
164
+
165
+ // Same pipeline as the boot-time pass, but scoped to a freshly mounted subtree
166
+ // (a conditional branch or iteration row) rather than the whole document. The
167
+ // boot pass only sees component scripts that are in the page at boot; a branch
168
+ // that mounts later — or RE-mounts after being unmounted — carries its own
169
+ // inlined `vibe-module` scripts that must run each time so component-local state
170
+ // (`component({...})` → `$._cN`) is re-registered. Without this, a re-opened
171
+ // conditional restores its markup but its `<!-- each _cN.x -->` reads state that
172
+ // was released on unmount (the AccountProgression overlay rendering blank on
173
+ // second open).
174
+ export const executeCompiledComponentScriptsIn = (nodes) => {
175
+ const scripts = [];
176
+ for (const node of nodes) {
177
+ if (node.nodeType !== 1) continue;
178
+ if (node.matches?.('script[type="vibe-module"]')) scripts.push(node);
179
+ node.querySelectorAll?.('script[type="vibe-module"]').forEach((s) => scripts.push(s));
180
+ }
181
+ if (!scripts.length) return null;
182
+ advanceComponentCounterPastIds(document);
183
+ return runVibeModuleScripts(scripts);
184
+ };
161
185
 
162
- // Build-time tagging already assigned _cN ids to wrappers; advance the
163
- // runtime counter past them so freshly generated ids never collide.
164
- document.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
186
+ // Build-time tagging already assigned _cN ids to wrappers; advance the runtime
187
+ // counter past them so freshly generated ids never collide.
188
+ const advanceComponentCounterPastIds = (root) => {
189
+ root.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
165
190
  const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
166
191
  if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
167
192
  });
193
+ };
168
194
 
195
+ const runVibeModuleScripts = (scripts) => {
169
196
  const asyncTasks = [];
170
197
  const claimed = new Set();
171
198
 
@@ -3,7 +3,7 @@ import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { createScopedState, renderAllIterations, initializeBlock, resolveIterationComponentProps } from './iterate.js';
5
5
  import { evalInScope } from './utils.js';
6
- import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
6
+ import { collectComponentIds, releaseOrphanedComponentState, executeCompiledComponentScriptsIn } from './component.js';
7
7
 
8
8
  // Registry of DOM nodes owned by conditional branches.
9
9
  // Maps a DOM node to { nodes: array_ref, index: number } so that
@@ -231,6 +231,13 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
231
231
  }
232
232
  }
233
233
 
234
+ // Compiled pages inline each component's setup script as type="vibe-module".
235
+ // The boot-time pass only runs scripts present at boot, so a branch that
236
+ // mounts (or re-mounts after unmount) must run its own scripts here to
237
+ // re-register component-local state. Must happen BEFORE rendering nested
238
+ // iterations/conditionals so `<!-- each _cN.x -->` sees the registered state.
239
+ executeCompiledComponentScriptsIn(clonedNodes);
240
+
234
241
  // Recursively render any nested iterations and conditionals
235
242
  if (branchTree) {
236
243
  renderAllIterations(branchTree, scopedState, manifest, parentScope);