diff --git a/.gitignore b/.gitignore index 3969bda46191d..2da96ce8b7d88 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,8 @@ __diff_output__ /coverage /docs/.next /docs/export +build +# TODO `dist` should be replaced with `build`, per convention dist node_modules +size-snapshot.json diff --git a/package.json b/package.json index e2604ba62dd11..5a19215590148 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "deduplicate": "node scripts/deduplicate.js", "stylelint": "stylelint '**/*.js' '**/*.ts' '**/*.tsx'", "prettier": "yarn babel-node -i '/node_modules/(?!@material-ui)/' ./scripts/prettier.js --branch master", + "prettier:all": "node ./scripts/prettier.js write", + "size:snapshot": "node --max-old-space-size=2048 ./scripts/sizeSnapshot/create", + "size:why": "yarn size:snapshot --analyze --accurateBundles", "test": "lerna run test --parallel", "test:coverage": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc mocha 'packages/**/*.test.tsx' --exclude '**/node_modules/**' && nyc report -r lcovonly", "test:coverage:html": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc mocha 'packages/**/*.test.tsx' --exclude '**/node_modules/**' && nyc report --reporter=html", @@ -66,6 +69,7 @@ "babel-plugin-transform-rename-import": "^2.3.0", "chai": "^4.2.0", "chai-dom": "^1.8.2", + "compression-webpack-plugin": "^6.0.2", "cross-env": "^7.0.2", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", diff --git a/scripts/sizeSnapshot/create.js b/scripts/sizeSnapshot/create.js new file mode 100644 index 0000000000000..8dab540f9d9aa --- /dev/null +++ b/scripts/sizeSnapshot/create.js @@ -0,0 +1,179 @@ +const fse = require('fs-extra'); +const lodash = require('lodash'); +const path = require('path'); +const { promisify } = require('util'); +const webpackCallbackBased = require('webpack'); +const yargs = require('yargs'); +const createWebpackConfig = require('./webpack.config'); + +const webpack = promisify(webpackCallbackBased); + +const workspaceRoot = path.join(__dirname, '../../'); +const snapshotDestPath = path.join(workspaceRoot, 'size-snapshot.json'); + +/** + * creates size snapshot for every bundle that built with webpack + */ +async function getWebpackSizes(webpackEnvironment) { + await fse.mkdirp(path.join(__dirname, 'build')); + + const configurations = await createWebpackConfig(webpack, webpackEnvironment); + const webpackMultiStats = await webpack(configurations); + + const sizes = []; + webpackMultiStats.stats.forEach((webpackStats) => { + if (webpackStats.hasErrors()) { + const { entrypoints, errors } = webpackStats.toJson({ + all: false, + entrypoints: true, + errors: true, + }); + throw new Error( + `The following errors occured during bundling of ${Object.keys( + entrypoints, + )} with webpack: \n${errors.join('\n')}`, + ); + } + + const stats = webpackStats.toJson({ all: false, assets: true }); + const assets = new Map(stats.assets.map((asset) => [asset.name, asset])); + + Object.entries(stats.assetsByChunkName).forEach(([chunkName, assetName]) => { + const parsedSize = assets.get(assetName).size; + const gzipSize = assets.get(`${assetName}.gz`).size; + sizes.push([chunkName, { parsed: parsedSize, gzip: gzipSize }]); + }); + }); + + return sizes; +} + +// waiting for String.prototype.matchAll in node 10 +function* matchAll(string, regex) { + let match = null; + do { + match = regex.exec(string); + if (match !== null) { + yield match; + } + } while (match !== null); +} + +/** + * Inverse to `pretty-bytes` + * @param {string} n + * @param {'B', 'kB' | 'MB' | 'GB' | 'TB' | 'PB'} unit + * @returns {number} + */ + +function prettyBytesInverse(n, unit) { + const metrixPrefix = unit.length < 2 ? '' : unit[0]; + const metricPrefixes = ['', 'k', 'M', 'G', 'T', 'P']; + const metrixPrefixIndex = metricPrefixes.indexOf(metrixPrefix); + if (metrixPrefixIndex === -1) { + throw new TypeError( + `unrecognized metric prefix '${metrixPrefix}' in unit '${unit}'. only '${metricPrefixes.join( + "', '", + )}' are allowed`, + ); + } + + const power = metrixPrefixIndex * 3; + return n * 10 ** power; +} + +/** + * parses output from next build to size snapshot format + * @returns {[string, { gzip: number, files: number, packages: number }][]} + */ + +async function getNextPagesSize() { + const consoleOutput = await fse.readFile(path.join(__dirname, 'build/docs.next'), { + encoding: 'utf8', + }); + const pageRegex = /(?┌|├|└)\s+((?λ|○|●)\s+)?(?[^\s]+)\s+(?[0-9.]+)\s+(?\w+)/gm; + + const sharedChunks = []; + + const entries = Array.from(matchAll(consoleOutput, pageRegex), (match) => { + const { pageUrl, sizeFormatted, sizeUnit } = match.groups; + + let snapshotId = `docs:${pageUrl}`; + // used to be tracked with custom logic hence the different ids + if (pageUrl === '/') { + snapshotId = 'docs.landing'; + // chunks contain a content hash that makes the names + // unsuitable for tracking. Using stable name instead: + } else if (/^chunks\/pages\/_app\.(.+)\.js$/.test(pageUrl)) { + snapshotId = 'docs.main'; + } else if (/^chunks\/main\.(.+)\.js$/.test(pageUrl)) { + snapshotId = 'docs:shared:runtime/main'; + } else if (/^chunks\/webpack\.(.+)\.js$/.test(pageUrl)) { + snapshotId = 'docs:shared:runtime/webpack'; + } else if (/^chunks\/commons\.(.+)\.js$/.test(pageUrl)) { + snapshotId = 'docs:shared:chunk/commons'; + } else if (/^chunks\/framework\.(.+)\.js$/.test(pageUrl)) { + snapshotId = 'docs:shared:chunk/framework'; + } else if (/^chunks\/(.*)\.js$/.test(pageUrl)) { + // shared chunks are unnamed and only have a hash + // we just track their tally and summed size + sharedChunks.push(prettyBytesInverse(sizeFormatted, sizeUnit)); + // and not each chunk individually + return null; + } + + return [ + snapshotId, + { + parsed: prettyBytesInverse(sizeFormatted, sizeUnit), + gzip: -1, + }, + ]; + }).filter((entry) => entry !== null); + + entries.push([ + 'docs:chunk:shared', + { + parsed: sharedChunks.reduce((sum, size) => sum + size, 0), + gzip: -1, + tally: sharedChunks.length, + }, + ]); + + return entries; +} + +async function run(argv) { + const { analyze, accurateBundles } = argv; + + const bundleSizes = lodash.fromPairs([ + ...(await getWebpackSizes({ analyze, accurateBundles })), + ...(await getNextPagesSize()), + ]); + + await fse.writeJSON(snapshotDestPath, bundleSizes, { spaces: 2 }); +} + +yargs + .command({ + command: '$0', + description: 'Saves a size snapshot in size-snapshot.json', + builder: (command) => { + return command + .option('analyze', { + default: false, + describe: 'Creates a webpack-bundle-analyzer report for each bundle.', + type: 'boolean', + }) + .option('accurateBundles', { + default: false, + describe: 'Displays used bundles accurately at the cost of CPU cycles.', + type: 'boolean', + }); + }, + handler: run, + }) + .help() + .strict(true) + .version(false) + .parse(); diff --git a/scripts/sizeSnapshot/webpack.config.js b/scripts/sizeSnapshot/webpack.config.js new file mode 100644 index 0000000000000..645b803518f2d --- /dev/null +++ b/scripts/sizeSnapshot/webpack.config.js @@ -0,0 +1,80 @@ +const path = require('path'); +const CompressionPlugin = require('compression-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); +const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); + +const workspaceRoot = path.join(__dirname, '..', '..'); + +async function getWebpackEntries() { + return [ + { + name: '@material-ui/x-grid', + path: 'packages/grid/x-grid/dist/index-esm.js', + }, + { + name: '@material-ui/data-grid', + path: 'packages/grid/data-grid/dist/index-esm.js', + }, + { + name: '@material-ui/x-license', + path: 'packages/x-license/dist/esm/index.js', + }, + ]; +} + +module.exports = async function webpackConfig(webpack, environment) { + const analyzerMode = environment.analyze ? 'static' : 'disabled'; + const concatenateModules = !environment.accurateBundles; + + const entries = await getWebpackEntries(); + const configurations = entries.map((entry) => { + return { + // ideally this would be computed from the bundles peer dependencies + externals: /^(react|react-dom|react\/jsx-runtime)$/, + mode: 'production', + optimization: { + concatenateModules, + minimizer: [ + new TerserPlugin({ + test: /\.js(\?.*)?$/i, + }), + ], + }, + output: { + filename: '[name].js', + path: path.join(__dirname, 'build'), + }, + plugins: [ + new CompressionPlugin(), + new BundleAnalyzerPlugin({ + analyzerMode, + // We create a report for each bundle so around 120 reports. + // Opening them all is spam. + // If opened with `webpack --config . --analyze` it'll still open one new tab though. + openAnalyzer: false, + // '[name].html' not supported: https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/12 + reportFilename: `${entry.name}.html`, + }), + ], + resolve: { + alias: { + '@material-ui/data-grid': path.join( + workspaceRoot, + 'packages/grid/x-grid/dist/index-esm.js', + ), + '@material-ui/x-grid': path.join( + workspaceRoot, + 'packages/grid/data-grid/dist/index-esm.js', + ), + '@material-ui/x-license': path.join( + workspaceRoot, + 'packages/x-license/dist/esm/index.js', + ), + }, + }, + entry: { [entry.name]: path.join(workspaceRoot, entry.path) }, + }; + }); + + return configurations; +}; diff --git a/yarn.lock b/yarn.lock index e94843e4157b9..35d94778a4c1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7987,6 +7987,17 @@ compressible@~2.0.16: dependencies: mime-db ">= 1.43.0 < 2" +compression-webpack-plugin@^6.0.2: + version "6.1.1" + resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-6.1.1.tgz#ae8e4b2ffdb7396bb776e66918d751a20d8ccf0e" + integrity sha512-BEHft9M6lwOqVIQFMS/YJGmeCYXVOakC5KzQk05TFpMBlODByh1qNsZCWjUBxCQhUP9x0WfGidxTbGkjbWO/TQ== + dependencies: + cacache "^15.0.5" + find-cache-dir "^3.3.1" + schema-utils "^3.0.0" + serialize-javascript "^5.0.1" + webpack-sources "^1.4.3" + compression@^1.7.4: version "1.7.4" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"