Describe the bug
After an HMR invalidation, concurrent imports through the SSR module runner can observe undefined bindings for plain named imports of a shared dependency whose evaluation is still in flight. The importing module finishes evaluation (and its mod.promise resolves) without awaiting the in-flight shared dependency, so code that runs right after gets:
TypeError: (0 , __vite_ssr_import_0__.fetchData) is not a function
where fetchData is a plain named export of a first-party module (a shared helper imported by several modules that re-evaluate concurrently after the invalidation).
This looks like a sibling of the race fixed by #22369 (shipped in vite@8.0.12, closes TanStack/router#7285). That fix is scoped to re-export chains — it replaced the mod.importers-based circular heuristics in ModuleRunner.cachedRequest with a callstack-driven check, so a concurrent caller no longer receives the partial exports of an in-flight export * chain. The failure reported here still reproduces on vite@8.1.3 (which includes that fix), with plain named imports and no export * involved.
Instrumenting the module runner's evaluator (runInlinedModule) shows the ordering after the invalidation, with 8 invalidated modules re-evaluating concurrently (one per in-flight request):
EVAL-START service1.actions.ts?tss-serverfn-split
EVAL-START service2.actions.ts?tss-serverfn-split
...
EVAL-START helper.ts <- shared dep starts evaluating (in-flight)
EVAL-START api1.ts
EVAL-END api1.ts <- finishes WITHOUT awaiting in-flight helper.ts
EVAL-END service1.actions.ts?tss-serverfn-split
...
EVAL-END helper.ts <- shared dep finishes last
api1.ts does import { fetchData } from './helper'; it completes evaluation while helper.ts (which has top-level await work) is still evaluating, so its imported binding is undefined when the request handler runs. Once a module has been poisoned this way it stays broken until the dev server restarts.
Analysis by @gonzoblasco in the TanStack issue suggests the mechanism is different from the #22369 one: it is not a circular-request misclassification, but the runner considering a module "done" (resolving its mod.promise) while a statically imported dependency's top-level execution has not completed, so the dependent's bindings are not yet populated.
How it surfaces: through TanStack Start's server-function RPCs in dev (/_serverFn/… requests import the handler modules concurrently after the file edit). Reported there as TanStack/router#7825; filing here because the trace places the race inside Vite's module runner. The same failure shape also appears for imports from @tanstack/react-start/server ((0 , __vite_ssr_import_1__.getCookie) is not a function), which is the re-export-chain flavor.
A note on minimality: I tried to reduce this to a plain createServerModuleRunner script (8 concurrent runner.import() calls sharing a top-level-await dependency; tried fresh concurrent imports, invalidateAll + evaluatedModules.clear() mid-flight, and real watcher-driven HMR over 20 edit rounds) and could not trigger it that way — those paths await the in-flight dependency correctly. The reproduction seems to need the module-id pattern the framework creates (the same source file imported both directly and under a ?tss-serverfn-split query, i.e. two module nodes per file) and/or its graph shape, so the linked repro uses TanStack Start. It is otherwise minimal (no nitro plugin, no router beyond one route) and reproduces reliably on every edit.
Things ruled out in the original app:
Reproduction
https://github.com/alvarogomezj/tanstack-start-serverfn-hmr-race
Steps to reproduce
npm install
npm run dev # terminal 1
npm run reproduce # terminal 2 — puppeteer opens the page, appends a comment
# to a server-fn file, and reports the broken RPC payloads
Or manually: open http://localhost:3100/ (a route whose loader calls 8 server functions in parallel, all importing a shared helper through per-service modules), append a comment to src/service1.actions.ts and save. Several of the 8 RPC responses now carry the is not a function error (typically 3–5 of 8; the count varies because it is a race).
The shared helper uses a 300 ms top-level await to stand in for the evaluation time a real import subtree takes naturally (the original app has ~40 server-function modules sharing a fetch-wrapper helper and hits this on every save of a server file, without any artificial delay).
System Info
System:
OS: macOS 26.5.2
CPU: (10) arm64 Apple M5
Memory: 219.70 MB / 24.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.14.1 - /Users/alvarogomez/.nvm/versions/node/v24.14.1/bin/node
npm: 11.11.0 - /Users/alvarogomez/.nvm/versions/node/v24.14.1/bin/npm
bun: 1.0.0 - /opt/homebrew/bin/bun
Browsers:
Brave Browser: 150.1.92.139
Chrome: 150.0.7871.125
Firefox: 149.0
Safari: 26.5.2
npmPackages:
@vitejs/plugin-react: 6.0.3 => 6.0.3
vite: 8.1.3 => 8.1.3
Used Package Manager
npm
Logs
Click to expand!
Output of npm run reproduce (run today against a fresh clone, vite@8.1.3):
before edits: ok=8 error=0
after edit 1: ok=5 error=3 | broken RPCs so far: 3
RPC responses containing "is not a function": 3
sample payload:
{"t":10,"i":0,"p":{"k":["result","error","context"],"v":[{"t":10,"i":1,"p":{"k":["ok","error"],"v":[{"t":2,"s":3},{"t":1,"s":"TypeError: (0 , __vite_ssr_import_0__.fetchData) is not a function"}]},"o":0},{"t":2,"s":1},{"t":11,"i":2,"p":{"k":[],"v":[]},"o":0}]},"o":0}
*** BUG REPRODUCED ***
Validations
Describe the bug
After an HMR invalidation, concurrent imports through the SSR module runner can observe
undefinedbindings for plain named imports of a shared dependency whose evaluation is still in flight. The importing module finishes evaluation (and itsmod.promiseresolves) without awaiting the in-flight shared dependency, so code that runs right after gets:where
fetchDatais a plain named export of a first-party module (a shared helper imported by several modules that re-evaluate concurrently after the invalidation).This looks like a sibling of the race fixed by #22369 (shipped in
vite@8.0.12, closes TanStack/router#7285). That fix is scoped to re-export chains — it replaced themod.importers-based circular heuristics inModuleRunner.cachedRequestwith a callstack-driven check, so a concurrent caller no longer receives the partial exports of an in-flightexport *chain. The failure reported here still reproduces onvite@8.1.3(which includes that fix), with plain named imports and noexport *involved.Instrumenting the module runner's evaluator (
runInlinedModule) shows the ordering after the invalidation, with 8 invalidated modules re-evaluating concurrently (one per in-flight request):api1.tsdoesimport { fetchData } from './helper'; it completes evaluation whilehelper.ts(which has top-level await work) is still evaluating, so its imported binding isundefinedwhen the request handler runs. Once a module has been poisoned this way it stays broken until the dev server restarts.Analysis by @gonzoblasco in the TanStack issue suggests the mechanism is different from the #22369 one: it is not a circular-request misclassification, but the runner considering a module "done" (resolving its
mod.promise) while a statically imported dependency's top-level execution has not completed, so the dependent's bindings are not yet populated.How it surfaces: through TanStack Start's server-function RPCs in dev (
/_serverFn/…requests import the handler modules concurrently after the file edit). Reported there as TanStack/router#7825; filing here because the trace places the race inside Vite's module runner. The same failure shape also appears for imports from@tanstack/react-start/server((0 , __vite_ssr_import_1__.getCookie) is not a function), which is the re-export-chain flavor.A note on minimality: I tried to reduce this to a plain
createServerModuleRunnerscript (8 concurrentrunner.import()calls sharing a top-level-await dependency; tried fresh concurrent imports,invalidateAll+evaluatedModules.clear()mid-flight, and real watcher-driven HMR over 20 edit rounds) and could not trigger it that way — those paths await the in-flight dependency correctly. The reproduction seems to need the module-id pattern the framework creates (the same source file imported both directly and under a?tss-serverfn-splitquery, i.e. two module nodes per file) and/or its graph shape, so the linked repro uses TanStack Start. It is otherwise minimal (no nitro plugin, no router beyond one route) and reproduces reliably on every edit.Things ruled out in the original app:
nitroVite plugin (the repro doesn't use it).evaluatedModules.clear()fix suggested in Server HMR broken: ModuleRunner evaluatedModules cache not cleared on reload nitrojs/nitro#4020 do not help.Reproduction
https://github.com/alvarogomezj/tanstack-start-serverfn-hmr-race
Steps to reproduce
Or manually: open
http://localhost:3100/(a route whose loader calls 8 server functions in parallel, all importing a shared helper through per-service modules), append a comment tosrc/service1.actions.tsand save. Several of the 8 RPC responses now carry theis not a functionerror (typically 3–5 of 8; the count varies because it is a race).The shared helper uses a 300 ms top-level
awaitto stand in for the evaluation time a real import subtree takes naturally (the original app has ~40 server-function modules sharing a fetch-wrapper helper and hits this on every save of a server file, without any artificial delay).System Info
System: OS: macOS 26.5.2 CPU: (10) arm64 Apple M5 Memory: 219.70 MB / 24.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 24.14.1 - /Users/alvarogomez/.nvm/versions/node/v24.14.1/bin/node npm: 11.11.0 - /Users/alvarogomez/.nvm/versions/node/v24.14.1/bin/npm bun: 1.0.0 - /opt/homebrew/bin/bun Browsers: Brave Browser: 150.1.92.139 Chrome: 150.0.7871.125 Firefox: 149.0 Safari: 26.5.2 npmPackages: @vitejs/plugin-react: 6.0.3 => 6.0.3 vite: 8.1.3 => 8.1.3Used Package Manager
npm
Logs
Click to expand!
Output of
npm run reproduce(run today against a fresh clone,vite@8.1.3):Validations