Skip to content

Hot-swap a dependency's components under vite dev - #68

Open
brenelz wants to merge 2 commits into
solidjs:mainfrom
brenelz:fix/rx-hmr
Open

brenelz wants to merge 2 commits into
solidjs:mainfrom
brenelz:fix/rx-hmr

Conversation

@brenelz

@brenelz brenelz commented Nov 14, 2026

Copy link
Copy Markdown

Under vite dev, every edit to a non-entry .rx module reloads the page (page reload src/components/Counter.rx), so all app state is lost.

This branch is stacked on #66 (its first commit). Review only the second commit, feat(vite): hot-swap a dependency's components under vite dev. It needs the solid-vite-plugin change in solidjs/solid-vite-plugin#539, which adds FrontendContext.hot and Frontend.hotUpdate. With an older plugin nothing changes: no HMR code is emitted, and edits reload as before.

Root cause

A dependency compiles to export default function module(__rt), a factory over the app (09 MO-1b, CP-4b module). Solid's refresh pass registers only top-level components, so it emits nothing for it, and the update travels up to the page. Even with an accept handler, the entry still reloads the page: it calls addWatchFile on every module of its program, so each edit also reaches it through a watch-only module that it does not accept.

What changed

  • Crate. app() now returns modules[path].hot for each dependency: its exported components, and a hash of its declarations with component bodies left out (each component's name and props stay in). hot is absent when that text names a binding from a JS import or calls an .rx import, because a replay would make that call again outside its record. The emitted JS is unchanged.
  • compiler/vite.mjs.
    • When hot is set, a dependency's factory is wrapped by @reactivescript/runtime/hot, and the module accepts its own updates. The wrap is skipped when the project's runtime has no ./hot entry.
    • hotUpdate rebuilds each program that holds the file. When no other module compiles differently and the file's hash and component list are unchanged, it keeps only the ?rx-dep modules, so the entry is left out. Otherwise it leaves Vite's list alone, and the page reloads.
    • The plugin then recompiles the file by its plain path for its stylesheet. That compile is now served as the dependency it is, using a set kept across failed builds. Before, it built the file as a stray entry.
  • Runtime (ref/compile/rt-hot.ts, published as @reactivescript/runtime/hot).
    • On first instantiation, exported components are wrapped in solid-js/refresh proxies ($$component), and the factory's top-level calls on __rt are recorded.
    • On an update, the new factory runs against the same app, and each top-level call is answered from that record. So it creates no module-level state, and its components close over the first instance's sources. Then the proxies swap.
    • It reloads instead when the calls or their non-function arguments differ (a component's guess changes how a module-level source is made), when the new version throws, or when the module's own code is unchanged. The last case means an import changed, and the recorded top level may have read the old version.
    • An app mounted after an update takes the current version. The swap waits for hydration to end and resets Solid's error halt first.
  • Test harness. compiler/test/vite.mjs now gives each project its own cacheDir. The default sat under compiler/package.json, so one run's pre-bundled runtime was served to the next.

Spec questions this leaves open

  1. @reactivescript/runtime/hot is runtime surface that CP-4b does not list. The front end emits it in dev client transforms only, but spec/11 may want to name it.
  2. "An edit outside component bodies reloads, and an edit inside swaps against the recorded top level" is a new rule about module instances. It keeps MO-1b, since no update creates a second instance, but spec/09 does not say it.
  3. The swap's proxy adds the same transparent memo Solid's own refresh adds on the client, so hydration order (spec/10) is unchanged. No test covers SSR with hydration, because the dev tests are client-only.
  4. A <style> edit changes the module's JS too, because the scoped class hash changes. So the component re-renders and loses its own state.

Verification

With SOLID_VITE_PLUGIN set to a build of the plugin PR:

  • node compiler/test/vite.mjs: 17 pass. The new hot (dev) block covers:

    • A module-level source total and another module's component state surviving markup, <style> and logic edits, and the new handler running.
    • A reload on a top-level edit, an entry edit, an edit to a JS module the dependency imports, and an edit to a module whose top level calls JS.
    • A dependency that fails to compile and is then fixed hot-swapping again.

    It fails with the published next.57 plugin (after a markup edit: … kept: false). The JS-import step fails without the code check, and the step after a failed compile fails with the previous vite.mjs.

  • cargo test --release, node compiler/test/node.mjs and node runtime/test.mjs pass.

  • In the solid-v2/with-reactivescript template, with the three packages linked, Playwright checked edits to Counter.rx. Markup, <style> and logic edits hot-swap with no reload and keep the guestbook's entries and the current user. A top-level edit and an App.rx edit reload.

🤖 Generated with Claude Code

…gram

Under vite dev, a <style> edit in a non-entry module never reached the page, because the program's whole stylesheet came only on the entry and the plugin recompiles CSS per file. Under Vitest's fsModuleCache, a cached entry left builds empty, so the next changed dependency threw "no program built so far contains it".
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant