Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nervous-workers-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-bundle-analyzer": patch
---

Scope parsed module sources to the asset they were parsed from, so assets that reuse the same module IDs, or that share a module, no longer report each other's parsed and compressed sizes.
29 changes: 21 additions & 8 deletions src/analyzer.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,14 @@ function getViewerData(bundleStats, bundleDir, opts) {
// Trying to parse bundle assets and get real module sizes if `bundleDir` is provided
/** @type {Record<string, { src: string, runtimeSrc: string }> | null} */
let bundlesSources = null;
/** @type {Record<string | number, boolean> | null} */
let parsedModules = null;
// Parsed sources are stored per asset because different assets can reuse the same module IDs
// (e.g. a root bundle and a worker bundle both numbering their modules from `0`).
/** @type {Record<string, import("./parseUtils").Modules> | null} */
let parsedModulesByAsset = null;

if (bundleDir) {
bundlesSources = {};
parsedModules = {};
parsedModulesByAsset = {};

for (const statAsset of bundleStats.assets) {
const assetFile = path.join(bundleDir, statAsset.name);
Expand Down Expand Up @@ -380,12 +382,12 @@ function getViewerData(bundleStats, bundleDir, opts) {
src: bundleInfo.src,
runtimeSrc: bundleInfo.runtimeSrc,
};
Object.assign(parsedModules, bundleInfo.modules);
parsedModulesByAsset[statAsset.name] = bundleInfo.modules;
}

if (Object.keys(bundlesSources).length === 0) {
bundlesSources = null;
parsedModules = null;
parsedModulesByAsset = null;
logger.warn(
"\nNo bundles were parsed. Analyzer will show only original module sizes from stats file.\n",
);
Expand Down Expand Up @@ -415,13 +417,24 @@ function getViewerData(bundleStats, bundleDir, opts) {
);
}

// A module that is a part of more than one asset is one stats object. Every asset that
// contains that module gets the same object. The module tree reads `parsedSrc` late, after
// all assets are processed. Copy the modules for each asset. Then a parsed source that is
// set for one asset cannot change what another asset shows.
assetModules = assetModules.map((statsModule) => ({ ...statsModule }));

const asset = (result[statAsset.name] = /** @type {Asset} */ ({
size: statAsset.size,
}));
const assetSources =
bundlesSources && Object.hasOwn(bundlesSources, statAsset.name)
? bundlesSources[statAsset.name]
: null;
const assetParsedModules =
parsedModulesByAsset &&
Object.hasOwn(parsedModulesByAsset, statAsset.name)
? parsedModulesByAsset[statAsset.name]
: null;

if (assetSources) {
asset.parsedSize = Buffer.byteLength(assetSources.src);
Expand All @@ -440,16 +453,16 @@ function getViewerData(bundleStats, bundleDir, opts) {
}

// Adding parsed sources
if (parsedModules) {
if (assetParsedModules) {
/** @type {StatsModule[]} */
const unparsedEntryModules = [];

for (const statsModule of assetModules) {
if (
typeof statsModule.id !== "undefined" &&
parsedModules[statsModule.id]
Object.hasOwn(assetParsedModules, statsModule.id)
) {
statsModule.parsedSrc = parsedModules[statsModule.id];
statsModule.parsedSrc = assetParsedModules[statsModule.id];
} else if (isEntryModule(statsModule)) {
unparsedEntryModules.push(statsModule);
}
Expand Down
94 changes: 94 additions & 0 deletions test/analyzer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ const fs = require("node:fs");
const path = require("node:path");
const url = require("node:url");
const puppeteer = require("puppeteer");
const Logger = require("../src/Logger");
const { getViewerData } = require("../src/analyzer");
const { parseBundle } = require("../src/parseUtils");
const { isZstdSupported } = require("../src/sizeUtils");

let browser;
Expand Down Expand Up @@ -126,6 +128,98 @@ describe("Analyzer", () => {
});
});

it("should not attribute parsed sources across assets that reuse module IDs", () => {
const statsDir = path.resolve(
__dirname,
"./stats/with-worker-loader-dynamic-import",
);
// `getViewerData` changes the stats it receives, so read a new copy of them.
const stats = JSON.parse(
fs.readFileSync(path.join(statsDir, "stats.json"), "utf8"),
);

// Two compilations number their modules from `0`. Module ID `0` is a different module in
// each asset.
const moduleId = "0";
const rootSource = parseBundle(path.join(statsDir, "bundle.js")).modules[
moduleId
];
const workerSource = parseBundle(path.join(statsDir, "bundle.worker.js"))
.modules[moduleId];

expect(rootSource).toEqual(expect.any(String));
expect(workerSource).not.toBe(rootSource);

const chartData = getViewerData(stats, statsDir);
const rootAsset = chartData.find((asset) => asset.label === "bundle.js");

// `parsedSize` is the length of the parsed source.
expect(rootAsset.groups).toHaveLength(1);
expect(rootAsset.groups[0].parsedSize).toBe(rootSource.length);
});

it("should not attribute parsed sources across assets that share a module", () => {
const statsDir = path.resolve(
__dirname,
"./stats/with-module-in-multiple-assets",
);
// `getViewerData` changes the stats it receives, so read a new copy of them.
const stats = JSON.parse(
fs.readFileSync(path.join(statsDir, "stats.json"), "utf8"),
);

// `messages.js` is one source module used by two bundles. Each bundle uses a different
// export, so Webpack creates a different factory for the module in each bundle.
const sharedModuleId = "906";
const expectedParsedSizes = {
"long-message.js": parseBundle(path.join(statsDir, "long-message.js"))
.modules[sharedModuleId].length,
"short-message.js": parseBundle(path.join(statsDir, "short-message.js"))
.modules[sharedModuleId].length,
};

expect(expectedParsedSizes["long-message.js"]).not.toBe(
expectedParsedSizes["short-message.js"],
);

const chartData = getViewerData(stats, statsDir);
const parsedSizes = Object.fromEntries(
chartData.map((asset) => [
asset.label,
asset.groups[0].groups.find((group) => group.label === "messages.js")
.parsedSize,
]),
);

expect(parsedSizes).toEqual(expectedParsedSizes);
});

it("should show only stat sizes when no bundle can be parsed", () => {
const statsDir = path.resolve(
__dirname,
"./stats/with-module-in-multiple-assets",
);
const stats = JSON.parse(
fs.readFileSync(path.join(statsDir, "stats.json"), "utf8"),
);
const logger = new Logger("silent");
const warn = jest.spyOn(logger, "warn");

// No asset file is in this directory, so every asset fails to parse.
const chartData = getViewerData(stats, path.join(statsDir, "src"), {
logger,
});

expect(warn).toHaveBeenCalledWith(
expect.stringContaining("No bundles were parsed"),
);

for (const asset of chartData) {
expect(asset.parsedSize).toBeUndefined();
expect(asset.statSize).toBeGreaterThan(0);
}
});

it("should update the treemap when a chunk is deselected", async () => {
generateReportFrom("with-worker-loader-dynamic-import/stats.json");
const page = await browser.newPage();
Expand Down
19 changes: 16 additions & 3 deletions test/analyzerUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");

const { getViewerData } = require("../src/analyzer");
const { getCompressedSize } = require("../src/sizeUtils");

const BUNDLES_DIR = path.resolve(__dirname, "./bundles");

Expand Down Expand Up @@ -76,10 +77,22 @@ describe("getViewerData", () => {
},
};

getViewerData(stats, BUNDLES_DIR);
const chartData = getViewerData(stats, BUNDLES_DIR);
const modulesByPath = Object.fromEntries(
chartData[0].groups[0].groups.map((group) => [group.path, group]),
);

expect(dependencyModule.parsedSrc).toBe(expectedModules[447]);
expect(entryModule.parsedSrc).toBe(expectedModules[956]);
// Asserting on the returned chart data rather than on the input stats objects, because
// `getViewerData` copies the modules of every asset before attributing parsed sources to them.
// Module `parsedSize` is the source length, and the gzip size pins the source itself.
expect(modulesByPath["./src/dependency.js"]).toMatchObject({
parsedSize: expectedModules[447].length,
gzipSize: getCompressedSize("gzip", expectedModules[447]),
});
expect(modulesByPath["./src/entry.js"]).toMatchObject({
parsedSize: expectedModules[956].length,
gzipSize: getCompressedSize("gzip", expectedModules[956]),
});
expect(chunksAccessCount).toBe(1);
});
});
45 changes: 45 additions & 0 deletions test/stats/with-module-in-multiple-assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Fixture: one module in two assets

This fixture tests one source module that Webpack puts in two output bundles.

## Test case

The build has two entry files:

- `src/long-message-entry.js`
- `src/short-message-entry.js`

Both files import `src/messages.js`. This is the source module that the two bundles share.

`messages.js` exports a long message and a short message. Each entry file imports only one of
these exports. Webpack creates these bundles:

- `long-message.js` contains the long message.
- `short-message.js` contains the short message.

The configuration disables module concatenation, split chunks, and the separate runtime chunk.
These settings keep the module visible in both bundles.

## Stats and bundle sizes

Webpack gives `messages.js` module ID `906`. The stats report its source size as `87` in both
chunks.

Webpack removes the unused export from each bundle. This makes the emitted module factories
different:

- The factory in `long-message.js` has 59 characters.
- The factory in `short-message.js` has 37 characters.

The analyzer must report the correct factory size for each bundle.

## Rebuild

Run this command from the repository root:

```sh
npx webpack --config test/stats/with-module-in-multiple-assets/webpack.config.js
```

The `stats.json` file is a reduced copy of the Webpack stats. It contains only the fields that the
analyzer reads.
1 change: 1 addition & 0 deletions test/stats/with-module-in-multiple-assets/long-message.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/stats/with-module-in-multiple-assets/short-message.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { longMessage } from "./messages.js";

console.log(longMessage);
2 changes: 2 additions & 0 deletions test/stats/with-module-in-multiple-assets/src/messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const longMessage = "This message is longer.";
export const shortMessage = "x";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { shortMessage } from "./messages.js";

console.log(shortMessage);
80 changes: 80 additions & 0 deletions test/stats/with-module-in-multiple-assets/stats.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"assets": [
{
"type": "asset",
"name": "long-message.js",
"size": 400,
"chunks": [379],
"info": { "javascriptModule": false }
},
{
"type": "asset",
"name": "short-message.js",
"size": 378,
"chunks": [453],
"info": { "javascriptModule": false }
}
],
"chunks": [
{
"id": 379,
"names": ["long-message"],
"files": ["long-message.js"],
"modules": [
{
"id": 907,
"identifier": "./src/long-message-entry.js",
"name": "./src/long-message-entry.js",
"size": 72,
"depth": 0,
"chunks": [379],
"usedExports": []
},
{
"id": 906,
"identifier": "./src/messages.js",
"name": "./src/messages.js",
"size": 87,
"depth": 1,
"chunks": [379, 453],
"usedExports": ["longMessage"]
}
]
},
{
"id": 453,
"names": ["short-message"],
"files": ["short-message.js"],
"modules": [
{
"id": 906,
"identifier": "./src/messages.js",
"name": "./src/messages.js",
"size": 87,
"depth": 1,
"chunks": [379, 453],
"usedExports": ["shortMessage"]
},
{
"id": 645,
"identifier": "./src/short-message-entry.js",
"name": "./src/short-message-entry.js",
"size": 74,
"depth": 0,
"chunks": [453],
"usedExports": []
}
]
}
],
"entrypoints": {
"long-message": {
"name": "long-message",
"assets": [{ "name": "long-message.js" }]
},
"short-message": {
"name": "short-message",
"assets": [{ "name": "short-message.js" }]
}
}
}
21 changes: 21 additions & 0 deletions test/stats/with-module-in-multiple-assets/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const path = require("node:path");

// Build two bundles that import different exports from the same module.
module.exports = {
mode: "production",
context: __dirname,
entry: {
"long-message": "./src/long-message-entry.js",
"short-message": "./src/short-message-entry.js",
},
output: {
path: __dirname,
filename: "[name].js",
},
optimization: {
concatenateModules: false,
runtimeChunk: false,
splitChunks: false,
moduleIds: "deterministic",
},
};
Loading