nitrojs / nitrojs/nitro

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

Open
#4,472 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug v3
Dominant language
TypeScript
Stars
11.2k
Forks
899
Avg merge
2d 24m
Merged PRs (30d)
40

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/build/vite/plugin.ts and src/runtime/internal/vite/dev-worker.mjs, then reproduce a shared-module edit with NITRO_DEBUG=1. Trace file-to-module associations, hotUpdate handling, and reload evaluation state; the fix is done when edited shared SSR modules are invalidated, reloaded, and rendered without stale evaluations or hydration mismatches.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, typescript, vite
Domain
backend, build-system, devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.