From 09fda8730d384b6c58cc83ed8b8b2ce14ce46f1d Mon Sep 17 00:00:00 2001 From: Natsu Xiao <784487301@qq.com> Date: Mon, 23 Jun 2025 20:22:23 +0800 Subject: [PATCH] feat: HMR support for ESM --- lib/esm/ModuleChunkFormatPlugin.js | 228 +++++++++--------- lib/esm/ModuleChunkLoadingPlugin.js | 26 ++ lib/esm/ModuleChunkLoadingRuntimeModule.js | 90 ++++++- test/HotTestCases.template.js | 7 +- .../esm-output/async-chunks/async-module.js | 3 + .../hotCases/esm-output/async-chunks/index.js | 34 +++ .../esm-output/async-chunks/lazy-module.js | 9 + .../esm-output/async-chunks/webpack.config.js | 17 ++ test/hotCases/esm-output/css-modules/index.js | 29 +++ .../esm-output/css-modules/style.module.css | 7 + .../esm-output/css-modules/style2.module.css | 7 + .../esm-output/css-modules/test.config.js | 8 + .../esm-output/css-modules/webpack.config.js | 18 ++ .../esm-output/runtime-chunk/async-shared.js | 4 + .../esm-output/runtime-chunk/index.js | 23 ++ .../esm-output/runtime-chunk/shared.js | 9 + .../runtime-chunk/webpack.config.js | 18 ++ test/hotCases/esm-output/simple/index.js | 22 ++ test/hotCases/esm-output/simple/module.js | 3 + .../esm-output/simple/webpack.config.js | 17 ++ .../esm-output/split-chunks/common/shared.js | 16 ++ .../hotCases/esm-output/split-chunks/index.js | 25 ++ .../split-chunks/node_modules/vendor-lib.js | 17 ++ .../esm-output/split-chunks/webpack.config.js | 34 +++ test/hotCases/update.esm.js | 16 ++ test/runner/index.js | 8 + 26 files changed, 578 insertions(+), 117 deletions(-) create mode 100644 test/hotCases/esm-output/async-chunks/async-module.js create mode 100644 test/hotCases/esm-output/async-chunks/index.js create mode 100644 test/hotCases/esm-output/async-chunks/lazy-module.js create mode 100644 test/hotCases/esm-output/async-chunks/webpack.config.js create mode 100644 test/hotCases/esm-output/css-modules/index.js create mode 100644 test/hotCases/esm-output/css-modules/style.module.css create mode 100644 test/hotCases/esm-output/css-modules/style2.module.css create mode 100644 test/hotCases/esm-output/css-modules/test.config.js create mode 100644 test/hotCases/esm-output/css-modules/webpack.config.js create mode 100644 test/hotCases/esm-output/runtime-chunk/async-shared.js create mode 100644 test/hotCases/esm-output/runtime-chunk/index.js create mode 100644 test/hotCases/esm-output/runtime-chunk/shared.js create mode 100644 test/hotCases/esm-output/runtime-chunk/webpack.config.js create mode 100644 test/hotCases/esm-output/simple/index.js create mode 100644 test/hotCases/esm-output/simple/module.js create mode 100644 test/hotCases/esm-output/simple/webpack.config.js create mode 100644 test/hotCases/esm-output/split-chunks/common/shared.js create mode 100644 test/hotCases/esm-output/split-chunks/index.js create mode 100644 test/hotCases/esm-output/split-chunks/node_modules/vendor-lib.js create mode 100644 test/hotCases/esm-output/split-chunks/webpack.config.js create mode 100644 test/hotCases/update.esm.js diff --git a/lib/esm/ModuleChunkFormatPlugin.js b/lib/esm/ModuleChunkFormatPlugin.js index af49333d5..224b0c958 100644 --- a/lib/esm/ModuleChunkFormatPlugin.js +++ b/lib/esm/ModuleChunkFormatPlugin.js @@ -6,8 +6,7 @@ "use strict"; const { ConcatSource } = require("webpack-sources"); -const { RuntimeGlobals } = require(".."); -const HotUpdateChunk = require("../HotUpdateChunk"); +const { RuntimeGlobals, HotUpdateChunk } = require(".."); const Template = require("../Template"); const { getAllChunks } = require("../javascript/ChunkHelpers"); const { @@ -72,30 +71,47 @@ class ModuleChunkFormatPlugin { hooks.renderChunk.tap(PLUGIN_NAME, (modules, renderContext) => { const { chunk, chunkGraph, runtimeTemplate } = renderContext; const hotUpdateChunk = chunk instanceof HotUpdateChunk ? chunk : null; + const source = new ConcatSource(); + source.add( + `export const __webpack_id__ = ${JSON.stringify(chunk.id)};\n` + ); + source.add( + `export const __webpack_ids__ = ${JSON.stringify(chunk.ids)};\n` + ); + source.add("export const __webpack_modules__ = "); + source.add(modules); + source.add(";\n"); + const runtimeModules = chunkGraph.getChunkRuntimeModulesInOrder(chunk); + if (runtimeModules.length > 0) { + source.add("export const __webpack_runtime__ =\n"); + source.add( + Template.renderChunkRuntimeModules(runtimeModules, renderContext) + ); + } if (hotUpdateChunk) { - throw new Error("HMR is not implemented for module chunk format yet"); - } else { - source.add( - `export const __webpack_id__ = ${JSON.stringify(chunk.id)};\n` - ); - source.add( - `export const __webpack_ids__ = ${JSON.stringify(chunk.ids)};\n` - ); - source.add("export const __webpack_modules__ = "); - source.add(modules); - source.add(";\n"); - const runtimeModules = - chunkGraph.getChunkRuntimeModulesInOrder(chunk); - if (runtimeModules.length > 0) { - source.add("export const __webpack_runtime__ =\n"); - source.add( - Template.renderChunkRuntimeModules(runtimeModules, renderContext) - ); - } - const { entries, runtimeChunk } = getChunkInfo(chunk, chunkGraph); - if (runtimeChunk) { - const currentOutputName = compilation + return source; + } + const { entries, runtimeChunk } = getChunkInfo(chunk, chunkGraph); + if (runtimeChunk) { + const currentOutputName = compilation + .getPath( + getChunkFilenameTemplate(chunk, compilation.outputOptions), + { + chunk, + contentHashType: "javascript" + } + ) + .replace(/^\/+/g, "") + .split("/"); + + /** + * @param {Chunk} chunk the chunk + * @returns {string} the relative path + */ + const getRelativePath = chunk => { + const baseOutputName = currentOutputName.slice(); + const chunkOutputName = compilation .getPath( getChunkFilenameTemplate(chunk, compilation.outputOptions), { @@ -106,101 +122,83 @@ class ModuleChunkFormatPlugin { .replace(/^\/+/g, "") .split("/"); - /** - * @param {Chunk} chunk the chunk - * @returns {string} the relative path - */ - const getRelativePath = chunk => { - const baseOutputName = currentOutputName.slice(); - const chunkOutputName = compilation - .getPath( - getChunkFilenameTemplate(chunk, compilation.outputOptions), - { - chunk, - contentHashType: "javascript" - } - ) - .replace(/^\/+/g, "") - .split("/"); - - // remove common parts except filename - while ( - baseOutputName.length > 1 && - chunkOutputName.length > 1 && - baseOutputName[0] === chunkOutputName[0] - ) { - baseOutputName.shift(); - chunkOutputName.shift(); - } - const last = chunkOutputName.join("/"); - // create final path - return getUndoPath(baseOutputName.join("/"), last, true) + last; - }; - - const entrySource = new ConcatSource(); - entrySource.add(source); - entrySource.add(";\n\n// load runtime\n"); - entrySource.add( - `import ${RuntimeGlobals.require} from ${JSON.stringify( - getRelativePath(/** @type {Chunk} */ (runtimeChunk)) - )};\n` - ); - - const startupSource = new ConcatSource(); - startupSource.add( - `var __webpack_exec__ = ${runtimeTemplate.returningFunction( - `${RuntimeGlobals.require}(${RuntimeGlobals.entryModuleId} = moduleId)`, - "moduleId" - )}\n` - ); - - const loadedChunks = new Set(); - let index = 0; - for (let i = 0; i < entries.length; i++) { - const [module, entrypoint] = entries[i]; - if (!chunkGraph.getModuleSourceTypes(module).has("javascript")) { - continue; - } - const final = i + 1 === entries.length; - const moduleId = chunkGraph.getModuleId(module); - const chunks = getAllChunks( - /** @type {Entrypoint} */ (entrypoint), - /** @type {Chunk} */ (runtimeChunk), - undefined - ); - for (const chunk of chunks) { - if (loadedChunks.has(chunk) || !chunkHasJs(chunk, chunkGraph)) - continue; - loadedChunks.add(chunk); - startupSource.add( - `import * as __webpack_chunk_${index}__ from ${JSON.stringify( - getRelativePath(chunk) - )};\n` - ); - startupSource.add( - `${RuntimeGlobals.externalInstallChunk}(__webpack_chunk_${index}__);\n` - ); - index++; - } - startupSource.add( - `${ - final ? `var ${RuntimeGlobals.exports} = ` : "" - }__webpack_exec__(${JSON.stringify(moduleId)});\n` - ); + // remove common parts except filename + while ( + baseOutputName.length > 1 && + chunkOutputName.length > 1 && + baseOutputName[0] === chunkOutputName[0] + ) { + baseOutputName.shift(); + chunkOutputName.shift(); } + const last = chunkOutputName.join("/"); + // create final path + return getUndoPath(baseOutputName.join("/"), last, true) + last; + }; - entrySource.add( - hooks.renderStartup.call( - startupSource, - entries[entries.length - 1][0], - { - ...renderContext, - inlined: false - } - ) + const entrySource = new ConcatSource(); + entrySource.add(source); + entrySource.add(";\n\n// load runtime\n"); + entrySource.add( + `import ${RuntimeGlobals.require} from ${JSON.stringify( + getRelativePath(/** @type {Chunk} */ (runtimeChunk)) + )};\n` + ); + + const startupSource = new ConcatSource(); + startupSource.add( + `var __webpack_exec__ = ${runtimeTemplate.returningFunction( + `${RuntimeGlobals.require}(${RuntimeGlobals.entryModuleId} = moduleId)`, + "moduleId" + )}\n` + ); + + const loadedChunks = new Set(); + let index = 0; + for (let i = 0; i < entries.length; i++) { + const [module, entrypoint] = entries[i]; + if (!chunkGraph.getModuleSourceTypes(module).has("javascript")) { + continue; + } + const final = i + 1 === entries.length; + const moduleId = chunkGraph.getModuleId(module); + const chunks = getAllChunks( + /** @type {Entrypoint} */ (entrypoint), + /** @type {Chunk} */ (runtimeChunk), + undefined + ); + for (const chunk of chunks) { + if (loadedChunks.has(chunk) || !chunkHasJs(chunk, chunkGraph)) + continue; + loadedChunks.add(chunk); + startupSource.add( + `import * as __webpack_chunk_${index}__ from ${JSON.stringify( + getRelativePath(chunk) + )};\n` + ); + startupSource.add( + `${RuntimeGlobals.externalInstallChunk}(__webpack_chunk_${index}__);\n` + ); + index++; + } + startupSource.add( + `${ + final ? `var ${RuntimeGlobals.exports} = ` : "" + }__webpack_exec__(${JSON.stringify(moduleId)});\n` ); - return entrySource; } + + entrySource.add( + hooks.renderStartup.call( + startupSource, + entries[entries.length - 1][0], + { + ...renderContext, + inlined: false + } + ) + ); + return entrySource; } return source; }); diff --git a/lib/esm/ModuleChunkLoadingPlugin.js b/lib/esm/ModuleChunkLoadingPlugin.js index 79df4ce5c..c92e69888 100644 --- a/lib/esm/ModuleChunkLoadingPlugin.js +++ b/lib/esm/ModuleChunkLoadingPlugin.js @@ -63,6 +63,12 @@ class ModuleChunkLoadingPlugin { compilation.hooks.runtimeRequirementInTree .for(RuntimeGlobals.onChunksLoaded) .tap(PLUGIN_NAME, handler); + compilation.hooks.runtimeRequirementInTree + .for(RuntimeGlobals.hmrDownloadUpdateHandlers) + .tap(PLUGIN_NAME, handler); + compilation.hooks.runtimeRequirementInTree + .for(RuntimeGlobals.hmrDownloadManifest) + .tap(PLUGIN_NAME, handler); compilation.hooks.runtimeRequirementInTree .for(RuntimeGlobals.externalInstallChunk) .tap(PLUGIN_NAME, (chunk, set) => { @@ -99,6 +105,26 @@ class ModuleChunkLoadingPlugin { set.add(RuntimeGlobals.getChunkScriptFilename); }); + + compilation.hooks.runtimeRequirementInTree + .for(RuntimeGlobals.hmrDownloadUpdateHandlers) + .tap(PLUGIN_NAME, (chunk, set) => { + if (!isEnabledForChunk(chunk)) return; + set.add(RuntimeGlobals.publicPath); + set.add(RuntimeGlobals.loadScript); + set.add(RuntimeGlobals.getChunkUpdateScriptFilename); + set.add(RuntimeGlobals.moduleCache); + set.add(RuntimeGlobals.hmrModuleData); + set.add(RuntimeGlobals.moduleFactoriesAddOnly); + }); + + compilation.hooks.runtimeRequirementInTree + .for(RuntimeGlobals.hmrDownloadManifest) + .tap(PLUGIN_NAME, (chunk, set) => { + if (!isEnabledForChunk(chunk)) return; + set.add(RuntimeGlobals.publicPath); + set.add(RuntimeGlobals.getUpdateManifestFilename); + }); }); } } diff --git a/lib/esm/ModuleChunkLoadingRuntimeModule.js b/lib/esm/ModuleChunkLoadingRuntimeModule.js index 727c918fa..ed850b0fd 100644 --- a/lib/esm/ModuleChunkLoadingRuntimeModule.js +++ b/lib/esm/ModuleChunkLoadingRuntimeModule.js @@ -109,6 +109,9 @@ class ModuleChunkLoadingRuntimeModule extends RuntimeModule { const withHmr = this._runtimeRequirements.has( RuntimeGlobals.hmrDownloadUpdateHandlers ); + const withHmrManifest = this._runtimeRequirements.has( + RuntimeGlobals.hmrDownloadManifest + ); const { linkPreload, linkPrefetch } = ModuleChunkLoadingRuntimeModule.getCompilationHooks(compilation); const isNeutralPlatform = runtimeTemplate.isNeutralPlatform(); @@ -346,7 +349,92 @@ class ModuleChunkLoadingRuntimeModule extends RuntimeModule { "installedChunks[chunkId] === 0", "chunkId" )};` - : "// no on chunks loaded" + : "// no on chunks loaded", + withHmr + ? Template.asString([ + Template.getFunctionContent( + require("../hmr/JavascriptHotModuleReplacement.runtime.js") + ) + .replace(/\$key\$/g, "jsonp") + .replace(/\$installedChunks\$/g, "installedChunks") + .replace(/\$loadUpdateChunk\$/g, "loadUpdateChunk") + .replace(/\$moduleCache\$/g, RuntimeGlobals.moduleCache) + .replace(/\$moduleFactories\$/g, RuntimeGlobals.moduleFactories) + .replace( + /\$ensureChunkHandlers\$/g, + RuntimeGlobals.ensureChunkHandlers + ) + .replace(/\$hasOwnProperty\$/g, RuntimeGlobals.hasOwnProperty) + .replace(/\$hmrModuleData\$/g, RuntimeGlobals.hmrModuleData) + .replace( + /\$hmrDownloadUpdateHandlers\$/g, + RuntimeGlobals.hmrDownloadUpdateHandlers + ) + .replace( + /\$hmrInvalidateModuleHandlers\$/g, + RuntimeGlobals.hmrInvalidateModuleHandlers + ), + "", + "function loadUpdateChunk(chunkId, updatedModulesList) {", + Template.indent([ + `return new Promise(${runtimeTemplate.basicFunction( + "resolve, reject", + [ + "// start update chunk loading", + `var url = ${RuntimeGlobals.publicPath} + ${RuntimeGlobals.getChunkUpdateScriptFilename}(chunkId);`, + `var onResolve = ${runtimeTemplate.basicFunction("obj", [ + "var updatedModules = obj.__webpack_modules__;", + "var updatedRuntime = obj.__webpack_runtime__;", + "if(updatedRuntime) currentUpdateRuntime.push(updatedRuntime);", + "for(var moduleId in updatedModules) {", + Template.indent([ + `if(${RuntimeGlobals.hasOwnProperty}(updatedModules, moduleId)) {`, + Template.indent([ + "currentUpdate[moduleId] = updatedModules[moduleId];", + "if(updatedModulesList) updatedModulesList.push(moduleId);" + ]), + "}" + ]), + "}", + "resolve(obj);" + ])};`, + `var onReject = ${runtimeTemplate.basicFunction("error", [ + "var errorMsg = error.message || 'unknown reason';", + "error.message = 'Loading hot update chunk ' + chunkId + ' failed.\\n(' + errorMsg + ')';", + "error.name = 'ChunkLoadError';", + "reject(error);" + ])}`, + `var loadScript = ${runtimeTemplate.basicFunction( + "url, onResolve, onReject", + [ + `return ${importFunctionName}(/* webpackIgnore: true */ url).then(onResolve).catch(onReject)` + ] + )} + loadScript(url, onResolve, onReject);` + ] + )});` + ]), + "}", + "" + ]) + : "// no HMR", + "", + withHmrManifest + ? Template.asString([ + `${ + RuntimeGlobals.hmrDownloadManifest + } = ${runtimeTemplate.basicFunction("", [ + 'if (typeof fetch === "undefined") throw new Error("No browser support: need fetch API");', + `return fetch(${RuntimeGlobals.publicPath} + ${ + RuntimeGlobals.getUpdateManifestFilename + }()).then(${runtimeTemplate.basicFunction("response", [ + "if(response.status === 404) return; // no update available", + 'if(!response.ok) throw new Error("Failed to fetch update manifest " + response.statusText);', + "return response.json();" + ])});` + ])};` + ]) + : "// no HMR manifest" ]); } } diff --git a/test/HotTestCases.template.js b/test/HotTestCases.template.js index 7b0d31653..b376c77e4 100644 --- a/test/HotTestCases.template.js +++ b/test/HotTestCases.template.js @@ -212,7 +212,12 @@ const describeCases = config => { link.href = file.name; runner._moduleScope.document.head.appendChild(link); } else { - runner.require(outputDirectory, `./${file.name}`); + const result = runner.require( + outputDirectory, + `./${file.name}` + ); + if (typeof result === "object" && "then" in result) + promise = promise.then(() => result); } } } else { diff --git a/test/hotCases/esm-output/async-chunks/async-module.js b/test/hotCases/esm-output/async-chunks/async-module.js new file mode 100644 index 000000000..f93cad69c --- /dev/null +++ b/test/hotCases/esm-output/async-chunks/async-module.js @@ -0,0 +1,3 @@ +export const message = "Hello from async module!"; +--- +export const message = "Updated async module!"; \ No newline at end of file diff --git a/test/hotCases/esm-output/async-chunks/index.js b/test/hotCases/esm-output/async-chunks/index.js new file mode 100644 index 000000000..99fa7481c --- /dev/null +++ b/test/hotCases/esm-output/async-chunks/index.js @@ -0,0 +1,34 @@ +import update from "../../update.esm"; +import.meta.webpackHot.accept(["./async-module", "./lazy-module"]); + +it("should handle HMR with async chunks in ESM format", (done) => { + // Initial load of async chunks + Promise.all([ + import("./async-module"), + import("./lazy-module") + ]).then(([asyncModule, lazyModule]) => { + expect(asyncModule.message).toBe("Hello from async module!"); + expect(lazyModule.data.value).toBe(42); + + NEXT(update(done, true, () => { + // Re-import after HMR update + Promise.all([ + import("./async-module"), + import("./lazy-module") + ]).then(([updatedAsyncModule, updatedLazyModule]) => { + expect(updatedAsyncModule.message).toBe("Updated async module!"); + expect(updatedLazyModule.data.value).toBe(100); + done(); + }).catch(done); + })); + }).catch(done); +}); + +it("should support dynamic imports with proper ESM chunk loading", (done) => { + // Test that dynamic imports work correctly with ESM chunk format + import("./async-module").then((module) => { + expect(module.message).toBeDefined(); + expect(typeof module.message).toBe("string"); + done(); + }).catch(done); +}); diff --git a/test/hotCases/esm-output/async-chunks/lazy-module.js b/test/hotCases/esm-output/async-chunks/lazy-module.js new file mode 100644 index 000000000..c3bddf0cb --- /dev/null +++ b/test/hotCases/esm-output/async-chunks/lazy-module.js @@ -0,0 +1,9 @@ +export const data = { + type: "lazy", + value: 42 +}; +--- +export const data = { + type: "lazy", + value: 100 +}; diff --git a/test/hotCases/esm-output/async-chunks/webpack.config.js b/test/hotCases/esm-output/async-chunks/webpack.config.js new file mode 100644 index 000000000..d877ef17c --- /dev/null +++ b/test/hotCases/esm-output/async-chunks/webpack.config.js @@ -0,0 +1,17 @@ +/** @type {import("../../../../types").Configuration} */ +module.exports = { + mode: "development", + experiments: { + outputModule: true + }, + output: { + module: true, + chunkFormat: "module", + filename: "[name].mjs", + chunkFilename: "[name].chunk.mjs", + enabledLibraryTypes: ["module"] + }, + optimization: { + minimize: false + } +}; diff --git a/test/hotCases/esm-output/css-modules/index.js b/test/hotCases/esm-output/css-modules/index.js new file mode 100644 index 000000000..e4511d95a --- /dev/null +++ b/test/hotCases/esm-output/css-modules/index.js @@ -0,0 +1,29 @@ +import * as styles from "./style.module.css"; +import update from "../../update.esm"; + +it("should work", async function (done) { + expect(styles).toMatchObject({ class: "_style_module_css-class" }); + + const styles2 = await import("./style2.module.css"); + + expect(styles2).toMatchObject({ + foo: "_style2_module_css-foo" + }); + + import.meta.webpackHot.accept(["./style.module.css", "./style2.module.css"], () => { + expect(styles).toMatchObject({ + "class-other": "_style_module_css-class-other" + }); + import("./style2.module.css").then(styles2 => { + expect(styles2).toMatchObject({ + "bar": "_style2_module_css-bar" + }); + + done(); + }); + }); + + NEXT(update(done)); +}); + +module.hot.accept(); diff --git a/test/hotCases/esm-output/css-modules/style.module.css b/test/hotCases/esm-output/css-modules/style.module.css new file mode 100644 index 000000000..98c6b2bb5 --- /dev/null +++ b/test/hotCases/esm-output/css-modules/style.module.css @@ -0,0 +1,7 @@ +.class { + color: red; +} +--- +.class-other { + color: blue; +} diff --git a/test/hotCases/esm-output/css-modules/style2.module.css b/test/hotCases/esm-output/css-modules/style2.module.css new file mode 100644 index 000000000..681b83a26 --- /dev/null +++ b/test/hotCases/esm-output/css-modules/style2.module.css @@ -0,0 +1,7 @@ +.foo { + color: red; +} +--- +.bar { + color: blue; +} diff --git a/test/hotCases/esm-output/css-modules/test.config.js b/test/hotCases/esm-output/css-modules/test.config.js new file mode 100644 index 000000000..429d75767 --- /dev/null +++ b/test/hotCases/esm-output/css-modules/test.config.js @@ -0,0 +1,8 @@ +module.exports = { + moduleScope(scope) { + const link = scope.window.document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://test.cases/path/bundle.css"; + scope.window.document.head.appendChild(link); + } +}; diff --git a/test/hotCases/esm-output/css-modules/webpack.config.js b/test/hotCases/esm-output/css-modules/webpack.config.js new file mode 100644 index 000000000..ef5dc7bd3 --- /dev/null +++ b/test/hotCases/esm-output/css-modules/webpack.config.js @@ -0,0 +1,18 @@ +/** @type {import("../../../../").Configuration} */ +module.exports = { + mode: "development", + experiments: { + outputModule: true, + css: true + }, + output: { + module: true, + chunkFormat: "module", + filename: "[name].mjs", + chunkFilename: "[name].chunk.mjs", + enabledLibraryTypes: ["module"] + }, + optimization: { + minimize: false + } +}; diff --git a/test/hotCases/esm-output/runtime-chunk/async-shared.js b/test/hotCases/esm-output/runtime-chunk/async-shared.js new file mode 100644 index 000000000..b2dfdeba8 --- /dev/null +++ b/test/hotCases/esm-output/runtime-chunk/async-shared.js @@ -0,0 +1,4 @@ +export const asyncData = { + loaded: true, + content: "Async shared content" +}; diff --git a/test/hotCases/esm-output/runtime-chunk/index.js b/test/hotCases/esm-output/runtime-chunk/index.js new file mode 100644 index 000000000..9f9747798 --- /dev/null +++ b/test/hotCases/esm-output/runtime-chunk/index.js @@ -0,0 +1,23 @@ +import { sharedData } from "./shared"; +import update from "../../update.esm"; + +it("should handle HMR with runtime chunk in ESM format", (done) => { + expect(sharedData.version).toBe("1.0.0"); + + import.meta.webpackHot.accept(["./shared"]); + + NEXT(update(done, true, () => { + import("./shared").then(updatedModule => { + expect(updatedModule.sharedData.version).toBe("2.0.0"); + done(); + }).catch(done); + })); +}); + +it("should load async shared module with runtime chunk", (done) => { + import("./async-shared").then(module => { + expect(module.asyncData.loaded).toBe(true); + expect(module.asyncData.content).toBe("Async shared content"); + done(); + }).catch(done); +}); diff --git a/test/hotCases/esm-output/runtime-chunk/shared.js b/test/hotCases/esm-output/runtime-chunk/shared.js new file mode 100644 index 000000000..92df22ed1 --- /dev/null +++ b/test/hotCases/esm-output/runtime-chunk/shared.js @@ -0,0 +1,9 @@ +export const sharedData = { + version: "1.0.0", + timestamp: Date.now() +}; +--- +export const sharedData = { + version: "2.0.0", + timestamp: Date.now() +}; diff --git a/test/hotCases/esm-output/runtime-chunk/webpack.config.js b/test/hotCases/esm-output/runtime-chunk/webpack.config.js new file mode 100644 index 000000000..43cb07660 --- /dev/null +++ b/test/hotCases/esm-output/runtime-chunk/webpack.config.js @@ -0,0 +1,18 @@ +/** @type {import("../../../../types").Configuration} */ +module.exports = { + mode: "development", + experiments: { + outputModule: true + }, + output: { + module: true, + chunkFormat: "module", + filename: "[name].mjs", + chunkFilename: "[name].chunk.mjs", + enabledLibraryTypes: ["module"] + }, + optimization: { + minimize: false, + runtimeChunk: "single" + } +}; diff --git a/test/hotCases/esm-output/simple/index.js b/test/hotCases/esm-output/simple/index.js new file mode 100644 index 000000000..02815b365 --- /dev/null +++ b/test/hotCases/esm-output/simple/index.js @@ -0,0 +1,22 @@ +import { greeting } from "./module.js"; +import update from "../../update.esm.js"; + +import.meta.webpackHot.accept(["./module.js"]); + +it("should update a simple ES module with HMR", (done) => { + expect(greeting).toBe("Hello World!"); + + NEXT(update(done, true, () => { + // After HMR update, we need to re-import the module in ESM + import("./module.js").then(updatedModule => { + expect(updatedModule.greeting).toBe("Hello HMR!"); + done(); + }).catch(done); + })); +}); + +it("should have HMR runtime available in ESM output", () => { + expect(typeof import.meta.webpackHot.accept).toBe("function"); + expect(typeof import.meta.webpackHot.decline).toBe("function"); + expect(typeof import.meta.webpackHot.dispose).toBe("function"); +}); diff --git a/test/hotCases/esm-output/simple/module.js b/test/hotCases/esm-output/simple/module.js new file mode 100644 index 000000000..9ce42fc23 --- /dev/null +++ b/test/hotCases/esm-output/simple/module.js @@ -0,0 +1,3 @@ +export const greeting = "Hello World!"; +--- +export const greeting = "Hello HMR!"; diff --git a/test/hotCases/esm-output/simple/webpack.config.js b/test/hotCases/esm-output/simple/webpack.config.js new file mode 100644 index 000000000..d877ef17c --- /dev/null +++ b/test/hotCases/esm-output/simple/webpack.config.js @@ -0,0 +1,17 @@ +/** @type {import("../../../../types").Configuration} */ +module.exports = { + mode: "development", + experiments: { + outputModule: true + }, + output: { + module: true, + chunkFormat: "module", + filename: "[name].mjs", + chunkFilename: "[name].chunk.mjs", + enabledLibraryTypes: ["module"] + }, + optimization: { + minimize: false + } +}; diff --git a/test/hotCases/esm-output/split-chunks/common/shared.js b/test/hotCases/esm-output/split-chunks/common/shared.js new file mode 100644 index 000000000..b97c6b020 --- /dev/null +++ b/test/hotCases/esm-output/split-chunks/common/shared.js @@ -0,0 +1,16 @@ +export function commonFunction(input) { + return `Common function processed: ${input}`; +} + +export const commonData = { + shared: true +}; +--- +export function commonFunction(input) { + return `Updated common function: ${input}`; +} + +export const commonData = { + shared: true, + updated: true +}; diff --git a/test/hotCases/esm-output/split-chunks/index.js b/test/hotCases/esm-output/split-chunks/index.js new file mode 100644 index 000000000..706146c99 --- /dev/null +++ b/test/hotCases/esm-output/split-chunks/index.js @@ -0,0 +1,25 @@ +import update from "../../update.esm"; +import.meta.webpackHot.accept(["./common/shared", "vendor-lib"]); + +it("should handle HMR with split chunks in ESM format", (done) => { + Promise.all([ + import("./common/shared"), + import("vendor-lib") + ]).then(([commonModule, vendorModule]) => { + expect(commonModule.commonFunction("test")).toBe("Common function processed: test"); + expect(vendorModule.default.version).toBe("1.0.0"); + done(); + }).catch(done); + + NEXT(update(done, true, () => { + // Re-import after HMR update + Promise.all([ + import("./common/shared"), + import("vendor-lib") + ]).then(([commonModule, vendorModule]) => { + expect(commonModule.commonFunction("test")).toBe("Updated common function: test"); + expect(vendorModule.default.version).toBe("2.0.0"); + done(); + }).catch(done); + })); +}); diff --git a/test/hotCases/esm-output/split-chunks/node_modules/vendor-lib.js b/test/hotCases/esm-output/split-chunks/node_modules/vendor-lib.js new file mode 100644 index 000000000..b686ed55c --- /dev/null +++ b/test/hotCases/esm-output/split-chunks/node_modules/vendor-lib.js @@ -0,0 +1,17 @@ +const vendorLib = { + version: "1.0.0", + init: function() { + console.log("Vendor lib initialized"); + } +}; + +export default vendorLib; +--- +const vendorLib = { + version: "2.0.0", + init: function() { + console.log("Vendor lib initialized v2"); + } +}; + +export default vendorLib; diff --git a/test/hotCases/esm-output/split-chunks/webpack.config.js b/test/hotCases/esm-output/split-chunks/webpack.config.js new file mode 100644 index 000000000..8093beac2 --- /dev/null +++ b/test/hotCases/esm-output/split-chunks/webpack.config.js @@ -0,0 +1,34 @@ +/** @type {import("../../../../types").Configuration} */ +module.exports = { + mode: "development", + experiments: { + outputModule: true + }, + output: { + module: true, + chunkFormat: "module", + filename: "[name].mjs", + chunkFilename: "[name].chunk.mjs", + enabledLibraryTypes: ["module"] + }, + optimization: { + minimize: false, + splitChunks: { + chunks: "all", + minSize: 0, + cacheGroups: { + common: { + test: /common/, + name: "common", + priority: 10, + enforce: true + }, + vendor: { + test: /node_modules/, + name: "vendor", + priority: 20 + } + } + } + } +}; diff --git a/test/hotCases/update.esm.js b/test/hotCases/update.esm.js new file mode 100644 index 000000000..6cfa77afc --- /dev/null +++ b/test/hotCases/update.esm.js @@ -0,0 +1,16 @@ +export default function update(done, options, callback) { + return function (err, stats) { + if (err) return done(err); + import.meta.webpackHot + .check(options || true) + .then(updatedModules => { + if (!updatedModules) { + return done(new Error("No update available")); + } + if (callback) callback(stats); + }) + .catch(err => { + done(err); + }); + }; +}; diff --git a/test/runner/index.js b/test/runner/index.js index 35cc0d8b2..e6bc9f739 100644 --- a/test/runner/index.js +++ b/test/runner/index.js @@ -222,6 +222,14 @@ class TestRunner { content: fs.readFileSync(module, "utf-8") }; } + if (module.startsWith("https://test.")) { + const realPath = urlToPath(module, currentDirectory); + return { + subPath: "", + modulePath: realPath, + content: fs.readFileSync(realPath, "utf-8") + }; + } } /**