Skip to content

How Vitest packages are Bundled in Vite+ and What we need to contribute back to Vitest ecosystem #1327

@Brooooooklyn

Description

@Brooooooklyn

Overview

The @voidzero-dev/vite-plus-test package (in packages/test/) bundles Vitest v4.1.2 using a hybrid 3-tier bundling strategy:

  1. COPY — 11 @vitest/* packages are copied verbatim into dist/@vitest/
  2. BUNDLE — 6 leaf dependencies are Rolldown-bundled into dist/vendor/*.mjs
  3. EXTERNAL — ~25 packages are kept external (runtime deps, peer deps, native bindings)

The build is orchestrated by packages/test/build.ts (~2,600 lines) and uses Rolldown as the bundler.


Tier 1: Copied Packages (Copied As-Is)

These 11 @vitest/* packages are copied from node_modules to dist/@vitest/, preserving their original file structure:

Package Purpose
@vitest/runner Test runner core
@vitest/utils Utilities (source-map, error, display, timers, etc.)
@vitest/spy Spy/mock implementation
@vitest/expect Assertion library
@vitest/snapshot Snapshot testing
@vitest/mocker Module mocking (node, browser, automock)
@vitest/pretty-format Output formatting
@vitest/browser Browser testing support
@vitest/browser-playwright Playwright browser provider
@vitest/browser-webdriverio WebdriverIO browser provider
@vitest/browser-preview Preview (testing-library) browser provider

Why Copy Instead of Bundle?

Critical reason: Browser/Node.js code separation.

If these packages were bundled through Rolldown, the bundler would create shared chunks that mix browser-safe code with Node.js-only code (e.g., __vite__injectQuery). When the browser test runner loads these mixed chunks, it crashes with errors like "Identifier already declared" because Node.js-specific code is not valid in browser contexts.

By copying the original file structure, the browser/Node.js separation that upstream Vitest maintains is preserved. The dist/index.js entry stays browser-safe, and dist/index-node.js includes the Node.js-only browser-provider exports.


Tier 2: Bundled Leaf Dependencies

These 6 packages are bundled by Rolldown into individual files under dist/vendor/:

Package Vendor File Purpose
chai dist/vendor/chai.mjs Assertion library (core of expect)
pathe dist/vendor/pathe.mjs Cross-platform path utilities
tinyrainbow dist/vendor/tinyrainbow.mjs Terminal color output
magic-string dist/vendor/magic-string.mjs String manipulation with source maps
estree-walker dist/vendor/estree-walker.mjs AST traversal utility
why-is-node-running dist/vendor/why-is-node-running.mjs Debug tool for hanging processes

Why Bundle These?

These are pure JavaScript leaf dependencies with no native bindings, no environment-specific behavior, and no reason to stay as separate installed packages. Bundling them:

  • Reduces install size (fewer node_modules entries)
  • Eliminates version resolution complexity for consumers
  • They were moved from dependencies to devDependencies since they ship inside the bundle

After bundling, all imports to these packages throughout the copied @vitest/* files are rewritten to point to ../vendor/<package>.mjs relative paths.


Tier 3: External Dependencies (NOT Bundled)

3a. Runtime Dependencies (dependencies in package.json)

These are installed alongside the package at npm install time:

Package Reason NOT Bundled
sirv Static file server with complex runtime behavior — bundling would break its dynamic module loading and middleware patterns
ws WebSocket server — has optional native binding dependencies (bufferutil, utf-8-validate) that can't be bundled
pixelmatch Image comparison library — optional feature (browser visual testing), kept separate to avoid bloating the core bundle
pngjs PNG encoding/decoding — optional feature companion to pixelmatch
es-module-lexer ESM import/export parser — small WASM-based module; bundling WASM modules is fragile and the package is tiny
expect-type Type-level testing utility — small CJS package that needs special default → named export extraction (CJS_REEXPORT_PACKAGES)
obug Debugging utility — very small, not worth the bundling complexity
picomatch Glob pattern matching — very small, widely shared dependency
std-env Environment detection (CI, browser, Node.js) — must run unbundled to correctly detect the runtime environment
tinybench Benchmarking library — optional feature (vitest bench), kept separate
tinyexec Child process execution — small, straightforward runtime dependency
tinyglobby File globbing — small, uses native filesystem APIs that must remain unbundled

3b. Peer/Optional Dependencies (consumers must install)

Package Reason NOT Bundled
playwright Native bindings — includes platform-specific browser binaries; cannot be bundled, user must install matching version
webdriverio Native bindings — platform-specific WebDriver binaries; same reason as playwright
happy-dom Optional peer dependency — alternative DOM implementation for jsdom; only needed if user chooses this environment
jsdom Optional peer dependency — DOM implementation for Node.js testing; large dependency tree, only needed if user opts in
@edge-runtime/vm Optional peer dependency — edge runtime VM for testing edge environments; niche use case
@opentelemetry/api Optional peer dependency — OpenTelemetry instrumentation; only for users who want tracing
@vitest/ui Optional peer dependency — Vitest's web UI dashboard; separate install since most users don't need it
vite Sibling package — resolved at runtime to @voidzero-dev/vite-plus-core (the bundled Vite); cannot self-bundle

3c. Explicitly Blocklisted (EXTERNAL_BLOCKLIST)

These are in build.ts lines 143-172 and are explicitly prevented from being bundled during the Rolldown leaf-dep bundling phase:

Package Specific Reason
@voidzero-dev/vite-plus-core Own package — resolved at runtime via package.json dependencies
@voidzero-dev/vite-plus-core/module-runner Subpath of own core package
vite Alias for the core package (rewritten during import rewriting)
vitest Self-reference — would create circular bundling
debug Environment detection breaks when bundleddebug uses process.env.DEBUG and feature-detects its output target (TTY, browser console, etc.) at module load time. When bundled, the detection code runs in the wrong context and debug() stops producing output
@standard-schema/spec Types-only import — imported by @vitest/expect purely for TypeScript types; has no runtime code to bundle
msw Optional peer dependency of @vitest/mocker — Mock Service Worker is large and only needed if users want MSW-based mocking
msw/browser Subpath of msw
msw/core/http Subpath of msw

3d. Browser Plugin Exclude List (Runtime Optimization)

A separate mechanism from EXTERNAL_BLOCKLIST — these are added to Vite's browser optimizer exclude list (in patchVitestBrowserPackage) to prevent pre-bundling during browser tests:

Package Reason
lightningcss Native bindings — platform-specific compiled binary
@tailwindcss/oxide Native bindings — Rust-compiled Tailwind engine
tailwindcss Transitively pulls in @tailwindcss/oxide (native)
@vitest/browser* Needs the vendor-aliases Vite plugin for correct resolution
@vitest/ui Optional peer dependency
@vitest/mocker/node Imports @voidzero-dev/vite-plus-core which is Node.js-only

Build Pipeline Summary

Source (vitest-dev v4.1.2 + 11 @vitest/* packages)
    │
    ├─ Step 1: bundleVitest()
    │   Copy vitest-dev/dist → dist/
    │   Rewrite `from "vite"` → `from "@voidzero-dev/vite-plus-core"`
    │
    ├─ Step 2: copyVitestPackages()
    │   Copy @vitest/* → dist/@vitest/ (preserves file structure)
    │
    ├─ Step 3: collectLeafDependencies()
    │   Parse all copied files with oxc-parser
    │   Identify external imports (chai, pathe, etc.)
    │   Exclude: Node builtins, @vitest/*, EXTERNAL_BLOCKLIST
    │
    ├─ Step 4: bundleLeafDeps()
    │   Rolldown bundle: chai, pathe, etc. → dist/vendor/*.mjs
    │
    ├─ Step 5: rewriteVitestImports()
    │   @vitest/* → relative paths to dist/@vitest/
    │   Leaf deps → relative paths to dist/vendor/*.mjs
    │   vite → @voidzero-dev/vite-plus-core
    │
    ├─ Steps 6-9: Patching & Post-processing
    │   Fix distRoot paths, inject vendor-aliases plugin,
    │   create browser/node entries, plugin exports, etc.
    │
    └─ Final: validateExternalDeps()
        Scan all output files, verify every external import
        is declared in dependencies or peerDependencies

Output Structure

dist/
├── @vitest/              # 11 COPIED packages (browser/Node.js safe)
│   ├── runner/
│   ├── utils/
│   ├── spy/
│   ├── expect/
│   ├── snapshot/
│   ├── mocker/
│   ├── pretty-format/
│   ├── browser/
│   ├── browser-playwright/
│   ├── browser-webdriverio/
│   └── browser-preview/
├── vendor/               # 6 BUNDLED leaf dependencies
│   ├── chai.mjs
│   ├── pathe.mjs
│   ├── tinyrainbow.mjs
│   ├── magic-string.mjs
│   ├── estree-walker.mjs
│   └── why-is-node-running.mjs
├── plugins/              # 33+ shim files for pnpm overrides
├── chunks/               # Vitest core chunks
├── client/               # Browser client runtime files
├── index.js              # Browser-safe entry (no Node.js code)
├── index-node.js         # Node.js entry (includes browser-provider)
├── module-runner-stub.js # Browser-safe stub
└── browser-compat.js     # @vitest/browser compatibility shim

All Patches Applied to Vitest Dist Files

After copying and bundling, the build applies 19 patching/creation functions to transform upstream Vitest into a fully integrated vite-plus component. Each patch exists for a specific technical reason.

1. bundleVitest() — Vite Import Rewriting (line 436)

What: Copies vitest-dev's dist/ into the package root, rewriting all vite/vitest import specifiers in .js, .mjs, .cjs, .d.ts, .d.cts files during copy.

Why: Vitest imports from "vite" everywhere. In vite-plus, the bundled Vite lives in @voidzero-dev/vite-plus-core. Without this rewrite, vitest would try to resolve upstream vite at runtime and fail.

Transformations:

Before After
from 'vite' from '@voidzero-dev/vite-plus-core'
from 'vite/module-runner' from '@voidzero-dev/vite-plus-core/module-runner'
import('vite') import('@voidzero-dev/vite-plus-core')
require('vite') require('@voidzero-dev/vite-plus-core')
declare module "vite" declare module "@voidzero-dev/vite-plus-core"
import('vitest') import('@voidzero-dev/vite-plus-test')

The import('vitest') rewrite is critical for globals.d.ts, which declares types like typeof import('vitest')['test']. Without it, vitest is not resolvable from the package context in pnpm's strict layout. TypeScript silently treats unresolved dynamic type imports as any, but oxlint's type-aware linting treats them as error types, causing no-unsafe-call errors.

2. brandVitest() — CLI Rebranding (line 492)

What: Patches dist/chunks/cac.*.js (CLI parser) and dist/chunks/cli-api.*.js (banner display) to rebrand vitest CLI output.

Why: When users run vp test, the CLI should show vite-plus branding and version, not upstream vitest. This prevents user confusion about which tool they're running.

Transformations:

Before After
cac("vitest") cac("vp test")
var version = "1.0.0" var version = process.env.VP_VERSION || "1.0.0"
/^vitest\/\d+\.\d+\.\d+$/ /^vp test\/[\d.]+$/
$ vitest --help --expand-help $ vp test --help --expand-help
Leading newline in printBanner() Removed
Conditional banner color logic Always blue with unified label + root path

3. copyVitestPackages() — Preserve File Structure (line 593)

What: Copies dist/ directories from 11 @vitest/* packages in node_modules to dist/@vitest/. Also copies root .d.ts files from @vitest/browser (e.g., context.d.ts, matchers.d.ts).

Why: Bundling these packages would create shared Rolldown chunks mixing browser-safe and Node.js-only code. Copying preserves upstream's careful browser/Node.js separation.

4. convertTabsToSpaces() — Formatting Normalization (line 1371)

What: Replaces all tab characters with two spaces in every dist/**/*.js file.

Why: Subsequent patching functions use space-based string matching patterns. Without normalization, patches that search for specific indented code would fail on files using tabs. This makes all pattern matching reliable and portable.

5. collectLeafDependencies() — Dependency Discovery (line 671)

What: Parses all copied .js files with oxc-parser, extracts every import specifier (static imports, re-exports, dynamic imports), and identifies external npm packages that should be bundled.

Why: Rather than maintaining a manual list of leaf dependencies, this function dynamically discovers which packages are actually imported. This ensures the bundled set stays in sync with upstream changes automatically.

Filtering rules:

  • Include: valid npm package names (bare specifiers)
  • Exclude: relative paths, @vitest/*, vitest/*, vite/*, Node.js builtins, EXTERNAL_BLOCKLIST entries, subpath imports (#)

6. bundleLeafDeps() — Leaf Dependency Bundling (line 782)

What: Runs Rolldown to bundle discovered leaf dependencies into individual dist/vendor/*.mjs files. Also generates .d.ts stubs via rolldown-plugin-dts.

Why: These pure JS packages (chai, pathe, etc.) have no reason to remain as separate installed packages. Bundling them reduces install size and eliminates version resolution issues. Each is bundled independently (not into shared chunks) to maintain the browser/Node.js separation principle.

Config: Platform neutral, treeshake false, externals include Node builtins + blocklist + @vitest/* + vitest/* + vite/*.

7. rewriteVitestImports() — Import Path Rewriting (line 891)

What: Rewrites all import specifiers in dist/@vitest/**, dist/*.js, dist/*.d.ts, and dist/chunks/** using AST-based analysis (oxc-parser). Transforms bare specifiers to relative paths.

Why: After copying and bundling, files are in new locations. Bare specifiers like from '@vitest/runner' or from 'chai' won't resolve from dist/. Every import must become a relative path to the actual file location.

Key rewrites:

Before After
from '@vitest/runner' from '../runner/index.js' (relative)
from 'chai' from '../vendor/chai.mjs' (relative)
from 'vitest' from './index.js' (relative)
from 'vitest/node' from './node.js' (relative)

Special handling:

  • Files inside @vitest/browser/ preserve bare vitest/browser — handled by the vendor-aliases virtual module plugin at runtime
  • @vitest/mocker entry files: removes redundant side-effect imports from vendor
  • All vitest-dev files: strips @voidzero-dev/vite-plus-core/module-runner side-effect imports to prevent browser hanging

8. patchVendorPaths() — Vendor Directory Depth Fix (line 1259)

What: Adjusts relative path calculations in dist/vendor/*.mjs files.

Why: Bundled vendor code inherited path calculations from the original packages that assume files are in dist/. But vendor files are one level deeper at dist/vendor/. Going up "../.." from dist/vendor/file.mjs reaches the wrong directory.

Transformations:

Before After
resolve(fileURLToPath(import.meta.url), "../..") resolve(fileURLToPath(import.meta.url), "../../..")
resolve(__dirname, "context.js") resolve(__dirname, "../context.js")

9. patchVitestCoreResolver() — Module Identity Fix (line 1315)

What: Patches the VitestCoreResolver plugin's resolveId in dist/chunks/cli-api.*.js to recognize vite-plus package names.

Why: CLI's export * from '@voidzero-dev/vite-plus-test' creates a re-export chain that breaks module identity in Vite's SSR transform. When expect.extend() mutates the chai module (adding custom matchers), those changes aren't visible through the re-export chain because SSR creates separate module instances. By resolving vite-plus/test and @voidzero-dev/vite-plus-test directly to dist/index.js, the resolver bypasses the re-export chain and ensures a single module instance.

Added resolution rules:

  • "vite-plus/test"dist/index.js (direct, bypasses re-export)
  • "@voidzero-dev/vite-plus-test"dist/index.js (direct)
  • "vite-plus/test/*" → delegates to "vitest/*" resolution
  • "@voidzero-dev/vite-plus-test/*" → delegates to "vitest/*" resolution

10. patchVitestPkgRootPaths() — Package Root Recalculation (line 1393)

What: Patches dist/@vitest/*/index.js for all 11 copied packages, replacing the pkgRoot/distRoot path calculation.

Why: Original packages calculate their root as resolve(import.meta.url, "../.."), assuming files are at node_modules/@vitest/pkg/dist/index.js. In the bundled output, files are at dist/@vitest/pkg/index.js — the directory structure is different. The original calculation would resolve to the wrong directory.

Before:

const pkgRoot = resolve(fileURLToPath(import.meta.url), "../..");
const distRoot = resolve(pkgRoot, "dist");

After:

const distRoot = dirname(fileURLToPath(import.meta.url));

11. patchVitestBrowserPackage() — Browser Plugin Injection (line 1442)

What: Applies 5 patches to dist/@vitest/browser/index.js:

  1. Injects the vitest:vendor-aliases Vite plugin
  2. Adds native dependency exclusions to the browser optimizer
  3. Removes broken "vitest > expect-type" include patterns
  4. Adds BrowserContext alias handling for vite-plus package paths
  5. Injects VP_VERSION env var to prevent "Running mixed versions" warning

Why (for each):

  1. Vendor aliases plugin: During browser tests, Vite processes imports. The @vitest/* and vitest/* bare specifiers need custom resolution to find the copied files in dist/@vitest/. This plugin intercepts those imports and resolves them to the correct paths. It also provides browser-safe stubs for Node.js-only modules like vite/module-runner.

  2. Native dep exclusions: Packages like lightningcss, @tailwindcss/oxide, and tailwindcss contain native bindings that crash Vite's browser optimizer. They must be excluded from pre-bundling.

  3. Include pattern removal: Vitest uses "vitest > expect-type" patterns to tell Vite's optimizer to bundle specific nested dependencies. These patterns don't work when the dependency tree is restructured by bundling.

  4. BrowserContext aliases: When vitest isn't overridden via pnpm, users import from vite-plus/test or @voidzero-dev/vite-plus-test. The BrowserContext plugin must recognize these aliases to return the correct virtual module.

  5. Version env var: Vitest checks that the CLI version matches the library version. Since vite-plus wraps vitest, the version check would fail without injecting the correct version.

12. patchBrowserProviderLocators() — Browser-Safe Provider Imports (line 1661)

What: Patches dist/@vitest/browser-*/locators.js for playwright, webdriverio, and preview providers.

Why: The original locator files import { page, server } from ../browser/index.js, which contains Node.js server code. In browser context, server doesn't exist. The patch changes the import to use context.js (browser-safe) and replaces server.config.browser.locators with window.__vitest_worker__.config.browser.locators.

Before:

import { page, server } from '../browser/index.js';
// uses server.config.browser.locators

After:

import { page } from '../browser/context.js';
// uses window.__vitest_worker__.config.browser.locators

13. createBrowserCompatShim() — @vitest/browser Override Support (line 1744)

What: Creates dist/browser-compat.js with re-exports of browser-provider symbols.

Why: When this package is used as a pnpm override for @vitest/browser, code that imports from @vitest/browser needs to find exports like defineBrowserProvider, defineBrowserCommand, parseKeyDef, resolveScreenshotPath. This shim re-exports them from the copied @vitest/browser/index.js.

14. createModuleRunnerStub() — Browser-Safe Module Runner (line 1779)

What: Creates dist/module-runner-stub.js with stub implementations of Vite's module runner classes.

Why: The real vite/module-runner contains Node.js-only code (process.platform, Buffer, fs) that crashes browsers. Browser code may import it transitively. The stub provides placeholder classes (EvaluatedModules, ModuleRunner, ESModulesEvaluator) and helper functions that either no-op or throw meaningful errors in the browser.

15. createNodeEntry() — Node.js-Specific Entry Point (line 1842)

What: Creates dist/index-node.js that re-exports everything from index.js plus browser-provider symbols from @vitest/browser/index.js.

Why: Browser code must use index.js (safe). Node.js code (CLI, config loading) needs additional exports like defineBrowserProvider, defineBrowserCommand, etc. The conditional export in package.json ("node": "./dist/index-node.js") routes Node.js to this file automatically. The "browser" condition is placed BEFORE "node" because vitest passes custom --conditions browser to worker processes (e.g., Nuxt edge/cloudflare presets). Without this ordering, Node.js would match "node" first, loading index-node.js which imports ws, but with --conditions browser, ws resolves to its browser stub that doesn't export WebSocketServer, causing a SyntaxError.

16. patchModuleAugmentations() — TypeScript Module Augmentation Fix (line 2100)

What: Rewrites TypeScript module augmentation declarations in dist/chunks/global.d.*.d.ts and adds BrowserCommands re-export to dist/@vitest/browser/context.d.ts.

Why: TypeScript module augmentations use bare specifiers like declare module "@vitest/expect". Since @vitest/expect is bundled inside dist/@vitest/expect/, the bare specifier doesn't resolve. The patch rewrites augmentations to use relative paths (declare module "../@vitest/expect/index.js") and merges the augmented interfaces directly into the target .d.ts files so TypeScript can find them.

