Skip to content

fix: don't treat bare specifiers as Vitest imports when dist is outside the root - #11159

Closed
amanprime07 wants to merge 1 commit into
vitest-dev:mainfrom
amanprime07:fix/cached-vitest-import-relative-root
Closed

amanprime07 wants to merge 1 commit into
vitest-dev:mainfrom
amanprime07:fix/cached-vitest-import-relative-root

Conversation

@amanprime07

@amanprime07 amanprime07 commented Sep 6, 2026 •

Copy link
Copy Markdown

Description

In a monorepo, a bare Node builtin import such as import { PassThrough } from "stream" can fail with:

Error: Cannot find module '/path/to/repo/packages/asset-service/stream'
imported from /path/to/repo/node_modules/vitest/dist/module-evaluator.js

...while import { PassThrough } from "node:stream" works, and the same file works in a sibling package.

Root cause

getCachedVitestImport in packages/vitest/src/runtime/moduleRunner/cachedResolver.ts computes:

const relativeRoot = relativeIds[root] ?? (relativeIds[root] = normalizedDistDir.slice(root.length))

This assumes the dist directory lives inside the project root (the comment below it refers to /node_modules/.pnpm/vitest/dist). In a monorepo, Vitest is hoisted to the repository root while config.root is the package directory, so distDir is not under root. The slice then returns an arbitrary suffix of the dist path, which is subsequently used as a prefix test:

if (relativeRoot && relativeRoot !== '/' && id.startsWith(relativeRoot)) {
  const path = join(root, file)
  const externalize = `${pathToFileURL(path)}${postfix}`   // -> file://<root>/stream

Because relativeRoot is always a suffix of …/vitest/dist, the only values that collide with real specifiers are "t" and "st" — which are prefixes of the builtins timers, tls, trace_events, tty, stream and string_decoder.

Node builtins are the only ids that reach this function unresolved (everything else is resolved to an absolute path first), so the impact is limited to bare builtin imports — but it is silent and confusing when it hits.

The affected builtins depend on the character length of the project root, so the same repository breaks differently depending on where it is checked out. Observed in one monorepo:

relativeRoot root length builtins shadowed
"st" 81 stream, string_decoder
"t" 82 timers, tls, trace_events, tty

node:-prefixed specifiers never match a …/vitest/dist suffix, which is why they always work.

Minimal reproduction

mkdir -p /tmp/mono/packages/asset-service/src && cd /tmp/mono
echo '{ "name": "mono", "private": true, "version": "1.0.0" }' > package.json
npm install vitest@latest

cd packages/asset-service
echo '{ "name": "asset-service", "private": true, "version": "1.0.0", "type": "module" }' > package.json
printf 'import { defineConfig } from "vitest/config";\nexport default defineConfig({ test: { environment: "node", include: ["src/**/*.test.ts"] } });\n' > vitest.config.ts
printf 'import { PassThrough } from "stream";\nimport { expect, it } from "vitest";\nit("bare stream", () => { expect(typeof PassThrough).toBe("function"); });\n' > src/bare.test.ts

/tmp/mono/node_modules/.bin/vitest run src/bare.test.ts

The directory name asset-service is chosen so that distDir.slice(root.length) === "st"; with /tmp/mono the package path is 32 characters and the dist path is 34. Renaming the package directory to a different length makes the failure disappear.

Reproduced on 3.2.0 → 3.2.7, 4.1.11 and 5.0.0. Not present in 2.0.5, 3.0.0 or 3.1.0 (the code was introduced in 3.2.0).

Fix

Only derive the relative dist path when the dist directory is genuinely inside the root; otherwise leave it empty so the branch is skipped. The intended <root>/node_modules/.pnpm/vitest/dist case is unchanged.

Two details worth noting:

  • the comparison is done on a path boundary, so a sibling directory whose name is a prefix of the root no longer matches either — with root=<...>/node_modules/vite and dist=<...>/node_modules/vitest/dist the old slice produced "st/dist"
  • root is normalized before comparing, because distDir is built with node:path (back-slashed on Windows) while config.root comes from pathe

Tests

test/unit/test/cached-vitest-import.test.ts covers both directions:

  • bare builtins (stream, stream/promises, tls, timers) are not externalized when dist is outside the root
  • a sibling directory whose name is a prefix of the root does not match
  • ids relative to a root that does contain dist are still externalized, as are absolute dist ids and bare vitest specifiers

Reverting just the implementation change and re-running gives 3 failed / 2 passed: the three guards fail, while the two "still externalizes" tests keep passing — i.e. the change only removes the broken path and leaves the intended behaviour intact.

Verification

  • test/unit: 649 files / 7701 tests passed, no failures
  • test/workspaces: 20 files / 25 tests, and 1 file / 3 tests, passed
  • test/node-runner: 1 passed, 0 failed
  • test/e2e: run against both main and this branch. The set of failing files is identical. Each apparently-new failure was reproduced on unmodified main individually (group-order, list, no-unexpected-logging, vm-threads, snapshots/domain-aria-inline); list and vm-threads are flaky locally (2/1/0 and pass/pass/fail across repeated runs) and the vm-threads failure is ReferenceError: gc is not defined. These look environment-related on my machine rather than related to this change.
  • Pre-existing tsc errors in src/node/pools/browser.ts (Property 'send' does not exist on type 'CDPSession') are present on main as well and are unrelated.

Marked as a draft: I would appreciate a maintainer sanity-check on whether returning '' is preferable to leaving the value undefined/skipping the cache entry, and whether you would like the test placed somewhere other than test/unit.

…de the root

getCachedVitestImport derives a "root relative" dist path with
normalizedDistDir.slice(root.length), which assumes the dist directory lives
inside the project root. In a monorepo Vitest is hoisted above the project
root, so the slice returns an unrelated suffix of the dist path ("st", "t")
that is then used as a prefix test against every import specifier.

Node builtins are the only ids that reach this function unresolved, so a bare
import such as "stream" or "tls" matches that suffix and is externalized to
<root>/stream, failing with ERR_MODULE_NOT_FOUND. Which builtins break depends
on the character length of the project root, so it varies per checkout path.

Only derive the relative dist path when the dist directory is actually inside
the root, comparing on a path boundary so a sibling directory whose name is a
prefix of the root (node_modules/vite vs node_modules/vitest) no longer
matches either. The root is normalized so the comparison also holds on
Windows, where distDir is built with node:path but config.root comes from
pathe.
@amanprime07
amanprime07 force-pushed the fix/cached-vitest-import-relative-root branch from 66abc54 to d1818ea Compare September 6, 2026 08:28
@amanprime07

Copy link
Copy Markdown
Author

CI note: the failing Test: unit, node-24, windows-latest job is not caused by this change.

The Vitest suite itself passes on Windows — Test Files 649 passed | 2 skipped (651), which is main's 646 plus this PR's one new test file across the three pools. The job's red comes from the Playwright spec that runs in the same job:

1) [chromium] › test\ui.spec.ts:130:3 › ui › suite report navigation
   at testSuiteReport (test\ui\test\ui.spec.ts:519:24)

That same test fails on main at 7c818153a, the commit this branch is based on (run 33958379769), in the same testSuiteReport helper.

I have deliberately not touched test/ui/test/ui.spec.ts here: it is unrelated to this fix, and the symptom (the report panel showing "All tests passed in this suite" where "failing nested suite" is expected) looks like wrong content rather than merely late content, so raising its timeout could mask a real UI issue rather than fix a flake.

For reference, an earlier push of this branch did fail on Windows for a real reason of mine: the test derived its root from distDir, which node:path back-slashes on Windows, while the resolver compares against the pathe-normalized form. That is fixed — the implementation now normalizes root, so the comparison holds on both platforms.

@github-actions github-actions Bot locked and limited conversation to collaborators Sep 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant