Skip to content

[vitest-pool-workers] Fix module fallback resolving bare specifiers to wrong subpath export#12615

Open
marshallswain wants to merge 1 commit intocloudflare:mainfrom
marshallswain:fix/vitest-pool-workers-subpath-export-collision
Open

[vitest-pool-workers] Fix module fallback resolving bare specifiers to wrong subpath export#12615
marshallswain wants to merge 1 commit intocloudflare:mainfrom
marshallswain:fix/vitest-pool-workers-subpath-export-collision

Conversation

@marshallswain
Copy link

@marshallswain marshallswain commented Feb 20, 2026

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 symlinked node_modules structure causes workerd to join the bare specifier with the referrer directory, and maybeGetTargetFilePath finds the subpath export file before any resolution logic runs.

pnpm layout that triggers the bug

node_modules/
├── my-adapter → .pnpm/my-adapter@1.0.0/node_modules/my-adapter  (symlink)
├── some-lib   → .pnpm/some-lib@1.0.0/node_modules/some-lib      (symlink)
└── .pnpm/
    ├── my-adapter@1.0.0/
    │   └── node_modules/
    │       ├── my-adapter/
    │       │   ├── package.json    ← exports: { ".": "./dist/index.js", "./some-lib": "./dist/some-lib.js" }
    │       │   └── dist/
    │       │       ├── index.js    ← import { createApp } from "some-lib"  (bare specifier → npm package)
    │       │       └── some-lib.js ← subpath export (WRONG target)
    │       └── some-lib → ../../some-lib@1.0.0/node_modules/some-lib  (symlink)
    └── some-lib@1.0.0/
        └── node_modules/
            └── some-lib/
                ├── package.json
                └── index.js        ← the CORRECT target

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. The maybeGetTargetFilePath() 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 walks node_modules to find the actual npm package, reads its package.json, and resolves the correct entry point through the exports map, module, or main fields.

The check is applied at both resolution paths:

  1. After maybeGetTargetFilePath() returns early (the primary bug path)
  2. After viteResolve() returns (a secondary path where Vite's resolver could also match the subpath export)

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: This is a bug fix with no public API changes

@marshallswain marshallswain requested a review from a team as a code owner February 20, 2026 02:08
@changeset-bot
Copy link

changeset-bot bot commented Feb 20, 2026

🦋 Changeset detected

Latest 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

devin-ai-integration[bot]

This comment was marked as resolved.

@marshallswain marshallswain force-pushed the fix/vitest-pool-workers-subpath-export-collision branch from fa9f199 to fee25ec Compare February 20, 2026 02:17
devin-ai-integration[bot]

This comment was marked as resolved.

…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.
@marshallswain marshallswain force-pushed the fix/vitest-pool-workers-subpath-export-collision branch from fee25ec to 8942d63 Compare February 20, 2026 04:40
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 10 additional findings in Devin Review.

Open in Devin Review

Comment on lines +303 to +309
function isBareSpecifier(specifier: string): boolean {
return (
specifier[0] !== "." &&
specifier[0] !== "/" &&
!specifier.includes(":")
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. maybeGetTargetFilePath finds /a/b/c/lodash.js on disk
  2. isBareSpecifier("lodash") returns true
  3. maybeCorrectSubpathCollision finds both files in the same package, then finds node_modules/lodash via findPackageInNodeModules
  4. The function resolves lodash's npm entry point and returns it
  5. The relative import "./lodash" is incorrectly redirected to the npm lodash package

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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments