Skip to content

SSR transform's hoisted export getter silently returns undefined for re-exports in cycles (spec says ReferenceError) #22291

Description

@jose-manuel-silva

Describe the bug

I am using Vite's SSR module loader (both server.ssrLoadModule and the Vite 8 environment API server.environments.ssr.runner.import) to evaluate modules that use export * as X from './…' in an import cycle.

What I expect — per ES module spec, and as demonstrated by native import() under both Node and Bun — is that accessing the re-exported namespace during the cycle window throws ReferenceError: Cannot access 'X' before initialization. The namespace object itself is stable; reading a member whose inner binding is in TDZ throws loudly.

What actually happens — Vite's SSR transform wraps every exported binding in try { return ${local} } catch {} (the defineExport helper in packages/vite/src/node/ssr/ssrTransform.ts, hoisted above the await __vite_ssr_import__(...) lines). When the cycle peer reads the re-export before its const is initialized, the catch {} silently swallows the TDZ ReferenceError and returns undefined — for the entire namespace binding.

Consumer code like const Root = Namespace.Root at module top-level then crashes downstream with a confusing TypeError: undefined is not an object (evaluating '…') far from the real cause.

The try/catch is self-flagged as provisional in PR #19959 (merged 2025-05-02, commit fd38d07, first shipped in 6.3.5):

We'll revisit whether we should remove try/catch after investigating that further.

This issue is the follow-up — with a deterministic repro and a confirmation that it is not specific to the runtime (both node and bun), not specific to the vite version (reproduces on 7.3.2 stable and 8.0.0), and not specific to the legacy API (reproduces on the new environment API too).

Not submitting a PR — leaving the design call to maintainers (options in the "Steps" notes below).

Drafted and researched by Claude Opus 4.7; reviewed and filed by me.

Reproduction

Minimal repro (4 JS files + driver, ~30 lines of user code total). I can push to a public repo and paste the URL here if needed. Full source included in Steps.

Steps to reproduce

  1. Create a project:
    mkdir ssr-cycle-repro && cd ssr-cycle-repro
    bun init -y
    bun add -d [email protected]
  2. Create modules/sub.js:
    export const a = 1;
  3. Create modules/cyc-a.js:
    import { valueFromB } from './cyc-b.js';
    export * as Ns from './sub.js';
    export const valueFromA = 'A:' + valueFromB;
  4. Create modules/cyc-b.js:
    import { Ns as NsFromA } from './cyc-a.js';
    export const valueFromB = 'B-value';
    export const NsKeys = NsFromA ? Object.keys(NsFromA) : 'NsFromA_IS_UNDEFINED';
    export const maybeA = NsFromA ? NsFromA.a : 'NsFromA_WAS_UNDEFINED';
  5. Create test-cycle.js:
    import { createServer } from 'vite';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    const server = await createServer({
        root: path.dirname(fileURLToPath(import.meta.url)),
        configFile: false,
        logLevel: 'silent',
        server: { middlewareMode: true },
        appType: 'custom',
        optimizeDeps: { noDiscovery: true }  // only for determinism; not load-bearing
    });
    
    await server.ssrLoadModule('/modules/cyc-a.js');
    const b = await server.ssrLoadModule('/modules/cyc-b.js');
    console.log('NsKeys =', b.NsKeys);
    console.log('maybeA =', b.maybeA);
    await server.close();
  6. Run: node test-cycle.js

Expected output (if behavior matched native ESM): a ReferenceError during cycle resolution.

Actual output:

NsKeys = NsFromA_IS_UNDEFINED
maybeA = NsFromA_WAS_UNDEFINED

Native-ESM comparison — same three modules/*.js files, no Vite:

$ node -e "import('./modules/cyc-a.js').then(async () => { const b = await import('./modules/cyc-b.js'); console.log(b.NsKeys, b.maybeA); })"
file:///.../modules/cyc-b.js:7
export const NsKeys = NsFromA ? Object.keys(NsFromA) : 'NsFromA_IS_UNDEFINED';
                                       ^
ReferenceError: Cannot access 'a' before initialization
    at Object.keys (<anonymous>)
    at file:///.../modules/cyc-b.js:7:40

Spec-correct TDZ throw. Vite's SSR loader swallows it.

Coverage matrix (reproduced identically)

Runtime Vite version API Result
Node 25.6.1 8.0.0 server.ssrLoadModule silent undefined
Bun 1.3.13 8.0.0 server.ssrLoadModule silent undefined
Node 25.6.1 8.0.0 server.environments.ssr.runner.import silent undefined
Bun 1.3.13 8.0.0 server.environments.ssr.runner.import silent undefined
Node 25.6.1 7.3.2 stable server.ssrLoadModule silent undefined
Bun 1.3.13 7.3.2 stable server.ssrLoadModule silent undefined

Not rolldown-vite-specific. Not runtime-specific. defineExport helper unchanged on main as of 2026-04-21.

Production build (vite build --ssr) — also silently-undefines, different mechanism

For completeness: running the same three files through vite build --ssr and executing the built artifact under node or bun prints the same fallback strings:

NsKeys = NsFromA_IS_UNDEFINED
maybeA = NsFromA_WAS_UNDEFINED

The built output shows var-hoisting ordering the dependency read before the dependency is defined:

//#region modules/cyc-b.js
var valueFromB = "B-value";
var NsKeys = sub_exports ? Object.keys(sub_exports) : "NsFromA_IS_UNDEFINED";  // sub_exports is `undefined` here
var maybeA = sub_exports ? 1 : "NsFromA_WAS_UNDEFINED";
//#endregion
//#region modules/sub.js
var sub_exports = /* @__PURE__ */ __exportAll({ a: () => 1, b: () => 2, greet: () => greet });

This is a different mechanism (rolldown var hoisting vs. SSR transform getter with try/catch) but the same spec-divergent outcome. I'm filing this issue specifically about the SSR transform because that's the one with the self-flagged try/catch TODO — but flagging the build-side behavior in case it's relevant to a holistic fix.

Transform output from Vite (identical across 7.3.2 and 8.0.0)

__vite_ssr_exportName__("Ns", () => { try { return __vite_ssr_import_1__ } catch {} });
__vite_ssr_exportName__("valueFromA", () => { try { return valueFromA } catch {} });
const __vite_ssr_import_0__ = await __vite_ssr_import__("/modules/cyc-b.js", {"importedNames":["valueFromB"]});
const __vite_ssr_import_1__ = await __vite_ssr_import__("/modules/sub.js");
const valueFromA = 'A:' + __vite_ssr_import_0__.valueFromB;

The getter for Ns is hoisted above the const __vite_ssr_import_1__ = await …. When cyc-b evaluates inside the cycle and reads Ns on the partially-initialized cyc-a module, the getter hits TDZ on __vite_ssr_import_1__, the catch {} swallows, and the getter returns undefined.

Root cause pointer

packages/vite/src/node/ssr/ssrTransform.ts, defineExport helper in transformExports (current main as of 2026-04-21):

function defineExport(name: string, local = name) {
    // wrap with try/catch to fallback to 'undefined' for backward compat.
    s.appendLeft(
        fileStartIndex,
        `${ssrExportNameKey}(${JSON.stringify(name)}, () => { try { return ${local} } catch {} });\n`,
    );
}

History of this line

Real-world impact

Hits as a confusing error in dev SSR for shadcn-svelte-style barrels over bits-ui:

import { Tooltip as TooltipPrimitive } from 'bits-ui';
const Root = TooltipPrimitive.Root;   // TooltipPrimitive === undefined during cycle

[email protected]/dist/bits/tooltip/index.js is literally export * as Tooltip from "./exports.js"; — the exact pattern this bug targets. Users see:

(ssr) Error when evaluating SSR module .../+layout.svelte: undefined is not an object (evaluating '__vite_ssr_import_0__.Tooltip.Root')

The error shape matches what this issue describes: a namespace import that should be the bits-ui Tooltip namespace is undefined, and the downstream destructure (const Root = TooltipPrimitive.Root in the shadcn barrel) crashes. I haven't proven the exact cycle mechanism in every real user report — the trigger in practice is often moduleGraph.invalidateAll() (e.g., via reloadOnTsconfigChange) combined with whatever re-evaluation ordering the runner picks, which may create transient states that look like cycles from the getter's point of view. The minimal source-level cycle above is the smallest deterministic way I found to surface the spec-divergent silent undefined.

Related prior report (closed as "user config", same stack-trace shape on a shadcn barrel, triggered by tailwind.config.js HMR invalidation rather than tsconfig): huntabyte/shadcn-svelte#1718.

Known workarounds (user-side, don't fix root cause)

  • optimizeDeps.include: ['bits-ui'] (or equivalent) — forces pre-bundling, SSR transform never runs on it.
  • Don't destructure the namespace at module top level; read at usage site so init has completed.

Proposed fix directions (maintainer's call)

  1. Remove the try/catch. Let ReferenceError propagate; matches native ESM. Likely regresses v6.3.3 ReferenceError: Cannot access '__vite_ssr_export_default__' before initialization #19929 unless combined with (3).
  2. Throw a Vite-specific error naming the module/export when local is uninitialized — clearer than raw TDZ.
  3. Replace the hoisted-getter approximation with a pattern that keeps the namespace object stable (not undefined) and TDZ-throws only on individual member reads — closest to ESM namespace semantics.

Related issues for hygiene

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-cycle.js
NsKeys = NsFromA_IS_UNDEFINED
maybeA = NsFromA_WAS_UNDEFINED

$ bun --bun test-cycle.js
NsKeys = NsFromA_IS_UNDEFINED
maybeA = NsFromA_WAS_UNDEFINED

# Native ESM comparison (no Vite):
$ node -e "import('./modules/cyc-a.js').then(async () => { const b = await import('./modules/cyc-b.js'); console.log(b.NsKeys, b.maybeA); })"
file:///.../modules/cyc-b.js:7
export const NsKeys = NsFromA ? Object.keys(NsFromA) : 'NsFromA_IS_UNDEFINED';
                                       ^
ReferenceError: Cannot access 'a' before initialization
    at Object.keys (<anonymous>)
    at file:///.../modules/cyc-b.js:7:40

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