Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

**New**

- Under `vite dev`, an edit inside a dependency's components re-renders them in place instead of reloading the page:
the module's top-level sources and every other component's state are kept. An edit to the module's top level or to
an entry still reloads. The runtime's new `@reactivescript/runtime/hot` entry does the swap; it needs a
`@solidjs/vite-plugin` with the front end's `hotUpdate` hook.

**Fixed**

- `affects` on a keyed or structured source that takes a guess threw `INVALID_AFFECTS_TARGET` in a dev build, so the
Expand Down
10 changes: 8 additions & 2 deletions compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ What happens to a `.rx` module in Vite:
The app mounts a program with `createApp` from `@reactivescript/runtime`
(`../runtime`, CP-4a), as the examples do.

Not yet: hot updates of `.rx` modules are untested; server rendering in Vite
needs a request-scoped app for server functions to run in.
Hot updates: with a Solid plugin that has the front end's `hotUpdate` hook,
an edit inside a dependency's components (markup, logic, `<style>`) swaps
them in place through `@reactivescript/runtime/hot`, keeping the module's
sources and every other component's state. An edit to a module's top level,
or to an entry, reloads the page.

Not yet: server rendering in Vite needs a request-scoped app for server
functions to run in.

### Releasing

Expand Down
4 changes: 2 additions & 2 deletions compiler/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub fn target(name: &str) -> Target {
}
}

/// `{entry, css, modules: {path: {code, map}}}`, or `{diagnostics: [...]}` / `{error}`. `entry` is an absolute path.
/// `{entry, css, modules: {path: {code, map, css}}}` (the program's stylesheet, and each module's own), or `{diagnostics: [...]}` / `{error}`. `entry` is an absolute path.
/// `spa`: a client-only build, checked under 08 CN-3d.
pub fn app(entry: &str, target: Target, module_name: &str, spa: bool) -> Value {
// module keys are POSIX paths (`modules::join_path`); Windows takes `D:/a/b.rx` as it takes `D:\a\b.rx`
Expand All @@ -49,7 +49,7 @@ pub fn app(entry: &str, target: Target, module_name: &str, spa: bool) -> Value {
let path = &out.paths[name];
let text = read_disk(path).unwrap_or_default();
let l = lower_view(module, path, target, module_name, Some((path, &text))).map_err(|e| json!({ "error": e }))?;
modules.insert(path.clone(), json!({ "code": l.code, "map": l.map }));
modules.insert(path.clone(), json!({ "code": l.code, "map": l.map, "css": out.module_css[name], "hot": out.module_hot.get(name).map(|(c, top)| json!({ "components": c, "top": top })) }));
}
Ok(json!({ "entry": set.entry, "css": out.css, "modules": modules }))
})()
Expand Down
55 changes: 54 additions & 1 deletion compiler/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ pub struct Compiled {
pub entry: String,
/// CSS-4: the modules' stylesheets, beside their JavaScript
pub css: String,
/// CSS-4: each module's own stylesheet, by its output name
pub module_css: IndexMap<String, String>,
/// a dependency's exported components and a fingerprint of the rest of its text, by output name: a dev server swaps the
/// components when the fingerprint holds
pub module_hot: IndexMap<String, (Vec<String>, String)>,
}

/// 09 MO-1: one ES module per `.rx` module. A dependency's module is a factory over the app (`__rt.module` runs it once per
Expand All @@ -197,8 +202,11 @@ pub fn compile_linked(set: &ModuleSet, extra: &[Vec<Stmt>], by_source: bool) ->
g.by_source = by_source;
let mut files = IndexMap::new();
let mut paths = IndexMap::new();
let mut module_css = IndexMap::new();
let mut module_hot = IndexMap::new();
for path in &set.order {
let p = &set.programs[path];
let css0 = g.css.len();
g.begin_module(path, &p.decls);
let mut deps: Vec<&str> = vec![];
for d in &p.decls {
Expand Down Expand Up @@ -230,12 +238,57 @@ pub fn compile_linked(set: &ModuleSet, extra: &[Vec<Stmt>], by_source: bool) ->
body.append(&mut g.top);
let mut fbody = vec![konst("__B", dot(id("__rt"), "B"))];
fbody.append(&mut inner);
if let Some(shape) = hot_shape(p, set.sources.get(path).map(String::as_str).unwrap_or("")) {
module_hot.insert(g.out(path), shape);
}
body.push(S::ExportDefault(Func { name: Some("module".into()), params: vec![P::Id("__rt".into())], directives: vec![], body: fbody, is_async: false, generator: false }));
}
module_css.insert(g.out(path), g.css[css0..].join("\n"));
files.insert(g.out(path), body);
paths.insert(g.out(path), path.clone());
}
Ok(Compiled { files, paths, entry: g.out(&set.entry), css: g.css.join("\n") })
Ok(Compiled { files, paths, entry: g.out(&set.entry), css: g.css.join("\n"), module_css, module_hot })
}

/// A dependency's exported components, and a hash of its text outside their bodies (each component's header stays in it);
/// `None` when that text names a JS import or calls an `.rx` one, which a replay would call again outside its record.
fn hot_shape(p: &Program, text: &str) -> Option<(Vec<String>, String)> {
use std::hash::{Hash, Hasher};
let names = |js: bool| -> Vec<String> {
p.decls.iter().filter_map(|d| match d { Decl::Import { resolved, names, .. } if resolved.is_none() == js => Some(names), _ => None }).flatten().map(|n| regex::escape(&n.alias)).collect()
};
let (js, rx) = (names(true), names(false));
let mut alts = vec![];
if !js.is_empty() {
alts.push(format!(r"\b(?:{})\b", js.join("|")));
}
if !rx.is_empty() {
alts.push(format!(r"\b(?:{})\s*\(", rx.join("|")));
}
let word = (!alts.is_empty()).then(|| regex::Regex::new(&alts.join("|")).ok()).flatten();
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut components = vec![];
for d in &p.decls {
let span = d.span();
let (start, end) = (span.start as usize, span.end as usize);
let own = text.get(start..end).unwrap_or("");
match d {
Decl::Component { name, props, props_type, exported, is_default, .. } => {
(name, props, props_type, exported, is_default).hash(&mut h);
if *exported {
components.push(name.clone());
}
}
Decl::Import { .. } => own.hash(&mut h),
_ => {
if word.as_ref().is_some_and(|w| w.is_match(own)) {
return None;
}
own.hash(&mut h);
}
}
}
Some((components, format!("{:016x}", h.finish())))
}

struct Gen<'p> {
Expand Down
110 changes: 108 additions & 2 deletions compiler/test/vite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ function project(name, entry) {
return dir;
}
const config = (dir, entry) => ({
root: dir, configFile: false, logLevel: "warn", clearScreen: false,
// a cache per project: the default, under the nearest package.json, would serve one run's runtime to the next
root: dir, cacheDir: path.join(dir, ".vite"), configFile: false, logLevel: "warn", clearScreen: false,
plugins: [solid()],
server: { fs: { allow: [repo, path.dirname(deps), path.resolve(repo, "../solid-next"), pluginDir] } },
build: { outDir: "dist", sourcemap: true, emptyOutDir: true },
Expand Down Expand Up @@ -118,9 +119,114 @@ if (!filter || "shared".includes(filter)) { // two programs on one page, shari
if (!filter || "todo".includes(filter)) { // the dev server: modules transformed on request
const dir = project("todo-dev", "todo.rx");
const server = await vite.createServer(config(dir, "todo.rx")); await server.listen(0);
await drive("todo (dev)", `http://127.0.0.1:${server.httpServer.address().port}/`, todo);
await drive("todo (dev)", server.resolvedUrls.local[0], todo);
await server.close();
}
if (!filter || "styles".includes(filter)) { // CSS-4 under the dev server: a dependency's <style> edit reaches the page
const dir = project("styles-dev", "styled/main.rx");
mkdirSync(path.join(dir, "src/styled"));
writeFileSync(path.join(dir, "src/styled/main.rx"), `import { Badge } from "./badge.rx"\n\nexport default component Main() {\n <main><Badge /></main>\n}\n`);
const badge = path.join(dir, "src/styled/badge.rx");
const style = color => `export component Badge() {\n <style>\n .badge { color: ${color} }\n </style>\n <p class="badge">badge</p>\n}\n`;
writeFileSync(badge, style("rgb(0, 0, 255)"));
const server = await vite.createServer(config(dir, "styled/main.rx")); await server.listen(0);
const url = server.resolvedUrls.local[0];
const color = page => page.$eval(".badge", e => getComputedStyle(e).color);
const until = async (page, want, what) => {
for (let i = 0; i < 40 && await color(page).catch(() => "") !== want; i++) await page.waitForTimeout(100);
const got = await color(page).catch(e => e.message.split("\n")[0]); if (got !== want) throw new Error(`${what}: color ${got}, not ${want}`);
};
await drive("styles (dev)", url, async page => {
await until(page, "rgb(0, 0, 255)", "first load");
writeFileSync(badge, style("rgb(255, 0, 0)"));
await until(page, "rgb(255, 0, 0)", "after the edit");
await page.reload(); await until(page, "rgb(255, 0, 0)", "after a reload");
const fresh = await browser.newPage();
try { await fresh.goto(url); await until(fresh, "rgb(255, 0, 0)", "in a new page"); } finally { await fresh.close(); }
});
await server.close();
}
if (!filter || "hot".includes(filter)) { // a dependency's component edit swaps in place; a top-level or entry edit reloads
const dir = project("hot-dev", "hot/main.rx");
mkdirSync(path.join(dir, "src/hot"));
const main = path.join(dir, "src/hot/main.rx"), panel = path.join(dir, "src/hot/panel.rx");
writeFileSync(main, `import { Panel } from "./panel.rx"\nimport { Other } from "./other.rx"\nimport { Tick } from "./tick.rx"\n\nexport default component Main() {\n <main><Panel /><Other /><Tick /></main>\n}\n`);
writeFileSync(path.join(dir, "src/hot/other.rx"), `export component Other() {\n source n := 0\n <button class="other" onClick={() => { n := n + 1 }}>other {n}</button>\n}\n`);
// a top level that calls into JS: a replay would call it again, so its module always reloads
const tick = path.join(dir, "src/hot/tick.rx");
writeFileSync(path.join(dir, "src/hot/clock.mjs"), `export const now = () => "one";\n`);
writeFileSync(tick, `import pure { now } from "./clock"\n\nfirst = now()\n\nexport component Tick() {\n <p class="tick">{first}</p>\n}\n`);
const lib = path.join(dir, "src/hot/lib.mjs");
writeFileSync(lib, `export const word = () => "one";\n`);
const source = ({ top = "", label = "total", color = "rgb(0, 0, 255)", step = 1 } = {}) =>
`import pure { word } from "./lib"\n\n${top}source total := 0\n\nexport component Panel() {\n said = word()\n <style>\n .panel { color: ${color} }\n </style>\n <button class="panel" onClick={() => { total := total + ${step} }}>${label} {total}</button>\n <p class="said">{said}</p>\n}\n`;
writeFileSync(panel, source());
const server = await vite.createServer(config(dir, "hot/main.rx")); await server.listen(0);
await drive("hot (dev)", server.resolvedUrls.local[0], async page => {
const seen = async () => ({
panel: (await page.textContent(".panel").catch(() => "")).trim(), other: (await page.textContent(".other").catch(() => "")).trim(),
color: await page.$eval(".panel", e => getComputedStyle(e).color).catch(() => ""), kept: await page.evaluate(() => window.__kept === 1).catch(() => false),
said: (await page.textContent(".said").catch(() => "")).trim(),
});
const until = async (what, want) => {
let got;
for (let i = 0; i < 50; i++) { got = await seen(); if (Object.entries(want).every(([k, v]) => got[k] === v)) return; await page.waitForTimeout(100); }
throw new Error(`${what}: ${JSON.stringify(got)}, not ${JSON.stringify(want)}`);
};
for (let i = 0; i < 3; i++) await page.click(".panel");
for (let i = 0; i < 2; i++) await page.click(".other");
await page.evaluate(() => { window.__kept = 1; });
await until("before the edits", { panel: "total 3", other: "other 2" });
// the module-level source and the other module's component state survive; the page is not reloaded
writeFileSync(panel, source({ label: "sum" }));
await until("after a markup edit", { panel: "sum 3", other: "other 2", kept: true });
writeFileSync(panel, source({ label: "sum", color: "rgb(255, 0, 0)" }));
await until("after a <style> edit", { color: "rgb(255, 0, 0)", panel: "sum 3", other: "other 2", kept: true });
writeFileSync(panel, source({ label: "plus", color: "rgb(255, 0, 0)", step: 10 }));
await until("after a logic edit", { panel: "plus 3", kept: true });
await page.click(".panel"); await until("the new handler", { panel: "plus 13", other: "other 2", kept: true });
// a JS module the dependency imports changed: what its top level read from it may be stale, so the page reloads
writeFileSync(lib, `export const word = () => "two";\n`);
await until("after an import's edit", { said: "two", panel: "plus 0", kept: false });
await page.evaluate(() => { window.__kept = 1; }); await page.click(".other");
writeFileSync(tick, readFileSync(tick, "utf8").replace('class="tick"', 'class="tick edited"'));
await until("after an edit to a module whose top level calls JS", { other: "other 0", kept: false });
await page.evaluate(() => { window.__kept = 1; });
// the module's top level changed: a reload, with the module's new state
writeFileSync(panel, source({ top: "base = 1\n", label: "plus", color: "rgb(255, 0, 0)" }));
await until("after a top-level edit", { panel: "plus 0", other: "other 0", kept: false });
await page.evaluate(() => { window.__kept = 1; }); await page.click(".other");
writeFileSync(main, readFileSync(main, "utf8").replace("<main>", `<main class="edited">`));
await until("after an entry edit", { other: "other 0", kept: false });
if (!await page.$("main.edited")) throw new Error("the entry's edit did not reach the page");
// a dependency that stops compiling, then compiles again, swaps as before
writeFileSync(panel, source({ label: "plus" }) + "\nexport bad = missingName\n"); await page.waitForTimeout(800);
writeFileSync(panel, source({ label: "back" }));
await until("after the fix", { panel: "back 0" });
await page.evaluate(() => { window.__kept = 1; }); await page.click(".panel");
writeFileSync(panel, source({ label: "again" }));
await until("after an edit that follows a failed compile", { panel: "again 1", kept: true });
});
await server.close();
}
if (!filter || "cold".includes(filter)) { // a dependency asked for before its entry, as Vitest's `fsModuleCache` leaves it
const { default: rx } = await import(pathToFileURL(path.join(pkg, "vite.mjs")).href);
const root = project("cold", "shop/main.rx"), catalog = path.join(root, "src/shop/catalog.rx");
const ctx = (id, moduleId) => ({ id, moduleId, root, ssr: false, solid: { generate: "dom", hydratable: false }, addWatchFile() {} });
try {
// a commented-out import is not an edge: read as one, it would make main.rx look imported and leave no entry
writeFileSync(catalog, readFileSync(catalog, "utf8") + `\n// import { x } from "./main.rx"\n`);
const warm = rx(); warm.compile("", ctx(path.join(root, "src/shop/main.rx"), path.join(root, "src/shop/main.rx")));
const want = warm.compile("", ctx(catalog, `${catalog}?rx-dep`)).code;
const got = rx().compile("", ctx(catalog, `${catalog}?rx-dep`)).code;
if (got !== want) throw new Error("the dependency compiled cold differs from the one compiled after its entry");
writeFileSync(catalog, readFileSync(catalog, "utf8") + "\nexport bad = missingName\n");
let report = "";
try { rx().compile("", ctx(catalog, `${catalog}?rx-dep`)); } catch (e) { report = e.message; }
if (!/catalog\.rx:\d+(:\d+)?: error /.test(report)) throw new Error(`a cold dependency that does not compile reports ${JSON.stringify(report.slice(0, 200))}`);
pass++; console.log("✓ cold dependency: compiled from the program that holds it, or that program's diagnostics");
} catch (e) { fail++; console.log(`✗ cold dependency: ${e.message.split("\n")[0]}`); }
}
await browser.close();
console.log(`\n${pass} pass, ${fail} fail (vite ${vite.version}, @solidjs/vite-plugin ${JSON.parse(readFileSync(path.join(pluginDir, "package.json"), "utf8")).version})`);
process.exit(fail ? 1 : 0);
Loading
Loading