@ape-egg/vibe 2.1.20 → 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 +19 -0
- package/compiler/bin/vibe-compile.js +12 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +274 -13
- package/compiler/src/compiler/watcher.rs +219 -3
- package/compiler/src/main.rs +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/runtime/component.js +96 -14
- package/runtime/hydrate.js +19 -0
- package/runtime/index.js +12 -0
- package/runtime/parse.js +19 -5
- package/runtime/pre-compiled-manifest.js +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
15
|
+
## [2.1.21] - 2026-07-01
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **An orphaned `--watch` compiler held its lock forever, blocking every future watch on the same output** (`compiler/bin/vibe-compile.js`, `compiler/src/compiler/watcher.rs`, compiler 2.0.2) — the watch lock added below is only released on clean shutdown (Drop), but the compiler is a native child spawned by the `vibe-compile.js` wrapper, and killing the wrapper (Ctrl+C, a dev server's `child.kill()`, a process manager) left that child alive as an orphan still holding the lock — so the next `--watch` exited loudly naming a holder pid that was long gone. Two coordinated fixes close both escape routes: **(1)** the wrapper now forwards `SIGINT`/`SIGTERM`/`SIGHUP` and its own `exit` to the spawned child (`wireLifecycle`), so a graceful kill of the wrapper unwinds the watcher's Drop and frees the lock normally; **(2)** for the ungraceful case (`SIGKILL`, a crashed parent) where no signal is forwarded, the watcher polls its own parent pid on a background thread (`exit_when_orphaned`, unix-only) and, the moment it's reparented to init/a reaper — the unambiguous orphan signal — removes its lockfile and `process::exit`s, since `exit` skips Drop. Together they guarantee a dead owner never leaves a lock behind, complementing the stale-lock stealing the next watcher already does.
|
|
20
|
+
- **Concurrent compilers corrupted hyperspeed manifests → pages hydrated blank with no errors** (`compiler/src/compiler/compile.rs`, `compiler/src/compiler/watcher.rs`, compiler 2.0.1 → 2.0.2) — manifest generation re-read each compiled page **from disk** after writing it, so when two `vibe compile --watch` processes shared one output directory (a leaked dev-server session next to a live one), one watcher's manifest pass could read a page the other was mid-rewrite. html5ever parses the torn prefix into a well-formed shell, producing a *valid but content-less* manifest that doesn't match its HTML — hydration mounts nothing, `vibe-fouc` never releases, and the page renders blank with zero console errors (observed in Battle Brawlers: a random subset of pages broke on every save of a widely-used component). Three-layer fix: **(1)** the compiler keeps each page's compiled HTML in memory (`Compiler.compiled_html`) and both manifest passes (`generate_manifests`, `generate_manifests_for_files`) build from those exact bytes — disk is only a fallback for pages the running compiler never produced (no-clean leftovers); **(2)** compiled HTML, stamped HTML, and manifests are written atomically (same-directory pid-tagged temp file + `rename`) so no reader — dev server, browser, or another process — can ever observe a partial file; **(3)** `watch` takes a per-output-directory lockfile (OS temp dir, keyed by canonicalized output path, holding the owner pid): a second watcher on the same output now exits loudly naming the holder instead of silently double-compiling and racing, and a lock whose process is dead (Ctrl+C/SIGTERM never unwind) is stolen. Tests: `compile.rs` (`manifest_survives_output_corruption_between_compile_and_manifests`, `incremental_manifest_survives_output_corruption`, `atomic_write_replaces_content_without_leaving_tmp_files`), `watcher.rs` (`second_watch_lock_on_same_output_fails_while_held`, `stale_lock_from_dead_process_is_stolen`, `locks_on_different_outputs_do_not_conflict`).
|
|
21
|
+
|
|
3
22
|
## [2.1.20] - 2026-06-25
|
|
4
23
|
|
|
5
24
|
### Added
|
|
@@ -35,6 +35,16 @@ const getPlatformBinary = () => {
|
|
|
35
35
|
return binary;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// The wrapper is what gets killed (Ctrl+C, a dev server's child.kill(), a
|
|
39
|
+
// process manager) — without forwarding, the compiler child survives as an
|
|
40
|
+
// orphan whose watch lock blocks every future `--watch` on the same output.
|
|
41
|
+
const wireLifecycle = (child) => {
|
|
42
|
+
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) =>
|
|
43
|
+
process.on(signal, () => child.kill(signal)),
|
|
44
|
+
);
|
|
45
|
+
process.on('exit', () => child.kill());
|
|
46
|
+
};
|
|
47
|
+
|
|
38
48
|
const run = () => {
|
|
39
49
|
const binary = getPlatformBinary();
|
|
40
50
|
const binaryPath = join(nativeDir, binary);
|
|
@@ -74,6 +84,7 @@ const run = () => {
|
|
|
74
84
|
cwd: srcDir,
|
|
75
85
|
stdio: 'inherit',
|
|
76
86
|
});
|
|
87
|
+
wireLifecycle(cargo);
|
|
77
88
|
|
|
78
89
|
cargo.on('error', (err) => {
|
|
79
90
|
if (err.code === 'ENOENT') {
|
|
@@ -95,6 +106,7 @@ const run = () => {
|
|
|
95
106
|
const child = spawn(binaryPath, userArgs, {
|
|
96
107
|
stdio: 'inherit',
|
|
97
108
|
});
|
|
109
|
+
wireLifecycle(child);
|
|
98
110
|
|
|
99
111
|
child.on('error', (err) => {
|
|
100
112
|
console.error('Failed to run vibe-compiler:', err.message);
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -39,6 +39,17 @@ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
|
|
|
39
39
|
.join("/")
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/// Write via a same-directory temp file + rename, so no reader — the dev
|
|
43
|
+
/// server, the browser, another compiler process — can ever observe a
|
|
44
|
+
/// partially-written file. Rename is atomic on POSIX; the temp name carries the
|
|
45
|
+
/// pid so two processes writing the same target never share a temp file.
|
|
46
|
+
pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
47
|
+
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
|
|
48
|
+
let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
|
|
49
|
+
fs::write(&tmp, contents)?;
|
|
50
|
+
fs::rename(&tmp, path)
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
// =============================================================================
|
|
43
54
|
// MIRROR_MODE: Copy asset files from source to output as-is, preserving
|
|
44
55
|
// directory structure. HTML files are compiled separately.
|
|
@@ -544,6 +555,11 @@ pub struct Compiler {
|
|
|
544
555
|
unique_components: HashSet<String>,
|
|
545
556
|
/// Cache for all components (both internal paths and external URLs)
|
|
546
557
|
component_cache: std::collections::HashMap<String, String>,
|
|
558
|
+
/// Compiled (pre-stamp) HTML of every page this compiler wrote, keyed by
|
|
559
|
+
/// output path. Manifest generation builds from these bytes — never from a
|
|
560
|
+
/// disk read-back — so a concurrent writer rewriting the output directory
|
|
561
|
+
/// (e.g. a second compiler) can't feed a torn read into a manifest.
|
|
562
|
+
compiled_html: HashMap<PathBuf, String>,
|
|
547
563
|
}
|
|
548
564
|
|
|
549
565
|
impl Compiler {
|
|
@@ -555,6 +571,7 @@ impl Compiler {
|
|
|
555
571
|
logger,
|
|
556
572
|
unique_components: HashSet::new(),
|
|
557
573
|
component_cache: HashMap::new(),
|
|
574
|
+
compiled_html: HashMap::new(),
|
|
558
575
|
}
|
|
559
576
|
}
|
|
560
577
|
|
|
@@ -898,6 +915,7 @@ impl Compiler {
|
|
|
898
915
|
// Resolve constant global-state keys once, shared read-only across pages.
|
|
899
916
|
let global_constants = self.compute_global_constants();
|
|
900
917
|
|
|
918
|
+
let compiled_html = &self.compiled_html;
|
|
901
919
|
let results: Vec<bool> = files
|
|
902
920
|
.par_iter()
|
|
903
921
|
.map(|file_path| {
|
|
@@ -911,13 +929,24 @@ impl Compiler {
|
|
|
911
929
|
return false;
|
|
912
930
|
}
|
|
913
931
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
932
|
+
// The HTML this compiler produced in memory is the source of
|
|
933
|
+
// truth; the on-disk file may have been rewritten by another
|
|
934
|
+
// process since we wrote it. Disk is only a fallback for pages
|
|
935
|
+
// this compiler never compiled (e.g. no-clean leftovers).
|
|
936
|
+
let disk_html;
|
|
937
|
+
let html: &str = match compiled_html.get(&output_path) {
|
|
938
|
+
Some(h) => h,
|
|
939
|
+
None => {
|
|
940
|
+
disk_html = match fs::read_to_string(&output_path) {
|
|
941
|
+
Ok(h) => h,
|
|
942
|
+
Err(_) => return false,
|
|
943
|
+
};
|
|
944
|
+
&disk_html
|
|
945
|
+
}
|
|
917
946
|
};
|
|
918
947
|
|
|
919
948
|
match Self::generate_file_manifest(
|
|
920
|
-
|
|
949
|
+
html,
|
|
921
950
|
&output_path,
|
|
922
951
|
&output_dir,
|
|
923
952
|
relative_path,
|
|
@@ -1009,7 +1038,7 @@ impl Compiler {
|
|
|
1009
1038
|
manifest_json
|
|
1010
1039
|
);
|
|
1011
1040
|
|
|
1012
|
-
|
|
1041
|
+
atomic_write(&manifest_path, &manifest_js)
|
|
1013
1042
|
.map_err(|e| format!("Failed to write manifest: {}", e))?;
|
|
1014
1043
|
|
|
1015
1044
|
// Stamp the compiled HTML with initial state values for FOUC prevention:
|
|
@@ -1023,7 +1052,7 @@ impl Compiler {
|
|
|
1023
1052
|
let stamped = stamper.stamp_html(html.to_string())
|
|
1024
1053
|
.map_err(|e| format!("Failed to stamp HTML: {}", e))?;
|
|
1025
1054
|
|
|
1026
|
-
|
|
1055
|
+
atomic_write(html_path, &stamped)
|
|
1027
1056
|
.map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
|
|
1028
1057
|
|
|
1029
1058
|
Ok(())
|
|
@@ -1108,6 +1137,7 @@ impl Compiler {
|
|
|
1108
1137
|
// pages (read-only; `&Map` is Sync so the parallel map can borrow it).
|
|
1109
1138
|
let global_constants = self.compute_global_constants();
|
|
1110
1139
|
|
|
1140
|
+
let compiled_html = &self.compiled_html;
|
|
1111
1141
|
let results: Vec<_> = html_files
|
|
1112
1142
|
.par_iter()
|
|
1113
1143
|
.map(|html_path| {
|
|
@@ -1116,14 +1146,24 @@ impl Compiler {
|
|
|
1116
1146
|
.to_str()
|
|
1117
1147
|
.unwrap();
|
|
1118
1148
|
|
|
1119
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1149
|
+
// Prefer the HTML this compiler produced in memory — the disk
|
|
1150
|
+
// copy may have been rewritten by another process since. Disk
|
|
1151
|
+
// is only a fallback for pages this compiler never compiled
|
|
1152
|
+
// (e.g. no-clean leftovers from an earlier run).
|
|
1153
|
+
let disk_html;
|
|
1154
|
+
let html: &str = match compiled_html.get(html_path.as_path()) {
|
|
1155
|
+
Some(h) => h,
|
|
1156
|
+
None => {
|
|
1157
|
+
disk_html = match fs::read_to_string(html_path) {
|
|
1158
|
+
Ok(h) => h,
|
|
1159
|
+
Err(_) => return (false, Some(relative_path.to_string())),
|
|
1160
|
+
};
|
|
1161
|
+
&disk_html
|
|
1162
|
+
}
|
|
1123
1163
|
};
|
|
1124
1164
|
|
|
1125
1165
|
// Try to generate manifest for this file (skip on error)
|
|
1126
|
-
match Self::generate_file_manifest(
|
|
1166
|
+
match Self::generate_file_manifest(html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants) {
|
|
1127
1167
|
Ok(()) => (true, None),
|
|
1128
1168
|
Err(e) => {
|
|
1129
1169
|
if verbose {
|
|
@@ -1320,7 +1360,7 @@ impl Compiler {
|
|
|
1320
1360
|
}
|
|
1321
1361
|
|
|
1322
1362
|
fn compile_html_file(
|
|
1323
|
-
&self,
|
|
1363
|
+
&mut self,
|
|
1324
1364
|
path: &Path,
|
|
1325
1365
|
parser: &HtmlParser,
|
|
1326
1366
|
relative_path: &str,
|
|
@@ -1361,11 +1401,14 @@ impl Compiler {
|
|
|
1361
1401
|
|
|
1362
1402
|
// Write to output
|
|
1363
1403
|
let output_path = self.get_output_path(path, relative_path)?;
|
|
1364
|
-
|
|
1404
|
+
atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
|
|
1365
1405
|
path: output_path.display().to_string(),
|
|
1366
1406
|
source: e,
|
|
1367
1407
|
})?;
|
|
1368
1408
|
|
|
1409
|
+
// Keep the exact bytes for this pass's manifest generation.
|
|
1410
|
+
self.compiled_html.insert(output_path, output);
|
|
1411
|
+
|
|
1369
1412
|
// Return component counts and src list
|
|
1370
1413
|
Ok((internal_count, external_count, component_srcs))
|
|
1371
1414
|
}
|
|
@@ -1450,6 +1493,47 @@ impl Compiler {
|
|
|
1450
1493
|
Ok(())
|
|
1451
1494
|
}
|
|
1452
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
|
+
|
|
1453
1537
|
/// Fetch components only for specific files (used in incremental compilation)
|
|
1454
1538
|
fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
|
|
1455
1539
|
for html_file in files {
|
|
@@ -2258,4 +2342,181 @@ mod tests {
|
|
|
2258
2342
|
let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
|
|
2259
2343
|
assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
|
|
2260
2344
|
}
|
|
2345
|
+
|
|
2346
|
+
#[test]
|
|
2347
|
+
fn atomic_write_replaces_content_without_leaving_tmp_files() {
|
|
2348
|
+
let dir = std::env::temp_dir().join("vibe_atomic_write_test");
|
|
2349
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2350
|
+
fs::create_dir_all(&dir).unwrap();
|
|
2351
|
+
let target = dir.join("page.html");
|
|
2352
|
+
|
|
2353
|
+
atomic_write(&target, "first").unwrap();
|
|
2354
|
+
assert_eq!(fs::read_to_string(&target).unwrap(), "first");
|
|
2355
|
+
|
|
2356
|
+
// Overwriting an existing file goes through the same tmp+rename path.
|
|
2357
|
+
atomic_write(&target, "second, longer content").unwrap();
|
|
2358
|
+
assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
|
|
2359
|
+
|
|
2360
|
+
let leftovers: Vec<String> = fs::read_dir(&dir)
|
|
2361
|
+
.unwrap()
|
|
2362
|
+
.filter_map(|e| e.ok())
|
|
2363
|
+
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
2364
|
+
.filter(|n| n != "page.html")
|
|
2365
|
+
.collect();
|
|
2366
|
+
assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// A minimal on-disk project: source with one page carrying a distinctive
|
|
2370
|
+
// binding, empty components dir, output dir sibling. Returns (config, page
|
|
2371
|
+
// source path, compiled page output path, manifest path).
|
|
2372
|
+
fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
|
|
2373
|
+
let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
|
|
2374
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2375
|
+
let source = dir.join("src");
|
|
2376
|
+
let output = dir.join("out");
|
|
2377
|
+
fs::create_dir_all(source.join("components")).unwrap();
|
|
2378
|
+
fs::write(
|
|
2379
|
+
source.join("index.html"),
|
|
2380
|
+
"<!doctype html>\n<html><head><title>t</title></head>\n\
|
|
2381
|
+
<body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
|
|
2382
|
+
)
|
|
2383
|
+
.unwrap();
|
|
2384
|
+
|
|
2385
|
+
let config = Config {
|
|
2386
|
+
source: source.clone(),
|
|
2387
|
+
output: output.clone(),
|
|
2388
|
+
_source_str: String::new(),
|
|
2389
|
+
_output_str: String::new(),
|
|
2390
|
+
components: "components".to_string(),
|
|
2391
|
+
pages: "pages".to_string(),
|
|
2392
|
+
_assets: String::new(),
|
|
2393
|
+
root: None,
|
|
2394
|
+
minify: false,
|
|
2395
|
+
elements_as_is: false,
|
|
2396
|
+
source_maps: false,
|
|
2397
|
+
reserved_elements: Vec::new(),
|
|
2398
|
+
skip_files: Vec::new(),
|
|
2399
|
+
node_modules_as_is: false,
|
|
2400
|
+
components_as_is: false,
|
|
2401
|
+
runtime_as_is: false,
|
|
2402
|
+
iterations_as_is: false,
|
|
2403
|
+
no_clean: false,
|
|
2404
|
+
fouc_as_is: false,
|
|
2405
|
+
working_dir: dir.clone(),
|
|
2406
|
+
};
|
|
2407
|
+
|
|
2408
|
+
let page_src = source.join("index.html");
|
|
2409
|
+
let page_out = output.join("index.html");
|
|
2410
|
+
let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
|
|
2411
|
+
(config, page_src, page_out, manifest)
|
|
2412
|
+
}
|
|
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
|
+
|
|
2465
|
+
// The watcher race: another writer truncates/rewrites a compiled page on
|
|
2466
|
+
// disk between our compile and our manifest pass. The manifest must be
|
|
2467
|
+
// built from the HTML this compiler just produced in memory — never from a
|
|
2468
|
+
// disk read-back — or a torn read yields a valid-but-empty manifest and the
|
|
2469
|
+
// page hydrates to a blank screen.
|
|
2470
|
+
#[test]
|
|
2471
|
+
fn manifest_survives_output_corruption_between_compile_and_manifests() {
|
|
2472
|
+
let (config, _page_src, page_out, manifest) =
|
|
2473
|
+
manifest_test_project("manifest_memory_full");
|
|
2474
|
+
|
|
2475
|
+
let mut compiler = Compiler::new(config, false);
|
|
2476
|
+
compiler.compile().expect("compile should succeed");
|
|
2477
|
+
assert!(
|
|
2478
|
+
fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
|
|
2479
|
+
"sanity: compiled page carries the binding"
|
|
2480
|
+
);
|
|
2481
|
+
|
|
2482
|
+
// Simulate the concurrent writer: the on-disk page is now a shell.
|
|
2483
|
+
fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
|
|
2484
|
+
|
|
2485
|
+
compiler.generate_manifests().expect("manifest generation should succeed");
|
|
2486
|
+
|
|
2487
|
+
let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
|
|
2488
|
+
assert!(
|
|
2489
|
+
manifest_js.contains("uniqueMarker123"),
|
|
2490
|
+
"manifest was built from the corrupted disk file instead of the in-memory compile output"
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// Same property on the incremental watch path (generate_manifests_for_files),
|
|
2495
|
+
// which is where the two-watcher race actually corrupted manifests.
|
|
2496
|
+
#[test]
|
|
2497
|
+
fn incremental_manifest_survives_output_corruption() {
|
|
2498
|
+
let (config, page_src, page_out, manifest) =
|
|
2499
|
+
manifest_test_project("manifest_memory_incremental");
|
|
2500
|
+
|
|
2501
|
+
let mut compiler = Compiler::new(config.clone(), false);
|
|
2502
|
+
let mut parser = HtmlParser::new(config.components_path());
|
|
2503
|
+
parser.load_elements().unwrap();
|
|
2504
|
+
|
|
2505
|
+
fs::create_dir_all(&config.output).unwrap();
|
|
2506
|
+
compiler
|
|
2507
|
+
.compile_specific_html_files(&[page_src.clone()], &parser)
|
|
2508
|
+
.expect("incremental compile should succeed");
|
|
2509
|
+
|
|
2510
|
+
fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
|
|
2511
|
+
|
|
2512
|
+
compiler
|
|
2513
|
+
.generate_manifests_for_files(&[page_src])
|
|
2514
|
+
.expect("incremental manifest generation should succeed");
|
|
2515
|
+
|
|
2516
|
+
let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
|
|
2517
|
+
assert!(
|
|
2518
|
+
manifest_js.contains("uniqueMarker123"),
|
|
2519
|
+
"incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2261
2522
|
}
|
|
@@ -206,6 +206,114 @@ fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<Stri
|
|
|
206
206
|
.map(|rel| format!("/{}", rel.replace('\\', "/")))
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/// Cross-process guard: exactly one `vibe compile --watch` per output
|
|
210
|
+
/// directory. Two concurrent watchers double-compile every save and race each
|
|
211
|
+
/// other's output writes — the loser reads a torn file back and emits a
|
|
212
|
+
/// valid-but-empty manifest, so the page hydrates blank with no errors. The
|
|
213
|
+
/// lock lives in the OS temp dir keyed by the output path, holds the owner's
|
|
214
|
+
/// pid, and a lock whose process is gone is stolen (a killed watcher never
|
|
215
|
+
/// unwinds, so Drop alone can't be trusted to clean up).
|
|
216
|
+
#[derive(Debug)]
|
|
217
|
+
pub struct WatchLock {
|
|
218
|
+
path: PathBuf,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
impl WatchLock {
|
|
222
|
+
/// Deterministic lock path for an output dir, stable whether or not the
|
|
223
|
+
/// output exists yet: canonicalize the output itself when possible, else
|
|
224
|
+
/// its (existing) parent — so a watcher that locked before the first
|
|
225
|
+
/// compile created the output still collides with one that locked after.
|
|
226
|
+
fn lock_path_for(output: &Path) -> PathBuf {
|
|
227
|
+
use std::collections::hash_map::DefaultHasher;
|
|
228
|
+
use std::hash::{Hash, Hasher};
|
|
229
|
+
|
|
230
|
+
let canonical = output.canonicalize().unwrap_or_else(|_| {
|
|
231
|
+
let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
|
232
|
+
let name = output.file_name().map(PathBuf::from).unwrap_or_default();
|
|
233
|
+
parent
|
|
234
|
+
.canonicalize()
|
|
235
|
+
.unwrap_or_else(|_| parent.to_path_buf())
|
|
236
|
+
.join(name)
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let mut hasher = DefaultHasher::new();
|
|
240
|
+
canonical.hash(&mut hasher);
|
|
241
|
+
std::env::temp_dir().join(format!("vibe-watch-{:016x}.lock", hasher.finish()))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub fn acquire(output: &Path) -> Result<Self, String> {
|
|
245
|
+
let path = Self::lock_path_for(output);
|
|
246
|
+
|
|
247
|
+
// Two attempts: the second runs only after a stale lock was removed.
|
|
248
|
+
for _ in 0..2 {
|
|
249
|
+
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
|
250
|
+
Ok(mut file) => {
|
|
251
|
+
use std::io::Write;
|
|
252
|
+
let _ = write!(file, "{}", std::process::id());
|
|
253
|
+
return Ok(Self { path });
|
|
254
|
+
}
|
|
255
|
+
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
256
|
+
let holder = std::fs::read_to_string(&path)
|
|
257
|
+
.ok()
|
|
258
|
+
.and_then(|s| s.trim().parse::<u32>().ok());
|
|
259
|
+
match holder {
|
|
260
|
+
Some(pid) if process_alive(pid) => {
|
|
261
|
+
return Err(format!(
|
|
262
|
+
"another `vibe compile --watch` (pid {}) is already watching this output directory. \
|
|
263
|
+
Concurrent watchers race each other's writes and corrupt manifests — stop the other one first. \
|
|
264
|
+
(lock: {})",
|
|
265
|
+
pid,
|
|
266
|
+
path.display()
|
|
267
|
+
));
|
|
268
|
+
}
|
|
269
|
+
// Dead owner or unreadable lock: stale, steal it.
|
|
270
|
+
_ => {
|
|
271
|
+
let _ = std::fs::remove_file(&path);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
Err(e) => {
|
|
276
|
+
return Err(format!(
|
|
277
|
+
"failed to create watch lock {}: {}",
|
|
278
|
+
path.display(),
|
|
279
|
+
e
|
|
280
|
+
));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
Err(format!(
|
|
286
|
+
"could not acquire watch lock {} — still held after stale-lock cleanup",
|
|
287
|
+
path.display()
|
|
288
|
+
))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
impl Drop for WatchLock {
|
|
293
|
+
fn drop(&mut self) {
|
|
294
|
+
let _ = std::fs::remove_file(&self.path);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[cfg(unix)]
|
|
299
|
+
fn process_alive(pid: u32) -> bool {
|
|
300
|
+
std::process::Command::new("kill")
|
|
301
|
+
.arg("-0")
|
|
302
|
+
.arg(pid.to_string())
|
|
303
|
+
.stdout(std::process::Stdio::null())
|
|
304
|
+
.stderr(std::process::Stdio::null())
|
|
305
|
+
.status()
|
|
306
|
+
.map(|s| s.success())
|
|
307
|
+
.unwrap_or(false)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Without a portable liveness probe, treat an existing lock as live — failing
|
|
311
|
+
/// loudly (with the lock path in the message) beats silently racing.
|
|
312
|
+
#[cfg(not(unix))]
|
|
313
|
+
fn process_alive(_pid: u32) -> bool {
|
|
314
|
+
true
|
|
315
|
+
}
|
|
316
|
+
|
|
209
317
|
/// Check if a path should be blacklisted based on SKIP_FILES patterns
|
|
210
318
|
/// This checks both the filename and all path components relative to source root
|
|
211
319
|
fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
|
|
@@ -297,8 +405,34 @@ fn scan_directory(
|
|
|
297
405
|
Ok(())
|
|
298
406
|
}
|
|
299
407
|
|
|
408
|
+
/// A watcher whose spawning wrapper dies without unwinding (SIGKILL, crashed
|
|
409
|
+
/// dev server) is orphaned: it keeps watching and its lock blocks every future
|
|
410
|
+
/// `--watch` on the same output. Reparenting is the orphan signal — when the
|
|
411
|
+
/// parent pid changes (to init or a reaper), the owner is gone, so release the
|
|
412
|
+
/// lock and exit. `std::process::exit` skips Drop, hence the explicit remove.
|
|
413
|
+
#[cfg(unix)]
|
|
414
|
+
fn exit_when_orphaned(lock_path: PathBuf) {
|
|
415
|
+
let parent = std::os::unix::process::parent_id();
|
|
416
|
+
std::thread::spawn(move || loop {
|
|
417
|
+
std::thread::sleep(Duration::from_secs(2));
|
|
418
|
+
if std::os::unix::process::parent_id() != parent {
|
|
419
|
+
eprintln!("parent process exited — shutting down watcher");
|
|
420
|
+
let _ = std::fs::remove_file(&lock_path);
|
|
421
|
+
std::process::exit(0);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[cfg(not(unix))]
|
|
427
|
+
fn exit_when_orphaned(_lock_path: PathBuf) {}
|
|
428
|
+
|
|
300
429
|
/// Start watching for file changes
|
|
301
430
|
pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
|
431
|
+
// Held for the watcher's whole lifetime; a second watcher on the same
|
|
432
|
+
// output exits loudly instead of silently racing this one.
|
|
433
|
+
let _watch_lock = WatchLock::acquire(&config.output)?;
|
|
434
|
+
exit_when_orphaned(_watch_lock.path.clone());
|
|
435
|
+
|
|
302
436
|
println!("{}", "Building dependency graph...".cyan());
|
|
303
437
|
let mut graph = build_dependency_graph(&config)?;
|
|
304
438
|
|
|
@@ -464,6 +598,11 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
464
598
|
// component plus the ancestors that inline it. Everything
|
|
465
599
|
// else stays cached and is reused.
|
|
466
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();
|
|
467
606
|
|
|
468
607
|
for path in &changed_paths {
|
|
469
608
|
// Skip files in output directory (avoid infinite loop)
|
|
@@ -486,6 +625,17 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
486
625
|
// Canonicalize to match how dependencies were stored (handles case sensitivity)
|
|
487
626
|
let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
|
|
488
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
|
+
|
|
489
639
|
// Refresh this component's own dependency edges so a
|
|
490
640
|
// newly-added <component src> (e.g. a child file created
|
|
491
641
|
// mid-session) is learned. Without this the new child maps
|
|
@@ -507,8 +657,6 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
507
657
|
|
|
508
658
|
let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
|
|
509
659
|
if !dependent_pages.is_empty() {
|
|
510
|
-
println!("{} {} changed", "[watch]".cyan(), relative_path.display());
|
|
511
|
-
|
|
512
660
|
// Invalidate only the edited component and the
|
|
513
661
|
// components whose cached inlined content embeds it
|
|
514
662
|
// (its ancestors). Every other component stays cached,
|
|
@@ -628,7 +776,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
628
776
|
}
|
|
629
777
|
}
|
|
630
778
|
|
|
631
|
-
if html_files.is_empty() && asset_files.is_empty() {
|
|
779
|
+
if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() {
|
|
632
780
|
continue;
|
|
633
781
|
}
|
|
634
782
|
|
|
@@ -717,6 +865,23 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
717
865
|
}
|
|
718
866
|
}
|
|
719
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
|
+
|
|
720
885
|
if !had_errors {
|
|
721
886
|
// Show what was updated
|
|
722
887
|
if total_stats.files_compiled > 0 {
|
|
@@ -912,6 +1077,57 @@ mod tests {
|
|
|
912
1077
|
);
|
|
913
1078
|
}
|
|
914
1079
|
|
|
1080
|
+
fn lock_test_output(name: &str) -> PathBuf {
|
|
1081
|
+
let out = std::env::temp_dir().join(format!("vibe_watch_lock_{}_out", name));
|
|
1082
|
+
let _ = std::fs::remove_dir_all(&out);
|
|
1083
|
+
std::fs::create_dir_all(&out).unwrap();
|
|
1084
|
+
// A previous crashed test run may have left a lock behind.
|
|
1085
|
+
let _ = std::fs::remove_file(WatchLock::lock_path_for(&out));
|
|
1086
|
+
out
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Two concurrent watchers on one output dir double-compile every save and
|
|
1090
|
+
// race each other's writes (torn manifest reads → blank pages). The second
|
|
1091
|
+
// watcher must refuse to start while the first holds the lock.
|
|
1092
|
+
#[test]
|
|
1093
|
+
fn second_watch_lock_on_same_output_fails_while_held() {
|
|
1094
|
+
let out = lock_test_output("same");
|
|
1095
|
+
|
|
1096
|
+
let first = WatchLock::acquire(&out).expect("first lock acquires");
|
|
1097
|
+
let second = WatchLock::acquire(&out);
|
|
1098
|
+
let msg = second.expect_err("second watcher on the same output must fail loudly");
|
|
1099
|
+
assert!(
|
|
1100
|
+
msg.contains("vibe compile --watch"),
|
|
1101
|
+
"error should explain the conflict: {msg}"
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
drop(first);
|
|
1105
|
+
WatchLock::acquire(&out).expect("released lock can be re-acquired");
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// A watcher killed without unwinding (Ctrl+C, SIGTERM from dev tooling)
|
|
1109
|
+
// leaves its lock file behind; the pid inside is dead, so the next watcher
|
|
1110
|
+
// steals the lock instead of being locked out forever.
|
|
1111
|
+
#[test]
|
|
1112
|
+
fn stale_lock_from_dead_process_is_stolen() {
|
|
1113
|
+
let out = lock_test_output("stale");
|
|
1114
|
+
|
|
1115
|
+
// No live process can have this pid (pid_max is 99998 on macOS,
|
|
1116
|
+
// ≤ 4194304 on Linux).
|
|
1117
|
+
std::fs::write(WatchLock::lock_path_for(&out), "4294967295").unwrap();
|
|
1118
|
+
|
|
1119
|
+
WatchLock::acquire(&out).expect("stale lock from a dead process must be stolen");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
#[test]
|
|
1123
|
+
fn locks_on_different_outputs_do_not_conflict() {
|
|
1124
|
+
let out_a = lock_test_output("indep_a");
|
|
1125
|
+
let out_b = lock_test_output("indep_b");
|
|
1126
|
+
|
|
1127
|
+
let _a = WatchLock::acquire(&out_a).expect("lock a");
|
|
1128
|
+
WatchLock::acquire(&out_b).expect("an unrelated output dir must not be blocked");
|
|
1129
|
+
}
|
|
1130
|
+
|
|
915
1131
|
#[test]
|
|
916
1132
|
fn cache_key_is_source_relative_with_leading_slash() {
|
|
917
1133
|
let source = PathBuf::from("/proj/src");
|
package/compiler/src/main.rs
CHANGED
|
@@ -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
|
|
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
package/runtime/component.js
CHANGED
|
@@ -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
|
|
477
|
-
//
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
}
|
package/runtime/hydrate.js
CHANGED
|
@@ -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>`)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
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
|
-
|
|
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(".");
|