Skip to content

vmThreads: file-scoped vi.mock factory from one test file intermittently resolves against another file's imports (isolate: true, v4 & v5) #11284

Description

@Jackela

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.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.
  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

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions