[vitest-pool-workers] Fix module fallback resolving bare specifiers to wrong subpath export#12615
Conversation
🦋 Changeset detectedLatest commit: 8942d63 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
fa9f199 to
fee25ec
Compare
…o wrong subpath export When a dependency has both an npm dependency and a subpath export with the same name (e.g. dependency "some-lib" and subpath export "./some-lib"), the module fallback service incorrectly resolves the bare specifier to the subpath export file instead of the actual npm package. This is triggered by pnpm's symlinked node_modules structure, which causes workerd to join the bare specifier with the referrer directory, accidentally matching the subpath export file. The fix detects this collision by checking whether a bare specifier resolved to a file within the same package as the referrer, and if so, walks the node_modules tree to find and resolve the actual npm package.
fee25ec to
8942d63
Compare
| function isBareSpecifier(specifier: string): boolean { | ||
| return ( | ||
| specifier[0] !== "." && | ||
| specifier[0] !== "/" && | ||
| !specifier.includes(":") | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔴 isBareSpecifier misclassifies same-directory relative imports, causing incorrect module redirection
The isBareSpecifier function at line 303-309 cannot distinguish between bare specifiers (e.g. import "lodash") and same-directory relative imports (e.g. import "./lodash"), because getApproximateSpecifier (module-fallback.ts:235-240) strips the ./ prefix from relative imports.
Root Cause and Impact
As documented in the comment at module-fallback.ts:222-223:
| import "./dep.mjs" | /a/b/c/dep.mjs | dep.mjs |
| import "pkg" | /a/b/c/pkg | pkg |
Both import "./lodash" and import "lodash" produce the same specifier "lodash" from getApproximateSpecifier, since posixPath.relative("/a/b/c", "/a/b/c/lodash") returns "lodash" in both cases. The isBareSpecifier("lodash") check returns true for both.
When a package has a local file like ./lodash.js (e.g., a wrapper around lodash) and imports it with import "./lodash", the following happens:
maybeGetTargetFilePathfinds/a/b/c/lodash.json diskisBareSpecifier("lodash")returnstruemaybeCorrectSubpathCollisionfinds both files in the same package, then findsnode_modules/lodashviafindPackageInNodeModules- The function resolves lodash's npm entry point and returns it
- The relative import
"./lodash"is incorrectly redirected to the npmlodashpackage
Impact: Any same-directory relative import whose filename (without extension) matches an installed npm package name will be silently redirected to the npm package instead of the local file. For example, import "./debug", import "./path", import "./utils" could all be affected if matching npm packages exist in node_modules.
Prompt for agents
In packages/vitest-pool-workers/src/pool/module-fallback.ts, the isBareSpecifier function at lines 303-309 cannot distinguish between bare specifiers and same-directory relative imports because getApproximateSpecifier (line 235-240) strips the ./ prefix. For example, both import "lodash" and import "./lodash" produce the specifier "lodash".
To fix this, the check needs additional context. One approach is to compare the specifier against the original target and referrer to determine if it was a relative import. Specifically, if the target equals posixPath.join(referrerDir, specifier), then it could be either a bare specifier or a relative import. But if the resolved file path is literally the target with an extension appended (i.e., the file is in the referrer's directory), it's more likely a relative import.
A more robust approach would be to check whether the specifier, when treated as a bare package name, actually exists in node_modules BEFORE checking the subpath collision. If findPackageInNodeModules finds the package AND the resolved file is in the same package as the referrer, only then should the correction be applied. This is already partially done in maybeCorrectSubpathCollision, but the issue is that the function is too aggressive - it should also verify that the resolved file path matches a known subpath export pattern in the referrer's package.json exports field, rather than assuming any same-package resolution is a collision.
Alternatively, modify maybeCorrectSubpathCollision (line 367-428) to read the referrer's package.json exports field and verify that the specifier actually matches a subpath export key (e.g., check if exports has a key like ./<specifier>). Only if such a subpath export exists should the collision correction be applied. This would prevent false positives from relative imports.
Was this helpful? React with 👍 or 👎 to provide feedback.
Fixes a bug where the module fallback service resolves bare specifiers to wrong subpath exports.
When a dependency has both an npm dependency and a subpath export with the same name (e.g., dependency
"some-lib"and subpath export"./some-lib"), the module fallback service could resolve the bare specifier to the subpath export file instead of the actual npm package. This is particularly triggered when using pnpm, whose symlinkednode_modulesstructure causes workerd to join the bare specifier with the referrer directory, andmaybeGetTargetFilePathfinds the subpath export file before any resolution logic runs.pnpm layout that triggers the bug
Root cause
workerd sends a module fallback request with the bare specifier
"some-lib"and the referrer path (e.g.,.../my-adapter/dist/index.js). The fallback handler joins these to produce a target path like.../my-adapter/dist/some-lib. ThemaybeGetTargetFilePath()function then tries file extensions and finds.../my-adapter/dist/some-lib.js— which is the subpath export file, not the npm package.Fix
Added a
maybeCorrectSubpathCollision()check that detects when a resolved path lands inside the same package as the referrer (indicating a likely subpath export collision). When detected, it walksnode_modulesto find the actual npm package, reads itspackage.json, and resolves the correct entry point through theexportsmap,module, ormainfields.The check is applied at both resolution paths:
maybeGetTargetFilePath()returns early (the primary bug path)viteResolve()returns (a secondary path where Vite's resolver could also match the subpath export)