From f9f9fe4ea994f226d9ce34446e319b9bbef736bd Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 13 Nov 2026 21:13:35 -0600 Subject: [PATCH 1/2] Give each .rx module its own stylesheet; find a cold dependency's program Under vite dev, a \n

badge

\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 || "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); diff --git a/compiler/vite.mjs b/compiler/vite.mjs index d65161b4..ed6f0275 100644 --- a/compiler/vite.mjs +++ b/compiler/vite.mjs @@ -3,8 +3,8 @@ // names it explicitly. // // The crate (its Node-API build, `src/node.rs`: what `rxc app` does) already hands each module to Solid's compiler -// (spec/11 CP-1), so a `.rx` module comes back as Solid's output for the environment's `generate`, with its source map onto the `.rx` and, on a -// program's entry, the program's stylesheet. Solid's plugin then takes it the way it takes lowered `.tsrx`: primitive +// (spec/11 CP-1), so a `.rx` module comes back as Solid's output for the environment's `generate`, with its source map onto the `.rx` and +// its own stylesheet. Solid's plugin then takes it the way it takes lowered `.tsrx`: primitive // names, `lazy()`, the HMR refresh pass, server functions (12 SF-7a), the stylesheet as a CSS module. // // Entries find themselves. JavaScript imports only a program's entry (its default export mounts the app); an `.rx` @@ -13,7 +13,7 @@ // changes. The crate's output imports its dependencies as `./x.rx?rx-dep`, and a dependency is served from the build // of a program that contains it. A module several programs share is one module: its instance is keyed by its factory // (11 CP-4b `module`), not by its place in a program. -import { existsSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -35,6 +35,29 @@ const isDep = moduleId => new URLSearchParams(moduleId.split("?")[1] ?? "").has( // `import __m0 from "./catalog.rx";` → `"./catalog.rx?rx-dep"` (one line each: the module's map is by line, CP-1c) const tagDeps = code => code.replace(/^(import \w+ from ")(\.{1,2}\/[^"?]*\.rx)(";?)$/gm, `$1$2?${DEP}$3`); +const RX_IMPORT = /^import\b[^"']*?\bfrom\s+(["'])(\.{1,2}\/[^"'?]*\.rx)\1/gm; +/** The `.rx` files under `root` from which `file` is reached by imports: first those no other `.rx` file imports. */ +function entriesOf(file, root) { + const importers = new Map(); + const walk = dir => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === "node_modules" || e.name.startsWith(".")) continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".rx")) for (const [, , spec] of readFileSync(p, "utf8").matchAll(RX_IMPORT)) { + const dep = path.resolve(path.dirname(p), spec); + importers.set(dep, [...(importers.get(dep) ?? []), p]); + } + } + }; + walk(root); + const seen = new Set([file]); + for (const f of seen) for (const i of importers.get(f) ?? []) seen.add(i); + seen.delete(file); + const top = f => !importers.has(f); + return [...seen].sort().sort((a, b) => top(b) - top(a)); +} + /** @returns {import("@solidjs/vite-plugin").Frontend} */ export default function rx() { const builds = new Map(); // entry\0target\0moduleName\0spa → { entry, target, moduleName, spa, out, stamps } @@ -50,28 +73,38 @@ export default function rx() { builds.set(key, b); return b; } + const built = (file, target, moduleName, spa) => + [...builds.values()].filter(b => b.target === target && b.moduleName === moduleName && b.spa === spa && b.out.modules?.[file]); /** A dependency: from a build of a program that has it, rebuilt if that program changed. */ - function containing(file, target, moduleName, spa) { - const has = [...builds.values()].filter(b => b.target === target && b.moduleName === moduleName && b.spa === spa && b.out.modules?.[file]); + function containing(file, target, moduleName, spa, root) { + const has = built(file, target, moduleName, spa); const b = has.find(fresh) ?? has[0]; - if (!b) throw new Error(`${file} is imported as an .rx dependency, but no program built so far contains it`); - return build(b.entry, target, moduleName, spa); + if (b) return build(b.entry, target, moduleName, spa); + // the entry's compile was skipped (Vitest's `fsModuleCache` served it), so find the program from the files + let failed; + for (const entry of entriesOf(file, root)) { + const found = build(entry, target, moduleName, spa); + if (found.out.modules?.[file]) return found; + if (!failed && found.out.diagnostics) failed = found; // the program holds it but does not compile: report why + } + if (failed) return failed; + throw new Error(`${file} is imported as an .rx dependency, but no program under ${root} contains it`); } return { - compile(_source, { id, moduleId, solid, addWatchFile }) { + compile(_source, { id, moduleId, root, solid, addWatchFile }) { const file = path.resolve(id); const target = solid.generate === "ssr" ? "ssr" : solid.generate === "universal" ? "universal" : "dom"; const moduleName = solid.moduleName ?? "@solidjs/web"; // 08 CN-3d: a client-only build (no SSR, nothing hydrates) has nothing to show on a first load nothing holds const spa = target === "dom" && solid.hydratable === false; const dep = isDep(moduleId); - const { entry, out } = dep ? containing(file, target, moduleName, spa) : build(file, target, moduleName, spa); + const { out } = dep ? containing(file, target, moduleName, spa, path.resolve(root ?? process.cwd())) : build(file, target, moduleName, spa); if (out.diagnostics) throw new Error(report(out.diagnostics)); if (out.error) throw new Error(out.error); const m = out.modules[file]; for (const p of Object.keys(out.modules)) if (p !== file) addWatchFile(p); - // CSS-4: the program's stylesheet, linked once, from its entry - return { code: tagDeps(m.code), map: m.map, css: !dep && file === entry ? out.css : "" }; + // CSS-4: each module links its own stylesheet, so a module several programs share links it once + return { code: tagDeps(m.code), map: m.map, css: m.css ?? "" }; }, }; } From d5171e91ae39a937520802e092653c819d859152 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 13 Nov 2026 21:36:22 -0600 Subject: [PATCH 2/2] feat(vite): hot-swap a dependency's components under vite dev A dependency compiles to a factory over the app, so Solid's refresh pass finds no component to swap, and the entry's watch on every module of its program reloads the page on any edit. The front end now wraps the factory with the runtime's new hot entry, which re-runs a new version against the same app with its module-level calls answered from the first run, and its hotUpdate leaves the entry out when no other module compiles differently and the text outside the components' bodies is unchanged. --- CHANGELOG.md | 7 +++ compiler/README.md | 10 +++- compiler/src/app.rs | 2 +- compiler/src/emit.rs | 50 ++++++++++++++++++- compiler/test/vite.mjs | 66 +++++++++++++++++++++++- compiler/vite.mjs | 56 +++++++++++++++++++-- ref/compile/rt-hot.ts | 111 +++++++++++++++++++++++++++++++++++++++++ runtime/build.mjs | 6 +-- runtime/package.json | 4 ++ 9 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 ref/compile/rt-hot.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ea93c6..a2fb8a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/compiler/README.md b/compiler/README.md index 17769fb8..94a2bdd8 100644 --- a/compiler/README.md +++ b/compiler/README.md @@ -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, `\n \n

{said}

\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