Skip to content

Vite dev: SSR serves stale modules after edits — no reload for shared modules, missing file associations, and dev-worker reload() reuses cached evaluations #4472

Description

@brenelz

Environment

  • nitro: 3.0.260610-beta (also reproduced against current main sources)
  • vite: 8.0.10
  • vite-plugin-solid: 3.0.0-next.15 (solid-js 2.0.0-beta.23, SSR via a ssr service environment)
  • Node.js 22 / macOS
  • dev runner: node-worker (default)

Reproduction

Any Vite + Nitro app where SSR renders modules that are also part of the client graph (i.e. every route component). With the dev server running:

  1. Load a page (SSR renders it).
  2. Edit the route component (or any module shared between client and server envs).
  3. Reload the browser: the server still renders the old module — hydration keys/markup no longer match the freshly HMR-updated client, producing hydration mismatches (unclaimed server nodes, and in our case a hard Cannot read properties of null (reading 'nextSibling') crash during hydration).
  4. Only restarting the dev server picks up the change.

This is the same end-user symptom as #4020 (closed, but the fix never landed — reload() on main still doesn't clear the cache) and #4043. Tracing the full chain with NITRO_DEBUG=1 and instrumented hotUpdate hooks turned up three independent gaps, each of which is sufficient to keep SSR stale:

Describe the bug

1. hotUpdate never sends full-reload for shared modules (src/build/vite/plugin.ts)

Modules that also exist in a client environment go into sharedModules and are neither invalidated in the server graph nor do they trigger env.hot.send({ type: "full-reload" }). Editing a route component therefore never reloads the server runner at all. (This is #4043; PR #4044 addressed the invalidation half but was closed unmerged.)

2. modules is empty for server environments anyway — file→module associations are missing

Even if (1) were fixed, the modules array hotUpdate receives for the server environments is empty: environment.moduleGraph.getModulesByFile(file) returns nothing, although the module is present in idToModuleMap under its resolved id (verified: id /abs/path/src/routes/Index.tsx exists while getModulesByFile('/abs/path/src/routes/Index.tsx') is empty). Since Vite's own file-change invalidation also goes through getModulesByFile, the server env's transform cache is never invalidated for edited files either. Modules that enter the graph via the module-runner fetchModule path apparently never get registered in fileToModulesMap.

3. ViteEnvRunner.reload() reuses stale evaluations (src/runtime/internal/vite/dev-worker.mjs)

When a full-reload does reach the dev worker, reload() calls this.runner.import(this.entryPath) without clearing evaluatedModules. fetchModule answers {cache: true} for any module whose transform is populated on the Vite side at fetch time — and during the reload itself, plugins can repopulate transforms for the whole graph (e.g. vite-plugin-solid's ?assets crawl), so the runner keeps the old evaluations for exactly the modules that changed. Observed with NITRO_DEBUG=1: after a reload the entry re-executes, but shared app modules are "fetching"-only (no "executing") and old code keeps rendering. Vite's own full-reload handler calls runner.evaluatedModules.clear() before re-importing; the worker's reload() skips that step. This is exactly the root cause described in #4020, which is closed but unfixed on main.

Additional context

Workaround we're using today: a userland plugin that, for server-consumer environments, matches the changed file against idToModuleMap ids directly (bypassing the broken file association), calls moduleGraph.invalidateAll(), and sends { type: "full-reload" } — combined with patching reload() to clear evaluatedModules. With both in place, edits to shared modules are reflected in SSR within a couple of seconds and hydration is clean again.

A PR with the one-line fix for item (3) follows.

Logs

# NITRO_DEBUG=1, after editing src/routes/Index.tsx and sending full-reload:
[module runner] fetching /Users/.../src/entry-server.tsx
[module runner] executing file:///Users/.../src/entry-server.tsx   <- entry re-evaluates
[module runner] fetching /src/App.tsx                              <- fetched...
[module runner] fetching /src/HtmlDocument.tsx                     <- ...but never "executing":
                                                                      {cache: true} keeps old evaluation
# subsequent SSR requests trigger no module activity and render the old component

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions