Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/fix-exports-field-parent-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"enhanced-resolve": patch
---

fix: prevent fallback to parent node_modules when exports field target file is not found

When a package has an `exports` field that maps a request to a target file,
but that target file does not exist on disk, enhanced-resolve was incorrectly
falling back to search parent `node_modules` directories. This violated the
Node.js ESM resolution spec, which requires resolution to fail with an error
rather than continue searching up the directory tree.

This manifested in monorepos where the same package exists at multiple levels
(e.g. `workspace/node_modules/pkg` and `root/node_modules/pkg`): if the
workspace version's exports-mapped target was missing, the resolver would
silently resolve to the root version instead.

Root cause: `ExportsFieldPlugin` was returning `null` on failure, which
`Resolver.doResolve` converted to `undefined`, causing
`ModulesInHierarchicalDirectoriesPlugin` to treat the lookup as "not found,
try next directory" rather than a hard stop.

Fix: when the `exports` field is present and a match is found but no valid
target file can be resolved, return an explicit error to stop directory
traversal. Closes #399.
17 changes: 16 additions & 1 deletion lib/ExportsFieldPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,22 @@ module.exports = class ExportsFieldPlugin {
* @param {(null | ResolveRequest)=} result result
* @returns {void}
*/
(err, result) => callback(err, result || null),
(err, result) => {
if (err) return callback(err);
// When an exports field match was found but the target file doesn't exist,
// return an error to prevent fallback to parent node_modules directories.
// Per the Node.js ESM spec, a matched exports entry that fails to resolve
// is a hard error, not a signal to continue searching up the directory tree.
// See: https://github.com/webpack/enhanced-resolve/issues/399
if (!result) {
return callback(
new Error(
`Package path ${remainingRequest} is exported from package ${request.descriptionFileRoot}, but no valid target file was found (see exports field in ${request.descriptionFilePath})`,
),
);
}
callback(null, result);
},
);
});
}
Expand Down
49 changes: 46 additions & 3 deletions test/exportsField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const fixture5 = path.resolve(
"fixtures",
"exports-field-invalid-package-target",
);
const fixture6 = path.resolve(
__dirname,
"fixtures",
"exports-field-nested-version",
);

describe("process exports field", () => {
/** @type {{ name: string, expect: string[] | Error, suite: [ExportsField, string, string[]] }[]} */
Expand Down Expand Up @@ -2256,7 +2261,10 @@ describe("exportsFieldPlugin", () => {
(err, result) => {
if (!err) return done(new Error(`expect error, got ${result}`));
expect(err).toBeInstanceOf(Error);
expect(err.message).toMatch(/Can't resolve/);
// exports field maps ./dist/main -> ./lib/main, but ./lib/main (no ext) doesn't exist.
// Resolution stops at the exports field level with a "Package path" error
// (no fallback to parent node_modules per issue #399 fix).
expect(err.message).toMatch(/Package path/);
done();
},
);
Expand All @@ -2271,7 +2279,7 @@ describe("exportsFieldPlugin", () => {
(err, result) => {
if (!err) return done(new Error(`expect error, got ${result}`));
expect(err).toBeInstanceOf(Error);
expect(err.message).toMatch(/Can't resolve/);
expect(err.message).toMatch(/Package path/);
done();
},
);
Expand Down Expand Up @@ -3204,8 +3212,12 @@ describe("exportsFieldPlugin", () => {
(err, result) => {
if (!err) return done(new Error(`expect error, got ${result}`));
expect(err).toBeInstanceOf(Error);
// exports maps ./non-existent.js -> ["-bad-specifier-", "./non-existent.js", "./a.js"]
// The first valid target (./non-existent.js) doesn't exist, so the paths array is
// abandoned (issue #400) and resolution fails with a "Package path" error
// (no fallback to parent node_modules per issue #399 fix).
expect(err.message).toMatch(
/Can't resolve '@exports-field\/bad-specifier\/non-existent\.js'/,
/Package path \.\/non-existent\.js.*no valid target file was found/,
);
done();
},
Expand Down Expand Up @@ -3339,4 +3351,35 @@ describe("exportsFieldPlugin", () => {
},
);
});

// issue #399: nested same package with different versions
// When workspace/node_modules/pkg has an exports field mapping ./src/index.js -> ./dist/index.js
// but ./dist/index.js does NOT exist, resolution should fail with an error
// and must NOT fall back to the root node_modules/pkg version.
it("should not fall back to parent node_modules when exports field maps to a missing file (issue #399)", (done) => {
const nestedResolver = ResolverFactory.createResolver({
extensions: [".js"],
fileSystem: new CachedInputFileSystem(fs, 4000),
conditionNames: ["node"],
fullySpecified: true,
});
nestedResolver.resolve(
{},
path.resolve(fixture6, "workspace"),
"pkg/src/index.js",
{},
(err, result) => {
if (!err) {
return done(
new Error(
`Expected resolution to fail, but got: ${result}. ` +
"Should not fall back to root node_modules when workspace's exports field maps to a missing file.",
),
);
}
expect(err).toBeInstanceOf(Error);
done();
},
);
});
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading