From 403dd3a73a6d8879290e83976d466151f8538157 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 26 Aug 2026 18:01:12 -0700 Subject: [PATCH 1/2] add bundle size package --- apps/bundle-size/README.md | 41 ++++++ apps/bundle-size/babel.config.js | 13 ++ apps/bundle-size/metro.config.js | 32 +++++ apps/bundle-size/package.json | 72 ++++++++++ apps/bundle-size/scenarios.json | 18 +++ apps/bundle-size/scripts/measure.mjs | 202 +++++++++++++++++++++++++++ apps/bundle-size/src/bootstrap.js | 8 ++ package.json | 1 + yarn.lock | 30 ++++ 9 files changed, 417 insertions(+) create mode 100644 apps/bundle-size/README.md create mode 100644 apps/bundle-size/babel.config.js create mode 100644 apps/bundle-size/metro.config.js create mode 100644 apps/bundle-size/package.json create mode 100644 apps/bundle-size/scenarios.json create mode 100644 apps/bundle-size/scripts/measure.mjs create mode 100644 apps/bundle-size/src/bootstrap.js diff --git a/apps/bundle-size/README.md b/apps/bundle-size/README.md new file mode 100644 index 0000000000..43d80bf07e --- /dev/null +++ b/apps/bundle-size/README.md @@ -0,0 +1,41 @@ +# Bundle-size fixture + +This private app measures minified, tree-shaken Metro consumer bundles without including a test +app or Storybook catalog. It uses `@rnx-kit/metro-config`, +`@rnx-kit/metro-resolver-symlinks` with `oxc-resolver`, and +`@rnx-kit/metro-serializer-esbuild`. Each target scenario is compared with the same React Native +shell on macOS, Win32, and Windows. + +Run every configured scenario from the repository root: + +```sh +yarn bundle-size +``` + +Limit a local run to one or more platforms: + +```sh +yarn bundle-size --platform windows +yarn bundle-size --platform macos --platform win32 +``` + +Results, source maps, and esbuild metafiles are written to the ignored `dist/bundle-size` +directory. The runner passes `--tree-shake true` and `--metafile .meta.json` to +`rnx-cli bundle`. The JSON report contains raw bytes, gzip bytes, contributing esbuild input +counts, Metro source-map counts, workspace package contribution bytes, and shell deltas. Raw +bytes are the primary comparison; gzip and module attribution are diagnostic signals. + +The Babel configuration preserves ESM for the serializer while explicitly lowering JSX. The +explicit JSX transform is required because the desktop React Native packages publish JSX in +`.js` files, which esbuild otherwise parses with its plain JavaScript loader. + +## Adding a package or submodule + +1. Add the package to this fixture's dependencies so the pnpm linker exposes it to Metro. +2. Add a scenario to `scenarios.json` with a stable name, module specifier, and either `exports` + or `namespace: true`. +3. Run `yarn bundle-size` and inspect the shell delta and generated esbuild metafile. The + metafile can also be loaded into the esbuild bundle analyzer. + +Use a supported public package or subpath export when measuring consumer cost. A source-relative +module path is useful for diagnostics, but it does not prove the cost of the published API. diff --git a/apps/bundle-size/babel.config.js b/apps/bundle-size/babel.config.js new file mode 100644 index 0000000000..51af4a05a3 --- /dev/null +++ b/apps/bundle-size/babel.config.js @@ -0,0 +1,13 @@ +const env = process.env.BABEL_ENV || process.env.NODE_ENV; + +module.exports = { + presets: [ + [ + 'module:@react-native/babel-preset', + { + disableImportExportTransform: env === 'production' && Boolean(process.env.RNX_METRO_SERIALIZER_ESBUILD), + }, + ], + ], + plugins: [['@babel/plugin-transform-react-jsx', { runtime: 'automatic' }]], +}; diff --git a/apps/bundle-size/metro.config.js b/apps/bundle-size/metro.config.js new file mode 100644 index 0000000000..873ca98564 --- /dev/null +++ b/apps/bundle-size/metro.config.js @@ -0,0 +1,32 @@ +const { makeMetroConfig } = require('@rnx-kit/metro-config'); +const MetroSymlinksResolver = require('@rnx-kit/metro-resolver-symlinks'); + +const symlinkResolver = MetroSymlinksResolver({ + resolver: 'oxc-resolver', +}); + +function resolvePlatformModule(moduleName, platform) { + if (platform !== 'win32') { + return moduleName; + } + + if (moduleName === 'react-native') { + return '@office-iss/react-native-win32'; + } + + if (moduleName.startsWith('react-native/')) { + return `@office-iss/react-native-win32/${moduleName.slice('react-native/'.length)}`; + } + + return moduleName; +} + +module.exports = makeMetroConfig({ + resolver: { + resolveRequest: (context, moduleName, platform) => symlinkResolver(context, resolvePlatformModule(moduleName, platform), platform), + unstable_enablePackageExports: true, + unstable_conditionNames: ['react-native', 'import', 'require'], + disableHierarchicalLookup: true, + enableSymlinks: true, + }, +}); diff --git a/apps/bundle-size/package.json b/apps/bundle-size/package.json new file mode 100644 index 0000000000..6483e9b603 --- /dev/null +++ b/apps/bundle-size/package.json @@ -0,0 +1,72 @@ +{ + "name": "@fluentui-react-native/bundle-size", + "version": "0.0.0", + "private": true, + "description": "Deterministic Metro consumer-bundle measurements", + "license": "MIT", + "scripts": { + "format": "fluentui-scripts format", + "lint": "fluentui-scripts lint", + "measure": "node scripts/measure.mjs" + }, + "dependencies": { + "@fluentui-react-native/components": "workspace:*", + "@office-iss/react-native-win32": "^0.81.0", + "@types/react": "~19.1.4", + "react": "19.1.4", + "react-native": "^0.81.6", + "react-native-macos": "^0.81.0", + "react-native-svg": "^15.12.1", + "react-native-windows": "^0.81.0" + }, + "devDependencies": { + "@babel/core": "catalog:", + "@babel/plugin-transform-react-jsx": "catalog:", + "@fluentui-react-native/scripts": "workspace:*", + "@react-native-community/cli": "^20.0.0", + "@react-native-community/cli-platform-android": "^20.0.0", + "@react-native-community/cli-platform-ios": "^20.0.0", + "@react-native-windows/cli": "^0.81.0", + "@react-native/babel-preset": "^0.81.0", + "@react-native/metro-config": "^0.81.0", + "@rnx-kit/cli": "catalog:", + "@rnx-kit/metro-config": "catalog:", + "@rnx-kit/metro-resolver-symlinks": "catalog:", + "@rnx-kit/metro-serializer-esbuild": "catalog:", + "metro": "^0.83.1", + "oxc-resolver": "catalog:" + }, + "rnx-kit": { + "kitType": "app", + "bundle": [ + { + "id": "measure", + "entryFile": "src/bootstrap.js", + "treeShake": true, + "targets": [ + "macos", + "win32", + "windows" + ] + } + ], + "alignDeps": { + "requirements": { + "production": [ + "react-native@0.81" + ] + }, + "capabilities": [ + "babel-preset-react-native", + "community/cli", + "core", + "core-macos", + "core-win32", + "core-windows", + "core/metro-config", + "metro" + ] + }, + "extends": "@fluentui-react-native/scripts/kit-config" + } +} diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json new file mode 100644 index 0000000000..ae8c7fc6f9 --- /dev/null +++ b/apps/bundle-size/scenarios.json @@ -0,0 +1,18 @@ +{ + "platforms": ["macos", "win32", "windows"], + "scenarios": [ + { + "name": "shell" + }, + { + "name": "components-button", + "module": "@fluentui-react-native/components", + "exports": ["Button"] + }, + { + "name": "components-catalog", + "module": "@fluentui-react-native/components", + "namespace": true + } + ] +} diff --git a/apps/bundle-size/scripts/measure.mjs b/apps/bundle-size/scripts/measure.mjs new file mode 100644 index 0000000000..fbda0cb54e --- /dev/null +++ b/apps/bundle-size/scripts/measure.mjs @@ -0,0 +1,202 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; + +const workspaceRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const repositoryRoot = dirname(dirname(workspaceRoot)); +const yarnVersion = JSON.parse(readFileSync(join(repositoryRoot, 'package.json'), 'utf8')).packageManager.split('@')[1]; +const yarnPath = join(repositoryRoot, '.yarn', 'releases', `yarn-${yarnVersion}.cjs`); +const configPath = join(workspaceRoot, 'scenarios.json'); +const outputRoot = join(workspaceRoot, 'dist', 'bundle-size'); +const entryRoot = join(outputRoot, 'entries'); + +function parseArgs(args) { + const options = { config: configPath, platforms: undefined }; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--config') { + options.config = args[++index]; + } else if (argument === '--platform') { + options.platforms ??= []; + options.platforms.push(args[++index]); + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + + return options; +} + +function createEntry(scenario) { + const lines = []; + + if (scenario.module) { + if (scenario.namespace) { + lines.push(`import * as bundleSizeTarget from ${JSON.stringify(scenario.module)};`); + lines.push('globalThis.__bundleSizeTarget = bundleSizeTarget;'); + } else if (scenario.exports?.length) { + lines.push(`import { ${scenario.exports.join(', ')} } from ${JSON.stringify(scenario.module)};`); + lines.push(`globalThis.__bundleSizeTarget = [${scenario.exports.join(', ')}];`); + } else { + throw new Error(`Scenario "${scenario.name}" must set "namespace" or "exports"`); + } + } + + const bootstrapPath = relative(entryRoot, join(workspaceRoot, 'src', 'bootstrap.js')).replaceAll('\\', '/'); + lines.push(`import ${JSON.stringify(bootstrapPath.startsWith('.') ? bootstrapPath : `./${bootstrapPath}`)};`, ''); + + return lines.join('\n'); +} + +function getWorkspacePackage(source) { + const packagesRoot = join(repositoryRoot, 'packages'); + const sourcePath = isAbsolute(source) ? source : resolve(workspaceRoot, source); + if (!sourcePath.startsWith(packagesRoot)) { + return undefined; + } + + let directory = dirname(sourcePath); + while (directory.startsWith(packagesRoot)) { + const manifestPath = join(directory, 'package.json'); + if (existsSync(manifestPath)) { + return JSON.parse(readFileSync(manifestPath, 'utf8')).name; + } + directory = dirname(directory); + } + + return undefined; +} + +function getWorkspaceContributions(metafile) { + const packageModules = new Map(); + const packageBytes = new Map(); + const contributingInputs = new Map(); + + for (const output of Object.values(metafile.outputs)) { + for (const [source, contribution] of Object.entries(output.inputs ?? {})) { + if (contribution.bytesInOutput > 0) { + contributingInputs.set(source, (contributingInputs.get(source) ?? 0) + contribution.bytesInOutput); + } + } + } + + for (const [source, bytes] of contributingInputs) { + const packageName = getWorkspacePackage(source); + if (packageName) { + packageModules.set(packageName, (packageModules.get(packageName) ?? 0) + 1); + packageBytes.set(packageName, (packageBytes.get(packageName) ?? 0) + bytes); + } + } + + const sortPackages = (entries) => Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right))); + return { + moduleCount: contributingInputs.size, + workspaceModules: sortPackages(packageModules), + workspaceBytes: sortPackages(packageBytes), + }; +} + +function runBundle(platform, scenario, resetCache) { + const entryPath = join(entryRoot, `${scenario.name}.js`); + const bundlePath = join(outputRoot, platform, `${scenario.name}.bundle`); + const sourceMapPath = `${bundlePath}.map`; + const metafileName = `${scenario.name}.meta.json`; + const metafilePath = join(dirname(bundlePath), metafileName); + const metafileOutput = relative(workspaceRoot, metafilePath).replaceAll('\\', '/'); + writeFileSync(entryPath, createEntry(scenario)); + mkdirSync(dirname(bundlePath), { recursive: true }); + + const bundleArgs = [ + yarnPath, + 'workspace', + '@fluentui-react-native/bundle-size', + 'rnx-cli', + 'bundle', + '--id', + 'measure', + '--entry-file', + entryPath, + '--platform', + platform, + '--dev', + 'false', + '--minify', + 'true', + '--tree-shake', + 'true', + '--metafile', + metafileOutput, + '--bundle-output', + bundlePath, + '--sourcemap-output', + sourceMapPath, + ]; + if (resetCache) { + bundleArgs.push('--reset-cache'); + } + + const result = spawnSync(process.execPath, bundleArgs, { cwd: repositoryRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + + if (result.status !== 0) { + process.stderr.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + throw new Error(`Metro failed for ${scenario.name} on ${platform}: ${result.error?.message ?? `exit ${result.status}`}`); + } + + const bundle = readFileSync(bundlePath); + const sourceMap = JSON.parse(readFileSync(sourceMapPath, 'utf8')); + const metafile = JSON.parse(readFileSync(metafilePath, 'utf8')); + const contributions = getWorkspaceContributions(metafile); + + return { + scenario: scenario.name, + rawBytes: statSync(bundlePath).size, + gzipBytes: gzipSync(bundle, { level: 9, mtime: 0 }).byteLength, + moduleCount: contributions.moduleCount, + metroModuleCount: sourceMap.sources.length, + metafileInputCount: Object.keys(metafile.inputs).length, + metafile: metafileOutput, + workspaceModules: contributions.workspaceModules, + workspaceBytes: contributions.workspaceBytes, + }; +} + +const { config: selectedConfigPath, platforms: selectedPlatforms } = parseArgs(process.argv.slice(2)); +const selectedConfig = JSON.parse(readFileSync(selectedConfigPath, 'utf8')); +const platforms = selectedPlatforms ?? selectedConfig.platforms; + +await mkdir(entryRoot, { recursive: true }); + +const measurements = []; +for (const platform of platforms) { + for (const [scenarioIndex, scenario] of selectedConfig.scenarios.entries()) { + process.stdout.write(`Bundling ${scenario.name} for ${platform}...\n`); + measurements.push({ platform, ...runBundle(platform, scenario, scenarioIndex === 0) }); + } +} + +const shells = new Map( + measurements.filter(({ scenario }) => scenario === 'shell').map((measurement) => [measurement.platform, measurement]), +); +const results = measurements.map((measurement) => { + const shell = shells.get(measurement.platform); + return { + ...measurement, + deltaBytes: measurement.rawBytes - shell.rawBytes, + deltaGzipBytes: measurement.gzipBytes - shell.gzipBytes, + deltaModules: measurement.moduleCount - shell.moduleCount, + }; +}); +const report = { + node: process.version, + results, +}; +const reportPath = join(outputRoot, 'results.json'); +writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + +console.table(results); +process.stdout.write(`Results: ${reportPath}\n`); diff --git a/apps/bundle-size/src/bootstrap.js b/apps/bundle-size/src/bootstrap.js new file mode 100644 index 0000000000..bb2235ff78 --- /dev/null +++ b/apps/bundle-size/src/bootstrap.js @@ -0,0 +1,8 @@ +import React from 'react'; +import { AppRegistry, View } from 'react-native'; + +function BundleSizeApp() { + return React.createElement(View); +} + +AppRegistry.registerComponent('BundleSize', () => BundleSizeApp); diff --git a/package.json b/package.json index 96876201a1..6bfdb68873 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "scripts": { "build": "lage root-build", "build:clean": "yarn root-build --clean && yarn build", + "bundle-size": "yarn workspace @fluentui-react-native/bundle-size measure", "docs": "yarn workspace fluent-rn-website start", "bundle:repo": "lage bundle", "clean": "tsc -b --clean && lage clean", diff --git a/yarn.lock b/yarn.lock index 275b66753a..9e3cd3e45d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2639,6 +2639,36 @@ __metadata: languageName: unknown linkType: soft +"@fluentui-react-native/bundle-size@workspace:apps/bundle-size": + version: 0.0.0-use.local + resolution: "@fluentui-react-native/bundle-size@workspace:apps/bundle-size" + dependencies: + "@babel/core": "catalog:" + "@babel/plugin-transform-react-jsx": "catalog:" + "@fluentui-react-native/components": "workspace:*" + "@fluentui-react-native/scripts": "workspace:*" + "@office-iss/react-native-win32": "npm:^0.81.0" + "@react-native-community/cli": "npm:^20.0.0" + "@react-native-community/cli-platform-android": "npm:^20.0.0" + "@react-native-community/cli-platform-ios": "npm:^20.0.0" + "@react-native-windows/cli": "npm:^0.81.0" + "@react-native/babel-preset": "npm:^0.81.0" + "@react-native/metro-config": "npm:^0.81.0" + "@rnx-kit/cli": "catalog:" + "@rnx-kit/metro-config": "catalog:" + "@rnx-kit/metro-resolver-symlinks": "catalog:" + "@rnx-kit/metro-serializer-esbuild": "catalog:" + "@types/react": "npm:~19.1.4" + metro: "npm:^0.83.1" + oxc-resolver: "catalog:" + react: "npm:19.1.4" + react-native: "npm:^0.81.6" + react-native-macos: "npm:^0.81.0" + react-native-svg: "npm:^15.12.1" + react-native-windows: "npm:^0.81.0" + languageName: unknown + linkType: soft + "@fluentui-react-native/button@workspace:*, @fluentui-react-native/button@workspace:packages/components/Button": version: 0.0.0-use.local resolution: "@fluentui-react-native/button@workspace:packages/components/Button" From bf14747f2c2a08c949222969974e54eaccc9123c Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 26 Aug 2026 22:45:52 -0700 Subject: [PATCH 2/2] add bundle size pr pipeline --- .github/workflows/pr.yml | 47 +++++++++ apps/bundle-size/README.md | 14 +++ apps/bundle-size/baseline.json | 152 +++++++++++++++++++++++++++ apps/bundle-size/package.json | 3 +- apps/bundle-size/scripts/measure.mjs | 129 ++++++++++++++++++++++- package.json | 1 + 6 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 apps/bundle-size/baseline.json diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ad8ff81e33..769c32079c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,6 +34,53 @@ jobs: - name: Build CI run: yarn lage buildci + bundle-size: + name: Bundle Size PR + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + - name: Set up toolchain + uses: microsoft/react-native-test-app/.github/actions/setup-toolchain@1230975885a9e5d3a2ad58b1483f80b09cec04f2 # 5.2.3 + with: + node-version: 22 + + - name: Install dependencies + run: yarn + + - name: Read base branch bundle baseline + shell: powershell + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + $baseline = "$env:RUNNER_TEMP\bundle-size-baseline.json" + $baselinePath = 'apps/bundle-size/baseline.json' + $baselineExists = git ls-tree --name-only $env:BASE_SHA -- $baselinePath + if ($baselineExists) { + git show "$env:BASE_SHA`:apps/bundle-size/baseline.json" | Out-File -FilePath $baseline -Encoding utf8 + } else { + Copy-Item $baselinePath $baseline + } + + - name: Measure bundle sizes + run: yarn bundle-size --baseline "$env:RUNNER_TEMP\bundle-size-baseline.json" + + - name: Publish bundle size summary + run: Get-Content apps\bundle-size\dist\bundle-size\report.md -Raw | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append + + - name: Upload bundle analysis + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bundle-size-analysis + path: apps/bundle-size/dist/bundle-size + if-no-files-found: warn + retention-days: 14 + android: name: Android PR runs-on: ubuntu-latest diff --git a/apps/bundle-size/README.md b/apps/bundle-size/README.md index 43d80bf07e..2840ee814f 100644 --- a/apps/bundle-size/README.md +++ b/apps/bundle-size/README.md @@ -29,6 +29,20 @@ The Babel configuration preserves ESM for the serializer while explicitly loweri explicit JSX transform is required because the desktop React Native packages publish JSX in `.js` files, which esbuild otherwise parses with its plain JavaScript loader. +## Baselines and pull requests + +`baseline.json` is the reviewed comparison point for pull requests. Ordinary measurement writes +`dist/bundle-size/report.md` with advisory deltas and never changes the baseline. Update it only +for an intentional bundle change: + +```sh +yarn bundle-size:update +``` + +Review the baseline diff together with the implementation that caused it. The PR workflow adds +the Markdown comparison to its job summary and uploads the complete `dist/bundle-size` directory, +including the esbuild metafiles, for investigation. + ## Adding a package or submodule 1. Add the package to this fixture's dependencies so the pnpm linker exposes it to Metro. diff --git a/apps/bundle-size/baseline.json b/apps/bundle-size/baseline.json new file mode 100644 index 0000000000..435b090c34 --- /dev/null +++ b/apps/bundle-size/baseline.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "results": [ + { + "platform": "macos", + "scenario": "shell", + "rawBytes": 820522, + "gzipBytes": 236436, + "moduleCount": 497, + "metroModuleCount": 495, + "metafileInputCount": 497, + "workspaceModules": {}, + "workspaceBytes": {} + }, + { + "platform": "macos", + "scenario": "components-button", + "rawBytes": 1088756, + "gzipBytes": 301160, + "moduleCount": 753, + "metroModuleCount": 748, + "metafileInputCount": 753, + "workspaceModules": { + "@fluentui-react-native/components": 98, + "@fluentui-react-native/design": 20, + "@fluentui-react-native/framework-base": 26 + }, + "workspaceBytes": { + "@fluentui-react-native/components": 120566, + "@fluentui-react-native/design": 26777, + "@fluentui-react-native/framework-base": 12813 + } + }, + { + "platform": "macos", + "scenario": "components-catalog", + "rawBytes": 1090777, + "gzipBytes": 301702, + "moduleCount": 753, + "metroModuleCount": 748, + "metafileInputCount": 753, + "workspaceModules": { + "@fluentui-react-native/components": 98, + "@fluentui-react-native/design": 20, + "@fluentui-react-native/framework-base": 26 + }, + "workspaceBytes": { + "@fluentui-react-native/components": 122538, + "@fluentui-react-native/design": 26777, + "@fluentui-react-native/framework-base": 12813 + } + }, + { + "platform": "win32", + "scenario": "shell", + "rawBytes": 825849, + "gzipBytes": 236958, + "moduleCount": 497, + "metroModuleCount": 495, + "metafileInputCount": 497, + "workspaceModules": {}, + "workspaceBytes": {} + }, + { + "platform": "win32", + "scenario": "components-button", + "rawBytes": 1094123, + "gzipBytes": 301643, + "moduleCount": 754, + "metroModuleCount": 749, + "metafileInputCount": 754, + "workspaceModules": { + "@fluentui-react-native/components": 98, + "@fluentui-react-native/design": 21, + "@fluentui-react-native/framework-base": 26 + }, + "workspaceBytes": { + "@fluentui-react-native/components": 120582, + "@fluentui-react-native/design": 26814, + "@fluentui-react-native/framework-base": 12813 + } + }, + { + "platform": "win32", + "scenario": "components-catalog", + "rawBytes": 1096141, + "gzipBytes": 302170, + "moduleCount": 754, + "metroModuleCount": 749, + "metafileInputCount": 754, + "workspaceModules": { + "@fluentui-react-native/components": 98, + "@fluentui-react-native/design": 21, + "@fluentui-react-native/framework-base": 26 + }, + "workspaceBytes": { + "@fluentui-react-native/components": 122554, + "@fluentui-react-native/design": 26814, + "@fluentui-react-native/framework-base": 12813 + } + }, + { + "platform": "windows", + "scenario": "shell", + "rawBytes": 829655, + "gzipBytes": 237915, + "moduleCount": 502, + "metroModuleCount": 500, + "metafileInputCount": 502, + "workspaceModules": {}, + "workspaceBytes": {} + }, + { + "platform": "windows", + "scenario": "components-button", + "rawBytes": 1097727, + "gzipBytes": 302564, + "moduleCount": 759, + "metroModuleCount": 754, + "metafileInputCount": 759, + "workspaceModules": { + "@fluentui-react-native/components": 98, + "@fluentui-react-native/design": 21, + "@fluentui-react-native/framework-base": 26 + }, + "workspaceBytes": { + "@fluentui-react-native/components": 120529, + "@fluentui-react-native/design": 26849, + "@fluentui-react-native/framework-base": 12813 + } + }, + { + "platform": "windows", + "scenario": "components-catalog", + "rawBytes": 1099748, + "gzipBytes": 303151, + "moduleCount": 759, + "metroModuleCount": 754, + "metafileInputCount": 759, + "workspaceModules": { + "@fluentui-react-native/components": 98, + "@fluentui-react-native/design": 21, + "@fluentui-react-native/framework-base": 26 + }, + "workspaceBytes": { + "@fluentui-react-native/components": 122501, + "@fluentui-react-native/design": 26849, + "@fluentui-react-native/framework-base": 12813 + } + } + ] +} diff --git a/apps/bundle-size/package.json b/apps/bundle-size/package.json index 6483e9b603..ed39a90323 100644 --- a/apps/bundle-size/package.json +++ b/apps/bundle-size/package.json @@ -7,7 +7,8 @@ "scripts": { "format": "fluentui-scripts format", "lint": "fluentui-scripts lint", - "measure": "node scripts/measure.mjs" + "measure": "node scripts/measure.mjs", + "update-baseline": "node scripts/measure.mjs --update-baseline" }, "dependencies": { "@fluentui-react-native/components": "workspace:*", diff --git a/apps/bundle-size/scripts/measure.mjs b/apps/bundle-size/scripts/measure.mjs index fbda0cb54e..96ded9771d 100644 --- a/apps/bundle-size/scripts/measure.mjs +++ b/apps/bundle-size/scripts/measure.mjs @@ -10,19 +10,24 @@ const repositoryRoot = dirname(dirname(workspaceRoot)); const yarnVersion = JSON.parse(readFileSync(join(repositoryRoot, 'package.json'), 'utf8')).packageManager.split('@')[1]; const yarnPath = join(repositoryRoot, '.yarn', 'releases', `yarn-${yarnVersion}.cjs`); const configPath = join(workspaceRoot, 'scenarios.json'); +const defaultBaselinePath = join(workspaceRoot, 'baseline.json'); const outputRoot = join(workspaceRoot, 'dist', 'bundle-size'); const entryRoot = join(outputRoot, 'entries'); function parseArgs(args) { - const options = { config: configPath, platforms: undefined }; + const options = { baseline: defaultBaselinePath, config: configPath, platforms: undefined, updateBaseline: false }; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; - if (argument === '--config') { + if (argument === '--baseline') { + options.baseline = resolve(args[++index]); + } else if (argument === '--config') { options.config = args[++index]; } else if (argument === '--platform') { options.platforms ??= []; options.platforms.push(args[++index]); + } else if (argument === '--update-baseline') { + options.updateBaseline = true; } else { throw new Error(`Unknown argument: ${argument}`); } @@ -165,7 +170,99 @@ function runBundle(platform, scenario, resetCache) { }; } -const { config: selectedConfigPath, platforms: selectedPlatforms } = parseArgs(process.argv.slice(2)); +function baselineResult(measurement) { + const { platform, scenario, rawBytes, gzipBytes, moduleCount, metroModuleCount, metafileInputCount, workspaceModules, workspaceBytes } = + measurement; + return { + platform, + scenario, + rawBytes, + gzipBytes, + moduleCount, + metroModuleCount, + metafileInputCount, + workspaceModules, + workspaceBytes, + }; +} + +function resultKey({ platform, scenario }) { + return `${platform}:${scenario}`; +} + +function createComparison(measurement, baseline, baselineShell) { + const isShell = measurement.scenario === 'shell'; + if (!baseline || (!isShell && !baselineShell)) { + return { status: 'new' }; + } + + const baselineCost = isShell ? baseline.rawBytes : baseline.rawBytes - baselineShell.rawBytes; + const currentCost = isShell ? measurement.rawBytes : measurement.deltaBytes; + const baselineGzipCost = isShell ? baseline.gzipBytes : baseline.gzipBytes - baselineShell.gzipBytes; + const currentGzipCost = isShell ? measurement.gzipBytes : measurement.deltaGzipBytes; + const baselineModuleCost = isShell ? baseline.moduleCount : baseline.moduleCount - baselineShell.moduleCount; + const currentModuleCost = isShell ? measurement.moduleCount : measurement.deltaModules; + const costDelta = currentCost - baselineCost; + return { + status: 'compared', + baselineCost, + currentCost, + costDelta, + costPercent: baselineCost === 0 ? 0 : (costDelta / baselineCost) * 100, + gzipCostDelta: currentGzipCost - baselineGzipCost, + moduleCostDelta: currentModuleCost - baselineModuleCost, + absoluteRawDelta: measurement.rawBytes - baseline.rawBytes, + }; +} + +function formatBytes(bytes) { + const sign = bytes > 0 ? '+' : ''; + return `${sign}${(bytes / 1024).toFixed(1)} KiB`; +} + +function formatPercent(percent) { + const sign = percent > 0 ? '+' : ''; + return `${sign}${percent.toFixed(2)}%`; +} + +function createMarkdownReport(results) { + const lines = [ + '# Bundle size report', + '', + 'Tree-shaken production Metro bundles. Component costs are relative to their platform shell; shell costs are absolute.', + '', + '| Platform | Scenario | Baseline cost | Current cost | Cost delta | Change | Gzip delta | Module delta |', + '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + + for (const result of results) { + const { comparison } = result; + if (comparison.status === 'new') { + lines.push(`| ${result.platform} | ${result.scenario} | New | ${(result.rawBytes / 1024).toFixed(1)} KiB | New | New | New | New |`); + } else { + lines.push( + `| ${result.platform} | ${result.scenario} | ${(comparison.baselineCost / 1024).toFixed(1)} KiB | ${(comparison.currentCost / 1024).toFixed(1)} KiB | ${formatBytes(comparison.costDelta)} | ${formatPercent(comparison.costPercent)} | ${formatBytes(comparison.gzipCostDelta)} | ${comparison.moduleCostDelta >= 0 ? '+' : ''}${comparison.moduleCostDelta} |`, + ); + } + } + + lines.push( + '', + 'The job is advisory: size changes are reported but do not fail the pull request. Bundle or analysis errors still fail.', + '', + ); + return lines.join('\n'); +} + +const { + baseline: selectedBaselinePath, + config: selectedConfigPath, + platforms: selectedPlatforms, + updateBaseline, +} = parseArgs(process.argv.slice(2)); +if (updateBaseline && selectedPlatforms) { + throw new Error('Baseline updates must include every configured platform; omit --platform'); +} const selectedConfig = JSON.parse(readFileSync(selectedConfigPath, 'utf8')); const platforms = selectedPlatforms ?? selectedConfig.platforms; @@ -182,21 +279,45 @@ for (const platform of platforms) { const shells = new Map( measurements.filter(({ scenario }) => scenario === 'shell').map((measurement) => [measurement.platform, measurement]), ); +const currentBaseline = { + schemaVersion: 1, + results: measurements.map(baselineResult), +}; +if (updateBaseline) { + writeFileSync(selectedBaselinePath, `${JSON.stringify(currentBaseline, null, 2)}\n`); +} + +const baseline = existsSync(selectedBaselinePath) + ? JSON.parse(readFileSync(selectedBaselinePath, 'utf8')) + : { schemaVersion: 1, results: [] }; +if (baseline.schemaVersion !== 1) { + throw new Error(`Unsupported baseline schema version: ${baseline.schemaVersion}`); +} +const baselineResults = new Map(baseline.results.map((result) => [resultKey(result), result])); +const baselineShells = new Map(baseline.results.filter(({ scenario }) => scenario === 'shell').map((result) => [result.platform, result])); + const results = measurements.map((measurement) => { const shell = shells.get(measurement.platform); - return { + const result = { ...measurement, deltaBytes: measurement.rawBytes - shell.rawBytes, deltaGzipBytes: measurement.gzipBytes - shell.gzipBytes, deltaModules: measurement.moduleCount - shell.moduleCount, }; + return { + ...result, + comparison: createComparison(result, baselineResults.get(resultKey(result)), baselineShells.get(result.platform)), + }; }); const report = { node: process.version, results, }; const reportPath = join(outputRoot, 'results.json'); +const markdownReportPath = join(outputRoot, 'report.md'); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); +writeFileSync(markdownReportPath, createMarkdownReport(results)); console.table(results); process.stdout.write(`Results: ${reportPath}\n`); +process.stdout.write(`Report: ${markdownReportPath}\n`); diff --git a/package.json b/package.json index 6bfdb68873..6563b8e6d8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build": "lage root-build", "build:clean": "yarn root-build --clean && yarn build", "bundle-size": "yarn workspace @fluentui-react-native/bundle-size measure", + "bundle-size:update": "yarn workspace @fluentui-react-native/bundle-size update-baseline", "docs": "yarn workspace fluent-rn-website start", "bundle:repo": "lage bundle", "clean": "tsc -b --clean && lage clean",