Skip to content

experimental.bundledDev breaks non-bundled environments in multi-environment setups #22012

@Destreyf

Description

@Destreyf

Description

When experimental.bundledDev is enabled in a multi-environment Vite setup (e.g., client + Cloudflare Workers via @cloudflare/vite-plugin), environments that extend DevEnvironment (rather than FullBundleDevEnvironment) crash because vite:import-analysis is excluded from the shared plugin list.

The Environment API is designed to support heterogeneous environments in a single Vite process, but bundledDev is a global top-level toggle that strips plugins from the shared pipeline, affecting all environments equally — including those that still rely on the standard unbundled transform pipeline.

Reproduction

  1. Create a Vite project with multiple environments — a client environment and a Cloudflare Worker environment (using @cloudflare/vite-plugin)
  2. Enable experimental: { bundledDev: true } in vite.config.ts
  3. Run vite dev

The worker environment crashes with:

Cannot find module 'virtual:cloudflare/nodejs-global-inject/@cloudflare/unenv-preset/node/process'

Root Cause Trace

1. importAnalysisPlugin excluded globally

In resolvePlugins(), the isBundled flag gates several plugins out of the shared list:

// vite/src/node/plugins/index.ts (resolvePlugins)
...isBundled ? [] : [
    clientInjectionsPlugin(config),
    cssAnalysisPlugin(config),
    importAnalysisPlugin(config)    // ← DROPPED when bundledDev is true
]

Where isBundled = config.experimental?.bundledDev || isBuild.

This exclusion is correct for FullBundleDevEnvironment (Rolldown handles its own import resolution), but incorrect for non-bundled environments that still use the standard DevEnvironment transform pipeline in the same Vite process.

2. Virtual module imports are not rewritten

Without importAnalysisPlugin, bare virtual module specifiers (e.g., virtual:cloudflare/...) are never rewritten to their URL form (/@id/__x00__virtual:cloudflare/...).

Normally, importAnalysisPlugin's transform resolves these via resolveId\0virtual:cloudflare/..., then normalizeResolvedIdToUrl calls wrapId/@id/__x00__virtual:cloudflare/.... This URL starts with /, which is critical for the next step.

3. fetchModule misroutes bare specifiers

In fetchModule(), the import specifier is checked:

// fetchModule
if (id.startsWith("file://") || id[0] === "." || id[0] === "/") {
    // → goes through transformRequest (correct path)
} else {
    // → enters tryNodeResolve (wrong path for virtual modules)
}

Without the /@id/ rewrite, virtual:cloudflare/... starts with v, enters the tryNodeResolve branch, and fails because it's not a real Node module.

4. The crash

tryNodeResolve throws: Cannot find module 'virtual:cloudflare/nodejs-global-inject/...'

Why it works without bundledDev

When bundledDev is false, importAnalysisPlugin is included in the shared plugin list. It rewrites virtual:cloudflare/.../@id/__x00__virtual:cloudflare/... during transform. The /@id/ URL starts with /, bypasses tryNodeResolve in fetchModule, and correctly falls through to transformRequest which goes through the full plugin pipeline.

Impact

This blocks adoption of bundledDev for any project that uses Vite's Environment API with mixed environment types — the primary use case the Environment API was designed for. Concretely, this affects:

  • Cloudflare Workers via @cloudflare/vite-plugin (client + worker in one Vite process)
  • Any custom environment that extends DevEnvironment rather than FullBundleDevEnvironment
  • Any environment plugin that registers virtual modules resolved through the standard transform pipeline

The Cloudflare Vite plugin is one of the most prominent consumers of the Environment API. This incompatibility means bundledDev is effectively unusable for a significant portion of the Environment API's target audience.

Suggested Fix

The cleanest resolution would be one of:

  1. Make bundledDev per-environment — move it into DevEnvironmentOptions so only environments that opt in use FullBundleDevEnvironment, while others keep the standard pipeline with importAnalysisPlugin. This aligns with the Environment API's design intent of heterogeneous environments.

  2. Keep importAnalysisPlugin in the shared list — have it no-op when running inside FullBundleDevEnvironment but still execute for standard DevEnvironment instances. Plugins already receive environment context during transform.

  3. Conditionally include/exclude plugins per environment — let the shared plugin pipeline filter based on which environment is invoking it.

Option 1 seems most aligned with the Environment API architecture.

Environment

  • Vite: 8.0.2
  • @cloudflare/vite-plugin: 1.30.0
  • Node.js: 24.x

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions