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)

Open
#11,284 0 comments 0 reactions 0 assignees View on GitHub

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):

  1. Split registry within one file's graph: the victim's transitive import (shared-dep.jslogger.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.
  2. 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
  • #9957 (browser-mode manual-mock leak, fixed by #10267 on the v5 line) — same symptom family, but ours is node-mode vmThreads with isolate: true and reproduces on 4.0.18 too.
  • #11152 (isolate: false receiving another file's mock) — ours reproduces with the default isolate: 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.