From 135962ab1e3b661f17fe28467280c3179c13ebc6 Mon Sep 17 00:00:00 2001 From: Christian Gonzalez <1581488+christiango@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:28:03 -0400 Subject: [PATCH 1/3] perf: index modules by chunk for asset matching --- .changeset/calm-assets-match.md | 5 ++ src/analyzer.js | 82 +++++++++++++++++++++++++++------ test/analyzer.js | 69 +++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 .changeset/calm-assets-match.md diff --git a/.changeset/calm-assets-match.md b/.changeset/calm-assets-match.md new file mode 100644 index 00000000..247f34f9 --- /dev/null +++ b/.changeset/calm-assets-match.md @@ -0,0 +1,5 @@ +--- +"webpack-bundle-analyzer": patch +--- + +Improve report generation performance for compilations with many assets. diff --git a/src/analyzer.js b/src/analyzer.js index f0634b2b..e2255424 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -143,6 +143,54 @@ function getBundleModules(bundleStats) { }); } +/** + * @typedef {{ module: StatsModule, index: number }} IndexedModule + */ + +/** + * @param {StatsModule[]} modules modules + * @returns {Map} modules indexed by chunk ID + */ +function getModulesByChunk(modules) { + /** @type {Map} */ + const modulesByChunk = new Map(); + + for (const [index, module] of modules.entries()) { + for (const chunk of module.chunks || []) { + let chunkModules = modulesByChunk.get(chunk); + + if (!chunkModules) { + chunkModules = []; + modulesByChunk.set(chunk, chunkModules); + } + + chunkModules.push({ module, index }); + } + } + + return modulesByChunk; +} + +/** + * @param {StatsAsset} statsAsset stats asset + * @param {Map} modulesByChunk modules indexed by chunk ID + * @returns {StatsModule[]} modules belonging to the asset in their original order + */ +function getAssetModulesByChunk(statsAsset, modulesByChunk) { + /** @type {Map} */ + const indexedModules = new Map(); + + for (const chunk of statsAsset.chunks || []) { + for (const indexedModule of modulesByChunk.get(chunk) || []) { + indexedModules.set(indexedModule.module, indexedModule); + } + } + + return [...indexedModules.values()] + .toSorted((a, b) => a.index - b.index) + .map(({ module }) => module); +} + /** @typedef {Record>} ChunkToInitialByEntrypoint */ /** @@ -316,16 +364,28 @@ 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) => { - // If asset is a childAsset, then calculate appropriate bundle modules by looking through stats.children - const assetBundles = statAsset.isChild - ? getChildAssetBundles(bundleStats, statAsset.name) - : bundleStats; /** @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) - : []; + 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, })); @@ -350,12 +410,6 @@ function getViewerData(bundleStats, bundleDir, opts) { } } - // Picking modules from current bundle script - /** @type {StatsModule[]} */ - let assetModules = (modules || []).filter((statModule) => - assetHasModule(statAsset, statModule), - ); - // Adding parsed sources if (parsedModules) { /** @type {StatsModule[]} */ diff --git a/test/analyzer.js b/test/analyzer.js index ec05eb5b..a14810b6 100644 --- a/test/analyzer.js +++ b/test/analyzer.js @@ -3,6 +3,7 @@ const fs = require("node:fs"); const path = require("node:path"); const url = require("node:url"); const puppeteer = require("puppeteer"); +const { getViewerData } = require("../lib/analyzer"); const { isZstdSupported } = require("../src/sizeUtils"); let browser; @@ -133,6 +134,74 @@ describe("Analyzer", () => { ); }); + it("should preserve module order and deduplicate modules for multi-chunk assets", () => { + const chartData = getViewerData( + { + assets: [ + { + name: "multi-chunk.js", + size: 5, + chunks: ["string-chunk", 1], + }, + ], + chunks: [], + entrypoints: {}, + modules: [ + { + id: 1, + identifier: "./numeric.js", + name: "./numeric.js", + size: 1, + chunks: [1], + }, + { + id: 2, + identifier: "./shared.js", + name: "./shared.js", + size: 1, + chunks: [1, "string-chunk"], + }, + { + id: 3, + identifier: "./string.js", + name: "./string.js", + size: 1, + chunks: ["string-chunk"], + }, + { + id: 4, + identifier: "./unchunked.js", + name: "./unchunked.js", + size: 1, + chunks: [], + }, + { + id: 1, + identifier: "./duplicate-id.js", + name: "./duplicate-id.js", + size: 1, + chunks: ["string-chunk"], + }, + { + id: 5, + identifier: "webpack/runtime/test", + name: "webpack/runtime/test", + moduleType: "runtime", + size: 1, + chunks: [1], + }, + ], + }, + null, + ); + + expect(chartData[0].groups.map((group) => group.label)).toEqual([ + "numeric.js", + "shared.js", + "string.js", + ]); + }); + it("should record accurate byte lengths for sources with special chars", async () => { generateReportFrom("with-special-chars/stats.json"); const chartData = await getChartData(); From 9f22427003ee90f7feb4eec5c8738a9a89835a78 Mon Sep 17 00:00:00 2001 From: Christian Gonzalez <1581488+christiango@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:42:47 -0400 Subject: [PATCH 2/3] test: import getViewerData from source --- test/analyzer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/analyzer.js b/test/analyzer.js index a14810b6..3b85a331 100644 --- a/test/analyzer.js +++ b/test/analyzer.js @@ -3,7 +3,7 @@ const fs = require("node:fs"); const path = require("node:path"); const url = require("node:url"); const puppeteer = require("puppeteer"); -const { getViewerData } = require("../lib/analyzer"); +const { getViewerData } = require("../src/analyzer"); const { isZstdSupported } = require("../src/sizeUtils"); let browser; From d252e7a2ceebbf1bc5e6a943af99087d621dfadf Mon Sep 17 00:00:00 2001 From: Christian Gonzalez <1581488+christiango@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:06:03 -0400 Subject: [PATCH 3/3] refactor: rename loop variable to avoid shadowing module --- src/analyzer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/analyzer.js b/src/analyzer.js index e2255424..a41b9a02 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -155,8 +155,8 @@ function getModulesByChunk(modules) { /** @type {Map} */ const modulesByChunk = new Map(); - for (const [index, module] of modules.entries()) { - for (const chunk of module.chunks || []) { + for (const [index, statsModule] of modules.entries()) { + for (const chunk of statsModule.chunks || []) { let chunkModules = modulesByChunk.get(chunk); if (!chunkModules) { @@ -164,7 +164,7 @@ function getModulesByChunk(modules) { modulesByChunk.set(chunk, chunkModules); } - chunkModules.push({ module, index }); + chunkModules.push({ module: statsModule, index }); } }