@mui/internal-bundle-size-checker 1.0.9-canary.5 → 1.0.9-canary.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
- import path from 'path';
2
- import fs from 'fs/promises';
3
- import * as zlib from 'zlib';
4
- import { promisify } from 'util';
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import * as zlib from 'node:zlib';
4
+ import { promisify } from 'node:util';
5
5
  import { build, transformWithEsbuild } from 'vite';
6
6
  import { visualizer } from 'rollup-plugin-visualizer';
7
+ import { escapeFilename } from './strings.js';
7
8
 
8
9
  const gzipAsync = promisify(zlib.gzip);
9
10
 
@@ -25,13 +26,32 @@ const rootDir = process.cwd();
25
26
  * @typedef {Record<string, ManifestChunk>} Manifest
26
27
  */
27
28
 
29
+ /**
30
+ * Creates a simple string replacement plugin
31
+ * @param {Record<string, string>} replacements - Object with string replacements
32
+ * @returns {import('vite').Plugin}
33
+ */
34
+ function createReplacePlugin(replacements) {
35
+ return {
36
+ name: 'string-replace',
37
+ transform(code) {
38
+ let transformedCode = code;
39
+ for (const [search, replace] of Object.entries(replacements)) {
40
+ transformedCode = transformedCode.replaceAll(search, replace);
41
+ }
42
+ return transformedCode !== code ? transformedCode : null;
43
+ },
44
+ };
45
+ }
46
+
28
47
  /**
29
48
  * Creates vite configuration for bundle size checking
30
49
  * @param {ObjectEntry} entry - Entry point (string or object)
31
50
  * @param {CommandLineArgs} args
32
- * @returns {Promise<{configuration: import('vite').InlineConfig, externalsArray: string[]}>}
51
+ * @param {Record<string, string>} [replacements] - String replacements to apply
52
+ * @returns {Promise<{ config:import('vite').InlineConfig, treemapPath: string }>}
33
53
  */
34
- async function createViteConfig(entry, args) {
54
+ async function createViteConfig(entry, args, replacements = {}) {
35
55
  const entryName = entry.id;
36
56
  let entryContent;
37
57
 
@@ -59,29 +79,43 @@ async function createViteConfig(entry, args) {
59
79
  const externalsArray = entry.externals || ['react', 'react-dom'];
60
80
 
61
81
  // Ensure build directory exists
62
- const outDir = path.join(rootDir, 'build', entryName);
82
+ const outDir = path.join(rootDir, 'build', escapeFilename(entryName));
63
83
  await fs.mkdir(outDir, { recursive: true });
84
+
85
+ const treemapPath = path.join(outDir, 'treemap.html');
86
+
64
87
  /**
65
88
  * @type {import('vite').InlineConfig}
66
89
  */
67
- const configuration = {
90
+ const config = {
68
91
  configFile: false,
69
92
  root: rootDir,
70
93
 
71
94
  build: {
72
95
  write: true,
73
- minify: true,
96
+ minify: args.debug ? 'esbuild' : true,
74
97
  outDir,
75
98
  emptyOutDir: true,
99
+ modulePreload: false,
76
100
  rollupOptions: {
77
- input: '/index.tsx',
78
- external: externalsArray,
101
+ input: {
102
+ ignore: '/ignore.ts',
103
+ bundle: '/entry.tsx',
104
+ },
105
+ output: {
106
+ // The output is for debugging purposes only. Remove all hashes to make it easier to compare two folders
107
+ // of build output.
108
+ entryFileNames: `assets/[name].js`,
109
+ chunkFileNames: `assets/[name].js`,
110
+ assetFileNames: `assets/[name].[ext]`,
111
+ },
112
+ external: (id) => externalsArray.some((ext) => id === ext || id.startsWith(`${ext}/`)),
79
113
  plugins: [
80
114
  ...(args.analyze
81
115
  ? [
82
116
  // File sizes are not accurate, use it only for relative comparison
83
117
  visualizer({
84
- filename: `${outDir}.html`,
118
+ filename: treemapPath,
85
119
  title: `Bundle Size Analysis: ${entryName}`,
86
120
  projectRoot: rootDir,
87
121
  open: false,
@@ -100,19 +134,25 @@ async function createViteConfig(entry, args) {
100
134
 
101
135
  esbuild: {
102
136
  legalComments: 'none',
137
+ ...(args.debug && {
138
+ minifyIdentifiers: false,
139
+ minifyWhitespace: false,
140
+ minifySyntax: true, // This enables tree-shaking and other safe optimizations
141
+ }),
103
142
  },
104
143
 
105
144
  define: {
106
- 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
145
+ 'process.env.NODE_ENV': JSON.stringify('production'),
107
146
  },
108
147
  logLevel: args.verbose ? 'info' : 'silent',
109
148
  // Add plugins to handle virtual entry points
110
149
  plugins: [
150
+ createReplacePlugin(replacements),
111
151
  {
112
152
  name: 'virtual-entry',
113
153
  resolveId(id) {
114
- if (id === '/index.tsx') {
115
- return `\0virtual:index.tsx`;
154
+ if (id === '/ignore.ts') {
155
+ return `\0virtual:ignore.ts`;
116
156
  }
117
157
  if (id === '/entry.tsx') {
118
158
  return `\0virtual:entry.tsx`;
@@ -120,7 +160,9 @@ async function createViteConfig(entry, args) {
120
160
  return null;
121
161
  },
122
162
  load(id) {
123
- if (id === `\0virtual:index.tsx`) {
163
+ if (id === `\0virtual:ignore.ts`) {
164
+ // ignore chunk will contain the vite preload code, we can ignore this chunk in the output
165
+ // See https://github.com/vitejs/vite/issues/18551
124
166
  return transformWithEsbuild(`import('/entry.tsx').then(console.log)`, id);
125
167
  }
126
168
  if (id === `\0virtual:entry.tsx`) {
@@ -132,7 +174,7 @@ async function createViteConfig(entry, args) {
132
174
  ],
133
175
  };
134
176
 
135
- return { configuration, externalsArray };
177
+ return { config, treemapPath };
136
178
  }
137
179
 
138
180
  /**
@@ -173,32 +215,49 @@ function walkDependencyTree(chunkKey, manifest, visited = new Set()) {
173
215
 
174
216
  /**
175
217
  * Process vite output to extract bundle sizes
176
- * @param {string} outDir - The output directory
218
+ * @param {import('vite').Rollup.RollupOutput['output']} output - The Vite output
177
219
  * @param {string} entryName - The entry name
178
- * @returns {Promise<Map<string, { parsed: number, gzip: number }>>} - Map of bundle names to size information
220
+ * @returns {Promise<Map<string, SizeSnapshotEntry>>} - Map of bundle names to size information
179
221
  */
180
- async function processBundleSizes(outDir, entryName) {
222
+ async function processBundleSizes(output, entryName) {
223
+ const chunksByFileName = new Map(output.map((chunk) => [chunk.fileName, chunk]));
224
+
181
225
  // Read the manifest file to find the generated chunks
182
- const manifestPath = path.join(outDir, '.vite/manifest.json');
183
- const manifestContent = await fs.readFile(manifestPath, 'utf8');
226
+ const manifestChunk = chunksByFileName.get('.vite/manifest.json');
227
+ if (manifestChunk?.type !== 'asset') {
228
+ throw new Error(`Manifest file not found in output for entry: ${entryName}`);
229
+ }
230
+
231
+ const manifestContent =
232
+ typeof manifestChunk.source === 'string'
233
+ ? manifestChunk.source
234
+ : new TextDecoder().decode(manifestChunk.source);
235
+
184
236
  /** @type {Manifest} */
185
237
  const manifest = JSON.parse(manifestContent);
186
238
 
187
239
  // Find the main entry point JS file in the manifest
188
- const mainEntry = manifest['virtual:entry.tsx'];
240
+ const mainEntry = Object.entries(manifest).find(([_, entry]) => entry.name === 'bundle');
189
241
 
190
242
  if (!mainEntry) {
191
243
  throw new Error(`No main entry found in manifest for ${entryName}`);
192
244
  }
193
245
 
194
246
  // Walk the dependency tree to get all chunks that are part of this entry
195
- const allChunks = walkDependencyTree('virtual:entry.tsx', manifest);
247
+ const allChunks = walkDependencyTree(mainEntry[0], manifest);
196
248
 
197
249
  // Process each chunk in the dependency tree in parallel
198
250
  const chunkPromises = Array.from(allChunks, async (chunkKey) => {
199
251
  const chunk = manifest[chunkKey];
200
- const filePath = path.join(outDir, chunk.file);
201
- const fileContent = await fs.readFile(filePath, 'utf8');
252
+ const outputChunk = chunksByFileName.get(chunk.file);
253
+ if (outputChunk?.type !== 'chunk') {
254
+ throw new Error(`Output chunk not found for ${chunk.file}`);
255
+ }
256
+ const fileContent = outputChunk.code;
257
+ if (chunk.name === 'preload-helper') {
258
+ // Skip the preload-helper chunk as it is not relevant for bundle size
259
+ return null;
260
+ }
202
261
 
203
262
  // Calculate sizes
204
263
  const parsed = Buffer.byteLength(fileContent);
@@ -206,28 +265,34 @@ async function processBundleSizes(outDir, entryName) {
206
265
  const gzipSize = Buffer.byteLength(gzipBuffer);
207
266
 
208
267
  // Use chunk key as the name, or fallback to entry name for main chunk
209
- const chunkName = chunkKey === 'virtual:entry.tsx' ? entryName : chunkKey;
268
+ const chunkName = chunk.name === 'bundle' ? entryName : chunk.name || chunkKey;
210
269
  return /** @type {const} */ ([chunkName, { parsed, gzip: gzipSize }]);
211
270
  });
212
271
 
213
272
  const chunkEntries = await Promise.all(chunkPromises);
214
- return new Map(chunkEntries);
273
+ return new Map(/** @type {[string, SizeSnapshotEntry][]} */ (chunkEntries.filter(Boolean)));
215
274
  }
216
275
 
217
276
  /**
218
277
  * Get sizes for a vite bundle
219
278
  * @param {ObjectEntry} entry - The entry configuration
220
279
  * @param {CommandLineArgs} args - Command line arguments
221
- * @returns {Promise<Map<string, { parsed: number, gzip: number }>>}
280
+ * @param {Record<string, string>} [replacements] - String replacements to apply
281
+ * @returns {Promise<{ sizes: Map<string, SizeSnapshotEntry>, treemapPath: string }>}
222
282
  */
223
- export async function getViteSizes(entry, args) {
283
+ export async function getBundleSizes(entry, args, replacements) {
224
284
  // Create vite configuration
225
- const { configuration } = await createViteConfig(entry, args);
226
- const outDir = path.join(rootDir, 'build', entry.id);
285
+ const { config, treemapPath } = await createViteConfig(entry, args, replacements);
227
286
 
228
287
  // Run vite build
229
- await build(configuration);
288
+ const { output } = /** @type {import('vite').Rollup.RollupOutput} */ (await build(config));
289
+ const manifestChunk = output.find((chunk) => chunk.fileName === '.vite/manifest.json');
290
+ if (!manifestChunk) {
291
+ throw new Error(`Manifest file not found in output for entry: ${entry.id}`);
292
+ }
230
293
 
231
294
  // Process the output to get bundle sizes
232
- return processBundleSizes(outDir, entry.id);
295
+ const sizes = await processBundleSizes(output, entry.id);
296
+
297
+ return { sizes, treemapPath };
233
298
  }
package/src/cli.js CHANGED
@@ -1,17 +1,50 @@
1
1
  // @ts-check
2
2
 
3
- import path from 'path';
4
- import os from 'os';
5
- import fs from 'fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import fs from 'node:fs/promises';
6
6
  import yargs from 'yargs';
7
7
  import { Piscina } from 'piscina';
8
8
  import micromatch from 'micromatch';
9
- import { execa } from 'execa';
10
- import gitUrlParse from 'git-url-parse';
9
+ import envCi from 'env-ci';
10
+ import { pathToFileURL } from 'node:url';
11
+ import chalk from 'chalk';
11
12
  import { loadConfig } from './configLoader.js';
12
13
  import { uploadSnapshot } from './uploadSnapshot.js';
13
14
  import { renderMarkdownReport } from './renderMarkdownReport.js';
14
15
  import { octokit } from './github.js';
16
+ import { getCurrentRepoInfo } from './git.js';
17
+ import { notifyPr } from './notifyPr.js';
18
+ import { DASHBOARD_ORIGIN } from './constants.js';
19
+
20
+ /**
21
+ * @param {string} repo
22
+ * @param {number} prNumber
23
+ * @param {string} bundleSizeInfo
24
+ */
25
+ function formatComment(repo, prNumber, bundleSizeInfo) {
26
+ return [
27
+ '## Bundle size report',
28
+ bundleSizeInfo,
29
+ '<hr>',
30
+ `Check out the [code infra dashboard](${DASHBOARD_ORIGIN}/repository/${repo}/prs/${prNumber}) for more information about this PR.`,
31
+ ].join('\n\n');
32
+ }
33
+
34
+ /**
35
+ */
36
+ function getCiInfo() {
37
+ const ciInfo = envCi();
38
+ if (!ciInfo.isCi) {
39
+ return null;
40
+ }
41
+ switch (ciInfo.name) {
42
+ case 'CircleCI':
43
+ return ciInfo;
44
+ default:
45
+ throw new Error(`Unsupported CI environment: ${ciInfo.name}`);
46
+ }
47
+ }
15
48
 
16
49
  /**
17
50
  * @typedef {import('./sizeDiff.js').SizeSnapshot} SizeSnapshot
@@ -23,32 +56,12 @@ const DEFAULT_CONCURRENCY = os.availableParallelism();
23
56
  const rootDir = process.cwd();
24
57
 
25
58
  /**
26
- * Gets the current repository owner and name from git remote
27
- * @returns {Promise<{owner: string | null, repo: string | null}>}
28
- */
29
- async function getCurrentRepoInfo() {
30
- try {
31
- const { stdout } = await execa('git', ['remote', 'get-url', 'origin']);
32
- const parsed = gitUrlParse(stdout.trim());
33
- return {
34
- owner: parsed.owner,
35
- repo: parsed.name,
36
- };
37
- } catch (error) {
38
- return {
39
- owner: null,
40
- repo: null,
41
- };
42
- }
43
- }
44
-
45
- /**
46
- * creates size snapshot for every bundle that built with webpack
59
+ * creates size snapshot for every bundle
47
60
  * @param {CommandLineArgs} args
48
61
  * @param {NormalizedBundleSizeCheckerConfig} config - The loaded configuration
49
- * @returns {Promise<Array<[string, { parsed: number, gzip: number }]>>}
62
+ * @returns {Promise<Array<[string, SizeSnapshotEntry]>>}
50
63
  */
51
- async function getWebpackSizes(args, config) {
64
+ async function getBundleSizes(args, config) {
52
65
  const worker = new Piscina({
53
66
  filename: new URL('./worker.js', import.meta.url).href,
54
67
  maxThreads: args.concurrency || DEFAULT_CONCURRENCY,
@@ -89,13 +102,60 @@ async function getWebpackSizes(args, config) {
89
102
 
90
103
  const sizeArrays = await Promise.all(
91
104
  validEntries.map((entry, index) =>
92
- worker.run({ entry, args, index, total: validEntries.length }),
105
+ worker.run({ entry, args, index, total: validEntries.length, replace: config.replace }),
93
106
  ),
94
107
  );
95
108
 
96
109
  return sizeArrays.flat();
97
110
  }
98
111
 
112
+ /**
113
+ * Posts initial "in progress" PR comment with CircleCI build information
114
+ * @returns {Promise<void>}
115
+ */
116
+ async function postInitialPrComment() {
117
+ // /** @type {envCi.CircleCiEnv} */
118
+ const ciInfo = getCiInfo();
119
+
120
+ if (!ciInfo || !ciInfo.isPr) {
121
+ return;
122
+ }
123
+
124
+ // In CI PR builds, all required info must be present
125
+ if (!ciInfo.slug || !ciInfo.pr) {
126
+ throw new Error('PR commenting enabled but repository information missing in CI PR build');
127
+ }
128
+
129
+ const prNumber = Number(ciInfo.pr);
130
+ const circleBuildNum = process.env.CIRCLE_BUILD_NUM;
131
+ const circleBuildUrl = process.env.CIRCLE_BUILD_URL;
132
+
133
+ if (!circleBuildNum || !circleBuildUrl) {
134
+ throw new Error(
135
+ 'PR commenting enabled but CircleCI environment variables missing in CI PR build',
136
+ );
137
+ }
138
+
139
+ try {
140
+ // eslint-disable-next-line no-console
141
+ console.log('Posting initial PR comment...');
142
+
143
+ const initialComment = formatComment(
144
+ ciInfo.slug,
145
+ prNumber,
146
+ `Bundle size will be reported once [CircleCI build #${circleBuildNum}](${circleBuildUrl}) finishes.\n\nStatus: 🟠 Processing...`,
147
+ );
148
+
149
+ await notifyPr(ciInfo.slug, prNumber, 'bundle-size-report', initialComment);
150
+
151
+ // eslint-disable-next-line no-console
152
+ console.log(`Initial PR comment posted for PR #${prNumber}`);
153
+ } catch (/** @type {any} */ error) {
154
+ console.error('Failed to post initial PR comment:', error.message);
155
+ // Don't fail the build for comment failures
156
+ }
157
+ }
158
+
99
159
  /**
100
160
  * Report command handler
101
161
  * @param {ReportCommandArgs} argv - Command line arguments
@@ -106,7 +166,7 @@ async function reportCommand(argv) {
106
166
  // Get current repo info and coerce with provided arguments
107
167
  const currentRepo = await getCurrentRepoInfo();
108
168
  const owner = argOwner ?? currentRepo.owner;
109
- const repo = argRepo ?? currentRepo.repo;
169
+ const repo = argRepo ?? currentRepo.name;
110
170
 
111
171
  if (typeof pr !== 'number') {
112
172
  throw new Error('Invalid pull request number. Please provide a valid --pr option.');
@@ -126,8 +186,18 @@ async function reportCommand(argv) {
126
186
  pull_number: pr,
127
187
  });
128
188
 
189
+ const getMergeBaseFromGithubApi = async (
190
+ /** @type {string} */ base,
191
+ /** @type {string} */ head,
192
+ ) => {
193
+ const { data } = await octokit.repos.compareCommits({ owner, repo, base, head });
194
+ return data.merge_base_commit.sha;
195
+ };
196
+
129
197
  // Generate and print the markdown report
130
- const report = await renderMarkdownReport(prInfo);
198
+ const report = await renderMarkdownReport(prInfo, {
199
+ getMergeBase: getMergeBaseFromGithubApi,
200
+ });
131
201
  // eslint-disable-next-line no-console
132
202
  console.log(report);
133
203
  }
@@ -143,18 +213,27 @@ async function run(argv) {
143
213
 
144
214
  const config = await loadConfig(rootDir);
145
215
 
216
+ // Post initial PR comment if enabled and in CI environment
217
+ if (config && config.comment) {
218
+ await postInitialPrComment();
219
+ }
220
+
146
221
  // eslint-disable-next-line no-console
147
222
  console.log(`Starting bundle size snapshot creation with ${concurrency} workers...`);
148
223
 
149
- const webpackSizes = await getWebpackSizes(argv, config);
150
- const bundleSizes = Object.fromEntries(webpackSizes.sort((a, b) => a[0].localeCompare(b[0])));
224
+ const bundleSizes = await getBundleSizes(argv, config);
225
+ const sortedBundleSizes = Object.fromEntries(
226
+ bundleSizes.sort((a, b) => a[0].localeCompare(b[0])),
227
+ );
151
228
 
152
229
  // Ensure output directory exists
153
230
  await fs.mkdir(path.dirname(snapshotDestPath), { recursive: true });
154
- await fs.writeFile(snapshotDestPath, JSON.stringify(bundleSizes, null, 2));
231
+ await fs.writeFile(snapshotDestPath, JSON.stringify(sortedBundleSizes, null, 2));
155
232
 
156
233
  // eslint-disable-next-line no-console
157
- console.log(`Bundle size snapshot written to ${snapshotDestPath}`);
234
+ console.log(
235
+ `Bundle size snapshot written to ${chalk.underline(pathToFileURL(snapshotDestPath))}`,
236
+ );
158
237
 
159
238
  // Upload the snapshot if upload configuration is provided and not null
160
239
  if (config && config.upload) {
@@ -169,6 +248,57 @@ async function run(argv) {
169
248
  // Exit with error code to indicate failure
170
249
  process.exit(1);
171
250
  }
251
+ } else {
252
+ // eslint-disable-next-line no-console
253
+ console.log('No upload configuration provided, skipping upload.');
254
+ }
255
+
256
+ // Post PR comment if enabled and in CI environment
257
+ if (config && config.comment) {
258
+ const ciInfo = getCiInfo();
259
+
260
+ // Skip silently if not in CI or not a PR
261
+ if (!ciInfo || !ciInfo.isPr) {
262
+ return;
263
+ }
264
+
265
+ // In CI PR builds, all required info must be present
266
+ if (!ciInfo.slug || !ciInfo.pr) {
267
+ throw new Error('PR commenting enabled but repository information missing in CI PR build');
268
+ }
269
+
270
+ const prNumber = Number(ciInfo.pr);
271
+
272
+ // eslint-disable-next-line no-console
273
+ console.log('Generating PR comment with bundle size changes...');
274
+
275
+ // Get tracked bundles from config
276
+ const trackedBundles = config.entrypoints
277
+ .filter((entry) => entry.track === true)
278
+ .map((entry) => entry.id);
279
+
280
+ // Get PR info for renderMarkdownReport
281
+ const { data: prInfo } = await octokit.pulls.get({
282
+ owner: ciInfo.slug.split('/')[0],
283
+ repo: ciInfo.slug.split('/')[1],
284
+ pull_number: prNumber,
285
+ });
286
+
287
+ // Generate markdown report
288
+ const report = await renderMarkdownReport(prInfo, {
289
+ track: trackedBundles.length > 0 ? trackedBundles : undefined,
290
+ });
291
+
292
+ // Post or update PR comment
293
+ await notifyPr(
294
+ ciInfo.slug,
295
+ prNumber,
296
+ 'bundle-size-report',
297
+ formatComment(ciInfo.slug, prNumber, report),
298
+ );
299
+
300
+ // eslint-disable-next-line no-console
301
+ console.log(`PR comment posted/updated for PR #${prNumber}`);
172
302
  }
173
303
  }
174
304
 
@@ -181,12 +311,7 @@ yargs(process.argv.slice(2))
181
311
  return cmdYargs
182
312
  .option('analyze', {
183
313
  default: false,
184
- describe: 'Creates a webpack-bundle-analyzer report for each bundle.',
185
- type: 'boolean',
186
- })
187
- .option('accurateBundles', {
188
- default: false,
189
- describe: 'Displays used bundles accurately at the cost of more CPU cycles.',
314
+ describe: 'Creates a report for each bundle.',
190
315
  type: 'boolean',
191
316
  })
192
317
  .option('verbose', {
@@ -194,9 +319,10 @@ yargs(process.argv.slice(2))
194
319
  describe: 'Show more detailed information during compilation.',
195
320
  type: 'boolean',
196
321
  })
197
- .option('vite', {
322
+ .option('debug', {
198
323
  default: false,
199
- describe: 'Use Vite instead of webpack for bundling.',
324
+ describe:
325
+ 'Build with readable output (no name mangling or whitespace collapse, but still tree-shake).',
200
326
  type: 'boolean',
201
327
  })
202
328
  .option('output', {