Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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 `<details>` 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.<format>]` 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
Expand Down
50 changes: 44 additions & 6 deletions crates/core/src/assets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ enum AssetSource<'b> {
User,
/// Contributed by an `[packages.<ns>]` 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,
Expand All @@ -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 `[[<plugin>.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
Expand All @@ -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> {
Expand Down Expand Up @@ -103,6 +124,7 @@ fn gather_entries<'b>(
source: AssetSource::Package {
source_root: &pkg.source_root,
module: pkg.js_module,
rehydrate: pkg.js_rehydrate,
},
}));
}
Expand Down Expand Up @@ -246,12 +268,19 @@ impl<'a> AssetResolver<'a> {
};

let mut sources: Vec<PathBuf> = Vec::new();
let mut modules: Vec<bool> = Vec::new();
// `module` and `rehydrate` travel together as one struct rather than a
// second parallel `Vec<bool>` (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<ScriptFlags> = 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(),
Expand All @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -684,6 +715,7 @@ mod tests {
!AssetSource::Package {
source_root: Path::new("/tmp"),
module: false,
rehydrate: false,
}
.warns_on_missing()
);
Expand Down Expand Up @@ -1149,6 +1181,7 @@ mod tests {
extra,
},
js_module: false,
js_rehydrate: false,
source_root: pkg_dir.clone(),
}];

Expand Down Expand Up @@ -1193,6 +1226,7 @@ mod tests {
extra,
},
js_module: false,
js_rehydrate: false,
source_root: pkg_dir,
}];

Expand Down Expand Up @@ -1246,6 +1280,7 @@ mod tests {
extra: pkg_extra,
},
js_module: false,
js_rehydrate: false,
source_root: pkg_dir,
}];

Expand Down Expand Up @@ -1307,6 +1342,7 @@ mod tests {
extra: pkg_extra,
},
js_module: false,
js_rehydrate: false,
source_root: pkg_dir,
}];

Expand Down Expand Up @@ -1370,6 +1406,7 @@ mod tests {
extra: pkg_extra,
},
js_module: false,
js_rehydrate: false,
source_root: pkg_dir,
}];

Expand Down Expand Up @@ -1439,6 +1476,7 @@ mod tests {
extra: pkg_extra,
},
js_module: false,
js_rehydrate: false,
source_root: pkg_dir,
}];

Expand Down
14 changes: 9 additions & 5 deletions crates/core/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ impl Build {
let default_section = PluginSection::default();
let resolver = self.package_resolver();
let packages = PackageIndex::resolved(
&crate::packages::scan_project_package_imports(&self.project.typ_files),
&crate::packages::scan_transitive_package_imports(&self.project.typ_files, &resolver),
&resolver,
);

Expand Down Expand Up @@ -922,9 +922,11 @@ impl Build {
.get(serving_plugin.name())
.unwrap_or(&default_section);
let (packages, package_resolver) = self.timed(phase::PACKAGES, None, || {
let package_imports =
crate::packages::scan_project_package_imports(&self.project.typ_files);
let resolver = self.package_resolver();
let package_imports = crate::packages::scan_transitive_package_imports(
&self.project.typ_files,
&resolver,
);
let packages = prewarm_and_resolve(
&package_imports,
plugin_section.auto_detect_packages.get(),
Expand Down Expand Up @@ -1161,9 +1163,11 @@ impl Build {
// package (a directory probe plus a `typst.toml` parse) once — both
// shared across every plugin in this build.
let packages = self.timed(phase::PACKAGES, None, || {
let package_imports =
crate::packages::scan_project_package_imports(&self.project.typ_files);
let resolver = self.package_resolver();
let package_imports = crate::packages::scan_transitive_package_imports(
&self.project.typ_files,
&resolver,
);
let packages =
prewarm_and_resolve(&package_imports, self.auto_detects_packages(), &resolver)?;
Ok::<_, RheoError>((packages, resolver))
Expand Down
73 changes: 69 additions & 4 deletions crates/core/src/html_dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -727,16 +735,20 @@ pub fn depth_relative_refs(paths: &[String], output_rel_path: &str) -> Vec<Strin
pub struct ScriptRef {
pub src: String,
pub module: bool,
/// Whether to stamp `data-rheo-rehydrate` on the emitted `<script>` 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<ScriptRef> {
let prefix = depth_prefix(output_rel_path);
scripts
.iter()
.map(|s| ScriptRef {
src: format!("{prefix}{}", s.src),
module: s.module,
rehydrate: s.rehydrate,
})
.collect()
}
Expand Down Expand Up @@ -970,6 +982,7 @@ mod tests {
ScriptRef {
src: src.to_string(),
module: false,
rehydrate: false,
}
}

Expand All @@ -985,6 +998,7 @@ mod tests {
&[ScriptRef {
src: "src/lib.js".to_string(),
module: true,
rehydrate: false,
}],
)
.unwrap();
Expand All @@ -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 = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
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 = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
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"),
];
Expand All @@ -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 = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
Expand Down
Loading
Loading