Skip to content

ModuleRunner.cachedRequest hands mid-eval exports to non-cycle consumers via graph-only isCircularImport detection #22293

@jose-manuel-silva

Description

@jose-manuel-silva

Describe the bug

Describe the bug

When ModuleRunner.cachedRequest is asked for a module that is mid-evaluation, three OR-conditions decide whether to short-circuit and return the partial mod.exports instead of awaiting mod.promise (packages/vite/src/module-runner/runner.ts):

if (
  (callstack.includes(moduleId) || this.isCircularModule(mod) || this.isCircularImport(importers, moduleId))
  && mod.exports
) return this.processImport(mod.exports, meta, metadata);

Two facts make this branch unsafe:

  1. mod.exports is truthy synchronously near the top of directRequest, before runInlinedModule runs the module body. So && mod.exports is not a "has the module finished" guard — it's effectively true for the entire eval window.

  2. isCircularImport walks the persisted mod.importers graph recursively. That graph is not cleared by evaluatedModules.invalidateModule — only evaluated, meta, promise, exports, and imports are reset:

    invalidateModule(node) {
      node.evaluated = false;
      node.meta = undefined;
      node.map = undefined;
      node.promise = undefined;
      node.exports = undefined;
      node.imports.clear();
      // node.importers is NOT cleared
    }

    After moduleGraph.invalidateAll() plus a fresh re-evaluation pass, two modules A and B that previously cycled (A → B → A) still report A.importers ⊇ {B} and B.importers ⊇ {A}. From that point on, any consumer of A — even one that has never been on a stack cycle with A — triggers isCircularImport=true, falls into the cycle short-circuit, and is handed A.exports.

When both happen, a non-cyclic consumer receives a mod.exports whose hoisted getters close over still-TDZ const __vite_ssr_import_N__ bindings. Vite's SSR transform wraps every exported binding in try { return X } catch {} (the defineExport helper, added in #19959 as a provisional mitigation for #19929), so the ReferenceError is silently swallowed and the consumer's destructure observes undefined.

Net effect, in the wild: under HMR-triggered invalidateAll (e.g. reloadOnTsconfigChange driven by svelte-check rewriting .svelte-kit/.svelte-check/tsconfig.json), entire namespace imports of barrel packages like bits-ui come back as undefined for non-cycle consumers, and downstream destructures (const Root = TooltipPrimitive.Root in shadcn-svelte barrels) crash with TypeError: undefined is not an object (evaluating '__vite_ssr_import_0__.Tooltip.Root') far from the actual fault.

This is the runtime-side mechanism behind the SSR-transform-side bug filed as #22291: that one is about the try/catch in defineExport swallowing the ReferenceError; this one is about why cachedRequest ever hands a TDZ-trapped namespace to a non-cycle consumer in the first place. Fixing this should remove the need for that swallow.

Investigation and writeup by Claude Opus 4.7; reviewed and filed by me.

Reproduction

https://github.com/jose-manuel-silva/vite-ssr-cycle-repro — driver test-graph-cycle.js (with --fix mode that applies the proposed narrowing).

Steps to reproduce

git clone https://github.com/jose-manuel-silva/vite-ssr-cycle-repro
cd vite-ssr-cycle-repro
bun install
node test-graph-cycle.js          # reproduces the bug (non-zero exit)
node test-graph-cycle.js --fix    # applies the proposed narrowing — both consumers see x = 1

The repro arranges:

  • A triangle t.js → u.js → v.js → t.js so that after warmup the persisted importers graph contains the cycle.
  • moduleGraph.invalidateAll() — server clears transformResult; on the next fetch each module comes back with invalidate: true and evaluatedModules.invalidateModule runs in the runner. imports, evaluated, promise, exports are cleared; importers is preserved (the cycle survives).
  • t.js parks mid-eval via a 100 ms top-level-await stall.js.
  • Two non-cycle consumers e1.js and e2.js are raced via Promise.all against the runner. e2.js has a small wait.js offset so it arrives at cachedRequest(t.js) after e1's chain has set mod.exports — i.e., while t is genuinely mid-eval with TDZ-trapped getters.

Expected (per ESM): both consumers wait for t to finish; both see x === 1.

Actual: one consumer is handed partial t.exports; reading x triggers the hoisted getter; TDZ is swallowed; consumer sees undefined.

[t.js] from=e2.js stack=[e2.js] flags={isCircImp} evaluated=false hasPromise=true hasExports=true
…
e1.seen = 1 (typeof number)
e2.seen = undefined (typeof undefined)
BUG REPRODUCED: a non-cycle consumer received `undefined` for x.

The flag breakdown — inCallstack=false, isCircMod=false, isCircImp=true — exactly matches what was observed in the original SvelteKit + bits-ui crash that surfaced this bug.

In-the-wild evidence

The same bug, reproduced with the same flag signature, in a SvelteKit + bits-ui app under Vite 8.0.0. Triggered via reloadOnTsconfigChange → invalidateAll. Of 14 partial-via-cycle firings in a single broken eval pass, 11 had inCallstack=true and were legitimate; the 3 that crashed all had only isCircImp=true. Zero overlap — the safe vs. unsafe split is perfectly clean on inCallstack:

Module Importer inCallstack isCircMod isCircImp Outcome
bits-ui/dist/index.js bits-ui/.../menu.svelte.js true false true OK
.../focus-scope.svelte.js .../focus-scope-manager.js true true true OK
.../date-time/field/segments.js .../field/helpers.js true true true OK
.../bits/command/index.js .../command.svelte.js true false true OK
.../bits/command/index.js .../command.svelte true false true OK
src/lib/.../range-calendar/index.ts range-calendar.svelte true true true OK
bits-ui/dist/index.js src/lib/.../popover/index.ts false false true CRASH
bits-ui/dist/index.js popover-content.svelte false false true CRASH
bits-ui/dist/index.js popover-trigger.svelte false false true CRASH
bits-ui/dist/index.js bits-ui/.../menu.svelte.js true false true OK
.../focus-scope.svelte.js .../focus-scope-manager.js true true true OK
.../date-time/field/segments.js .../field/helpers.js true true true OK
.../bits/command/index.js .../command.svelte.js true false true OK
.../bits/command/index.js .../command.svelte true false true OK

Raw log excerpt (instrumented cachedRequest):

[ssr-dbg] 87611 cachedRequest ENTER .../popover/index.ts
  importer= .../reports/__components/MonthlyAggregateTab.svelte
  evaluated=false hasPromise=false hasExports=false
  inCallstack=false isCircMod=false isCircImp=false
[ssr-dbg] 87612 cachedRequest BRANCH=fresh-directRequest .../popover/index.ts
[ssr-dbg] 87612 directRequest START .../popover/index.ts
[ssr-dbg] 87612 cachedRequest ENTER .../bits-ui/dist/index.js
  importer= .../popover/index.ts
  evaluated=false hasPromise=true hasExports=true
  inCallstack=false isCircMod=false isCircImp=true          ← only isCircImp true
[ssr-dbg] 87612 cachedRequest BRANCH=partial-via-cycle .../bits-ui/dist/index.js
  exportKeys=[Accordion, AlertDialog, AspectRatio, Avatar, BitsConfig, Button, Calendar, Checkbox]
[ssr-debug] 87627 TDZ on export Popover local= __vite_ssr_import_0__.Popover
  ReferenceError: Cannot access '__vite_ssr_import_0__' before initialization
    at Module.eval [as Popover] (.../bits-ui/dist/index.js:28:50)
    at eval (.../popover/index.ts:17:36)

15 ms separate the partial-via-cycle branch from the TDZ read.

Proposed fix directions (maintainer's call)

Minimal — narrow the cycle short-circuit to cases where the caller is actually on the current eval callstack:

-  if (
-    (callstack.includes(moduleId) || this.isCircularModule(mod) || this.isCircularImport(importers, moduleId))
-    && mod.exports
-  ) return this.processImport(mod.exports, meta, metadata);
+  if (callstack.includes(moduleId) && mod.exports) {
+    return this.processImport(mod.exports, meta, metadata);
+  }

Real stack-level cycles remain safe — the caller is synchronously waiting on its own ancestor, and ESM allows it to see the partial namespace. Graph-only cycles fall through to the existing await mod.promise path, which is the spec-correct behavior: wait for the module to finish.

Conservative alternative — preserve the existing structure and add a second gate:

   if (
     (callstack.includes(moduleId) || this.isCircularModule(mod) || this.isCircularImport(importers, moduleId))
     && mod.exports
+    && (callstack.includes(moduleId) || mod.evaluated)
   ) return this.processImport(mod.exports, meta, metadata);

Either eliminates the regression in the repro and in the SvelteKit + bits-ui app it was first observed in.

If cachedRequest is fixed, the try { return X } catch {} in defineExport (added in #19959 as a self-flagged provisional mitigation, the subject of #22291) likely should also go — every export-getter TDZ throw past that point would represent a genuine runtime bug worth surfacing, not a routing artifact.

Validation against the vite test suite

The proposed minimal narrowing was applied locally to main (vite 8.0.9 HEAD) and the unit suite was run:

  • packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts40 / 40 pass, including every cycle-related fixture (cyclic2/test1..test4, cyclic invalid 1, cyclic invalid 2, cyclic with mixed import and re-export, execution-order-re-export, export default getter is hoisted).
  • All SSR + module-runner tests (packages/vite/src/node/ssr + packages/vite/src/module-runner) — 142 / 142 pass across 9 files.
  • Full packages/vite unit suite — 742 / 742 pass across 54 files.

So the narrowing does not regress any existing cycle fixture: every cycle the test suite exercises is one where callstack.includes(moduleId) is true at the relevant call. If maintainers prefer the conservative variant (keeping the OR but adding && (callstack.includes(moduleId) || mod.evaluated)) it likewise resolves the regression with the same test outcome.

Context — how this got here

This issue and #22291 together are the next step on that revisit: the cycle branch is where partial namespaces can be served to non-cycle consumers, and the try/catch in defineExport is downstream of that.

System Info

System:
    OS: macOS 15.7.3
    CPU: (10) arm64 Apple M4
    Memory: 97.02 MB / 24.00 GB
    Shell: 5.9 - /bin/zsh
Binaries:
    Node: 25.6.1 - /opt/homebrew/bin/node
    npm: 11.9.0 - /opt/homebrew/bin/npm
    pnpm: 10.32.1 - /opt/homebrew/bin/pnpm
    bun: 1.3.13 - /opt/homebrew/bin/bun
    Deno: 2.7.4 - /opt/homebrew/bin/deno
Browsers:
    Safari: 26.2
npmPackages:
    vite: 8.0.0 => 8.0.0

Used Package Manager

bun

Logs

Click to expand
$ node test-graph-cycle.js
runtime: node   vite: 8.0.0

--- SSR-transformed t.js (first 5 lines) ---
__vite_ssr_exportName__("x", () => { try { return x } catch {} });
__vite_ssr_exportName__("fromU", () => { try { return fromU } catch {} });
const __vite_ssr_import_0__ = await __vite_ssr_import__("/modules-graph/u.js", {"importedNames":["y"]});
const __vite_ssr_import_1__ = await __vite_ssr_import__("/modules-graph/stall.js");

--- after warmup ---
  t.js       importers=[v.js] imports=[u.js,stall.js] evaluated=true hasExports=true hasPromise=true
  u.js       importers=[t.js] imports=[v.js] evaluated=true hasExports=true hasPromise=true
  v.js       importers=[u.js] imports=[t.js] evaluated=true hasExports=true hasPromise=true

--- cachedRequest trace (race phase) ---
  ...
  [t.js] from=e2.js stack=[e2.js] flags={isCircImp} evaluated=false hasPromise=true hasExports=true

--- result ---
e1.seen = 1 (typeof number)
e2.seen = undefined (typeof undefined)

BUG REPRODUCED: a non-cycle consumer received `undefined` for x.

$ node test-graph-cycle.js --fix
[mode] running with proposed narrowed cycle check
...
e1.seen = 1 (typeof number)
e2.seen = 1 (typeof number)
OK: both consumers saw x = 1.

Validations

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions