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:
-
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.
-
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
Describe the bug
Describe the bug
When
ModuleRunner.cachedRequestis asked for a module that is mid-evaluation, three OR-conditions decide whether to short-circuit and return the partialmod.exportsinstead of awaitingmod.promise(packages/vite/src/module-runner/runner.ts):Two facts make this branch unsafe:
mod.exportsis truthy synchronously near the top ofdirectRequest, beforerunInlinedModuleruns the module body. So&& mod.exportsis not a "has the module finished" guard — it's effectivelytruefor the entire eval window.isCircularImportwalks the persistedmod.importersgraph recursively. That graph is not cleared byevaluatedModules.invalidateModule— onlyevaluated,meta,promise,exports, andimportsare reset:After
moduleGraph.invalidateAll()plus a fresh re-evaluation pass, two modulesAandBthat previously cycled (A → B → A) still reportA.importers ⊇ {B}andB.importers ⊇ {A}. From that point on, any consumer ofA— even one that has never been on a stack cycle withA— triggersisCircularImport=true, falls into the cycle short-circuit, and is handedA.exports.When both happen, a non-cyclic consumer receives a
mod.exportswhose hoisted getters close over still-TDZconst __vite_ssr_import_N__bindings. Vite's SSR transform wraps every exported binding intry { return X } catch {}(thedefineExporthelper, added in #19959 as a provisional mitigation for #19929), so theReferenceErroris silently swallowed and the consumer's destructure observesundefined.Net effect, in the wild: under HMR-triggered
invalidateAll(e.g.reloadOnTsconfigChangedriven bysvelte-checkrewriting.svelte-kit/.svelte-check/tsconfig.json), entire namespace imports of barrel packages likebits-uicome back asundefinedfor non-cycle consumers, and downstream destructures (const Root = TooltipPrimitive.Rootin shadcn-svelte barrels) crash withTypeError: 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/catchindefineExportswallowing theReferenceError; this one is about whycachedRequestever hands a TDZ-trapped namespace to a non-cycle consumer in the first place. Fixing this should remove the need for that swallow.Reproduction
https://github.com/jose-manuel-silva/vite-ssr-cycle-repro — driver
test-graph-cycle.js(with--fixmode that applies the proposed narrowing).Steps to reproduce
The repro arranges:
t.js → u.js → v.js → t.jsso that after warmup the persistedimportersgraph contains the cycle.moduleGraph.invalidateAll()— server clearstransformResult; on the next fetch each module comes back withinvalidate: trueandevaluatedModules.invalidateModuleruns in the runner.imports,evaluated,promise,exportsare cleared;importersis preserved (the cycle survives).t.jsparks mid-eval via a 100 ms top-level-awaitstall.js.e1.jsande2.jsare raced viaPromise.allagainst the runner.e2.jshas a smallwait.jsoffset so it arrives atcachedRequest(t.js)aftere1's chain has setmod.exports— i.e., whiletis genuinely mid-eval with TDZ-trapped getters.Expected (per ESM): both consumers wait for
tto finish; both seex === 1.Actual: one consumer is handed partial
t.exports; readingxtriggers the hoisted getter; TDZ is swallowed; consumer seesundefined.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 14partial-via-cyclefirings in a single broken eval pass, 11 hadinCallstack=trueand were legitimate; the 3 that crashed all had onlyisCircImp=true. Zero overlap — the safe vs. unsafe split is perfectly clean oninCallstack:inCallstackisCircModisCircImpbits-ui/dist/index.jsbits-ui/.../menu.svelte.js.../focus-scope.svelte.js.../focus-scope-manager.js.../date-time/field/segments.js.../field/helpers.js.../bits/command/index.js.../command.svelte.js.../bits/command/index.js.../command.sveltesrc/lib/.../range-calendar/index.tsrange-calendar.sveltebits-ui/dist/index.jssrc/lib/.../popover/index.tsbits-ui/dist/index.jspopover-content.sveltebits-ui/dist/index.jspopover-trigger.sveltebits-ui/dist/index.jsbits-ui/.../menu.svelte.js.../focus-scope.svelte.js.../focus-scope-manager.js.../date-time/field/segments.js.../field/helpers.js.../bits/command/index.js.../command.svelte.js.../bits/command/index.js.../command.svelteRaw log excerpt (instrumented
cachedRequest):15 ms separate the
partial-via-cyclebranch 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:
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.promisepath, 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
cachedRequestis fixed, thetry { return X } catch {}indefineExport(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).packages/vite/src/node/ssr+packages/vite/src/module-runner) — 142 / 142 pass across 9 files.packages/viteunit 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
ModuleRunner.isCurcularImport#17740 (July 2024) — fixed infinite recursion inisCircularImportbut didn't change when it should be consulted vs. the callstack.fix(ssr): hoist export to handle cyclic import better. Hoisted export registration aboveawait __vite_ssr_import__(...), which created the TDZ window in the SSR transform.fd38d07, first shipped in 6.3.5) —fix(ssr): handle uninitialized export access as undefined. Added thetry/catchindefineExportas a mitigation, with the explicit "we'll revisit whether we should remove try/catch after investigating that further" note.mod.evaluated && mod.promisefast-path above the cycle branch incachedRequest, but deliberately didn't touch the cycle branch itself.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/catchindefineExportis 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.0Used Package Manager
bun
Logs
Click to expand
Validations