From 915535cd59456581115b89634654d749bafda840 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Fri, 18 Sep 2026 11:01:45 +0200 Subject: [PATCH 1/2] Asks a package whether its scripts survive a DOM morph before patching a page A morph does not re-execute the page's scripts, and refetched page bytes are the pre-hydration build output, so a package whose script does work at boot had that work reverted with nothing re-running to redo it. The widget was left drawn as though it had never started, and nothing threw. A package now declares `js_rehydrate = true` in its `[tool.rheo.]` block and pushes a callback onto `window.__rheoRehydrate`. Declaring scripts render with `data-rheo-rehydrate`; the live client surveys every script on the page before morphing and reloads instead if any one of them is undeclared, then runs the callbacks after a morph it did perform. --- changelog.md | 30 ++++++++++++ crates/core/src/assets/mod.rs | 50 ++++++++++++++++--- crates/core/src/html_dom.rs | 73 ++++++++++++++++++++++++++-- crates/core/src/packages/manifest.rs | 13 ++++- crates/core/src/plugins/mod.rs | 6 +++ crates/html/src/lib.rs | 2 + crates/html/src/live/live-reload.js | 46 ++++++++++++++++++ crates/html/src/server.rs | 7 ++- docs/contract.md | 62 +++++++++++++++++++++++ 9 files changed, 276 insertions(+), 13 deletions(-) diff --git a/changelog.md b/changelog.md index ff002537..2eb112f5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,35 @@ # 0.6.4 — user-visible changes +## `rheo watch` patches the page on a content edit, and a package must opt in + +A rebuild that touched only `.typ` sources no longer reloads the page. The +dev-server client refetches it and morphs the new HTML into the live DOM +instead, so scroll position, focus, text selection, open `
` and +playing media survive an edit. A rebuild that touched assets still reloads. + +**A package shipping JavaScript has to declare that it can cope, or its page +falls back to a reload.** A morph does not re-execute the page's scripts, and +refetched page bytes are the pre-hydration build output — so for a package +whose script does work at boot (pressing the buttons the URL names, hiding +filtered rows, setting the attribute its stylesheet keys off), a morph would +revert all of it and re-run nothing, leaving the widget drawn as though it had +never started. Nothing would throw. + +So rheo asks. A package sets `js_rehydrate = true` in its +`[tool.rheo.]` block and pushes a callback onto +`window.__rheoRehydrate`; rheo renders its scripts with `data-rheo-rehydrate` +and calls the callbacks after each morph. Before morphing, the client checks +every script on the page and reloads instead if any one of them is +undeclared — a page is a single DOM, and patching it for the widgets that cope +would break the ones that do not. + +The upshot for an existing project is that nothing changes yet: a page +carrying package JavaScript keeps reloading exactly as it did, and pages with +no scripts get the morph immediately. `docs/contract.md` has the protocol, +including what idempotence on a morphed DOM actually requires of a hook — +Idiomorph mutates elements in place, so listeners survive and a naive re-wire +double-fires. + ## A configured releases namespace caches by its host, not in the shared Typst cache A namespace configured with `releases = ...` now caches its downloaded diff --git a/crates/core/src/assets/mod.rs b/crates/core/src/assets/mod.rs index 0eeb1935..5f073cca 100644 --- a/crates/core/src/assets/mod.rs +++ b/crates/core/src/assets/mod.rs @@ -26,7 +26,11 @@ enum AssetSource<'b> { User, /// Contributed by an `[packages.]` block, resolved against the /// package's own directory. - Package { source_root: &'b Path, module: bool }, + Package { + source_root: &'b Path, + module: bool, + rehydrate: bool, + }, /// The project-root filename convention, pushed by [`gather_entries`] /// only when the project declared no `User` entry of its own. ProjectDefault, @@ -44,6 +48,16 @@ impl<'b> AssetSource<'b> { matches!(self, AssetSource::Package { module: true, .. }) } + fn rehydrate(&self) -> bool { + matches!( + self, + AssetSource::Package { + rehydrate: true, + .. + } + ) + } + /// Only a path the project wrote down in a `[[.assets]]` block /// warns when it's missing. A package file and the root convention are /// paths rheo proposed itself, so their absence is routine rather than @@ -53,6 +67,13 @@ impl<'b> AssetSource<'b> { } } +/// An on-disk source's module/rehydrate flags, collected in [`AssetResolver::copy_group`] +/// alongside its path and zipped back onto the copied output there. +struct ScriptFlags { + module: bool, + rehydrate: bool, +} + /// One candidate source for a declared asset, gathered from user overrides, /// package blocks, or the project-root convention. struct AssetEntry<'b> { @@ -103,6 +124,7 @@ fn gather_entries<'b>( source: AssetSource::Package { source_root: &pkg.source_root, module: pkg.js_module, + rehydrate: pkg.js_rehydrate, }, })); } @@ -246,12 +268,19 @@ impl<'a> AssetResolver<'a> { }; let mut sources: Vec = Vec::new(); - let mut modules: Vec = Vec::new(); + // `module` and `rehydrate` travel together as one struct rather than a + // second parallel `Vec` (or an unlabelled `Vec<(bool, bool)>`), so + // the zip below reads as `flags.module` / `flags.rehydrate` instead of a + // positional `.0`/`.1`. + let mut flags: Vec = Vec::new(); for entry in &group.entries { let abs = group.root.join(entry.path); if abs.is_file() { sources.push(abs); - modules.push(entry.source.module()); + flags.push(ScriptFlags { + module: entry.source.module(), + rehydrate: entry.source.rehydrate(), + }); } else if entry.source.warns_on_missing() { warn!( plugin = plugin.name(), @@ -269,8 +298,8 @@ impl<'a> AssetResolver<'a> { outputs .into_iter() .zip(sources.iter()) - .zip(modules.iter()) - .map(|((abs, src), module)| { + .zip(flags.iter()) + .map(|((abs, src), flags)| { let rel = abs .strip_prefix(self.plugin_output_dir) .expect("copy_each output is always under plugin_output_dir") @@ -287,7 +316,8 @@ impl<'a> AssetResolver<'a> { seen_relative_paths.insert(rel.clone(), src.clone()); Ok(Asset { config: asset_config.clone(), - module: *module, + module: flags.module, + rehydrate: flags.rehydrate, source_path: src.clone(), resolved_path: abs, built_relative_path: rel, @@ -333,6 +363,7 @@ impl<'a> AssetResolver<'a> { Ok(Some(Asset { config: asset_config.clone(), module: false, + rehydrate: false, source_path: dest.clone(), resolved_path: dest, built_relative_path: rel, @@ -684,6 +715,7 @@ mod tests { !AssetSource::Package { source_root: Path::new("/tmp"), module: false, + rehydrate: false, } .warns_on_missing() ); @@ -1149,6 +1181,7 @@ mod tests { extra, }, js_module: false, + js_rehydrate: false, source_root: pkg_dir.clone(), }]; @@ -1193,6 +1226,7 @@ mod tests { extra, }, js_module: false, + js_rehydrate: false, source_root: pkg_dir, }]; @@ -1246,6 +1280,7 @@ mod tests { extra: pkg_extra, }, js_module: false, + js_rehydrate: false, source_root: pkg_dir, }]; @@ -1307,6 +1342,7 @@ mod tests { extra: pkg_extra, }, js_module: false, + js_rehydrate: false, source_root: pkg_dir, }]; @@ -1370,6 +1406,7 @@ mod tests { extra: pkg_extra, }, js_module: false, + js_rehydrate: false, source_root: pkg_dir, }]; @@ -1439,6 +1476,7 @@ mod tests { extra: pkg_extra, }, js_module: false, + js_rehydrate: false, source_root: pkg_dir, }]; diff --git a/crates/core/src/html_dom.rs b/crates/core/src/html_dom.rs index ca86f0df..310c0e80 100644 --- a/crates/core/src/html_dom.rs +++ b/crates/core/src/html_dom.rs @@ -396,13 +396,21 @@ impl Element { /// /// A module gets `type="module"` and NO `defer`: modules are deferred by /// default and `defer` is ignored on them, so emitting both would only - /// mislead a reader of the output. + /// mislead a reader of the output. A script whose package declared + /// `js_rehydrate = true` ALSO carries a bare `data-rheo-rehydrate` + /// attribute, regardless of which of those two forms it takes — the flag + /// is orthogonal to module vs. classic loading. pub fn create_script(script: &ScriptRef) -> Self { + let mut attrs: Vec<(&str, &str)> = vec![("src", &script.src)]; if script.module { - Self::create_element("script", &[("src", &script.src), ("type", "module")]) + attrs.push(("type", "module")); } else { - Self::create_element("script", &[("src", &script.src), ("defer", "")]) + attrs.push(("defer", "")); } + if script.rehydrate { + attrs.push(("data-rheo-rehydrate", "")); + } + Self::create_element("script", &attrs) } /// Prepend a child element to this element. @@ -727,9 +735,12 @@ pub fn depth_relative_refs(paths: &[String], output_rel_path: &str) -> Vec` tag. + pub rehydrate: bool, } -/// [`depth_relative_refs`] for scripts, carrying each one's module flag through. +/// [`depth_relative_refs`] for scripts, carrying each one's module and +/// rehydrate flags through. pub fn depth_relative_scripts(scripts: &[ScriptRef], output_rel_path: &str) -> Vec { let prefix = depth_prefix(output_rel_path); scripts @@ -737,6 +748,7 @@ pub fn depth_relative_scripts(scripts: &[ScriptRef], output_rel_path: &str) -> V .map(|s| ScriptRef { src: format!("{prefix}{}", s.src), module: s.module, + rehydrate: s.rehydrate, }) .collect() } @@ -970,6 +982,7 @@ mod tests { ScriptRef { src: src.to_string(), module: false, + rehydrate: false, } } @@ -985,6 +998,7 @@ mod tests { &[ScriptRef { src: "src/lib.js".to_string(), module: true, + rehydrate: false, }], ) .unwrap(); @@ -998,12 +1012,48 @@ mod tests { ); } + /// A script flagged `js_rehydrate = true` carries a bare + /// `data-rheo-rehydrate` attribute — orthogonal to, and independent of, + /// whichever of the two forms above it also takes. + #[test] + fn test_inject_head_links_rehydrate_script() { + let html = "Test"; + let mut dom = HtmlDom::parse(html).unwrap(); + dom.inject_head_links( + &[], + &[], + &[ScriptRef { + src: "src/lib.js".to_string(), + module: false, + rehydrate: true, + }], + ) + .unwrap(); + let result = dom.serialize().unwrap(); + + assert!(result.contains(r#"src="src/lib.js""#)); + assert!(result.contains("data-rheo-rehydrate")); + } + + /// Without the flag, no `data-rheo-rehydrate` attribute is emitted at all. + #[test] + fn test_inject_head_links_without_rehydrate_omits_the_attribute() { + let html = "Test"; + let mut dom = HtmlDom::parse(html).unwrap(); + dom.inject_head_links(&[], &[], &[classic("index.js")]) + .unwrap(); + let result = dom.serialize().unwrap(); + + assert!(!result.contains("data-rheo-rehydrate")); + } + #[test] fn test_depth_relative_scripts_keeps_the_module_flag() { let scripts = vec![ ScriptRef { src: "a.js".to_string(), module: true, + rehydrate: false, }, classic("b.js"), ]; @@ -1014,6 +1064,21 @@ mod tests { assert!(!out[1].module); } + #[test] + fn test_depth_relative_scripts_keeps_the_rehydrate_flag() { + let scripts = vec![ + ScriptRef { + src: "a.js".to_string(), + module: false, + rehydrate: true, + }, + classic("b.js"), + ]; + let out = depth_relative_scripts(&scripts, "deep/page.html"); + assert!(out[0].rehydrate); + assert!(!out[1].rehydrate); + } + #[test] fn test_inject_head_links_scripts_with_stylesheets() { let html = "Test"; diff --git a/crates/core/src/packages/manifest.rs b/crates/core/src/packages/manifest.rs index 1288627b..aea0f39a 100644 --- a/crates/core/src/packages/manifest.rs +++ b/crates/core/src/packages/manifest.rs @@ -182,6 +182,7 @@ impl PackageManifest { merged.assets.copy = source.assets.copy.clone(); } merged.js_module = source.js_module; + merged.js_rehydrate = source.js_rehydrate; Some(merged) } @@ -242,6 +243,10 @@ impl PackageManifest { .get("js_module") .and_then(|v| v.as_bool()) .unwrap_or(false); + let js_rehydrate = section + .get("js_rehydrate") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let copy = section .get("copy") .and_then(|v| v.as_array()) @@ -266,6 +271,7 @@ impl PackageManifest { }, source_root: self.pkg.source_root.clone(), js_module, + js_rehydrate, }) } @@ -819,7 +825,7 @@ css_stylesheet = "style.css" pkg_dir.join("typst.toml"), "[tool.rheo]\nmin_version = \"0.5.0\"\n\n[tool.rheo.html]\ncss_stylesheet = \"a.css\"\n\ js_scripts = \"dist/lib.js\"\n\n[tool.rheo.source.html]\n\ - js_scripts = [\"src/a.js\", \"src/b.js\"]\njs_module = true\n", + js_scripts = [\"src/a.js\", \"src/b.js\"]\njs_module = true\njs_rehydrate = true\n", ) .unwrap(); let pkg = make_resolved(&pkg_dir, "ns", "pkg", "1.0"); @@ -834,14 +840,17 @@ css_stylesheet = "style.css" // Release mode keeps the bundle and its classic tag. let release = manifest.assets_for("html", false).unwrap(); assert!(!release.js_module); + assert!(!release.js_rehydrate); assert_eq!( release.assets.extra.get("js_scripts").unwrap().as_str(), Some("dist/lib.js"), ); - // Source mode takes the unbundled list and asks for modules. + // Source mode takes the unbundled list, asks for modules, and flags + // them for client-side rehydration. let source = manifest.assets_for("html", true).unwrap(); assert!(source.js_module); + assert!(source.js_rehydrate); assert_eq!( source .assets diff --git a/crates/core/src/plugins/mod.rs b/crates/core/src/plugins/mod.rs index 7dec6ef3..0060fb56 100644 --- a/crates/core/src/plugins/mod.rs +++ b/crates/core/src/plugins/mod.rs @@ -77,6 +77,9 @@ pub struct Asset { /// Whether this script must be loaded as an ES module. Only ever true for a /// package's source-mode block, whose unbundled files use `import`. pub module: bool, + /// Whether this script carries `data-rheo-rehydrate` on its emitted `"#; + // + // `data-rheo-live` MARKS THIS TAG AS RHEO'S OWN, because the client has to + // survey the page's other scripts to decide whether a morph is safe (see + // `live/live-reload.js`) and must not count itself among them. Matching on + // the `src` instead would tie that decision to this route's spelling. + const SCRIPT: &str = r#""#; // Try to inject before , fall back to end of document let modified = if let Some(pos) = html_str.rfind("") { diff --git a/docs/contract.md b/docs/contract.md index 280190e7..cce9139a 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -196,6 +196,7 @@ never break the build): | --- | --- | --- | --- | | `[tool.rheo.]` | `css_stylesheet` | `str` (path, relative to the package's own root) | Consulted only by plugins that declare an `AssetConfig` under that name — today only `html` (`crates/html/src/lib.rs:48`). | | `[tool.rheo.]` | `js_scripts` | `str` (path) | Same as above (`crates/html/src/lib.rs:49`). | +| `[tool.rheo.]` | `js_rehydrate` | `bool`, default `false` | Declares that every script this block ships can rebuild its own state on demand, and registers itself to be asked. Renders the script tag with `data-rheo-rehydrate`, which is what admits the page to the dev server's morph path — see **Dev-server rehydrate protocol** below. Absent or `false` is the safe reading, not a defect. | | `[tool.rheo.]` | `copy` | `array` of glob strings | Copied into that format's output dir for every format `manifest_blocks_for` is called with (html/pdf/epub alike), independent of whether that format defines named asset keys. | | `[tool.rheo]` | `min_version` | `str`, semver | A floor: if this build's own version is below it, `check_package_min_versions` fails the build, naming every offending import in one error (`crates/core/src/plugins/typst_manifest.rs:199-219`). Runs for every scanned `@`-import **unconditionally** — unlike asset/marrow auto-detection, it is *not* gated by the package auto-detect opt-out (`crates/core/src/build.rs:920-927`, called from both the full-build and dev-server-preview paths). Absent or unparseable → no floor, not an error. | @@ -268,6 +269,67 @@ prefix is a hard build error naming the offending file and label canonical-label collision rule above (which silently skips injection instead of erroring). +## Dev-server rehydrate protocol (`window.__rheoRehydrate`) + +`rheo watch` patches the live DOM on a content edit rather than navigating: +a rebuild that touched only `.typ` sources broadcasts `morph`, and the client +(`crates/html/src/live/live-reload.js`) refetches the page and morphs it in +with Idiomorph, preserving scroll, focus, selection, open `
` and +media playback. A rebuild that touched assets still broadcasts `reload` +(`ReloadKind::for_change`, `crates/core/src/plugins/mod.rs`). + +**A morph does not re-execute the page's scripts.** Idiomorph reuses a +byte-identical `