From 56283c305cf5e56065bf5b567c84a4fcf82552a5 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Fri, 27 Mar 2026 18:52:12 +0800 Subject: [PATCH] fix(imports): prevent chained resolution of non-relative imports targets --- .changeset/fix-imports-field-no-chaining.md | 10 ++++++ lib/ResolverFactory.js | 12 ++++++- .../imports-field-chained/package.json | 8 +++++ test/fixtures/imports-field-chained/the.js | 0 test/importsField.test.js | 33 +++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-imports-field-no-chaining.md create mode 100644 test/fixtures/imports-field-chained/package.json create mode 100644 test/fixtures/imports-field-chained/the.js diff --git a/.changeset/fix-imports-field-no-chaining.md b/.changeset/fix-imports-field-no-chaining.md new file mode 100644 index 00000000..062283be --- /dev/null +++ b/.changeset/fix-imports-field-no-chaining.md @@ -0,0 +1,10 @@ +--- +"enhanced-resolve": patch +--- + +Imports field spec deviation: non-relative targets (e.g. `"#a": "#b"`) +no longer re-enter imports resolution, aligning with the Node.js ESM spec +where `PACKAGE_IMPORTS_RESOLVE` does not recursively resolve `#` specifiers. + +Previously `{ "#a": "#b", "#b": "./the.js" }` would chain-resolve `#a` to +`./the.js`; now it correctly fails, matching Node.js behavior. diff --git a/lib/ResolverFactory.js b/lib/ResolverFactory.js index fb599435..132e9141 100644 --- a/lib/ResolverFactory.js +++ b/lib/ResolverFactory.js @@ -357,6 +357,7 @@ module.exports.createResolver = function createResolver(options) { resolver.ensureHook("resolve"); resolver.ensureHook("internalResolve"); resolver.ensureHook("newInternalResolve"); + resolver.ensureHook("importsResolve"); resolver.ensureHook("parsedResolve"); resolver.ensureHook("describedResolve"); resolver.ensureHook("rawResolve"); @@ -391,6 +392,15 @@ module.exports.createResolver = function createResolver(options) { for (const { source, resolveOptions } of [ { source: "resolve", resolveOptions: { fullySpecified } }, { source: "internal-resolve", resolveOptions: { fullySpecified: false } }, + // Entry point for non-relative targets from the imports field. + // Sets internal: false to prevent re-entering imports resolution, + // aligning with the Node.js ESM spec where PACKAGE_IMPORTS_RESOLVE + // does not recursively resolve # specifiers. + // https://nodejs.org/api/esm.html#resolution-algorithm-specification + { + source: "imports-resolve", + resolveOptions: { fullySpecified: false, internal: false }, + }, ]) { plugins.push(new ParsePlugin(source, resolveOptions, "parsed-resolve")); } @@ -482,7 +492,7 @@ module.exports.createResolver = function createResolver(options) { conditionNames, importsField, "relative", - "internal-resolve", + "imports-resolve", ), ); } diff --git a/test/fixtures/imports-field-chained/package.json b/test/fixtures/imports-field-chained/package.json new file mode 100644 index 00000000..5750abe7 --- /dev/null +++ b/test/fixtures/imports-field-chained/package.json @@ -0,0 +1,8 @@ +{ + "name": "imports-field-chained", + "version": "1.0.0", + "imports": { + "#a": "#b", + "#b": "./the.js" + } +} diff --git a/test/fixtures/imports-field-chained/the.js b/test/fixtures/imports-field-chained/the.js new file mode 100644 index 00000000..e69de29b diff --git a/test/importsField.test.js b/test/importsField.test.js index e486c593..e8ee8049 100644 --- a/test/importsField.test.js +++ b/test/importsField.test.js @@ -1616,6 +1616,39 @@ describe("importsFieldPlugin", () => { }); }); + // Test for spec compliance: non-relative imports targets should not + // re-enter imports resolution (Node.js uses PACKAGE_RESOLVE for these, + // which only does node_modules lookup). + // See: https://github.com/orgs/webpack/discussions/20684 + describe("should not chain imports resolution for non-relative targets", () => { + const chainedFixture = path.resolve( + __dirname, + "fixtures", + "imports-field-chained", + ); + + it("should fail to resolve #a when it maps to #b (another import specifier)", (done) => { + resolver.resolve({}, chainedFixture, "#a", {}, (err, result) => { + if (!err) { + return done( + new Error(`expected error for chained imports, got ${result}`), + ); + } + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + + it("should still resolve #b to ./the.js directly", (done) => { + resolver.resolve({}, chainedFixture, "#b", {}, (err, result) => { + if (err) return done(err); + if (!result) return done(new Error("No result")); + expect(result).toEqual(path.resolve(chainedFixture, "the.js")); + done(); + }); + }); + }); + // Tests for #/ slash pattern support (node.js PR #60864) // These tests cover the new Node.js behavior that allows #/ patterns // See: https://github.com/nodejs/node/pull/60864