Skip to content

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

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.ts — 40 / 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

Activity

  1. sapphi-red commented on May 21, 2026

    @sapphi-red
    Member

    This was fixed by #22369 and is released in 8.0.12+

  2. added
    p3-minor-bugAn edge case that only affects very specific usage (priority)
    and removed on May 21, 2026
  3. locked and limited conversation to collaborators on Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feat: ssrp3-minor-bugAn edge case that only affects very specific usage (priority)

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions