From a4027ea889ba8f22474335e55d087dc859019424 Mon Sep 17 00:00:00 2001 From: Ryuya Date: Sat, 16 Aug 2025 23:55:55 -0700 Subject: [PATCH 1/3] test: add test for ESM library dynamic imports using new URL() --- .../module-dynamic-import-url/chunk.js | 3 ++ .../module-dynamic-import-url/entry.js | 4 +++ .../module-dynamic-import-url/index.js | 30 +++++++++++++++++++ .../module-dynamic-import-url/test.config.js | 7 +++++ .../webpack.config.js | 24 +++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 test/configCases/library/module-dynamic-import-url/chunk.js create mode 100644 test/configCases/library/module-dynamic-import-url/entry.js create mode 100644 test/configCases/library/module-dynamic-import-url/index.js create mode 100644 test/configCases/library/module-dynamic-import-url/test.config.js create mode 100644 test/configCases/library/module-dynamic-import-url/webpack.config.js diff --git a/test/configCases/library/module-dynamic-import-url/chunk.js b/test/configCases/library/module-dynamic-import-url/chunk.js new file mode 100644 index 000000000..141872c87 --- /dev/null +++ b/test/configCases/library/module-dynamic-import-url/chunk.js @@ -0,0 +1,3 @@ +export default function () { + return 2; +} diff --git a/test/configCases/library/module-dynamic-import-url/entry.js b/test/configCases/library/module-dynamic-import-url/entry.js new file mode 100644 index 000000000..4cce80149 --- /dev/null +++ b/test/configCases/library/module-dynamic-import-url/entry.js @@ -0,0 +1,4 @@ +export default async function getNumber() { + const num = (await import("./chunk.js")).default; + return 1 + num(); +} diff --git a/test/configCases/library/module-dynamic-import-url/index.js b/test/configCases/library/module-dynamic-import-url/index.js new file mode 100644 index 000000000..997f48180 --- /dev/null +++ b/test/configCases/library/module-dynamic-import-url/index.js @@ -0,0 +1,30 @@ +// Test for issue #15947 - ESM library with dynamic imports +it("should use new URL() for dynamic imports in ESM library output", () => { + // This test verifies that dynamic imports in ESM library output + // use new URL() with import.meta.url for proper path resolution + // in nested bundling scenarios + + const fs = require("fs"); + const path = require("path"); + + // Read the generated library output file (lib.js) + const outputPath = path.join(__dirname, "lib.js"); + const content = fs.readFileSync(outputPath, "utf-8"); + + // The correct implementation should use: import(new URL('./chunk.xxx.js', import.meta.url)) + // instead of: import("./" + __webpack_require__.u(chunkId)) + + // This test should FAIL until issue #15947 is fixed + // Check that webpack uses new URL() for dynamic imports (expected behavior) + expect(content).toMatch(/new\s+URL\s*\([^)]*import\.meta\.url/); // Should use new URL + expect(content).not.toMatch(/import\s*\(\s*["']\.\/["']\s*\+/); // Should NOT use string concatenation + + // Verify that the chunk file was created + const chunkFiles = fs + .readdirSync(__dirname) + .filter(f => f.startsWith("chunk.") && f.endsWith(".js")); + expect(chunkFiles.length).toBeGreaterThan(0); + + // Verify the ESM export is present + expect(content).toMatch(/export\s*\{/); +}); diff --git a/test/configCases/library/module-dynamic-import-url/test.config.js b/test/configCases/library/module-dynamic-import-url/test.config.js new file mode 100644 index 000000000..8c7705e12 --- /dev/null +++ b/test/configCases/library/module-dynamic-import-url/test.config.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + findBundle() { + return ["./index.js"]; + } +}; diff --git a/test/configCases/library/module-dynamic-import-url/webpack.config.js b/test/configCases/library/module-dynamic-import-url/webpack.config.js new file mode 100644 index 000000000..da71c0917 --- /dev/null +++ b/test/configCases/library/module-dynamic-import-url/webpack.config.js @@ -0,0 +1,24 @@ +"use strict"; + +/** @type {import("../../../../types").Configuration} */ +module.exports = [ + { + entry: "./entry.js", + output: { + filename: "lib.js", + chunkFilename: "chunk.[chunkhash:8].js", + library: { + type: "module" + } + }, + experiments: { + outputModule: true + } + }, + { + entry: "./index.js", + output: { + filename: "index.js" + } + } +]; From cf2634d839eaa9d4f956ec0dbb061ca23c98fb05 Mon Sep 17 00:00:00 2001 From: Ryuya Date: Sun, 17 Aug 2025 01:21:07 -0700 Subject: [PATCH 2/3] fix: use new URL() for dynamic imports in ESM library output --- lib/esm/ModuleChunkLoadingRuntimeModule.js | 51 ++++++++++++++----- lib/util/LibraryHelpers.js | 30 +++++++++++ .../module-dynamic-import-url/index.js | 15 ++---- 3 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 lib/util/LibraryHelpers.js diff --git a/lib/esm/ModuleChunkLoadingRuntimeModule.js b/lib/esm/ModuleChunkLoadingRuntimeModule.js index 3df1e1e3a..12abb9a70 100644 --- a/lib/esm/ModuleChunkLoadingRuntimeModule.js +++ b/lib/esm/ModuleChunkLoadingRuntimeModule.js @@ -17,6 +17,7 @@ const { getChunkFilenameTemplate } = require("../javascript/JavascriptModulesPlugin"); const { getInitialChunkIds } = require("../javascript/StartupHelpers"); +const { getLibraryType } = require("../util/LibraryHelpers"); const compileBooleanMatcher = require("../util/compileBooleanMatcher"); const { getUndoPath } = require("../util/identifier"); @@ -98,6 +99,13 @@ class ModuleChunkLoadingRuntimeModule extends RuntimeModule { runtimeTemplate, outputOptions: { importFunctionName, crossOriginLoading, charset } } = compilation; + const outputModule = + compilation.options && + compilation.options.experiments && + compilation.options.experiments.outputModule; + + const libraryType = getLibraryType(chunk, compilation); + const isESMLibrary = libraryType === "module"; const fn = RuntimeGlobals.ensureChunkHandlers; const withBaseURI = this._runtimeRequirements.has(RuntimeGlobals.baseURI); const withExternalInstallChunk = this._runtimeRequirements.has( @@ -224,19 +232,36 @@ class ModuleChunkLoadingRuntimeModule extends RuntimeModule { : `if(${hasJsMatcher("chunkId")}) {`, Template.indent([ "// setup Promise in chunk cache", - `var promise = ${importFunctionName}(${ - compilation.outputOptions.publicPath === "auto" - ? JSON.stringify(rootOutputDir) - : RuntimeGlobals.publicPath - } + ${ - RuntimeGlobals.getChunkScriptFilename - }(chunkId)).then(installChunk, ${runtimeTemplate.basicFunction( - "e", - [ - "if(installedChunks[chunkId] !== 0) installedChunks[chunkId] = undefined;", - "throw e;" - ] - )});`, + outputModule && isESMLibrary + ? // Use new URL() for ESM library output + `var promise = ${importFunctionName}(new URL(${JSON.stringify( + rootOutputDir + )} + ${ + RuntimeGlobals.getChunkScriptFilename + }(chunkId), ${ + compilation.outputOptions.importMetaName || + "import.meta" + }.url)).then(installChunk, ${runtimeTemplate.basicFunction( + "e", + [ + "if(installedChunks[chunkId] !== 0) installedChunks[chunkId] = undefined;", + "throw e;" + ] + )});` + : // Traditional string concatenation for non-ESM output + `var promise = ${importFunctionName}(${ + compilation.outputOptions.publicPath === "auto" + ? JSON.stringify(rootOutputDir) + : RuntimeGlobals.publicPath + } + ${ + RuntimeGlobals.getChunkScriptFilename + }(chunkId)).then(installChunk, ${runtimeTemplate.basicFunction( + "e", + [ + "if(installedChunks[chunkId] !== 0) installedChunks[chunkId] = undefined;", + "throw e;" + ] + )});`, `var promise = Promise.race([promise, new Promise(${runtimeTemplate.expressionFunction( "installedChunkData = installedChunks[chunkId] = [resolve]", "resolve" diff --git a/lib/util/LibraryHelpers.js b/lib/util/LibraryHelpers.js new file mode 100644 index 000000000..7115e0eb9 --- /dev/null +++ b/lib/util/LibraryHelpers.js @@ -0,0 +1,30 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ + +"use strict"; + +/** @typedef {import("../../declarations/WebpackOptions").LibraryOptions} LibraryOptions */ +/** @typedef {import("../../declarations/WebpackOptions").LibraryType} LibraryType */ +/** @typedef {import("../Chunk")} Chunk */ +/** @typedef {import("../Compilation")} Compilation */ + +/** + * Determine library type from chunk entry options or compilation output options + * @param {Chunk} chunk The chunk to get library type for + * @param {Compilation} compilation The compilation + * @returns {LibraryType | undefined} The library type or undefined + */ +module.exports.getLibraryType = (chunk, compilation) => { + const entryOptions = chunk.getEntryOptions(); + const libraryType = + entryOptions && entryOptions.library !== undefined + ? entryOptions.library.type + : compilation.outputOptions.library && + typeof compilation.outputOptions.library === "object" && + !Array.isArray(compilation.outputOptions.library) + ? compilation.outputOptions.library.type + : undefined; + return libraryType; +}; diff --git a/test/configCases/library/module-dynamic-import-url/index.js b/test/configCases/library/module-dynamic-import-url/index.js index 997f48180..7e120f8e8 100644 --- a/test/configCases/library/module-dynamic-import-url/index.js +++ b/test/configCases/library/module-dynamic-import-url/index.js @@ -1,23 +1,14 @@ // Test for issue #15947 - ESM library with dynamic imports it("should use new URL() for dynamic imports in ESM library output", () => { - // This test verifies that dynamic imports in ESM library output - // use new URL() with import.meta.url for proper path resolution - // in nested bundling scenarios - const fs = require("fs"); const path = require("path"); - // Read the generated library output file (lib.js) const outputPath = path.join(__dirname, "lib.js"); const content = fs.readFileSync(outputPath, "utf-8"); - // The correct implementation should use: import(new URL('./chunk.xxx.js', import.meta.url)) - // instead of: import("./" + __webpack_require__.u(chunkId)) - - // This test should FAIL until issue #15947 is fixed - // Check that webpack uses new URL() for dynamic imports (expected behavior) - expect(content).toMatch(/new\s+URL\s*\([^)]*import\.meta\.url/); // Should use new URL - expect(content).not.toMatch(/import\s*\(\s*["']\.\/["']\s*\+/); // Should NOT use string concatenation + expect(content).toMatch(/new\s+URL/); + expect(content).toMatch(/import\.meta\.url/); + expect(content).not.toMatch(/import\s*\(\s*["']\.\/["']\s*\+/); // Verify that the chunk file was created const chunkFiles = fs From b8c489f6902a9c9441c5434168a04d3543f72bc3 Mon Sep 17 00:00:00 2001 From: Ryuya Date: Mon, 15 Sep 2025 08:08:02 -0700 Subject: [PATCH 3/3] fix(esm): generate static new URL() imports for ESM library chunks --- lib/esm/ModuleChunkLoadingPlugin.js | 11 ++- lib/esm/ModuleChunkLoadingRuntimeModule.js | 77 +++++++++++++++---- .../module-dynamic-import-url/index.js | 10 ++- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/lib/esm/ModuleChunkLoadingPlugin.js b/lib/esm/ModuleChunkLoadingPlugin.js index eb3d384bc..aa0254493 100644 --- a/lib/esm/ModuleChunkLoadingPlugin.js +++ b/lib/esm/ModuleChunkLoadingPlugin.js @@ -6,6 +6,7 @@ "use strict"; const RuntimeGlobals = require("../RuntimeGlobals"); +const { getLibraryType } = require("../util/LibraryHelpers"); const ExportWebpackRequireRuntimeModule = require("./ExportWebpackRequireRuntimeModule"); const ModuleChunkLoadingRuntimeModule = require("./ModuleChunkLoadingRuntimeModule"); @@ -103,7 +104,15 @@ class ModuleChunkLoadingPlugin { set.add(RuntimeGlobals.publicPath); } - set.add(RuntimeGlobals.getChunkScriptFilename); + // Avoid generating dynamic filename helper for ESM libraries with outputModule + const outputModule = + compilation.options && + compilation.options.experiments && + compilation.options.experiments.outputModule; + const isESMLibrary = getLibraryType(chunk, compilation) === "module"; + if (!(outputModule && isESMLibrary)) { + set.add(RuntimeGlobals.getChunkScriptFilename); + } }); compilation.hooks.runtimeRequirementInTree diff --git a/lib/esm/ModuleChunkLoadingRuntimeModule.js b/lib/esm/ModuleChunkLoadingRuntimeModule.js index 12abb9a70..ed7bf72a5 100644 --- a/lib/esm/ModuleChunkLoadingRuntimeModule.js +++ b/lib/esm/ModuleChunkLoadingRuntimeModule.js @@ -233,21 +233,68 @@ class ModuleChunkLoadingRuntimeModule extends RuntimeModule { Template.indent([ "// setup Promise in chunk cache", outputModule && isESMLibrary - ? // Use new URL() for ESM library output - `var promise = ${importFunctionName}(new URL(${JSON.stringify( - rootOutputDir - )} + ${ - RuntimeGlobals.getChunkScriptFilename - }(chunkId), ${ - compilation.outputOptions.importMetaName || - "import.meta" - }.url)).then(installChunk, ${runtimeTemplate.basicFunction( - "e", - [ - "if(installedChunks[chunkId] !== 0) installedChunks[chunkId] = undefined;", - "throw e;" - ] - )});` + ? // For ESM library output generate statically analyzable imports per chunk + (() => { + // Build a switch over known async JS chunks with literal URLs + const meta = + compilation.outputOptions.importMetaName || + "import.meta"; + const relevantChunks = new Set(); + for (const c of chunk.getAllAsyncChunks()) { + relevantChunks.add(c); + } + const includeEntries = chunkGraph + .getTreeRuntimeRequirements(chunk) + .has( + RuntimeGlobals.ensureChunkIncludeEntries + ); + if (includeEntries) { + for (const c of chunkGraph.getRuntimeChunkDependentChunksIterable( + chunk + )) { + relevantChunks.add(c); + } + } + for (const ep of chunk.getAllReferencedAsyncEntrypoints()) { + relevantChunks.add( + ep.chunks[ep.chunks.length - 1] + ); + } + const cases = []; + for (const c of relevantChunks) { + if (!chunkHasJs(c, chunkGraph)) continue; + const filename = compilation.getPath( + getChunkFilenameTemplate( + c, + compilation.outputOptions + ), + { chunk: c, contentHashType: "javascript" } + ); + const spec = JSON.stringify( + rootOutputDir + filename + ); + const cid = JSON.stringify( + /** @type {string|number} */ (c.id) + ); + cases.push( + `case ${cid}: promise = ${importFunctionName}(new URL(${spec}, ${meta}.url).href); break;` + ); + } + return Template.asString([ + "var promise;", + "switch(chunkId) {", + Template.indent(cases), + "default: promise = Promise.reject(new Error('Missing chunk mapping for ' + chunkId));", + "}", + `promise = promise.then(installChunk, ${runtimeTemplate.basicFunction( + "e", + [ + "if(installedChunks[chunkId] !== 0) installedChunks[chunkId] = undefined;", + "throw e;" + ] + )});` + ]); + })() : // Traditional string concatenation for non-ESM output `var promise = ${importFunctionName}(${ compilation.outputOptions.publicPath === "auto" diff --git a/test/configCases/library/module-dynamic-import-url/index.js b/test/configCases/library/module-dynamic-import-url/index.js index 7e120f8e8..86e1a18c2 100644 --- a/test/configCases/library/module-dynamic-import-url/index.js +++ b/test/configCases/library/module-dynamic-import-url/index.js @@ -1,14 +1,16 @@ // Test for issue #15947 - ESM library with dynamic imports -it("should use new URL() for dynamic imports in ESM library output", () => { +it("should generate statically analyzable dynamic imports for ESM library output", () => { const fs = require("fs"); const path = require("path"); const outputPath = path.join(__dirname, "lib.js"); const content = fs.readFileSync(outputPath, "utf-8"); - expect(content).toMatch(/new\s+URL/); - expect(content).toMatch(/import\.meta\.url/); - expect(content).not.toMatch(/import\s*\(\s*["']\.\/["']\s*\+/); + // Should use new URL with import.meta.url and literal path + expect(content).toMatch(/import\(\s*new\s+URL\(\s*"[^"]+"\s*,\s*import\.meta\.url\s*\)\.href\s*\)/); + // Should not use dynamic __webpack_require__.u() or publicPath string concatenation + expect(content).not.toMatch(/__webpack_require__\.u\(/); + expect(content).not.toMatch(/\+\s*__webpack_require__\.p\s*\+/); // Verify that the chunk file was created const chunkFiles = fs