Skip to content

Module runner: plain named-import bindings are undefined after HMR when a shared dependency is still evaluating (race not covered by #22369) #22999

Description

@alvarogomezj

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    p3-downstream-blockerBlocking the downstream ecosystem to work properly (priority)

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions