Describe the bug
With pool: "vmThreads" and default isolation (isolate: true), a file-scoped vi.mock(path, factory) registered by test file A intermittently applies to test file B's module graph when both files run in the same worker. B declares no mock for that module. Observed on both 4.0.18 and 5.0.1 (not a v5 regression), at a ~3-10% rate depending on worker grouping.
Two additional observations from a larger real-world suite (jsdom + @vitejs/plugin-react, where we first hit this):
- Split registry within one file's graph: the victim's transitive import (
shared-dep.js → logger.js) received the polluter's factory while the victim's direct import * as ns from "logger.js" still received the real module — in the same test file, in the same run.
- Polluter-context closure evaluation: the leaked factory's closure evaluates in the polluter's VM context — its
console reference is the polluter context's console, so the victim's vi.spyOn(console, "info") cannot observe its output. This is the observable signature that made this flaky rather than deterministic.
Reproduction
Plain two-file setups (no React plugin, plain .js) did not reproduce in 22 runs. The trigger combination we isolated includes @vitejs/plugin-react + .tsx test files + vi.hoisted:
mkdir vmleak-repro && cd vmleak-repro && npm init -y >/dev/null && npm pkg set type=module
npm i -D vitest@5.0.1 jsdom @vitejs/plugin-react
cat > vitest.config.mjs <<'JS'
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: { environment: "jsdom", pool: "vmThreads", globals: true },
});
JS
cat > logger.js <<'JS'
export function who() {
console.info("served-by:real-module");
return "real";
}
JS
cat > shared-dep.js <<'JS'
import { who } from "./logger.js";
export function useLogger() {
return who();
}
JS
cat > a-polluter.test.tsx <<'JS'
import { vi, test, expect } from "vitest";
const mockTag = vi.hoisted(() => vi.fn(() => "shared"));
vi.mock("./logger.js", () => ({
who: () => {
console.info("served-by:polluter-factory");
return mockTag();
},
}));
test("polluter", () => {
expect(mockTag()).toBe("shared");
});
JS
cat > b-victim.test.tsx <<'JS'
import { expect, vi, test } from "vitest";
import { useLogger } from "./shared-dep.js";
import * as loggerModule from "./logger.js";
test("no leaked factory", () => {
expect(vi.isMockFunction(loggerModule.who)).toBe(false);
});
test("own console", () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
expect(useLogger()).toBe("real");
expect(spy).toHaveBeenCalledTimes(1);
});
JS
for i in $(seq 1 30); do npx vitest run --maxWorkers=1 a-polluter.test.tsx b-victim.test.tsx >/dev/null 2>&1 || echo "run $i: leaked"; done
Observed: 2/30 runs fail with expected 'shared' to be 'real' (in the real-world suite: 3/6 for the file pair, ~3-10% in full runs; seeds alone don't pin it — it's worker-scheduling dependent).
Workarounds we found
- Victim-side pin works: a file that declares its own
vi.mock for the module (even a passthrough vi.mock(p, async (importOriginal) => await importOriginal())) appears immune — the leak only hits files that declare no mock for that module.
- Polluter-side completion does not: completing the polluter factory via
importOriginal() + spread crashed the victim with Error: Vitest mocker was not initialized (1/6 runs).
Related
System Info
System: macOS 15 (arm64)
Node: v24.19.0
Vitest: 4.0.18 and 5.0.1 (both reproduce)
Pool: vmThreads (wrapper forces it; config default falls back to forks)
Environment: jsdom
Plugins: @vitejs/plugin-react ^5.2.0 (present in trigger combination)
Package Manager: npm
Happy to run further diagnostics (probe patches, verbose registry logs) if that helps pinpoint the registry reuse.
Describe the bug
With
pool: "vmThreads"and default isolation (isolate: true), a file-scopedvi.mock(path, factory)registered by test file A intermittently applies to test file B's module graph when both files run in the same worker. B declares no mock for that module. Observed on both 4.0.18 and 5.0.1 (not a v5 regression), at a ~3-10% rate depending on worker grouping.Two additional observations from a larger real-world suite (jsdom + @vitejs/plugin-react, where we first hit this):
shared-dep.js→logger.js) received the polluter's factory while the victim's directimport * as ns from "logger.js"still received the real module — in the same test file, in the same run.consolereference is the polluter context's console, so the victim'svi.spyOn(console, "info")cannot observe its output. This is the observable signature that made this flaky rather than deterministic.Reproduction
Plain two-file setups (no React plugin, plain
.js) did not reproduce in 22 runs. The trigger combination we isolated includes@vitejs/plugin-react+.tsxtest files +vi.hoisted:Observed: 2/30 runs fail with
expected 'shared' to be 'real'(in the real-world suite: 3/6 for the file pair, ~3-10% in full runs; seeds alone don't pin it — it's worker-scheduling dependent).Workarounds we found
vi.mockfor the module (even a passthroughvi.mock(p, async (importOriginal) => await importOriginal())) appears immune — the leak only hits files that declare no mock for that module.importOriginal()+ spread crashed the victim withError: Vitest mocker was not initialized(1/6 runs).Related
isolate: trueand reproduces on 4.0.18 too.isolate: false, a test file that declares novi.mockreceives another file's mock, and can't opt out #11152 (isolate: falsereceiving another file's mock) — ours reproduces with the defaultisolate: true.System Info
Happy to run further diagnostics (probe patches, verbose registry logs) if that helps pinpoint the registry reuse.