17. patchChaiTypeReference() — Chai Type Resolution (line 2300)

What: Adds /// <reference types="chai" /> to dist/@vitest/expect/index.d.ts.

Why: @vitest/expect types use the Chai namespace (e.g., Chai.ChaiPlugin) without explicit reference. When @vitest/expect is bundled inside the package, TypeScript can't automatically discover @types/chai. Without the reference directive, chai-specific features like the .not property are missing from type completions.

18. patchMockerHoistedModule() — Mock API Source Recognition (line 2331)

What: Patches the hoistMocks equality check in dist/@vitest/mocker/node.js (or its chunk).

Why: Vitest's mocker checks if vi is imported from 'vitest' to validate mock API usage. When users import from 'vite-plus/test' or '@voidzero-dev/vite-plus-test', the check fails and throws "There are some problems in resolving the mocks API". The patch adds these package names as valid sources.

Before:

if (hoistedModule === source) {

After:

if (hoistedModule === source || source === "vite-plus/test" || source === "@voidzero-dev/vite-plus-test") {

19. patchServerDepsInline() — Module Identity for expect.extend() (line 2387)

What: Patches dist/chunks/cli-api.*.js to auto-inline specific third-party packages in the ModuleRunnerTransform plugin's configResolved handler.

Why: Libraries like @testing-library/jest-dom, @storybook/test, and jest-extended call expect.extend() internally to add custom matchers. Under npm/pnpm override, these libraries create separate module instances of chai — one from the library's own node_modules and one from vite-plus's bundled version. When expect.extend() registers matchers on the library's chai instance, tests using vite-plus's chai instance don't see the matchers. Auto-inlining forces these libraries through Vite's module runner, which resolves them to the same chai instance.

Auto-inlined packages:

  • @testing-library/jest-dom
  • @storybook/test
  • jest-extended

The patch uses createRequire().resolve() to check if each package is installed before adding it, so uninstalled packages are silently skipped.

20. createPluginExports() — pnpm Override Shims (line 2455)

What: Creates dist/plugins/*.mjs shim files, one for each @vitest/* package and subpath.

Why: pnpm overrides redirect @vitest/runner@voidzero-dev/vite-plus-test. But the package's main export is the vitest entry, not @vitest/runner. These shim files provide dedicated export paths (./plugins/runner) that re-export from the correct copied file (../@vitest/runner/index.js).

21. mergePackageJson() — Package Metadata Assembly (line 250)

What: Merges upstream vitest's package.json fields (exports, peerDependencies, engines, etc.) into the vite-plus-test package.json. Adds conditional Node.js/browser exports, browser-provider exports, plugin exports, and records the bundled vitest version.

Why: The published package needs to expose all of vitest's export paths so consumers can import any vitest/* subpath. It also removes bundled browser providers from peerDependencies (users don't need to install them separately) and adds custom export paths for vite-plus-specific usage patterns.

Key decisions in this function:

  • "browser" condition placed BEFORE "node" in exports to handle Nuxt edge/cloudflare --conditions browser (see issue vp test fails with ws CJS named export error when nitro.preset is cloudflare_module #831)
  • @vitest/browser-playwright, @vitest/browser-webdriverio, @vitest/browser-preview removed from peerDependencies (now bundled)
  • Vitest's dependencies merged into devDependencies (since they're bundled), except packages already in runtime dependencies

22. validateExternalDeps() — Build Integrity Check (line 2495)

What: Scans all dist/**/*.{js,mjs,cjs} files with oxc-parser, extracts every external import specifier, and verifies each is declared in dependencies or peerDependencies.

Why: After all the copying, bundling, and rewriting, an undeclared external dependency would silently fail at runtime in consumer projects. This validation catches any dependency that was missed — whether from a vitest upgrade introducing a new dep, or a rewriting bug leaving a bare specifier intact.


TODO: Patches That Could Be Contributed Back to Upstream Vitest

The patches above fall into two categories: vite-plus-specific (namespace rewriting, bundling mechanics) and genuine upstream improvements that benefit all vitest users. Below is the analysis.

Clearly Upstreamable

TODO 1 Exclude native bindings from browser optimizer (patchVitestBrowserPackage)

What: Add lightningcss, @tailwindcss/oxide, and tailwindcss to the browser optimizeDeps.exclude list in @vitest/browser.

Why upstream needs this: Upstream vitest's exclude list (in @vitest/browser/dist/index.js) does NOT include these packages. When users run vitest browser mode in a project using Tailwind CSS v4 or Lightning CSS, Vite's dependency optimizer tries to pre-bundle these native-binding packages, which crashes because native .node binaries can't be optimized. This affects all vitest browser mode users with these dependencies, not just vite-plus.

Evidence: Upstream exclude list only has: vitest, vitest/browser, @vitest/*, std-env, tinybench, tinyspy, tinyrainbow, pathe, msw, msw/browser. No native binding packages.

Contribution form: PR to packages/browser/src/node/plugin.ts in vitest-dev/vitest adding these to the exclude array.

TODO 2: Make mocker hoistMocks source check extensible (patchMockerHoistedModule)

What: The hoistMocks function in @vitest/mocker hardcodes hoistedModule = "vitest" and checks if (hoistedModule === source). This means any wrapper package that re-exports vitest's vi object (not just vite-plus, but any custom test harness) will fail with "There are some problems in resolving the mocks API."

Why upstream needs this: The hardcoded check prevents the vitest ecosystem from building wrapper packages. The hoistMocks options already accept hoistedModule as a parameter, but the default is hardcoded and there's no mechanism for wrapper packages to register alternative source names. A more flexible approach (e.g., accepting an array of valid source names, or a callback) would enable the ecosystem.

Evidence: @vitest/mocker/dist/chunk-hoistMocks.js line 368: hoistedModule = "vitest" with strict equality on line 384.

Contribution form: PR to @vitest/mocker to accept hoistedModule as string | string[], or add a hoistedModules array option. This is a non-breaking change since the existing string option still works.

TODO 3: Auto-inline packages that use expect.extend() (patchServerDepsInline)

What: Automatically add @testing-library/jest-dom, @storybook/test, and jest-extended to server.deps.inline when they're installed.

Why upstream needs this: The module identity problem with expect.extend() affects any vitest user using npm/pnpm overrides (not just vite-plus). When these libraries call require('vitest').expect.extend(matchers), the override mechanism can create a separate module instance, so matchers register on a different chai instance than the test runner uses. This manifests as "expect(...).toBeInTheDocument is not a function" — a common vitest issue. Auto-inlining ensures these libraries share the same module instance.

Evidence: The issue is well-documented in vite-plus issue #897, but the root cause (module identity under overrides) is a general vitest concern.

Contribution form: PR to vitest's ModuleRunnerTransform plugin (configResolved handler) to auto-detect and inline known expect.extend() packages. Include a test.server.deps.autoInline config option to control this behavior.

TODO 4: Export condition ordering for --conditions browser (mergePackageJson)

What: The "browser" export condition must come before "node" in vitest's package.json exports.

Why upstream needs this: When frameworks like Nuxt set edge/cloudflare presets, vitest passes --conditions browser to worker processes. Node.js resolvers check conditions in order — if "node" comes before "browser", Node.js matches "node" first, loads the Node.js entry which imports ws, but with --conditions browser active, ws resolves to its browser stub (ws/browser.js) that doesn't export WebSocketServer, causing a SyntaxError. This affects all vitest users with Nuxt edge/cloudflare presets.

Evidence: Documented in vite-plus issue #831. Upstream vitest's package.json doesn't have separate browser/node entries for the main export (it uses a single "import" field), so upstream may not hit this today — but if vitest ever adds conditional exports (which is a growing pattern), the ordering matters.

Contribution form: If/when vitest adds conditional exports, ensure "browser" precedes "node". Could also be contributed as documentation or a test case.

Potentially Upstreamable (Design Improvements)

TODO 5: Browser-safe module-runner stub (createModuleRunnerStub)

What: Provide an official browser-safe stub for vite/module-runner that exports placeholder classes instead of Node.js-only code.

Why upstream could benefit: The real vite/module-runner contains process.platform, Buffer, and fs imports that crash browsers. Any vitest browser mode code that transitively imports vite/module-runner will hang. Currently upstream vitest avoids this through careful import structure, but it's fragile — any refactoring that accidentally pulls in module-runner will break browser mode silently.

Contribution form: PR to vite itself to provide a "browser" export condition for vite/module-runner that exports safe stubs, similar to how ws provides ws/browser.js.

TODO 6: Browser provider locators should not import server (patchBrowserProviderLocators)

What: The locator files in @vitest/browser-playwright, @vitest/browser-webdriverio, and @vitest/browser-preview import { page, server } from vitest/browser. In browser context, server is unnecessary — the config is available via window.__vitest_worker__.config.

Why upstream could benefit: Importing server from the main browser index pulls in Node.js server code into the browser module graph. While upstream vitest avoids issues through its module resolution, this coupling is architecturally fragile. If the locator files used window.__vitest_worker__.config.browser.locators instead of server.config.browser.locators, it would be cleaner and more resilient.

Evidence: @vitest/browser-playwright/dist/locators.js line 2: import { page, server } from 'vitest/browser';

Contribution form: PR to @vitest/browser-* locator implementations to use window.__vitest_worker__.config instead of the server import for configuration access.

NOT Upstreamable (Vite-Plus-Specific)

These patches exist solely because of vite-plus's bundling strategy (embedding vitest inside a different package namespace) and have no upstream equivalent:

Patch Why It's Vite-Plus-Specific
bundleVitest() Rewrites vite@voidzero-dev/vite-plus-core (namespace change)
brandVitest() Rebrands CLI to "vp test" (product branding)
copyVitestPackages() Copies @vitest/* to dist/ (bundling strategy)
convertTabsToSpaces() Normalizes formatting for subsequent patches
collectLeafDependencies() Discovers deps to bundle (bundling strategy)
bundleLeafDeps() Bundles chai, pathe, etc. into vendor/ (bundling strategy)
rewriteVitestImports() Rewrites bare specifiers to relative paths (bundling strategy)
patchVendorPaths() Fixes depth calculation for vendor/ subdirectory
patchVitestCoreResolver() Adds vite-plus package name recognition to resolver
patchVitestPkgRootPaths() Fixes distRoot after file relocation
patchVitestBrowserPackage() (vendor-aliases) Injects plugin for custom resolution of relocated packages
createBrowserCompatShim() Shim for @vitest/browser pnpm override
createNodeEntry() Separate browser/node entry points for bundled package
patchModuleAugmentations() Fixes TS augmentations using bare specifiers that don't resolve after bundling
patchChaiTypeReference() Adds /// <reference types="chai" /> lost during bundling
createPluginExports() Creates shim files for pnpm overrides
mergePackageJson() Assembles package.json for published package
validateExternalDeps() Build integrity check for bundled output

Key Design Decisions

  1. Browser/Node.js separation is the primary architectural constraint. The entire hybrid strategy exists because Rolldown's chunk-splitting would mix browser and Node.js code, causing runtime crashes in browser test mode.

  2. Import rewriting is pervasive. Every import to vite, @vitest/*, and leaf dependencies is rewritten to point to the correct location within the dist/ tree. This is handled by rewriteVitestImports() using oxc-parser for accurate AST-based transformations.

  3. vendor-aliases plugin is injected at runtime. Since imports are rewritten to relative paths within dist/, a Vite plugin (vendor-aliases) is injected into @vitest/browser to resolve @vitest/* and vitest/* imports correctly when Vite processes them during browser tests.

  4. Build-time validation catches drift. validateExternalDeps() scans all bundled files and verifies every external import is declared in dependencies or peerDependencies, preventing silent runtime failures when upgrading Vitest.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions