Skip to content

[Bug]: bundledDev emits a cyclic ESM import used at module-init as unbound (X is not defined) #9946

Description

@ShMcK

Reproduction link or steps

Minimal, self-contained (pure ESM, no CJS/barrel/dynamic-import). 4 files:

vite.config.js

export default { experimental: { bundledDev: true } };

index.html

<!doctype html><html><body><div id="app"></div>
<script type="module" src="/src/main.js"></script></body></html>

src/main.js

import { defaults } from './a.js';
document.getElementById('app').textContent = 'button=' + defaults.button.name;

src/a.js

import { B } from './b.js';
// top-level (module-init) use of the cyclic import — this is what breaks
export const defaults = { button: B };

src/b.js

import { defaults } from './a.js'; // cycle back to a.js
export class B {}
export const usesA = () => defaults;  // used only inside a fn -> bound fine

Run vite (dev), open the page.

What is expected?

Page renders button=B. B resolves to the class exported by b.js, as it does
in classic Vite dev (rollup+esbuild) and in vite build.

What is actually happening?

ReferenceError: B is not defined.

The served entry chunk /assets/index.js emits:

var defaults = { button: B }   // B referenced...

…but class B is not declared anywhere in that chunk (it lives in b.js's
separate lazy chunk). So the value-position reference to the cyclic import is
emitted unbound.

Key detail: only imports used during module initialization (top-level) break —
export const x = { button: B }, class X extends B, const y = makeThing(B), etc.
Sibling imports from the same module that are referenced only inside function
bodies
are bound correctly. The unbound one happens to participate in an import
cycle.

This is experimental.bundledDev only; experimental.devMode.lazy: false does not
change it. Classic dev avoids it (esbuild optimizeDeps flattens dep cycles + native
per-module ESM live bindings).

Real-world impact

Surfaced trying to enable bundledDev on a large app. The same unbound-cyclic-import
pattern broke, in cascade, @sentry/core (class ServerRuntimeClient extends BaseClient
BaseClient is not defined; also defineIntegration), @datadog/browser-core
(ONE_SECOND), and first-party code (export const defaultProps = { buttonClass: SelectButton }
across a package cycle). It is widespread enough that per-package workarounds don't scale.

System Info

vite 8.1.0
rolldown 1.1.2 (latest published)
@vitejs/plugin-react 6.0.3 (not required — repro above has no React)

Any additional comments?

Possibly related: #9691 (lazy mode drops an import → node_http2 is not defined).
#9485 (circular import in vite dev) was closed as fixed by #9419, but that was the
CJS-interop variant — this pure-ESM cycle still reproduces on 1.1.2.

Metadata

Metadata

Assignees

Type

Fields

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions