From c8d3b117355e65e7a2204938314cb0bc882cca22 Mon Sep 17 00:00:00 2001 From: Even Stensberg Date: Fri, 28 Aug 2026 13:52:55 +0200 Subject: [PATCH] chore: no chunk fallbacks A stats file written with `stats: { chunks: false }` still names the chunk ids on every module, so the app followed them into a chunk list that was not there and threw on the first lookup. The chunk lookups now fall back rather than assume: an id that leads nowhere is skipped, the fields a stats file may omit are filled in as it is read, and a chunk page for a chunk that is not in the file says so instead of throwing. The README says the same for anyone who meets the error in an older build, along with the stats options this app reads. Closes #34 Co-Authored-By: Claude Opus 5 --- README.md | 43 +++++++++++++++++++++ app/app.js | 8 ++++ app/graphs/chunks.js | 5 ++- app/graphs/modules.js | 8 +++- app/pages/chunk/missing.pug | 3 ++ app/pages/chunk/page.js | 14 ++++--- app/pages/chunks/chunks.pug | 6 +++ test/appLoad.test.js | 77 +++++++++++++++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 app/pages/chunk/missing.pug create mode 100644 test/appLoad.test.js diff --git a/README.md b/README.md index 3d32ccd..38d948f 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,49 @@ them off the graph is never built at all, so the rest of the app stays quick. 3. Open the analyse app and load that file. 4. Inspect modules, chunks, and assets to find large bundles or suspicious dependency patterns. +## Troubleshooting + +### `Cannot read property 'parents' of undefined` when opening the modules tab + +The stats file has modules but no chunks. Every module still names the chunk +ids it belongs to, and the app follows those ids into a chunk list that is not +there ([#34](https://github.com/webpack/analyse/issues/34)). The chunk views +are empty for the same reason. + +webpack leaves them out when it is configured with `stats: { chunks: false }`, +and a tool that writes the stats for you can pass a filtered subset of its own. +The simplest fix is to let webpack write everything: + +```bash +npx webpack --profile --json > stats.json +``` + +From the config, keep at least the parts this app reads: + +```js +// webpack.config.js +module.exports = { + stats: { + modules: true, + reasons: true, // what pulls each module in + chunks: true, + chunkOrigins: true, // what asked for each chunk + assets: true + } +}; +``` + +`stats: { all: true }` (webpack 5) or the `verbose` preset turns on everything, +which is more than the app needs but never less. With +[webpack-stats-plugin](https://github.com/FormidableLabs/webpack-stats-plugin), +pass the same through its `stats` option, since its default is a small subset. + +### The graph never appears, or the tab hangs on a large build + +The graphs are off above 5000 modules or chunks, because laying one out that +big can take the tab down with it. The control under the graph turns them on +and off, and remembers the choice. See [Reading the graphs](#reading-the-graphs). + ## Notes - The app expects the webpack stats JSON, not a raw asset bundle. diff --git a/app/app.js b/app/app.js index 5de048b..c3df1fa 100644 --- a/app/app.js +++ b/app/app.js @@ -29,6 +29,7 @@ function load(stats) { }); stats.modules.forEach(function(module) { module.reasons = module.reasons || []; + module.chunks = module.chunks || []; module.reasons.forEach(function(reason) { var m = mapModulesIdent["$" + reason.moduleIdentifier]; if (!m) return; @@ -59,8 +60,15 @@ function load(stats) { })(module); }); stats.chunks.forEach(function(chunk) { + // Which of these a stats file carries depends on the options it was + // written with, and a parent can name a chunk that was left out of it. + chunk.parents = chunk.parents || []; + chunk.origins = chunk.origins || []; + chunk.names = chunk.names || []; + chunk.files = chunk.files || []; chunk.parents.forEach(function(parent) { var c = mapChunks[parent]; + if (!c) return; c.children.push(chunk.id); }); chunk.origins.forEach(function(origin) { diff --git a/app/graphs/chunks.js b/app/graphs/chunks.js index 4f5b818..80cd8ee 100644 --- a/app/graphs/chunks.js +++ b/app/graphs/chunks.js @@ -34,7 +34,7 @@ app.stats.chunks.forEach(function (chunk, idx) { "[" + chunk.id + "] " + - chunk.origins + (chunk.origins || []) .map(function (o) { return (o.reasons || []) .concat(o.name) @@ -48,7 +48,10 @@ app.stats.chunks.forEach(function (chunk, idx) { }); }); app.stats.chunks.forEach(function (chunk) { + // app.load fills these in, but the graph is built from whatever the stats + // hold: a parent can name a chunk that is not in the file at all. chunk.parents.forEach(function (parent) { + if (!app.mapChunks[parent]) return; edges.push({ id: "edge" + chunk.id + "-" + parent, source: "chunk" + parent, diff --git a/app/graphs/modules.js b/app/graphs/modules.js index 8f0c636..8a079be 100644 --- a/app/graphs/modules.js +++ b/app/graphs/modules.js @@ -76,7 +76,13 @@ app.stats.modules.forEach(function(module, idx) { }); if (chunks.length === 0) return false; return chunks.some(function(c) { - return isInChunks(app.mapChunks[c].parents, checked.concat(c)); + // A stats file written with `chunks: false` still names chunk + // ids on the modules while leaving the chunks themselves out, + // so this lookup can come back empty (webpack/analyse#34). + var parent = app.mapChunks[c]; + return parent + ? isInChunks(parent.parents || [], checked.concat(c)) + : false; }); })(parentModule.chunks, []); }); diff --git a/app/pages/chunk/missing.pug b/app/pages/chunk/missing.pug new file mode 100644 index 0000000..5b1bd9c --- /dev/null +++ b/app/pages/chunk/missing.pug @@ -0,0 +1,3 @@ +.container-fluid: .row: .col-md-12: .well + h4 chunk #{id} + p This chunk is not in the stats file. Chunks are left out when webpack is run with stats.chunks = false, while the modules still name the chunks they belong to. Generate the stats again with chunks included to see this one. diff --git a/app/pages/chunk/page.js b/app/pages/chunk/page.js index 8429b57..cf362a1 100644 --- a/app/pages/chunk/page.js +++ b/app/pages/chunk/page.js @@ -6,15 +6,17 @@ module.exports = function (id) { id = isNaN(parseInt(id, 10)) ? decodeURIComponent(id) : parseInt(id, 10); document.title = "chunk " + id; sortableTable.enable(); + // The module table links to every chunk a module names, and those ids can + // point at chunks a stats file leaves out (webpack/analyse#34). + var chunk = app.mapChunks[id]; $(".page").html( - require("./chunk.pug")({ - stats: app.stats, - id: id, - chunk: app.mapChunks[id], - }) + chunk + ? require("./chunk.pug")({ stats: app.stats, id: id, chunk: chunk }) + : require("./missing.pug")({ id: id }) ); modulesGraph.show(); - modulesGraph.setActiveChunk(id); + if (chunk) modulesGraph.setActiveChunk(id); + else modulesGraph.setNormal(); return function () { modulesGraph.hide(); }; diff --git a/app/pages/chunks/chunks.pug b/app/pages/chunks/chunks.pug index efcb5d6..4d9cf61 100644 --- a/app/pages/chunks/chunks.pug +++ b/app/pages/chunks/chunks.pug @@ -1,3 +1,9 @@ +if stats.chunks.length === 0 + p.text-muted. + No chunks in this stats file. webpack leaves them out when it is run + with #[code stats.chunks = false], while the modules still name the + chunks they belong to; generate the stats again with chunks included + to see them here. table.table.table-condensed thead tr diff --git a/test/appLoad.test.js b/test/appLoad.test.js new file mode 100644 index 0000000..521ae49 --- /dev/null +++ b/test/appLoad.test.js @@ -0,0 +1,77 @@ +// Checks that reading a stats file survives the parts a stats file is allowed +// to leave out (webpack/analyse#34). Run with `npm test`. +var test = require("node:test"); +var assert = require("node:assert"); + +var app = require("../app/app"); + +function load(stats) { + // Loading reports to google analytics, which falls back to console.log + // outside the browser. + var log = console.log; + console.log = function() {}; + try { + app.load(stats); + } finally { + console.log = log; + } + return app.stats; +} + +function module_(id, chunks) { + return { + id: id, + identifier: "/m" + id + ".js", + name: "./m" + id + ".js", + size: 100, + chunks: chunks + }; +} + +test("reads a stats file written with chunks: false", function() { + // webpack leaves the chunks out but still names them on every module, + // which is what used to throw on "parents of undefined". + var stats = load({ + modules: [module_(1, [0]), module_(2, [1])] + }); + assert.deepStrictEqual(stats.chunks, []); + assert.deepStrictEqual(stats.modules[0].chunks, [0], "the ids are kept"); + assert.strictEqual(app.mapChunks[0], undefined, "and lead nowhere"); +}); + +test("survives a chunk whose parent is not in the file", function() { + var stats = load({ + modules: [module_(1, [1])], + chunks: [ + { id: 1, size: 100, parents: [7], names: ["main"], files: ["main.js"] } + ] + }); + assert.deepStrictEqual(stats.chunks[0].children, [], "nothing to link to"); + assert.deepStrictEqual(stats.chunks[0].parents, [7], "the id is kept"); +}); + +test("fills in the chunk fields a stats file can omit", function() { + var stats = load({ modules: [], chunks: [{ id: 0, size: 10 }] }); + var chunk = stats.chunks[0]; + assert.deepStrictEqual(chunk.parents, []); + assert.deepStrictEqual(chunk.origins, []); + assert.deepStrictEqual(chunk.names, []); + assert.deepStrictEqual(chunk.files, []); + assert.deepStrictEqual(chunk.children, []); +}); + +test("fills in the module fields a stats file can omit", function() { + var stats = load({ + modules: [{ id: 1, identifier: "/m1.js", name: "./m1.js", size: 100 }] + }); + assert.deepStrictEqual(stats.modules[0].chunks, []); + assert.deepStrictEqual(stats.modules[0].reasons, []); + assert.deepStrictEqual(stats.modules[0].dependencies, []); +}); + +test("reads a stats file with nothing in it at all", function() { + var stats = load({}); + assert.deepStrictEqual(stats.modules, []); + assert.deepStrictEqual(stats.chunks, []); + assert.deepStrictEqual(stats.assets, []); +});