Skip to content

Commit

Permalink
Merge pull request #12075 from webpack/bugfix/hmr-multiple-runtimes
Browse files Browse the repository at this point in the history
fix problem when HMR and different runtimes
  • Loading branch information
sokra committed Nov 28, 2020
2 parents 81b3b7e + 4c3e18f commit c16e968
Show file tree
Hide file tree
Showing 28 changed files with 427 additions and 106 deletions.
206 changes: 170 additions & 36 deletions lib/HotModuleReplacementPlugin.js
Expand Up @@ -12,6 +12,7 @@ const Compilation = require("./Compilation");
const HotUpdateChunk = require("./HotUpdateChunk");
const NormalModule = require("./NormalModule");
const RuntimeGlobals = require("./RuntimeGlobals");
const WebpackError = require("./WebpackError");
const ConstDependency = require("./dependencies/ConstDependency");
const ImportMetaHotAcceptDependency = require("./dependencies/ImportMetaHotAcceptDependency");
const ImportMetaHotDeclineDependency = require("./dependencies/ImportMetaHotDeclineDependency");
Expand All @@ -22,15 +23,22 @@ const JavascriptParser = require("./javascript/JavascriptParser");
const {
evaluateToIdentifier
} = require("./javascript/JavascriptParserHelpers");
const { find } = require("./util/SetHelpers");
const { find, isSubset } = require("./util/SetHelpers");
const TupleSet = require("./util/TupleSet");
const { compareModulesById } = require("./util/comparators");
const { getRuntimeKey, keyToRuntime } = require("./util/runtime");
const {
getRuntimeKey,
keyToRuntime,
forEachRuntime,
mergeRuntimeOwned,
subtractRuntime
} = require("./util/runtime");

/** @typedef {import("./Chunk")} Chunk */
/** @typedef {import("./Compilation").AssetInfo} AssetInfo */
/** @typedef {import("./Compiler")} Compiler */
/** @typedef {import("./Module")} Module */
/** @typedef {import("./RuntimeModule")} RuntimeModule */

/**
* @typedef {Object} HMRJavascriptParserHooks
Expand Down Expand Up @@ -388,27 +396,58 @@ class HotModuleReplacementPlugin {
}
chunkModuleHashes[key] = hash;
}
const hotUpdateMainContent = {
c: [],
r: [],
m: undefined
};

/** @type {Map<string, { updatedChunkIds: Set<string|number>, removedChunkIds: Set<string|number>, removedModules: Set<Module>, filename: string, assetInfo: AssetInfo }>} */
const hotUpdateMainContentByRuntime = new Map();
let allOldRuntime;
for (const key of Object.keys(records.chunkRuntime)) {
const runtime = keyToRuntime(records.chunkRuntime[key]);
allOldRuntime = mergeRuntimeOwned(allOldRuntime, runtime);
}
forEachRuntime(allOldRuntime, runtime => {
const {
path: filename,
info: assetInfo
} = compilation.getPathWithInfo(
compilation.outputOptions.hotUpdateMainFilename,
{
hash: records.hash,
runtime
}
);
hotUpdateMainContentByRuntime.set(runtime, {
updatedChunkIds: new Set(),
removedChunkIds: new Set(),
removedModules: new Set(),
filename,
assetInfo
});
});
if (hotUpdateMainContentByRuntime.size === 0) return;

// Create a list of all active modules to verify which modules are removed completely
/** @type {Map<number|string, Module>} */
const allModules = new Map();
for (const module of compilation.modules) {
allModules.set(chunkGraph.getModuleId(module), module);
const id = chunkGraph.getModuleId(module);
allModules.set(id, module);
}

// List of completely removed modules
const allRemovedModules = new Set();
/** @type {Set<string | number>} */
const completelyRemovedModules = new Set();

for (const key of Object.keys(records.chunkHashs)) {
// Check which modules are completely removed
const oldRuntime = keyToRuntime(records.chunkRuntime[key]);
/** @type {Module[]} */
const remainingModules = [];
// Check which modules are removed
for (const id of records.chunkModuleIds[key]) {
if (!allModules.has(id)) {
allRemovedModules.add(id);
const module = allModules.get(id);
if (module === undefined) {
completelyRemovedModules.add(id);
} else {
remainingModules.push(module);
}
}

Expand All @@ -417,6 +456,7 @@ class HotModuleReplacementPlugin {
let newRuntimeModules;
let newFullHashModules;
let newRuntime;
let removedFromRuntime;
const currentChunk = find(
compilation.chunks,
chunk => `${chunk.id}` === key
Expand All @@ -438,18 +478,61 @@ class HotModuleReplacementPlugin {
Array.from(fullHashModules).filter(module =>
updatedModules.has(module, currentChunk)
);
removedFromRuntime = subtractRuntime(oldRuntime, newRuntime);
} else {
// chunk has completely removed
chunkId = `${+key}` === key ? +key : key;
hotUpdateMainContent.r.push(chunkId);
const runtime = keyToRuntime(records.chunkRuntime[key]);
for (const id of records.chunkModuleIds[key]) {
const module = allModules.get(id);
if (!module) continue;
const hash = chunkGraph.getModuleHash(module, runtime);
removedFromRuntime = oldRuntime;
newRuntime = oldRuntime;
}
if (removedFromRuntime) {
// chunk was removed from some runtimes
forEachRuntime(removedFromRuntime, runtime => {
hotUpdateMainContentByRuntime
.get(runtime)
.removedChunkIds.add(chunkId);
});
// dispose modules from the chunk in these runtimes
// where they are no longer in this runtime
for (const module of remainingModules) {
const moduleKey = `${key}|${module.identifier()}`;
if (hash !== records.chunkModuleHashes[moduleKey]) {
newModules = newModules || [];
newModules.push(module);
const oldHash = records.chunkModuleHashes[moduleKey];
const runtimes = chunkGraph.getModuleRuntimes(module);
if (oldRuntime === newRuntime && runtimes.has(newRuntime)) {
// Module is still in the same runtime combination
const hash = chunkGraph.getModuleHash(module, newRuntime);
if (hash !== oldHash) {
if (module.type === "runtime") {
newRuntimeModules = newRuntimeModules || [];
newRuntimeModules.push(
/** @type {RuntimeModule} */ (module)
);
} else {
newModules = newModules || [];
newModules.push(module);
}
}
} else {
// module is no longer in this runtime combination
// We (incorrectly) assume that it's not in an overlapping runtime combination
// and dispose it from the main runtimes the chunk was removed from
forEachRuntime(removedFromRuntime, runtime => {
// If the module is still used in this runtime, do not dispose it
// This could create a bad runtime state where the module is still loaded,
// but no chunk which contains it. This means we don't receive further HMR updates
// to this module and that's bad.
// TODO force load one of the chunks which contains the module
for (const moduleRuntime of runtimes) {
if (typeof moduleRuntime === "string") {
if (moduleRuntime === runtime) return;
} else if (moduleRuntime !== undefined) {
if (moduleRuntime.has(runtime)) return;
}
}
hotUpdateMainContentByRuntime
.get(runtime)
.removedModules.add(module);
});
}
}
}
Expand Down Expand Up @@ -516,24 +599,75 @@ class HotModuleReplacementPlugin {
compilation.hooks.chunkAsset.call(currentChunk, filename);
}
}
hotUpdateMainContent.c.push(chunkId);
forEachRuntime(newRuntime, runtime => {
hotUpdateMainContentByRuntime
.get(runtime)
.updatedChunkIds.add(chunkId);
});
}
}
hotUpdateMainContent.m = Array.from(allRemovedModules);
const source = new RawSource(JSON.stringify(hotUpdateMainContent));
const {
path: filename,
info: assetInfo
} = compilation.getPathWithInfo(
compilation.outputOptions.hotUpdateMainFilename,
{
hash: records.hash
}
const completelyRemovedModulesArray = Array.from(
completelyRemovedModules
);
compilation.emitAsset(filename, source, {
hotModuleReplacement: true,
...assetInfo
});
const hotUpdateMainContentByFilename = new Map();
for (const {
removedChunkIds,
removedModules,
updatedChunkIds,
filename,
assetInfo
} of hotUpdateMainContentByRuntime.values()) {
const old = hotUpdateMainContentByFilename.get(filename);
if (
old &&
(!isSubset(old.removedChunkIds, removedChunkIds) ||
!isSubset(old.removedModules, removedModules) ||
!isSubset(old.updatedChunkIds, updatedChunkIds))
) {
compilation.warnings.push(
new WebpackError(`HotModuleReplacementPlugin
The configured output.hotUpdateMainFilename doesn't lead to unique filenames per runtime and HMR update differs between runtimes.
This might lead to incorrect runtime behavior of the applied update.
To fix this, make sure to include [runtime] in the output.hotUpdateMainFilename option, or use the default config.`)
);
for (const chunkId of removedChunkIds)
old.removedChunkIds.add(chunkId);
for (const chunkId of removedModules)
old.removedModules.add(chunkId);
for (const chunkId of updatedChunkIds)
old.updatedChunkIds.add(chunkId);
continue;
}
hotUpdateMainContentByFilename.set(filename, {
removedChunkIds,
removedModules,
updatedChunkIds,
assetInfo
});
}
for (const [
filename,
{ removedChunkIds, removedModules, updatedChunkIds, assetInfo }
] of hotUpdateMainContentByFilename) {
const hotUpdateMainJson = {
c: Array.from(updatedChunkIds),
r: Array.from(removedChunkIds),
m:
removedModules.size === 0
? completelyRemovedModulesArray
: completelyRemovedModulesArray.concat(
Array.from(removedModules, m =>
chunkGraph.getModuleId(m)
)
)
};

const source = new RawSource(JSON.stringify(hotUpdateMainJson));
compilation.emitAsset(filename, source, {
hotModuleReplacement: true,
...assetInfo
});
}
}
);

Expand Down
8 changes: 8 additions & 0 deletions lib/TemplatedPathPlugin.js
Expand Up @@ -274,6 +274,14 @@ const replacePathVariables = (path, data, assetInfo) => {
if (data.url) {
replacements.set("url", replacer(data.url));
}
if (typeof data.runtime === "string") {
replacements.set(
"runtime",
replacer(() => prepareId(data.runtime))
);
} else {
replacements.set("runtime", replacer("_"));
}

if (typeof path === "function") {
path = path(data, assetInfo);
Expand Down
2 changes: 1 addition & 1 deletion lib/config/defaults.js
Expand Up @@ -642,7 +642,7 @@ const applyOutputDefaults = (
F(output, "pathinfo", () => development);
D(output, "sourceMapFilename", "[file].map[query]");
D(output, "hotUpdateChunkFilename", "[id].[fullhash].hot-update.js");
D(output, "hotUpdateMainFilename", "[fullhash].hot-update.json");
D(output, "hotUpdateMainFilename", "[runtime].[fullhash].hot-update.json");
D(output, "crossOriginLoading", false);
F(output, "scriptType", () => (output.module ? "module" : false));
D(
Expand Down
6 changes: 4 additions & 2 deletions lib/runtime/GetMainFilenameRuntimeModule.js
Expand Up @@ -26,12 +26,14 @@ class GetMainFilenameRuntimeModule extends RuntimeModule {
* @returns {string} runtime code
*/
generate() {
const { global, filename, compilation } = this;
const { global, filename, compilation, chunk } = this;
const { runtimeTemplate } = compilation;
const url = compilation.getPath(JSON.stringify(filename), {
hash: `" + ${RuntimeGlobals.getFullHash}() + "`,
hashWithLength: length =>
`" + ${RuntimeGlobals.getFullHash}().slice(0, ${length}) + "`
`" + ${RuntimeGlobals.getFullHash}().slice(0, ${length}) + "`,
chunk,
runtime: chunk.runtime
});
return Template.asString([
`${global} = ${runtimeTemplate.returningFunction(url)};`
Expand Down
4 changes: 4 additions & 0 deletions lib/util/runtime.js
Expand Up @@ -485,6 +485,10 @@ class RuntimeSpecSet {
this._map.set(getRuntimeKey(runtime), runtime);
}

has(runtime) {
return this._map.has(getRuntimeKey(runtime));
}

[Symbol.iterator]() {
return this._map.values();
}
Expand Down
51 changes: 3 additions & 48 deletions test/ConfigTestCases.template.js
Expand Up @@ -322,54 +322,9 @@ const describeCases = config => {
moduleScope.window = globalContext;
moduleScope.self = globalContext;
moduleScope.URL = URL;
moduleScope.Worker = class Worker {
constructor(url, options) {
expect(url).toBeInstanceOf(URL);
expect(url.origin).toBe("https://test.cases");
expect(url.pathname.startsWith("/path/")).toBe(
true
);
const file = url.pathname.slice(6);
const workerBootstrap = `
const { parentPort } = require("worker_threads");
const { URL } = require("url");
const path = require("path");
global.self = global;
self.URL = URL;
self.importScripts = url => {
require(path.resolve(${JSON.stringify(outputDirectory)}, \`./\${url}\`));
};
parentPort.on("message", data => {
if(self.onmessage) self.onmessage({
data
});
});
self.postMessage = data => {
parentPort.postMessage(data);
};
require(${JSON.stringify(path.resolve(outputDirectory, file))});
`;
// eslint-disable-next-line node/no-unsupported-features/node-builtins
this.worker = new (require("worker_threads").Worker)(
workerBootstrap,
{
eval: true
}
);
}

set onmessage(value) {
this.worker.on("message", data => {
value({
data
});
});
}

postMessage(data) {
this.worker.postMessage(data);
}
};
moduleScope.Worker = require("./helpers/createFakeWorker")(
{ outputDirectory }
);
runInNewContext = true;
}
if (testConfig.moduleScope) {
Expand Down
2 changes: 1 addition & 1 deletion test/Defaults.unittest.js
Expand Up @@ -306,7 +306,7 @@ describe("Defaults", () => {
"hashSalt": undefined,
"hotUpdateChunkFilename": "[id].[fullhash].hot-update.js",
"hotUpdateGlobal": "webpackHotUpdatewebpack",
"hotUpdateMainFilename": "[fullhash].hot-update.json",
"hotUpdateMainFilename": "[runtime].[fullhash].hot-update.json",
"iife": true,
"importFunctionName": "import",
"importMetaName": "import.meta",
Expand Down
2 changes: 1 addition & 1 deletion test/HotModuleReplacementPlugin.test.js
Expand Up @@ -152,7 +152,7 @@ describe("HotModuleReplacementPlugin", () => {
fs.writeFileSync(statsFile3, stats.toString());
const result = JSON.parse(
fs.readFileSync(
path.join(outputPath, `${hash}.hot-update.json`),
path.join(outputPath, `0.${hash}.hot-update.json`),
"utf-8"
)
)["c"];
Expand Down

0 comments on commit c16e968

Please sign in to comment.