vitest-dev / vitest-dev/vitest
vmThreads: file-scoped vi.mock factory from one test file intermittently resolves against another file's imports (isolate: true, v4 & v5)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 17.1k
- Forks
- 2k
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 94
Description
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 directimport * 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
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 + .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.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. - Polluter-side completion does not: completing the polluter factory via
importOriginal()+ spread crashed the victim withError: Vitest mocker was not initialized(1/6 runs).
Related
- #9957 (browser-mode manual-mock leak, fixed by #10267 on the v5 line) — same symptom family, but ours is node-mode vmThreads with
isolate: trueand reproduces on 4.0.18 too. - #11152 (
isolate: falsereceiving another file's mock) — ours reproduces with the defaultisolate: true.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the reproduction files vitest.config.mjs, a-polluter.test.tsx, b-victim.test.tsx, shared-dep.js, and logger.js, then run the provided vmThreads loop to observe the intermittent leak. Trace how file-scoped vi.mock factories and VM contexts are reused between the two test files; done means the victim receives the real module and its console spy consistently across repeated runs.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100