diff --git a/.changeset/fix-child-compilations-type-error.md b/.changeset/fix-child-compilations-type-error.md new file mode 100644 index 00000000..08b0f34b --- /dev/null +++ b/.changeset/fix-child-compilations-type-error.md @@ -0,0 +1,5 @@ +--- +"webpack-bundle-analyzer": patch +--- + +Fix `TypeError: Cannot read property 'forEach' of undefined` when child compilations or entrypoints have undefined or missing assets. diff --git a/src/analyzer.js b/src/analyzer.js index 1d6def95..d4c8dfea 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -79,20 +79,22 @@ function flatten(arr) { } /** - * @param {StatsCompilation} bundleStats bundle stats + * @param {StatsCompilation[]} children child stats list * @param {string} assetName asset name - * @returns {boolean} child asset bundlers + * @returns {StatsCompilation | undefined} child asset compilation */ -function getChildAssetBundles(bundleStats, assetName) { - return flatten( - (bundleStats.children || /** @type {StatsCompilation} */ ([])).find( - /** - * @param {StatsCompilation} child child stats - * @returns {string[][]} assets by chunk name - */ - (child) => Object.values(child.assetsByChunkName || []), - ), - ).includes(assetName); +function getChildAssetBundles(children, assetName) { + return children.find((child) => { + if (!child) return false; + if ( + Array.isArray(child.assets) && + child.assets.some((asset) => asset && asset.name === assetName) + ) { + return true; + } + const chunkAssets = flatten(Object.values(child.assetsByChunkName || {})); + return chunkAssets.includes(assetName); + }); } /** @@ -195,7 +197,7 @@ function getAssetModulesByChunk(statsAsset, modulesByChunk) { /** @typedef {Record>} ChunkToInitialByEntrypoint */ /** - * @param {StatsCompilation} bundleStats bundle stats + * @param {StatsCompilation | undefined} bundleStats bundle stats * @returns {ChunkToInitialByEntrypoint} chunk to initial by entrypoint */ function getChunkToInitialByEntrypoint(bundleStats) { @@ -206,8 +208,10 @@ function getChunkToInitialByEntrypoint(bundleStats) { const chunkToEntrypointInititalMap = {}; for (const entrypoint of Object.values(bundleStats.entrypoints || {})) { for (const asset of entrypoint.assets || []) { - chunkToEntrypointInititalMap[asset.name] ??= {}; - chunkToEntrypointInititalMap[asset.name][ + const assetName = typeof asset === "string" ? asset : asset?.name; + if (!assetName) continue; + chunkToEntrypointInititalMap[assetName] ??= {}; + chunkToEntrypointInititalMap[assetName][ /** @type {string} */ (entrypoint.name) ] = true; @@ -255,7 +259,7 @@ function isEntryModule(statsModule) { */ /** - * @param {StatsCompilation} bundleStats bundle stats + * @param {StatsCompilation | StatsCompilation[]} bundleStats bundle stats * @param {string | null} bundleDir bundle dir * @param {ViewerDataOptions=} opts options * @returns {ChartData} chart data @@ -267,34 +271,54 @@ function getViewerData(bundleStats, bundleDir, opts) { excludeAssets = null, } = opts || {}; + if (!bundleStats) return []; + + if (Array.isArray(bundleStats)) { + bundleStats = { + children: bundleStats, + }; + } + const isAssetIncluded = createAssetsFilter(excludeAssets); + // Preserve all children from the root bundleStats (e.g. for MultiCompiler or child compilations) + const allChildren = Array.isArray(bundleStats.children) + ? [...bundleStats.children] + : []; + /** @type {WeakMap} */ + const childAssetBundles = new WeakMap(); + // Sometimes all the information is located in `children` array (e.g. problem in #10) if ( (bundleStats.assets === null || bundleStats.assets === undefined || bundleStats.assets.length === 0) && - bundleStats.children && - bundleStats.children.length > 0 + allChildren.length > 0 ) { - const { children } = bundleStats; - [bundleStats] = bundleStats.children; + bundleStats = { ...allChildren[0] }; + bundleStats.assets = Array.isArray(bundleStats.assets) + ? [...bundleStats.assets] + : []; + // Sometimes if there are additional child chunks produced add them as child assets, // leave the 1st one as that is considered the 'root' asset. - for (let i = 1; i < children.length; i++) { - for (const asset of children[i].assets || []) { + for (let i = 1; i < allChildren.length; i++) { + const child = allChildren[i]; + for (const asset of child?.assets || []) { + childAssetBundles.set(asset, child); asset.isChild = true; - /** @type {StatsAsset[]} */ - (bundleStats.assets).push(asset); + bundleStats.assets.push(asset); } } - } else if (bundleStats.children && bundleStats.children.length > 0) { + } else if (allChildren.length > 0) { + bundleStats.assets = [.../** @type {StatsAsset[]} */ (bundleStats.assets)]; + // Sometimes if there are additional child chunks produced add them as child assets - for (const child of bundleStats.children) { - for (const asset of child.assets || []) { + for (const child of allChildren) { + for (const asset of child?.assets || []) { + childAssetBundles.set(asset, child); asset.isChild = true; - /** @type {StatsAsset[]} */ - (bundleStats.assets).push(asset); + bundleStats.assets.push(asset); } } } @@ -395,27 +419,35 @@ function getViewerData(bundleStats, bundleDir, opts) { } } - /** @typedef {{ size: number, parsedSize?: number, gzipSize?: number, brotliSize?: number, zstdSize?: number, modules: StatsModule[], tree: Folder }} Asset */ + const rootChunkToInitialByEntrypoint = + getChunkToInitialByEntrypoint(bundleStats); + + /** @typedef {{ size: number, parsedSize?: number, gzipSize?: number, brotliSize?: number, zstdSize?: number, modules: StatsModule[], tree: Folder, isInitialByEntrypoint: Record }} Asset */ const assets = bundleStats.assets.reduce((result, statAsset) => { /** @type {StatsModule[]} */ let assetModules; + /** @type {Record} */ + let isInitialByEntrypoint; if (statAsset.isChild) { // Preserve child-compilation matching because child assets use a different module list. - const assetBundles = getChildAssetBundles(bundleStats, statAsset.name); + const assetBundles = + childAssetBundles.get(statAsset) || + getChildAssetBundles(allChildren, 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) - : []; + const modules = assetBundles ? getBundleModules(assetBundles) : []; assetModules = modules.filter((statModule) => assetHasModule(statAsset, statModule), ); + isInitialByEntrypoint = + getChunkToInitialByEntrypoint(assetBundles)[statAsset.name] ?? {}; } else { assetModules = /** @type {StatsModule[]} */ ( - rootAssetModules.get(statAsset) + rootAssetModules.get(statAsset) || [] ); + isInitialByEntrypoint = + rootChunkToInitialByEntrypoint[statAsset.name] ?? {}; } // A module that is a part of more than one asset is one stats object. Every asset that @@ -426,6 +458,7 @@ function getViewerData(bundleStats, bundleDir, opts) { const asset = (result[statAsset.name] = /** @type {Asset} */ ({ size: statAsset.size, + isInitialByEntrypoint, })); const assetSources = bundlesSources && Object.hasOwn(bundlesSources, statAsset.name) @@ -503,8 +536,6 @@ function getViewerData(bundleStats, bundleDir, opts) { return result; }, /** @type {Record} */ ({})); - const chunkToInitialByEntrypoint = getChunkToInitialByEntrypoint(bundleStats); - return Object.entries(assets).map(([filename, asset]) => ({ label: filename, isAsset: true, @@ -518,7 +549,7 @@ function getViewerData(bundleStats, bundleDir, opts) { brotliSize: asset.brotliSize, zstdSize: asset.zstdSize, groups: Object.values(asset.tree.children).map((i) => i.toChartData()), - isInitialByEntrypoint: chunkToInitialByEntrypoint[filename] ?? {}, + isInitialByEntrypoint: asset.isInitialByEntrypoint, })); } diff --git a/test/analyzerUtils.js b/test/analyzerUtils.js index f3197572..e493b553 100644 --- a/test/analyzerUtils.js +++ b/test/analyzerUtils.js @@ -95,4 +95,497 @@ describe("getViewerData", () => { }); expect(chunksAccessCount).toBe(1); }); + + it("handles child compilations with missing or undefined assets without throwing", () => { + const stats = { + children: [ + { + name: "child-with-assets", + assets: [ + { + name: "child-bundle.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./child-mod.js", + name: "./child-mod.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + { + name: "child-without-assets", + // assets is undefined (e.g. logging/internal child compilation) + }, + ], + }; + + expect(() => getViewerData(stats)).not.toThrow(); + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(1); + expect(chartData[0].label).toBe("child-bundle.js"); + }); + + it("handles when the first child compilation has undefined assets", () => { + const stats = { + children: [ + { + name: "child-without-assets", + // assets is undefined + }, + { + name: "child-with-assets", + assets: [ + { + name: "child-bundle.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./child-mod.js", + name: "./child-mod.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + ], + }; + + expect(() => getViewerData(stats)).not.toThrow(); + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(1); + expect(chartData[0].label).toBe("child-bundle.js"); + }); + + it("handles stats provided as an array of compilations", () => { + const stats = [ + { + assets: [ + { + name: "first.js", + size: 100, + chunks: [1], + }, + ], + entrypoints: { + firstEntry: { + name: "firstEntry", + assets: [{ name: "first.js" }], + }, + }, + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./first.js", + name: "./first.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + { + assets: [ + { + name: "second.js", + size: 200, + chunks: [2], + }, + ], + entrypoints: { + secondEntry: { + name: "secondEntry", + assets: [{ name: "second.js" }], + }, + }, + chunks: [ + { + id: 2, + modules: [ + { + id: 2, + identifier: "./second.js", + name: "./second.js", + size: 80, + chunks: [2], + }, + ], + }, + ], + }, + ]; + + expect(() => getViewerData(stats)).not.toThrow(); + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(2); + expect(chartData[0].label).toBe("first.js"); + expect(chartData[0].isInitialByEntrypoint).toEqual({ firstEntry: true }); + expect(chartData[1].label).toBe("second.js"); + expect(chartData[1].isInitialByEntrypoint).toEqual({ secondEntry: true }); + }); + + it("maps modules for child compilation assets correctly", () => { + const stats = { + children: [ + { + name: "client", + assets: [ + { + name: "client.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./client-module.js", + name: "./client-module.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + { + name: "server", + assets: [ + { + name: "server.js", + size: 200, + chunks: [2], + }, + ], + chunks: [ + { + id: 2, + modules: [ + { + id: 2, + identifier: "./server-module.js", + name: "./server-module.js", + size: 80, + chunks: [2], + }, + ], + }, + ], + }, + ], + }; + + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(2); + expect(chartData[0].label).toBe("client.js"); + expect(chartData[0].groups).toHaveLength(1); + expect(chartData[1].label).toBe("server.js"); + expect(chartData[1].groups).toHaveLength(1); + }); + + it("handles string entrypoint assets from Webpack 4", () => { + const stats = { + assets: [ + { + name: "legacy.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./legacy.js", + name: "./legacy.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + entrypoints: { + main: { + name: "main", + assets: ["legacy.js"], + }, + }, + }; + + const chartData = getViewerData(stats); + expect(chartData[0].isInitialByEntrypoint).toEqual({ main: true }); + }); + + it("handles entrypoint assets with empty or null items", () => { + const stats = { + assets: [ + { + name: "bundle.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./bundle.js", + name: "./bundle.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + entrypoints: { + main: { + name: "main", + assets: [null, { name: "" }, "bundle.js"], + }, + }, + }; + + const chartData = getViewerData(stats); + expect(chartData[0].isInitialByEntrypoint).toEqual({ main: true }); + }); + + it("handles null or undefined entries in children array", () => { + const stats = { + children: [ + null, + { + name: "child", + assets: [ + { + name: "bundle.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./bundle.js", + name: "./bundle.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + ], + }; + + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(1); + expect(chartData[0].label).toBe("bundle.js"); + }); + + it("resolves pre-marked isChild assets via getChildAssetBundles fallback", () => { + const stats = { + assets: [ + { + name: "fallback.js", + size: 100, + chunks: [1], + isChild: true, + }, + ], + children: [ + null, + { + name: "empty-child", + assets: [], + // no assetsByChunkName to exercise empty fallback + }, + { + name: "child", + assets: [ + { + name: "fallback.js", + size: 100, + chunks: [1], + }, + ], + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./fallback-module.js", + name: "./fallback-module.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + ], + }; + + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(1); + expect(chartData[0].label).toBe("fallback.js"); + expect(chartData[0].groups[0].label).toBe("fallback-module.js"); + }); + + it("handles null or undefined bundleStats gracefully", () => { + expect(getViewerData(null)).toEqual([]); + expect(getViewerData(undefined)).toEqual([]); + }); + + it("resolves child assets by assetsByChunkName when assets array is not present", () => { + const stats = { + assets: [ + { + name: "chunk-asset.js", + size: 100, + chunks: [1], + isChild: true, + }, + ], + children: [ + { + name: "legacy-child", + assetsByChunkName: { + main: ["chunk-asset.js"], + }, + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./chunk-mod.js", + name: "./chunk-mod.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + }, + ], + }; + + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(1); + expect(chartData[0].label).toBe("chunk-asset.js"); + expect(chartData[0].groups[0].label).toBe("chunk-mod.js"); + }); + + it("preserves entrypoints for both root and child compilations when root also has assets", () => { + const stats = { + assets: [ + { + name: "root.js", + size: 100, + chunks: [1], + }, + { + name: "unmatched-child.js", + size: 50, + chunks: [99], + isChild: true, + }, + ], + entrypoints: { + rootEntry: { + name: "rootEntry", + assets: [{ name: "root.js" }], + }, + }, + chunks: [ + { + id: 1, + modules: [ + { + id: 1, + identifier: "./root.js", + name: "./root.js", + size: 50, + chunks: [1], + }, + ], + }, + ], + children: [ + { + name: "worker-child", + assets: [ + { + name: "worker.js", + size: 150, + chunks: [2], + }, + ], + entrypoints: { + workerEntry: { + name: "workerEntry", + assets: [{ name: "worker.js" }], + }, + }, + chunks: [ + { + id: 2, + modules: [ + { + id: 2, + identifier: "./worker.js", + name: "./worker.js", + size: 75, + chunks: [2], + }, + ], + }, + ], + }, + ], + }; + + const chartData = getViewerData(stats); + expect(chartData).toHaveLength(3); + expect(chartData[0].label).toBe("root.js"); + expect(chartData[0].isInitialByEntrypoint).toEqual({ rootEntry: true }); + expect(chartData[1].label).toBe("unmatched-child.js"); + expect(chartData[1].isInitialByEntrypoint).toEqual({}); + expect(chartData[2].label).toBe("worker.js"); + expect(chartData[2].isInitialByEntrypoint).toEqual({ workerEntry: true }); + }); });