From 079ac63a34ba726dc9ceaad18d8ae378164d94a6 Mon Sep 17 00:00:00 2001 From: Menny Mezamer-Tov <3127778+Menny1337@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:08:05 +0300 Subject: [PATCH 1/4] fix: parse webpack 5 module maps nested in UMD wrappers Discover nested Webpack bootstrap IIFEs and select the correct non-empty module map using stats IDs and runtime structure, preserving accurate parsed module attribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2cd1d86-732d-44fb-b556-cf0b08f9906c --- .changeset/bright-bundles-parse.md | 5 + src/analyzer.js | 77 +++-- src/parseUtils.js | 274 +++++++++++++----- test/analyzerUtils.js | 68 +++++ .../bundles/webpack5UmdBundleWithDecoyIIFE.js | 70 +++++ ...ebpack5UmdBundleWithDecoyIIFE.modules.json | 6 + test/parseUtils.js | 21 ++ 7 files changed, 432 insertions(+), 89 deletions(-) create mode 100644 .changeset/bright-bundles-parse.md create mode 100644 test/analyzerUtils.js create mode 100644 test/bundles/webpack5UmdBundleWithDecoyIIFE.js create mode 100644 test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json diff --git a/.changeset/bright-bundles-parse.md b/.changeset/bright-bundles-parse.md new file mode 100644 index 00000000..f24adbad --- /dev/null +++ b/.changeset/bright-bundles-parse.md @@ -0,0 +1,5 @@ +--- +"webpack-bundle-analyzer": patch +--- + +Parse Webpack 5 module maps nested in UMD wrappers, using bundle module IDs and Webpack runtime structure to choose among IIFE candidates. diff --git a/src/analyzer.js b/src/analyzer.js index a41b9a02..2bfbee22 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -191,6 +191,29 @@ function getAssetModulesByChunk(statsAsset, modulesByChunk) { .map(({ module }) => module); } +/** + * @param {StatsCompilation} bundleStats bundle stats + * @param {StatsAsset} statAsset stats asset + * @param {Map} rootModulesByChunk root modules indexed by chunk ID + * @returns {StatsModule[]} modules in asset + */ +function getAssetModules(bundleStats, statAsset, rootModulesByChunk) { + if (!statAsset.isChild) { + return getAssetModulesByChunk(statAsset, rootModulesByChunk); + } + + const assetBundles = getChildAssetBundles(bundleStats, statAsset.name); + /** @type {StatsModule[]} */ + const modules = assetBundles + ? // @ts-expect-error TODO looks like we have a bug with child compilation parsing, need to add test cases + getBundleModules(assetBundles) + : []; + + return modules.filter((statsModule) => + assetHasModule(statAsset, statsModule), + ); +} + /** @typedef {Record>} ChunkToInitialByEntrypoint */ /** @@ -317,6 +340,32 @@ function getViewerData(bundleStats, bundleDir, opts) { ); }); + const rootModules = getBundleModules(bundleStats); + const rootModulesByChunk = getModulesByChunk(rootModules); + + /** @type {Map} */ + const assetModuleInfo = new Map(); + + for (const statAsset of bundleStats.assets) { + const modules = getAssetModules( + bundleStats, + statAsset, + rootModulesByChunk, + ); + const expectedModuleIds = modules.reduce((moduleIds, statsModule) => { + if ( + typeof statsModule.id === "string" || + typeof statsModule.id === "number" + ) { + moduleIds.push(statsModule.id); + } + + return moduleIds; + }, /** @type {(string | number)[]} */ ([])); + + assetModuleInfo.set(statAsset, { modules, expectedModuleIds }); + } + // Trying to parse bundle assets and get real module sizes if `bundleDir` is provided /** @type {Record | null} */ let bundlesSources = null; @@ -329,11 +378,14 @@ function getViewerData(bundleStats, bundleDir, opts) { for (const statAsset of bundleStats.assets) { const assetFile = path.join(bundleDir, statAsset.name); + const expectedModuleIds = + assetModuleInfo.get(statAsset)?.expectedModuleIds || []; let bundleInfo; try { bundleInfo = parseBundle(assetFile, { sourceType: statAsset.info.javascriptModule ? "module" : "script", + expectedModuleIds, }); } catch (err) { const msg = @@ -364,28 +416,7 @@ function getViewerData(bundleStats, bundleDir, opts) { /** @typedef {{ size: number, parsedSize?: number, gzipSize?: number, brotliSize?: number, zstdSize?: number, modules: StatsModule[], tree: Folder }} Asset */ - const rootModules = getBundleModules(bundleStats); - const rootModulesByChunk = getModulesByChunk(rootModules); - const assets = bundleStats.assets.reduce((result, statAsset) => { - /** @type {StatsModule[]} */ - let assetModules; - - if (statAsset.isChild) { - // Preserve child-compilation matching because child assets use a different module list. - const assetBundles = getChildAssetBundles(bundleStats, statAsset.name); - /** @type {StatsModule[]} */ - const modules = assetBundles - ? // @ts-expect-error TODO looks like we have a bug with child compilation parsing, need to add test cases - getBundleModules(assetBundles) - : []; - assetModules = modules.filter((statModule) => - assetHasModule(statAsset, statModule), - ); - } else { - assetModules = getAssetModulesByChunk(statAsset, rootModulesByChunk); - } - const asset = (result[statAsset.name] = /** @type {Asset} */ ({ size: statAsset.size, })); @@ -410,6 +441,10 @@ function getViewerData(bundleStats, bundleDir, opts) { } } + // Picking modules from current bundle script + /** @type {StatsModule[]} */ + let assetModules = assetModuleInfo.get(statAsset)?.modules || []; + // Adding parsed sources if (parsedModules) { /** @type {StatsModule[]} */ diff --git a/src/parseUtils.js b/src/parseUtils.js index 4a6918aa..ac62431e 100644 --- a/src/parseUtils.js +++ b/src/parseUtils.js @@ -151,6 +151,7 @@ function getModuleLocation(node) { } /** @typedef {Record} ModulesLocations */ +/** @typedef {{ locations: ModulesLocations, hasWebpackRuntime: boolean, isTopLevel: boolean, start: number }} Webpack5IIFECandidate */ /** * @param {Expression | SpreadElement} node node @@ -216,27 +217,186 @@ function getModulesLocations(node) { /** * @param {ExpressionStatement} node node - * @returns {boolean} true when IIFE, otherwise false + * @returns {CallExpression | null} IIFE call expression */ -function isIIFE(node) { +function getIIFECallExpression(node) { + if (node.expression.type === "CallExpression") { + return node.expression; + } + + if ( + node.expression.type === "UnaryExpression" && + node.expression.argument.type === "CallExpression" + ) { + return node.expression.argument; + } + + return null; +} + +/** + * @param {Node} node node + * @param {string} modulesVariableName modules variable name + * @returns {boolean} true when the node calls a module wrapper + */ +function callsModulesMap(node, modulesVariableName) { + let callsModule = false; + + walk.simple(node, { + CallExpression(callExpression) { + const { callee } = callExpression; + + if ( + callee.type === "MemberExpression" && + ((callee.object.type === "Identifier" && + callee.object.name === modulesVariableName) || + (callee.object.type === "MemberExpression" && + callee.object.object.type === "Identifier" && + callee.object.object.name === modulesVariableName)) + ) { + callsModule = true; + } + }, + }); + + return callsModule; +} + +/** + * @param {import("acorn").BlockStatement} body body + * @param {import("acorn").VariableDeclaration} modulesDeclaration modules declaration + * @param {string} modulesVariableName modules variable name + * @returns {boolean} true when the candidate contains Webpack module loading + */ +function hasWebpackModulesRuntime( + body, + modulesDeclaration, + modulesVariableName, +) { + const declarationIndex = body.body.indexOf(modulesDeclaration); + + return body.body + .slice(declarationIndex + 1) + .some((statement) => callsModulesMap(statement, modulesVariableName)); +} + +/** + * @param {CallExpression} node node + * @param {boolean} isTopLevel is top-level IIFE + * @returns {Webpack5IIFECandidate | null} modules candidate + */ +function getWebpack5IIFEModulesCandidate(node, isTopLevel) { + if ( + node.arguments.length !== 0 || + (node.callee.type !== "FunctionExpression" && + node.callee.type !== "ArrowFunctionExpression") || + node.callee.params.length !== 0 || + node.callee.body.type !== "BlockStatement" + ) { + return null; + } + + const firstVariableDeclaration = node.callee.body.body.find( + (node) => node.type === "VariableDeclaration", + ); + + if (firstVariableDeclaration) { + for (const declaration of firstVariableDeclaration.declarations) { + if (declaration.init && isModulesList(declaration.init)) { + const locations = getModulesLocations(declaration.init); + + if (Object.keys(locations).length === 0) { + continue; + } + + return { + locations, + hasWebpackRuntime: + declaration.id.type === "Identifier" && + hasWebpackModulesRuntime( + node.callee.body, + firstVariableDeclaration, + declaration.id.name, + ), + isTopLevel, + start: node.start, + }; + } + } + } + + return null; +} + +/** + * @param {Webpack5IIFECandidate} candidateA first candidate + * @param {Webpack5IIFECandidate} candidateB second candidate + * @returns {number} candidate sort order + */ +function compareWebpack5IIFECandidates(candidateA, candidateB) { return ( - node.type === "ExpressionStatement" && - (node.expression.type === "CallExpression" || - (node.expression.type === "UnaryExpression" && - node.expression.argument.type === "CallExpression")) + Number(candidateB.hasWebpackRuntime) - + Number(candidateA.hasWebpackRuntime) || + Number(candidateB.isTopLevel) - Number(candidateA.isTopLevel) || + Object.keys(candidateB.locations).length - + Object.keys(candidateA.locations).length || + candidateA.start - candidateB.start ); } /** - * @param {ExpressionStatement} node node - * @returns {Expression} IIFE call expression + * @param {Webpack5IIFECandidate[]} candidates candidates + * @param {(string | number)[] | undefined} expectedModuleIds expected module ids + * @returns {ModulesLocations | null} selected modules locations */ -function getIIFECallExpression(node) { - if (node.expression.type === "UnaryExpression") { - return node.expression.argument; +function selectWebpack5IIFEModulesLocations(candidates, expectedModuleIds) { + if (candidates.length === 0) { + return null; } - return node.expression; + if (expectedModuleIds?.length) { + const expectedIds = new Set(expectedModuleIds.map(String)); + const rankedCandidates = candidates + .map((candidate) => ({ + candidate, + expectedIdsIntersection: Object.keys(candidate.locations).filter( + (moduleId) => expectedIds.has(moduleId), + ).length, + })) + .toSorted( + (candidateA, candidateB) => + candidateB.expectedIdsIntersection - + candidateA.expectedIdsIntersection || + compareWebpack5IIFECandidates( + candidateA.candidate, + candidateB.candidate, + ), + ); + + if (rankedCandidates[0].expectedIdsIntersection > 0) { + return rankedCandidates[0].candidate.locations; + } + } + + const webpackCandidates = candidates + .filter((candidate) => candidate.hasWebpackRuntime) + .toSorted( + (candidateA, candidateB) => + Number(candidateB.isTopLevel) - Number(candidateA.isTopLevel) || + candidateA.start - candidateB.start || + Object.keys(candidateB.locations).length - + Object.keys(candidateA.locations).length, + ); + + if (webpackCandidates.length > 0) { + return webpackCandidates[0].locations; + } + + const topLevelCandidates = candidates + .filter((candidate) => candidate.isTopLevel) + .toSorted((candidateA, candidateB) => candidateA.start - candidateB.start); + + return topLevelCandidates[0]?.locations || null; } /** @@ -323,11 +483,11 @@ function isAsyncWebWorkerChunkExpression(node) { /** * @param {string} bundlePath bundle path - * @param {{ sourceType: "script" | "module" }} opts options + * @param {{ sourceType?: "script" | "module", expectedModuleIds?: (string | number)[] }} opts options * @returns {{ modules: Modules, src: string, runtimeSrc: string }} parsed result */ module.exports.parseBundle = function parseBundle(bundlePath, opts) { - const { sourceType = "script" } = opts || {}; + const { sourceType = "script", expectedModuleIds } = opts || {}; const content = fs.readFileSync(bundlePath, "utf8"); const ast = acorn.parse(content, { @@ -335,62 +495,26 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { ecmaVersion: "latest", }); - /** @type {{ locations: ModulesLocations | null, expressionStatementDepth: number }} */ - const walkState = { - locations: null, - expressionStatementDepth: 0, - }; - - walk.recursive(ast, walkState, { - ExpressionStatement(node, state, callback) { - if (state.locations) return; - - state.expressionStatementDepth++; + /** @type {Set} */ + const topLevelIIFECalls = new Set(); - if ( - // Webpack 5 stores modules in the the top-level IIFE - state.expressionStatementDepth === 1 && - ast.body.includes(node) && - isIIFE(node) - ) { - const fn = getIIFECallExpression(node); - - if ( - fn.type === "CallExpression" && - // It should not contain neither arguments - fn.arguments.length === 0 && - (fn.callee.type === "FunctionExpression" || - fn.callee.type === "ArrowFunctionExpression") && - // ...nor parameters - fn.callee.params.length === 0 && - fn.callee.body.type === "BlockStatement" - ) { - // Modules are stored in the very first variable declaration as hash - const firstVariableDeclaration = fn.callee.body.body.find( - (node) => node.type === "VariableDeclaration", - ); - - if (firstVariableDeclaration) { - for (const declaration of firstVariableDeclaration.declarations) { - if (declaration.init && isModulesList(declaration.init)) { - state.locations = getModulesLocations(declaration.init); - - if (state.locations) { - break; - } - } - } - } - } - } + for (const node of ast.body) { + if (node.type === "ExpressionStatement") { + const iifeCall = getIIFECallExpression(node); - if (!state.locations) { - callback(node.expression, state); + if (iifeCall) { + topLevelIIFECalls.add(iifeCall); } + } + } - state.expressionStatementDepth--; - }, + /** @type {{ locations: ModulesLocations | null, webpack5IIFECandidates: Webpack5IIFECandidate[] }} */ + const walkState = { + locations: null, + webpack5IIFECandidates: [], + }; + walk.recursive(ast, walkState, { AssignmentExpression(node, state) { if (state.locations) return; @@ -417,6 +541,14 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { if (state.locations) return; const args = node.arguments; + const webpack5IIFEModulesCandidate = getWebpack5IIFEModulesCandidate( + node, + topLevelIIFECalls.has(node), + ); + + if (webpack5IIFEModulesCandidate) { + state.webpack5IIFECandidates.push(webpack5IIFEModulesCandidate); + } // Main chunk with webpack loader. // Modules are stored in first argument: @@ -472,11 +604,17 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { }, }); + const modulesLocations = + walkState.locations || + selectWebpack5IIFEModulesLocations( + walkState.webpack5IIFECandidates, + expectedModuleIds, + ); /** @type {Modules} */ const modules = {}; - if (walkState.locations) { - for (const [id, loc] of Object.entries(walkState.locations)) { + if (modulesLocations) { + for (const [id, loc] of Object.entries(modulesLocations)) { modules[id] = content.slice(loc.start, loc.end); } } @@ -484,6 +622,6 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { return { modules, src: content, - runtimeSrc: getBundleRuntime(content, walkState.locations), + runtimeSrc: getBundleRuntime(content, modulesLocations), }; }; diff --git a/test/analyzerUtils.js b/test/analyzerUtils.js new file mode 100644 index 00000000..fd35c75e --- /dev/null +++ b/test/analyzerUtils.js @@ -0,0 +1,68 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { getViewerData } = require("../src/analyzer"); + +const BUNDLES_DIR = path.resolve(__dirname, "./bundles"); + +describe("getViewerData", () => { + it("passes asset module ids when parsing competing Webpack 5 IIFEs", () => { + const bundleName = "webpack5UmdBundleWithDecoyIIFE"; + const bundleFilename = `${bundleName}.js`; + const expectedModules = JSON.parse( + fs.readFileSync(`${BUNDLES_DIR}/${bundleName}.modules.json`), + ).modules; + const dependencyModule = { + id: 447, + identifier: "./src/dependency.js", + name: "./src/dependency.js", + size: 40, + chunks: [1], + depth: 1, + }; + const entryModule = { + id: 956, + identifier: "./src/entry.js", + name: "./src/entry.js", + size: 80, + chunks: [1], + depth: 0, + }; + let chunksAccessCount = 0; + const chunks = [ + { + id: 1, + modules: [dependencyModule, entryModule], + }, + ]; + const stats = { + assets: [ + { + type: "asset", + name: bundleFilename, + size: fs.statSync(`${BUNDLES_DIR}/${bundleFilename}`).size, + info: { + javascriptModule: false, + }, + chunks: [1], + }, + ], + get chunks() { + chunksAccessCount++; + return chunks; + }, + entrypoints: { + main: { + name: "main", + assets: [{ name: bundleFilename }], + }, + }, + }; + + getViewerData(stats, BUNDLES_DIR); + + expect(dependencyModule.parsedSrc).toBe(expectedModules[447]); + expect(entryModule.parsedSrc).toBe(expectedModules[956]); + expect(chunksAccessCount).toBe(1); + }); +}); diff --git a/test/bundles/webpack5UmdBundleWithDecoyIIFE.js b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js new file mode 100644 index 00000000..463d4b9d --- /dev/null +++ b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js @@ -0,0 +1,70 @@ +(()=>{var emptyModules={};return emptyModules;})(); +(()=>{var decoyModules={999:()=>"decoy-not-webpack"};return decoyModules;})(); +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(); + else if(typeof define === 'function' && define.amd) + define([], factory); + else if(typeof exports === 'object') + exports["FixtureLibrary"] = factory(); + else + root["FixtureLibrary"] = factory(); +})(this, () => { +return /******/ (() => { // webpackBootstrap +/******/ var __webpack_modules__ = ({ + +/***/ 447 +(module) { + +module.exports = "fixture-dependency"; + + +/***/ }, + +/***/ 956 +(module, __unused_webpack_exports, __webpack_require__) { + +const dependency = __webpack_require__(447); + +module.exports = { dependency }; + + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __webpack_require__(956); +/******/ +/******/ return __webpack_exports__; +/******/ })() +; +}); diff --git a/test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json b/test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json new file mode 100644 index 00000000..3ffe153f --- /dev/null +++ b/test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json @@ -0,0 +1,6 @@ +{ + "modules": { + "447": "(module) {\n\nmodule.exports = \"fixture-dependency\";\n\n\n/***/ }", + "956": "(module, __unused_webpack_exports, __webpack_require__) {\n\nconst dependency = __webpack_require__(447);\n\nmodule.exports = { dependency };\n\n\n/***/ }" + } +} diff --git a/test/parseUtils.js b/test/parseUtils.js index c36b2ddb..abb18c02 100644 --- a/test/parseUtils.js +++ b/test/parseUtils.js @@ -26,6 +26,27 @@ describe("parseBundle", () => { }); } + for (const [description, expectedModuleIds] of [ + ["without module id hints", undefined], + ["with partial module id hints", [447]], + ["with tied partial module id hints", [447, 999]], + ["with non-matching module id hints", ["missing"]], + ]) { + it(`should ignore empty and decoy IIFEs ${description}`, () => { + const bundleName = "webpack5UmdBundleWithDecoyIIFE"; + const bundleFile = `${BUNDLES_DIR}/${bundleName}.js`; + const expectedModules = JSON.parse( + fs.readFileSync(`${BUNDLES_DIR}/${bundleName}.modules.json`), + ); + const bundle = parseBundle( + bundleFile, + expectedModuleIds ? { expectedModuleIds } : undefined, + ); + + expect(bundle.modules).toEqual(expectedModules.modules); + }); + } + it("should parse invalid bundle and return it's content and empty modules hash", () => { const bundleFile = `${BUNDLES_DIR}/invalidBundle.js`; const bundle = parseBundle(bundleFile); From f08b9bbb25cfb9855a453e34ea402e2c08b45cf7 Mon Sep 17 00:00:00 2001 From: Menny Mezamer-Tov <3127778+Menny1337@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:08:05 +0300 Subject: [PATCH 2/4] test: normalize UMD fixture whitespace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2cd1d86-732d-44fb-b556-cf0b08f9906c --- test/bundles/webpack5UmdBundleWithDecoyIIFE.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/bundles/webpack5UmdBundleWithDecoyIIFE.js b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js index 463d4b9d..68e880d9 100644 --- a/test/bundles/webpack5UmdBundleWithDecoyIIFE.js +++ b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js @@ -35,7 +35,7 @@ module.exports = { dependency }; /************************************************************************/ /******/ // The module cache /******/ var __webpack_module_cache__ = {}; -/******/ +/******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ // Check if module is in cache @@ -49,21 +49,21 @@ module.exports = { dependency }; /******/ // no module.loaded needed /******/ exports: {} /******/ }; -/******/ +/******/ /******/ // Execute the module function /******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); -/******/ +/******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } -/******/ +/******/ /************************************************************************/ -/******/ +/******/ /******/ // startup /******/ // Load entry module and return exports /******/ // This entry module is referenced by other modules so it can't be inlined /******/ var __webpack_exports__ = __webpack_require__(956); -/******/ +/******/ /******/ return __webpack_exports__; /******/ })() ; From c24d5704d5d9a4d7aa3affc11466a99f3d5ca7f2 Mon Sep 17 00:00:00 2001 From: Menny Mezamer-Tov <3127778+Menny1337@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:05:43 +0300 Subject: [PATCH 3/4] test: cover nested Webpack 5 UMD parsing Add a generated UMD fixture parsed through the standard no-options harness and cover candidate fallback behavior while simplifying selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/analyzer.js | 6 +- src/parseUtils.js | 58 ++++++---------- test/bundles/validWebpack5UmdBundle.js | 68 +++++++++++++++++++ .../validWebpack5UmdBundle.modules.json | 6 ++ .../webpack5NestedModulesWithoutRuntime.js | 11 +++ .../webpack5TopLevelModulesWithoutRuntime.js | 7 ++ ...TopLevelModulesWithoutRuntime.modules.json | 5 ++ test/parseUtils.js | 16 +++++ 8 files changed, 135 insertions(+), 42 deletions(-) create mode 100644 test/bundles/validWebpack5UmdBundle.js create mode 100644 test/bundles/validWebpack5UmdBundle.modules.json create mode 100644 test/bundles/webpack5NestedModulesWithoutRuntime.js create mode 100644 test/bundles/webpack5TopLevelModulesWithoutRuntime.js create mode 100644 test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json diff --git a/src/analyzer.js b/src/analyzer.js index 2bfbee22..d7e44249 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -347,11 +347,7 @@ function getViewerData(bundleStats, bundleDir, opts) { const assetModuleInfo = new Map(); for (const statAsset of bundleStats.assets) { - const modules = getAssetModules( - bundleStats, - statAsset, - rootModulesByChunk, - ); + const modules = getAssetModules(bundleStats, statAsset, rootModulesByChunk); const expectedModuleIds = modules.reduce((moduleIds, statsModule) => { if ( typeof statsModule.id === "string" || diff --git a/src/parseUtils.js b/src/parseUtils.js index ac62431e..c03ef878 100644 --- a/src/parseUtils.js +++ b/src/parseUtils.js @@ -151,7 +151,7 @@ function getModuleLocation(node) { } /** @typedef {Record} ModulesLocations */ -/** @typedef {{ locations: ModulesLocations, hasWebpackRuntime: boolean, isTopLevel: boolean, start: number }} Webpack5IIFECandidate */ +/** @typedef {{ locations: ModulesLocations, hasWebpackRuntime: boolean, isTopLevel: boolean }} Webpack5IIFECandidate */ /** * @param {Expression | SpreadElement} node node @@ -319,7 +319,6 @@ function getWebpack5IIFEModulesCandidate(node, isTopLevel) { declaration.id.name, ), isTopLevel, - start: node.start, }; } } @@ -329,18 +328,17 @@ function getWebpack5IIFEModulesCandidate(node, isTopLevel) { } /** - * @param {Webpack5IIFECandidate} candidateA first candidate - * @param {Webpack5IIFECandidate} candidateB second candidate - * @returns {number} candidate sort order + * @param {Webpack5IIFECandidate[]} candidates candidates + * @returns {Webpack5IIFECandidate | null} selected candidate */ -function compareWebpack5IIFECandidates(candidateA, candidateB) { +function selectWebpack5IIFECandidate(candidates) { return ( - Number(candidateB.hasWebpackRuntime) - - Number(candidateA.hasWebpackRuntime) || - Number(candidateB.isTopLevel) - Number(candidateA.isTopLevel) || - Object.keys(candidateB.locations).length - - Object.keys(candidateA.locations).length || - candidateA.start - candidateB.start + candidates.find( + (candidate) => candidate.hasWebpackRuntime && candidate.isTopLevel, + ) || + candidates.find((candidate) => candidate.hasWebpackRuntime) || + candidates.find((candidate) => candidate.isTopLevel) || + null ); } @@ -366,37 +364,23 @@ function selectWebpack5IIFEModulesLocations(candidates, expectedModuleIds) { .toSorted( (candidateA, candidateB) => candidateB.expectedIdsIntersection - - candidateA.expectedIdsIntersection || - compareWebpack5IIFECandidates( - candidateA.candidate, - candidateB.candidate, - ), + candidateA.expectedIdsIntersection, ); if (rankedCandidates[0].expectedIdsIntersection > 0) { - return rankedCandidates[0].candidate.locations; + const bestCandidates = rankedCandidates + .filter( + (rankedCandidate) => + rankedCandidate.expectedIdsIntersection === + rankedCandidates[0].expectedIdsIntersection, + ) + .map((rankedCandidate) => rankedCandidate.candidate); + + return selectWebpack5IIFECandidate(bestCandidates)?.locations || null; } } - const webpackCandidates = candidates - .filter((candidate) => candidate.hasWebpackRuntime) - .toSorted( - (candidateA, candidateB) => - Number(candidateB.isTopLevel) - Number(candidateA.isTopLevel) || - candidateA.start - candidateB.start || - Object.keys(candidateB.locations).length - - Object.keys(candidateA.locations).length, - ); - - if (webpackCandidates.length > 0) { - return webpackCandidates[0].locations; - } - - const topLevelCandidates = candidates - .filter((candidate) => candidate.isTopLevel) - .toSorted((candidateA, candidateB) => candidateA.start - candidateB.start); - - return topLevelCandidates[0]?.locations || null; + return selectWebpack5IIFECandidate(candidates)?.locations || null; } /** diff --git a/test/bundles/validWebpack5UmdBundle.js b/test/bundles/validWebpack5UmdBundle.js new file mode 100644 index 00000000..991e6fd5 --- /dev/null +++ b/test/bundles/validWebpack5UmdBundle.js @@ -0,0 +1,68 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(); + else if(typeof define === 'function' && define.amd) + define([], factory); + else if(typeof exports === 'object') + exports["FixtureLibrary"] = factory(); + else + root["FixtureLibrary"] = factory(); +})(this, () => { +return /******/ (() => { // webpackBootstrap +/******/ var __webpack_modules__ = ({ + +/***/ 447 +(module) { + +module.exports = "fixture-dependency"; + + +/***/ }, + +/***/ 956 +(module, __unused_webpack_exports, __webpack_require__) { + +const dependency = __webpack_require__(447); + +module.exports = { dependency }; + + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __webpack_require__(956); +/******/ +/******/ return __webpack_exports__; +/******/ })() +; +}); diff --git a/test/bundles/validWebpack5UmdBundle.modules.json b/test/bundles/validWebpack5UmdBundle.modules.json new file mode 100644 index 00000000..3ffe153f --- /dev/null +++ b/test/bundles/validWebpack5UmdBundle.modules.json @@ -0,0 +1,6 @@ +{ + "modules": { + "447": "(module) {\n\nmodule.exports = \"fixture-dependency\";\n\n\n/***/ }", + "956": "(module, __unused_webpack_exports, __webpack_require__) {\n\nconst dependency = __webpack_require__(447);\n\nmodule.exports = { dependency };\n\n\n/***/ }" + } +} diff --git a/test/bundles/webpack5NestedModulesWithoutRuntime.js b/test/bundles/webpack5NestedModulesWithoutRuntime.js new file mode 100644 index 00000000..e883e960 --- /dev/null +++ b/test/bundles/webpack5NestedModulesWithoutRuntime.js @@ -0,0 +1,11 @@ +(function webpackUniversalModuleDefinition(root, factory) { + root["FixtureLibrary"] = factory(); +})(this, () => { + return (() => { + var modules = { + 123: () => "not-a-webpack-runtime", + }; + + return modules; + })(); +}); diff --git a/test/bundles/webpack5TopLevelModulesWithoutRuntime.js b/test/bundles/webpack5TopLevelModulesWithoutRuntime.js new file mode 100644 index 00000000..a946eeb2 --- /dev/null +++ b/test/bundles/webpack5TopLevelModulesWithoutRuntime.js @@ -0,0 +1,7 @@ +(() => { + var modules = { + 123: () => "top-level-fallback", + }; + + return modules; +})(); diff --git a/test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json b/test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json new file mode 100644 index 00000000..18f797e4 --- /dev/null +++ b/test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json @@ -0,0 +1,5 @@ +{ + "modules": { + "123": "() => \"top-level-fallback\"" + } +} diff --git a/test/parseUtils.js b/test/parseUtils.js index abb18c02..300f6622 100644 --- a/test/parseUtils.js +++ b/test/parseUtils.js @@ -47,6 +47,22 @@ describe("parseBundle", () => { }); } + it("should preserve the top-level Webpack 5 module-map fallback", () => { + const bundleName = "webpack5TopLevelModulesWithoutRuntime"; + const bundleFile = `${BUNDLES_DIR}/${bundleName}.js`; + const expectedModules = JSON.parse( + fs.readFileSync(`${BUNDLES_DIR}/${bundleName}.modules.json`), + ); + + expect(parseBundle(bundleFile).modules).toEqual(expectedModules.modules); + }); + + it("should ignore nested module maps without a Webpack runtime", () => { + const bundleFile = `${BUNDLES_DIR}/webpack5NestedModulesWithoutRuntime.js`; + + expect(parseBundle(bundleFile).modules).toEqual({}); + }); + it("should parse invalid bundle and return it's content and empty modules hash", () => { const bundleFile = `${BUNDLES_DIR}/invalidBundle.js`; const bundle = parseBundle(bundleFile); From eba7f748cc7cd9d961e59c68dfd3cd87c93435ab Mon Sep 17 00:00:00 2001 From: Menny Mezamer-Tov <3127778+Menny1337@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:21:46 +0300 Subject: [PATCH 4/4] test: cover nested UMD parser branches Exercise every production line and branch added by the nested Webpack 5 UMD fix while preserving single-pass root asset module selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/analyzer.js | 88 +++++++++---------- test/analyzerUtils.js | 19 +++- .../validWebpack5NestedModuleAccessor.js | 7 ++ ...dWebpack5NestedModuleAccessor.modules.json | 5 ++ .../webpack5TopLevelModulesWithoutRuntime.js | 4 + .../bundles/webpack5UmdBundleWithDecoyIIFE.js | 1 + test/parseUtils.js | 3 + 7 files changed, 81 insertions(+), 46 deletions(-) create mode 100644 test/bundles/validWebpack5NestedModuleAccessor.js create mode 100644 test/bundles/validWebpack5NestedModuleAccessor.modules.json diff --git a/src/analyzer.js b/src/analyzer.js index d7e44249..076fd6a1 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -191,29 +191,6 @@ function getAssetModulesByChunk(statsAsset, modulesByChunk) { .map(({ module }) => module); } -/** - * @param {StatsCompilation} bundleStats bundle stats - * @param {StatsAsset} statAsset stats asset - * @param {Map} rootModulesByChunk root modules indexed by chunk ID - * @returns {StatsModule[]} modules in asset - */ -function getAssetModules(bundleStats, statAsset, rootModulesByChunk) { - if (!statAsset.isChild) { - return getAssetModulesByChunk(statAsset, rootModulesByChunk); - } - - const assetBundles = getChildAssetBundles(bundleStats, statAsset.name); - /** @type {StatsModule[]} */ - const modules = assetBundles - ? // @ts-expect-error TODO looks like we have a bug with child compilation parsing, need to add test cases - getBundleModules(assetBundles) - : []; - - return modules.filter((statsModule) => - assetHasModule(statAsset, statsModule), - ); -} - /** @typedef {Record>} ChunkToInitialByEntrypoint */ /** @@ -342,24 +319,16 @@ function getViewerData(bundleStats, bundleDir, opts) { const rootModules = getBundleModules(bundleStats); const rootModulesByChunk = getModulesByChunk(rootModules); - - /** @type {Map} */ - const assetModuleInfo = new Map(); + /** @type {Map} */ + const rootAssetModules = new Map(); for (const statAsset of bundleStats.assets) { - const modules = getAssetModules(bundleStats, statAsset, rootModulesByChunk); - const expectedModuleIds = modules.reduce((moduleIds, statsModule) => { - if ( - typeof statsModule.id === "string" || - typeof statsModule.id === "number" - ) { - moduleIds.push(statsModule.id); - } - - return moduleIds; - }, /** @type {(string | number)[]} */ ([])); - - assetModuleInfo.set(statAsset, { modules, expectedModuleIds }); + if (!statAsset.isChild) { + rootAssetModules.set( + statAsset, + getAssetModulesByChunk(statAsset, rootModulesByChunk), + ); + } } // Trying to parse bundle assets and get real module sizes if `bundleDir` is provided @@ -374,8 +343,21 @@ function getViewerData(bundleStats, bundleDir, opts) { for (const statAsset of bundleStats.assets) { const assetFile = path.join(bundleDir, statAsset.name); - const expectedModuleIds = - assetModuleInfo.get(statAsset)?.expectedModuleIds || []; + const expectedModuleIds = statAsset.isChild + ? [] + : /** @type {StatsModule[]} */ (rootAssetModules.get(statAsset)).reduce( + (moduleIds, statsModule) => { + if ( + typeof statsModule.id === "string" || + typeof statsModule.id === "number" + ) { + moduleIds.push(statsModule.id); + } + + return moduleIds; + }, + /** @type {(string | number)[]} */ ([]), + ); let bundleInfo; try { @@ -413,6 +395,26 @@ function getViewerData(bundleStats, bundleDir, opts) { /** @typedef {{ size: number, parsedSize?: number, gzipSize?: number, brotliSize?: number, zstdSize?: number, modules: StatsModule[], tree: Folder }} Asset */ const assets = bundleStats.assets.reduce((result, statAsset) => { + /** @type {StatsModule[]} */ + let assetModules; + + if (statAsset.isChild) { + // Preserve child-compilation matching because child assets use a different module list. + const assetBundles = getChildAssetBundles(bundleStats, statAsset.name); + /** @type {StatsModule[]} */ + const modules = assetBundles + ? // @ts-expect-error TODO looks like we have a bug with child compilation parsing, need to add test cases + getBundleModules(assetBundles) + : []; + assetModules = modules.filter((statModule) => + assetHasModule(statAsset, statModule), + ); + } else { + assetModules = /** @type {StatsModule[]} */ ( + rootAssetModules.get(statAsset) + ); + } + const asset = (result[statAsset.name] = /** @type {Asset} */ ({ size: statAsset.size, })); @@ -437,10 +439,6 @@ function getViewerData(bundleStats, bundleDir, opts) { } } - // Picking modules from current bundle script - /** @type {StatsModule[]} */ - let assetModules = assetModuleInfo.get(statAsset)?.modules || []; - // Adding parsed sources if (parsedModules) { /** @type {StatsModule[]} */ diff --git a/test/analyzerUtils.js b/test/analyzerUtils.js index fd35c75e..3e06846d 100644 --- a/test/analyzerUtils.js +++ b/test/analyzerUtils.js @@ -28,11 +28,18 @@ describe("getViewerData", () => { chunks: [1], depth: 0, }; + const moduleWithoutId = { + identifier: "./src/no-id.js", + name: "./src/no-id.js", + size: 20, + chunks: [1], + depth: 1, + }; let chunksAccessCount = 0; const chunks = [ { id: 1, - modules: [dependencyModule, entryModule], + modules: [dependencyModule, entryModule, moduleWithoutId], }, ]; const stats = { @@ -46,6 +53,16 @@ describe("getViewerData", () => { }, chunks: [1], }, + { + type: "asset", + name: "validWebpack5UmdBundle.js", + size: fs.statSync(`${BUNDLES_DIR}/validWebpack5UmdBundle.js`).size, + info: { + javascriptModule: false, + }, + chunks: [2], + isChild: true, + }, ], get chunks() { chunksAccessCount++; diff --git a/test/bundles/validWebpack5NestedModuleAccessor.js b/test/bundles/validWebpack5NestedModuleAccessor.js new file mode 100644 index 00000000..3c712d7d --- /dev/null +++ b/test/bundles/validWebpack5NestedModuleAccessor.js @@ -0,0 +1,7 @@ +(() => { + var modules = { + 123: () => "nested-module-accessor", + }; + + modules.default[123](); +})(); diff --git a/test/bundles/validWebpack5NestedModuleAccessor.modules.json b/test/bundles/validWebpack5NestedModuleAccessor.modules.json new file mode 100644 index 00000000..9cad71b6 --- /dev/null +++ b/test/bundles/validWebpack5NestedModuleAccessor.modules.json @@ -0,0 +1,5 @@ +{ + "modules": { + "123": "() => \"nested-module-accessor\"" + } +} diff --git a/test/bundles/webpack5TopLevelModulesWithoutRuntime.js b/test/bundles/webpack5TopLevelModulesWithoutRuntime.js index a946eeb2..c0379ae4 100644 --- a/test/bundles/webpack5TopLevelModulesWithoutRuntime.js +++ b/test/bundles/webpack5TopLevelModulesWithoutRuntime.js @@ -1,7 +1,11 @@ +const marker = true; + (() => { var modules = { 123: () => "top-level-fallback", }; + noop(); + return modules; })(); diff --git a/test/bundles/webpack5UmdBundleWithDecoyIIFE.js b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js index 68e880d9..e7be790d 100644 --- a/test/bundles/webpack5UmdBundleWithDecoyIIFE.js +++ b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js @@ -1,3 +1,4 @@ +(()=>{return "not-a-candidate";})(); (()=>{var emptyModules={};return emptyModules;})(); (()=>{var decoyModules={999:()=>"decoy-not-webpack"};return decoyModules;})(); (function webpackUniversalModuleDefinition(root, factory) { diff --git a/test/parseUtils.js b/test/parseUtils.js index 300f6622..bef7cbb3 100644 --- a/test/parseUtils.js +++ b/test/parseUtils.js @@ -61,6 +61,9 @@ describe("parseBundle", () => { const bundleFile = `${BUNDLES_DIR}/webpack5NestedModulesWithoutRuntime.js`; expect(parseBundle(bundleFile).modules).toEqual({}); + expect( + parseBundle(bundleFile, { expectedModuleIds: [123] }).modules, + ).toEqual({}); }); it("should parse invalid bundle and return it's content and empty modules hash", () => {