diff --git a/.github/workflows/pr-bundle-size-comment.yml b/.github/workflows/pr-bundle-size-comment.yml index 1895e8376e..752a5d1177 100644 --- a/.github/workflows/pr-bundle-size-comment.yml +++ b/.github/workflows/pr-bundle-size-comment.yml @@ -35,6 +35,9 @@ jobs: repository: ${{ github.repository }} run-id: ${{ github.event.workflow_run.id }} + - name: Validate downloaded bundle size report + run: node apps/bundle-size/scripts/validate-report.mjs bundle-size-analysis/report.md + - name: Publish bundle size PR comment env: EXPECTED_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2e4e41d98b..8889967fc6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -75,6 +75,9 @@ jobs: - name: Measure bundle sizes run: yarn bundle-size --baseline "$env:RUNNER_TEMP\bundle-size-baseline.json" + - name: Validate bundle size report + run: yarn workspace @fluentui-react-native/bundle-size validate-report + - 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 diff --git a/apps/bundle-size/README.md b/apps/bundle-size/README.md index f472889dad..431106f68f 100644 --- a/apps/bundle-size/README.md +++ b/apps/bundle-size/README.md @@ -1,10 +1,8 @@ # 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. +This private app measures minified, tree-shaken esbuild consumer bundles without including a test +app or Storybook catalog. React, React Native, React Native SVG, desktop React Native forks, and +`@svgo` imports are external so measurements contain only the package code under test. Run every configured scenario from the repository root: @@ -20,16 +18,12 @@ 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 -terminal summary shows the shell's absolute cost and each package scenario's incremental module -and byte cost above its platform shell, plus signed deltas from the baseline. - -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. +directory. Platform-specific module resolution prefers `.`, then `.native`, then +generic TypeScript and JavaScript extensions. Package exports use the `react-native` condition. +The JSON report contains raw bytes, gzip bytes, contributing esbuild input counts, and workspace +package contribution bytes. Raw bytes are the primary comparison; gzip and module attribution +are diagnostic signals. The terminal summary shows each scenario's absolute module and byte cost +plus signed deltas from the baseline. ## Baselines and pull requests @@ -42,18 +36,19 @@ 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, updates one persistent PR comment, and uploads the -complete `dist/bundle-size` directory, including the esbuild metafiles, for investigation. -Same-repository PRs publish directly; fork and Dependabot reports are strictly validated and -published by a separate trusted completion workflow. +the Markdown comparison to its job summary only after validating the trusted report format, +updates one persistent PR comment, and uploads the complete `dist/bundle-size` directory, +including the esbuild metafiles, for investigation. Same-repository PRs publish directly; fork +and Dependabot reports are validated again and published by a separate trusted completion +workflow. ## Adding a package or submodule -1. Add the package to this fixture's dependencies so the pnpm linker exposes it to Metro. +1. Add the package to this fixture's dependencies so the pnpm linker exposes it to esbuild. 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. +3. Run `yarn bundle-size` and inspect the 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 deleted file mode 100644 index 51af4a05a3..0000000000 --- a/apps/bundle-size/babel.config.js +++ /dev/null @@ -1,13 +0,0 @@ -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/baseline.json b/apps/bundle-size/baseline.json index abf8eb3ce7..191652db93 100644 --- a/apps/bundle-size/baseline.json +++ b/apps/bundle-size/baseline.json @@ -1,234 +1,232 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "results": [ - { - "platform": "macos", - "scenario": "shell", - "rawBytes": 820522, - "gzipBytes": 236436, - "moduleCount": 497, - "metroModuleCount": 495, - "metafileInputCount": 497, - "workspaceModules": {}, - "workspaceBytes": {} - }, { "platform": "macos", "scenario": "components-button", - "rawBytes": 871797, - "gzipBytes": 249494, - "moduleCount": 554, - "metroModuleCount": 550, - "metafileInputCount": 758, + "rawBytes": 49476, + "gzipBytes": 13148, + "moduleCount": 62, + "metafileInputCount": 176, "workspaceModules": { - "@fluentui-react-native/components": 10, + "@fluentui-react-native/components": 16, "@fluentui-react-native/design": 21, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/framework-base": 24 }, "workspaceBytes": { - "@fluentui-react-native/components": 9482, - "@fluentui-react-native/design": 30207, - "@fluentui-react-native/framework-base": 11146 + "@fluentui-react-native/components": 8860, + "@fluentui-react-native/design": 28715, + "@fluentui-react-native/framework-base": 11865 } }, { "platform": "macos", "scenario": "components-catalog", - "rawBytes": 1094254, - "gzipBytes": 302948, - "moduleCount": 754, - "metroModuleCount": 749, - "metafileInputCount": 758, + "rawBytes": 153766, + "gzipBytes": 37994, + "moduleCount": 159, + "metafileInputCount": 176, "workspaceModules": { - "@fluentui-react-native/components": 98, + "@fluentui-react-native/components": 112, "@fluentui-react-native/design": 21, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/framework-base": 25 }, "workspaceBytes": { - "@fluentui-react-native/components": 122383, - "@fluentui-react-native/design": 30391, - "@fluentui-react-native/framework-base": 12813 + "@fluentui-react-native/components": 110315, + "@fluentui-react-native/design": 28810, + "@fluentui-react-native/framework-base": 14512 } }, { "platform": "macos", "scenario": "design-theme-state", - "rawBytes": 854603, - "gzipBytes": 243954, - "moduleCount": 541, - "metroModuleCount": 535, - "metafileInputCount": 542, + "rawBytes": 30094, + "gzipBytes": 6422, + "moduleCount": 33, + "metafileInputCount": 58, "workspaceModules": { - "@fluentui-react-native/design": 18, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/design": 15, + "@fluentui-react-native/framework-base": 17 }, "workspaceBytes": { - "@fluentui-react-native/design": 30289, - "@fluentui-react-native/framework-base": 3413 + "@fluentui-react-native/design": 28151, + "@fluentui-react-native/framework-base": 1903 + } + }, + { + "platform": "macos", + "scenario": "design-themed-root", + "rawBytes": 32059, + "gzipBytes": 7098, + "moduleCount": 37, + "metafileInputCount": 58, + "workspaceModules": { + "@fluentui-react-native/design": 19, + "@fluentui-react-native/framework-base": 17 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 30116, + "@fluentui-react-native/framework-base": 1903 } }, { "platform": "macos", "scenario": "design-color-lib", - "rawBytes": 827767, - "gzipBytes": 239469, - "moduleCount": 503, - "metroModuleCount": 501, - "metafileInputCount": 503, + "rawBytes": 6328, + "gzipBytes": 2794, + "moduleCount": 6, + "metafileInputCount": 7, "workspaceModules": { - "@fluentui-react-native/design": 6 + "@fluentui-react-native/design": 5 }, "workspaceBytes": { - "@fluentui-react-native/design": 7097 + "@fluentui-react-native/design": 6289 } }, { "platform": "macos", "scenario": "design-theme-state-legacy-source", - "rawBytes": 856701, - "gzipBytes": 244382, - "moduleCount": 543, - "metroModuleCount": 537, - "metafileInputCount": 545, + "rawBytes": 31750, + "gzipBytes": 6742, + "moduleCount": 34, + "metafileInputCount": 61, "workspaceModules": { - "@fluentui-react-native/design": 20, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/design": 16, + "@fluentui-react-native/framework-base": 17 }, "workspaceBytes": { - "@fluentui-react-native/design": 32361, - "@fluentui-react-native/framework-base": 3413 + "@fluentui-react-native/design": 29807, + "@fluentui-react-native/framework-base": 1903 } }, { "platform": "macos", "scenario": "design-legacy-theme-from-flex", - "rawBytes": 923158, - "gzipBytes": 257703, - "moduleCount": 563, - "metroModuleCount": 558, - "metafileInputCount": 566, + "rawBytes": 97709, + "gzipBytes": 19853, + "moduleCount": 57, + "metafileInputCount": 82, "workspaceModules": { - "@fluentui-react-native/design": 32, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/design": 28, + "@fluentui-react-native/framework-base": 20 }, "workspaceBytes": { - "@fluentui-react-native/design": 61746, - "@fluentui-react-native/framework-base": 4291 + "@fluentui-react-native/design": 58553, + "@fluentui-react-native/framework-base": 2469 } }, - { - "platform": "windows", - "scenario": "shell", - "rawBytes": 829655, - "gzipBytes": 237915, - "moduleCount": 502, - "metroModuleCount": 500, - "metafileInputCount": 502, - "workspaceModules": {}, - "workspaceBytes": {} - }, { "platform": "windows", "scenario": "components-button", - "rawBytes": 880864, - "gzipBytes": 250891, - "moduleCount": 560, - "metroModuleCount": 556, - "metafileInputCount": 764, + "rawBytes": 49400, + "gzipBytes": 13110, + "moduleCount": 63, + "metafileInputCount": 177, "workspaceModules": { - "@fluentui-react-native/components": 10, + "@fluentui-react-native/components": 16, "@fluentui-react-native/design": 22, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/framework-base": 24 }, "workspaceBytes": { - "@fluentui-react-native/components": 9480, - "@fluentui-react-native/design": 30142, - "@fluentui-react-native/framework-base": 11146 + "@fluentui-react-native/components": 8860, + "@fluentui-react-native/design": 28639, + "@fluentui-react-native/framework-base": 11865 } }, { "platform": "windows", "scenario": "components-catalog", - "rawBytes": 1103086, - "gzipBytes": 304267, - "moduleCount": 760, - "metroModuleCount": 755, - "metafileInputCount": 764, + "rawBytes": 153690, + "gzipBytes": 37969, + "moduleCount": 160, + "metafileInputCount": 177, "workspaceModules": { - "@fluentui-react-native/components": 98, + "@fluentui-react-native/components": 112, "@fluentui-react-native/design": 22, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/framework-base": 25 }, "workspaceBytes": { - "@fluentui-react-native/components": 122346, - "@fluentui-react-native/design": 30326, - "@fluentui-react-native/framework-base": 12813 + "@fluentui-react-native/components": 110315, + "@fluentui-react-native/design": 28734, + "@fluentui-react-native/framework-base": 14512 } }, { "platform": "windows", "scenario": "design-theme-state", - "rawBytes": 863667, - "gzipBytes": 245363, - "moduleCount": 547, - "metroModuleCount": 541, - "metafileInputCount": 548, + "rawBytes": 30018, + "gzipBytes": 6388, + "moduleCount": 34, + "metafileInputCount": 59, "workspaceModules": { - "@fluentui-react-native/design": 19, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/design": 16, + "@fluentui-react-native/framework-base": 17 + }, + "workspaceBytes": { + "@fluentui-react-native/design": 28075, + "@fluentui-react-native/framework-base": 1903 + } + }, + { + "platform": "windows", + "scenario": "design-themed-root", + "rawBytes": 31983, + "gzipBytes": 7065, + "moduleCount": 38, + "metafileInputCount": 59, + "workspaceModules": { + "@fluentui-react-native/design": 20, + "@fluentui-react-native/framework-base": 17 }, "workspaceBytes": { - "@fluentui-react-native/design": 30222, - "@fluentui-react-native/framework-base": 3413 + "@fluentui-react-native/design": 30039, + "@fluentui-react-native/framework-base": 1903 } }, { "platform": "windows", "scenario": "design-color-lib", - "rawBytes": 836902, - "gzipBytes": 240953, - "moduleCount": 508, - "metroModuleCount": 506, - "metafileInputCount": 508, + "rawBytes": 6328, + "gzipBytes": 2794, + "moduleCount": 6, + "metafileInputCount": 7, "workspaceModules": { - "@fluentui-react-native/design": 6 + "@fluentui-react-native/design": 5 }, "workspaceBytes": { - "@fluentui-react-native/design": 7097 + "@fluentui-react-native/design": 6289 } }, { "platform": "windows", "scenario": "design-theme-state-legacy-source", - "rawBytes": 865767, - "gzipBytes": 245825, - "moduleCount": 549, - "metroModuleCount": 543, - "metafileInputCount": 551, + "rawBytes": 31674, + "gzipBytes": 6712, + "moduleCount": 35, + "metafileInputCount": 62, "workspaceModules": { - "@fluentui-react-native/design": 21, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/design": 17, + "@fluentui-react-native/framework-base": 17 }, "workspaceBytes": { - "@fluentui-react-native/design": 32294, - "@fluentui-react-native/framework-base": 3413 + "@fluentui-react-native/design": 29731, + "@fluentui-react-native/framework-base": 1903 } }, { "platform": "windows", "scenario": "design-legacy-theme-from-flex", - "rawBytes": 932037, - "gzipBytes": 259016, - "moduleCount": 569, - "metroModuleCount": 564, - "metafileInputCount": 572, + "rawBytes": 97576, + "gzipBytes": 19774, + "moduleCount": 58, + "metafileInputCount": 83, "workspaceModules": { - "@fluentui-react-native/design": 33, - "@fluentui-react-native/framework-base": 26 + "@fluentui-react-native/design": 29, + "@fluentui-react-native/framework-base": 20 }, "workspaceBytes": { - "@fluentui-react-native/design": 61490, - "@fluentui-react-native/framework-base": 4291 + "@fluentui-react-native/design": 58420, + "@fluentui-react-native/framework-base": 2469 } } ] diff --git a/apps/bundle-size/metro.config.js b/apps/bundle-size/metro.config.js deleted file mode 100644 index 873ca98564..0000000000 --- a/apps/bundle-size/metro.config.js +++ /dev/null @@ -1,32 +0,0 @@ -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 index 3a456e4307..e2a0b888bf 100644 --- a/apps/bundle-size/package.json +++ b/apps/bundle-size/package.json @@ -2,14 +2,15 @@ "name": "@fluentui-react-native/bundle-size", "version": "0.0.0", "private": true, - "description": "Deterministic Metro consumer-bundle measurements", + "description": "Deterministic esbuild consumer-bundle measurements", "license": "MIT", "scripts": { "format": "fluentui-scripts format", "lint": "fluentui-scripts lint", "measure": "node scripts/measure.mjs", "test": "node --test scripts/*.test.mjs", - "update-baseline": "node scripts/measure.mjs --update-baseline" + "update-baseline": "node scripts/measure.mjs --update-baseline", + "validate-report": "node scripts/validate-report.mjs" }, "dependencies": { "@fluentui-react-native/components": "workspace:*", @@ -23,38 +24,13 @@ "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:", "@rnx-kit/tools-filesystem": "^0.2.0", "@rnx-kit/tools-formatting": "^0.1.0", - "metro": "^0.83.8", - "oxc-resolver": "catalog:" + "esbuild": "^0.28.2" }, "rnx-kit": { "kitType": "app", - "bundle": [ - { - "id": "measure", - "entryFile": "src/bootstrap.js", - "treeShake": true, - "targets": [ - "macos", - "win32", - "windows" - ] - } - ], "alignDeps": { "requirements": { "production": [ @@ -62,14 +38,10 @@ ] }, "capabilities": [ - "babel-preset-react-native", - "community/cli", "core", "core-macos", "core-win32", - "core-windows", - "core/metro-config", - "metro" + "core-windows" ] }, "extends": "@fluentui-react-native/scripts/kit-config" diff --git a/apps/bundle-size/scenarios.json b/apps/bundle-size/scenarios.json index d86c4dda25..bd5bb63949 100644 --- a/apps/bundle-size/scenarios.json +++ b/apps/bundle-size/scenarios.json @@ -1,9 +1,6 @@ { "platforms": ["macos", "windows"], "scenarios": [ - { - "name": "shell" - }, { "name": "components-button", "module": "@fluentui-react-native/components", diff --git a/apps/bundle-size/scripts/esbuild-config.mjs b/apps/bundle-size/scripts/esbuild-config.mjs new file mode 100644 index 0000000000..eb666e6621 --- /dev/null +++ b/apps/bundle-size/scripts/esbuild-config.mjs @@ -0,0 +1,43 @@ +const sourceExtensions = ['tsx', 'ts', 'jsx', 'js']; + +export const externalPackages = [ + 'react-native', + 'react', + '@svgo', + 'react-native-svg', + 'react-native-windows', + 'react-native-macos', + '@office-iss/react-native-win32', +]; + +export function getResolveExtensions(platform) { + return [ + ...sourceExtensions.map((extension) => `.${platform}.${extension}`), + ...sourceExtensions.map((extension) => `.native.${extension}`), + ...sourceExtensions.map((extension) => `.${extension}`), + '.mjs', + '.cjs', + '.json', + ]; +} + +export function createEsbuildOptions({ bundlePath, entryPath, platform, workspaceRoot }) { + return { + absWorkingDir: workspaceRoot, + bundle: true, + conditions: ['react-native'], + entryPoints: [entryPath], + external: externalPackages.flatMap((packageName) => [packageName, `${packageName}/*`]), + format: 'esm', + jsx: 'automatic', + legalComments: 'none', + mainFields: ['react-native', 'module', 'main'], + metafile: true, + minify: true, + outfile: bundlePath, + platform: 'neutral', + resolveExtensions: getResolveExtensions(platform), + sourcemap: 'external', + treeShaking: true, + }; +} diff --git a/apps/bundle-size/scripts/esbuild-config.test.mjs b/apps/bundle-size/scripts/esbuild-config.test.mjs new file mode 100644 index 0000000000..46cc6b286f --- /dev/null +++ b/apps/bundle-size/scripts/esbuild-config.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createEsbuildOptions, externalPackages, getResolveExtensions } from './esbuild-config.mjs'; + +describe('getResolveExtensions', () => { + it('prefers the requested platform, then native, then generic modules', () => { + assert.deepEqual(getResolveExtensions('windows'), [ + '.windows.tsx', + '.windows.ts', + '.windows.jsx', + '.windows.js', + '.native.tsx', + '.native.ts', + '.native.jsx', + '.native.js', + '.tsx', + '.ts', + '.jsx', + '.js', + '.mjs', + '.cjs', + '.json', + ]); + }); +}); + +describe('createEsbuildOptions', () => { + it('uses React Native package conditions and externalizes runtime packages and subpaths', () => { + const options = createEsbuildOptions({ + bundlePath: '/fixture/output.js', + entryPath: '/fixture/input.js', + platform: 'macos', + workspaceRoot: '/fixture', + }); + + assert.deepEqual(options.conditions, ['react-native']); + assert.deepEqual(options.mainFields, ['react-native', 'module', 'main']); + assert.deepEqual( + options.external, + externalPackages.flatMap((packageName) => [packageName, `${packageName}/*`]), + ); + }); +}); diff --git a/apps/bundle-size/scripts/format.mjs b/apps/bundle-size/scripts/format.mjs index 5ab029b6a1..2ac3a237e0 100644 --- a/apps/bundle-size/scripts/format.mjs +++ b/apps/bundle-size/scripts/format.mjs @@ -24,22 +24,57 @@ function formatSigned(value, formatter) { return `${sign}${formatter(Math.abs(value))}`; } +function formatWithDelta(value, delta, formatter, status) { + if (status === 'new') { + return `${formatter(value)} (New)`; + } + + const magnitude = formatter(Math.abs(delta)); + const integerDigits = magnitude.match(/^[\d,]+/)?.[0].replaceAll(',', '').length ?? 0; + const padding = integerDigits === 1 ? ' ' : ''; + return `${formatter(value)} ${padding}(${formatSigned(delta, formatter)})`; +} + +export function groupComparisonsByScenario(results) { + const scenarios = new Map(); + for (const { platform, scenario, comparison } of results) { + const platformResults = scenarios.get(scenario) ?? new Map(); + platformResults.set(platform, comparison); + scenarios.set(scenario, platformResults); + } + return scenarios; +} + +export function formatModuleComparison(comparison) { + return comparison + ? formatWithDelta(comparison.currentModuleCost, comparison.moduleCostDelta, integerFormatter.format, comparison.status) + : '-'; +} + +export function formatSizeComparison(comparison) { + return comparison ? formatWithDelta(comparison.currentCost, comparison.costDelta, formatSize, comparison.status) : '-'; +} + export function formatBundleSizeTable(results) { - const rows = results.map(({ platform, scenario, comparison }) => [ - `${platform}: ${scenario}`, - comparison.currentModuleCost, - comparison.status === 'new' ? 'New' : formatSigned(comparison.moduleCostDelta, integerFormatter.format), - formatSize(comparison.currentCost), - comparison.status === 'new' ? 'New' : formatSigned(comparison.costDelta, formatSize), - ]); + const rows = [...groupComparisonsByScenario(results)].map(([scenario, platformResults]) => { + const macos = platformResults.get('macos'); + const windows = platformResults.get('windows'); + return [ + scenario, + formatModuleComparison(macos), + formatModuleComparison(windows), + formatSizeComparison(macos), + formatSizeComparison(windows), + ]; + }); return formatAsTable(rows, { columns: [ - { label: 'Platform: scenario' }, - { label: 'New modules', align: 'right', format: integerFormatter.format }, - { label: 'Module delta', align: 'right' }, - { label: 'New size', align: 'right' }, - { label: 'Size delta', align: 'right' }, + { label: 'Scenario' }, + { label: 'Modules-Mac (Δ)', align: 'right' }, + { label: 'Modules-Win (Δ)', align: 'right' }, + { label: 'Size-Mac (Δ)', align: 'right' }, + { label: 'Size-Win (Δ)', align: 'right' }, ], }); } diff --git a/apps/bundle-size/scripts/format.test.mjs b/apps/bundle-size/scripts/format.test.mjs index 88f17003da..9512ce60db 100644 --- a/apps/bundle-size/scripts/format.test.mjs +++ b/apps/bundle-size/scripts/format.test.mjs @@ -18,15 +18,11 @@ describe('formatSize', () => { }); describe('formatBundleSizeTable', () => { - it('shows shell-relative costs and signed baseline deltas', () => { + it('shows bundle costs and signed baseline deltas', () => { const output = formatBundleSizeTable([ { platform: 'macos', scenario: 'components-button', - moduleCount: 1234, - rawBytes: 1_234_560, - deltaModules: 737, - deltaBytes: 414_038, comparison: { status: 'compared', currentModuleCost: 737, @@ -35,24 +31,9 @@ describe('formatBundleSizeTable', () => { costDelta: -999, }, }, - { - platform: 'win32', - scenario: 'shell', - moduleCount: 497, - rawBytes: 999, - comparison: { - status: 'new', - currentModuleCost: 497, - currentCost: 999, - }, - }, { platform: 'windows', - scenario: 'components-catalog', - moduleCount: 2000, - rawBytes: 2000, - deltaModules: 1503, - deltaBytes: 1000, + scenario: 'components-button', comparison: { status: 'compared', currentModuleCost: 1503, @@ -61,22 +42,27 @@ describe('formatBundleSizeTable', () => { costDelta: 1000, }, }, + { + platform: 'macos', + scenario: 'design-color-lib', + comparison: { + status: 'new', + currentModuleCost: 6, + currentCost: 999, + }, + }, ]); - assert.match(output, /Platform: scenario/); - assert.match(output, /New modules/); - assert.match(output, /macos: components-button/); - assert.match(output, /737/); - assert.match(output, /\+2/); - assert.match(output, /414\.04k/); + assert.match(output, /Scenario/); + assert.match(output, /Modules-Mac \(Δ\)/); + assert.match(output, /Modules-Win \(Δ\)/); + assert.match(output, /Size-Mac \(Δ\)/); + assert.match(output, /Size-Win \(Δ\)/); + assert.match( + output, + /components-button\s+│\s+737 {2}\(\+2\)\s+│\s+1,503 \(-12\)\s+│\s+414\.04k \(-999b\)\s+│\s+1\.00k {2}\(\+1\.00k\)/, + ); assert.doesNotMatch(output, /1,234\.56k/); - assert.match(output, /-999b/); - assert.match(output, /win32: shell/); - assert.match(output, /999b/); - assert.match(output, /New/); - assert.match(output, /windows: components-catalog/); - assert.match(output, /1,503/); - assert.match(output, /-12/); - assert.match(output, /\+1\.00k/); + assert.match(output, /design-color-lib\s+│\s+6 \(New\)\s+│\s+-\s+│\s+999b \(New\)\s+│\s+-/); }); }); diff --git a/apps/bundle-size/scripts/measure.mjs b/apps/bundle-size/scripts/measure.mjs index 533d1d1661..129ccac0b0 100644 --- a/apps/bundle-size/scripts/measure.mjs +++ b/apps/bundle-size/scripts/measure.mjs @@ -1,17 +1,16 @@ import { readJSONFileSync, writeJSONFileSync } from '@rnx-kit/tools-filesystem'; -import { spawnSync } from 'node:child_process'; +import { build } from 'esbuild'; 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'; -import { formatBundleSizeTable } from './format.mjs'; +import { createEsbuildOptions } from './esbuild-config.mjs'; +import { formatBundleSizeTable, formatModuleComparison, formatSizeComparison, groupComparisonsByScenario } from './format.mjs'; const workspaceRoot = dirname(dirname(fileURLToPath(import.meta.url))); const repositoryRoot = dirname(dirname(workspaceRoot)); -const yarnVersion = readJSONFileSync(join(repositoryRoot, 'package.json')).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'); @@ -61,10 +60,7 @@ function createEntry(scenario) { lines.push(`globalThis.__bundleSizeTarget = [${targets.join(', ')}];`); } - const bootstrapPath = relative(entryRoot, join(workspaceRoot, 'src', 'bootstrap.js')).replaceAll('\\', '/'); - lines.push(`import ${JSON.stringify(bootstrapPath.startsWith('.') ? bootstrapPath : `./${bootstrapPath}`)};`, ''); - - return lines.join('\n'); + return `${lines.join('\n')}\n`; } function getWorkspacePackage(source) { @@ -115,56 +111,19 @@ function getWorkspaceContributions(metafile) { }; } -function runBundle(platform, scenario, resetCache) { +async function runBundle(platform, scenario) { 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 { metafile } = await build(createEsbuildOptions({ bundlePath, entryPath, platform, workspaceRoot })); + writeJSONFileSync(metafilePath, metafile); const bundle = readFileSync(bundlePath); - const sourceMap = readJSONFileSync(sourceMapPath); - const metafile = readJSONFileSync(metafilePath); const inputPaths = Object.keys(metafile.inputs).map((source) => source.replaceAll('\\', '/')); for (const pattern of scenario.forbiddenInputPatterns ?? []) { const match = inputPaths.find((source) => source.includes(pattern)); @@ -184,7 +143,6 @@ function runBundle(platform, scenario, resetCache) { 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, @@ -193,15 +151,13 @@ function runBundle(platform, scenario, resetCache) { } function baselineResult(measurement) { - const { platform, scenario, rawBytes, gzipBytes, moduleCount, metroModuleCount, metafileInputCount, workspaceModules, workspaceBytes } = - measurement; + const { platform, scenario, rawBytes, gzipBytes, moduleCount, metafileInputCount, workspaceModules, workspaceBytes } = measurement; return { platform, scenario, rawBytes, gzipBytes, moduleCount, - metroModuleCount, metafileInputCount, workspaceModules, workspaceBytes, @@ -212,18 +168,14 @@ function resultKey({ platform, scenario }) { return `${platform}:${scenario}`; } -function createComparison(measurement, baseline, baselineShell) { - const isShell = measurement.scenario === 'shell'; - const currentCost = isShell ? measurement.rawBytes : measurement.deltaBytes; - const currentModuleCost = isShell ? measurement.moduleCount : measurement.deltaModules; - if (!baseline || (!isShell && !baselineShell)) { +function createComparison(measurement, baseline) { + const currentCost = measurement.rawBytes; + const currentModuleCost = measurement.moduleCount; + if (!baseline) { return { status: 'new', currentCost, currentModuleCost }; } - const baselineCost = isShell ? baseline.rawBytes : baseline.rawBytes - baselineShell.rawBytes; - 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 baselineCost = baseline.rawBytes; const costDelta = currentCost - baselineCost; return { status: 'compared', @@ -232,43 +184,27 @@ function createComparison(measurement, baseline, baselineShell) { currentModuleCost, costDelta, costPercent: baselineCost === 0 ? 0 : (costDelta / baselineCost) * 100, - gzipCostDelta: currentGzipCost - baselineGzipCost, - moduleCostDelta: currentModuleCost - baselineModuleCost, - absoluteRawDelta: measurement.rawBytes - baseline.rawBytes, + gzipCostDelta: measurement.gzipBytes - baseline.gzipBytes, + moduleCostDelta: currentModuleCost - baseline.moduleCount, }; } -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.', + 'Tree-shaken, minified production esbuild bundles with React and React Native runtimes externalized.', '', - '| Platform | Scenario | Baseline cost | Current cost | Cost delta | Change | Gzip delta | Module delta |', - '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |', + '| Scenario | Modules-Mac (Δ) | Modules-Win (Δ) | Size-Mac (Δ) | Size-Win (Δ) |', + '| --- | ---: | ---: | ---: | ---: |', ]; - for (const result of results) { - const { comparison } = result; - if (comparison.status === 'new') { - lines.push( - `| ${result.platform} | ${result.scenario} | New | ${(comparison.currentCost / 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} |`, - ); - } + for (const [scenario, platformResults] of groupComparisonsByScenario(results)) { + const macos = platformResults.get('macos'); + const windows = platformResults.get('windows'); + lines.push( + `| ${scenario} | ${formatModuleComparison(macos)} | ${formatModuleComparison(windows)} | ${formatSizeComparison(macos)} | ${formatSizeComparison(windows)} |`, + ); } lines.push( @@ -295,41 +231,33 @@ await mkdir(entryRoot, { recursive: true }); const measurements = []; for (const platform of platforms) { - for (const [scenarioIndex, scenario] of selectedConfig.scenarios.entries()) { + for (const scenario of selectedConfig.scenarios) { process.stdout.write(`Bundling ${scenario.name} for ${platform}...\n`); - measurements.push({ platform, ...runBundle(platform, scenario, scenarioIndex === 0) }); + measurements.push({ platform, ...(await runBundle(platform, scenario)) }); } } -const shells = new Map( - measurements.filter(({ scenario }) => scenario === 'shell').map((measurement) => [measurement.platform, measurement]), -); const currentBaseline = { - schemaVersion: 1, + schemaVersion: 2, results: measurements.map(baselineResult), }; if (updateBaseline) { writeJSONFileSync(selectedBaselinePath, currentBaseline); } -const baseline = existsSync(selectedBaselinePath) ? readJSONFileSync(selectedBaselinePath) : { schemaVersion: 1, results: [] }; -if (baseline.schemaVersion !== 1) { +const baseline = existsSync(selectedBaselinePath) ? readJSONFileSync(selectedBaselinePath) : { schemaVersion: 2, results: [] }; +if (baseline.schemaVersion !== 1 && baseline.schemaVersion !== 2) { 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])); +if (baseline.schemaVersion === 1) { + process.stderr.write('Baseline schema version 1 contains Metro shell-relative measurements; treating all esbuild scenarios as new.\n'); +} +const baselineResults = new Map(baseline.schemaVersion === 2 ? baseline.results.map((result) => [resultKey(result), result]) : []); const results = measurements.map((measurement) => { - const shell = shells.get(measurement.platform); - 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)), + ...measurement, + comparison: createComparison(measurement, baselineResults.get(resultKey(measurement))), }; }); const report = { diff --git a/apps/bundle-size/scripts/post-pr-comment.mjs b/apps/bundle-size/scripts/post-pr-comment.mjs index 31f5590cdf..c19c8d0c57 100644 --- a/apps/bundle-size/scripts/post-pr-comment.mjs +++ b/apps/bundle-size/scripts/post-pr-comment.mjs @@ -5,14 +5,16 @@ import { pathToFileURL } from 'node:url'; export const bundleSizeCommentMarker = ''; const reportHeader = '# Bundle size report'; -const reportDescription = - 'Tree-shaken production Metro bundles. Component costs are relative to their platform shell; shell costs are absolute.'; -const tableHeader = '| Platform | Scenario | Baseline cost | Current cost | Cost delta | Change | Gzip delta | Module delta |'; -const tableSeparator = '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |'; +const reportDescription = 'Tree-shaken, minified production esbuild bundles with React and React Native runtimes externalized.'; +const tableHeader = '| Scenario | Modules-Mac (Δ) | Modules-Win (Δ) | Size-Mac (Δ) | Size-Win (Δ) |'; +const tableSeparator = '| --- | ---: | ---: | ---: | ---: |'; const reportFooter = 'The job is advisory: size changes are reported but do not fail the pull request. Bundle or analysis errors still fail.'; -const tableRowPattern = - /^\| [A-Za-z0-9][A-Za-z0-9._/-]{0,79} \| [A-Za-z0-9][A-Za-z0-9._:/-]{0,79} \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+\.\d{2}%) \| (?:New|[+-]?\d+\.\d KiB) \| (?:New|[+-]?\d+) \|$/; +const moduleValuePattern = String.raw`(?:-|[0-9,]+ {1,2}\((?:New|[+-][0-9,]+)\))`; +const sizeValuePattern = String.raw`(?:-|(?:\d+b|[\d,]+\.\d{2}k) {1,2}\((?:New|[+-](?:\d+b|[\d,]+\.\d{2}k))\))`; +const tableRowPattern = new RegExp( + String.raw`^\| [A-Za-z0-9][A-Za-z0-9._:/-]{0,79} \| ${moduleValuePattern} \| ${moduleValuePattern} \| ${sizeValuePattern} \| ${sizeValuePattern} \|$`, +); const maximumReportBytes = 60_000; const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; diff --git a/apps/bundle-size/scripts/post-pr-comment.test.mjs b/apps/bundle-size/scripts/post-pr-comment.test.mjs index cf156aca71..7da1345125 100644 --- a/apps/bundle-size/scripts/post-pr-comment.test.mjs +++ b/apps/bundle-size/scripts/post-pr-comment.test.mjs @@ -11,12 +11,12 @@ import { const validReport = `# Bundle size report -Tree-shaken production Metro bundles. Component costs are relative to their platform shell; shell costs are absolute. +Tree-shaken, minified production esbuild bundles with React and React Native runtimes externalized. -| Platform | Scenario | Baseline cost | Current cost | Cost delta | Change | Gzip delta | Module delta | -| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| macos | shell | 801.3 KiB | 801.3 KiB | 0.0 KiB | 0.00% | 0.0 KiB | +0 | -| macos | components-button | New | 46.4 KiB | New | New | New | New | +| Scenario | Modules-Mac (Δ) | Modules-Win (Δ) | Size-Mac (Δ) | Size-Win (Δ) | +| --- | ---: | ---: | ---: | ---: | +| design-color-lib | 6 (+0) | 6 (+0) | 6.33k (+0b) | 6.33k (+0b) | +| components-button | 62 (New) | 63 (New) | 49.48k (New) | 49.40k (New) | The job is advisory: size changes are reported but do not fail the pull request. Bundle or analysis errors still fail. `; @@ -48,6 +48,8 @@ describe('createBundleSizeComment', () => { const comment = createBundleSizeComment(validReport, 'microsoft/fluentui-react-native', '123'); assert.match(comment, new RegExp(`^${bundleSizeCommentMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + assert.match(comment, /\| Scenario \| Modules-Mac \(Δ\) \| Modules-Win \(Δ\) \| Size-Mac \(Δ\) \| Size-Win \(Δ\) \|/); + assert.match(comment, /\| design-color-lib \| 6 {2}\(\+0\) \| 6 {2}\(\+0\) \| 6\.33k {2}\(\+0b\) \| 6\.33k {2}\(\+0b\) \|/); assert.match(comment, /https:\/\/github\.com\/microsoft\/fluentui-react-native\/actions\/runs\/123\)$/); }); diff --git a/apps/bundle-size/scripts/validate-report.mjs b/apps/bundle-size/scripts/validate-report.mjs new file mode 100644 index 0000000000..24e9b456a7 --- /dev/null +++ b/apps/bundle-size/scripts/validate-report.mjs @@ -0,0 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { validateBundleSizeReport } from './post-pr-comment.mjs'; + +const workspaceRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const reportPath = process.argv[2] ? resolve(process.argv[2]) : join(workspaceRoot, 'dist', 'bundle-size', 'report.md'); + +validateBundleSizeReport(await readFile(reportPath, 'utf8')); +process.stdout.write(`Validated bundle-size report: ${reportPath}\n`); diff --git a/apps/bundle-size/src/bootstrap.js b/apps/bundle-size/src/bootstrap.js deleted file mode 100644 index bb2235ff78..0000000000 --- a/apps/bundle-size/src/bootstrap.js +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { AppRegistry, View } from 'react-native'; - -function BundleSizeApp() { - return React.createElement(View); -} - -AppRegistry.registerComponent('BundleSize', () => BundleSizeApp); diff --git a/yarn.lock b/yarn.lock index ca2e8eeaf6..ba450260e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2749,27 +2749,14 @@ __metadata: 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/design": "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:" "@rnx-kit/tools-filesystem": "npm:^0.2.0" "@rnx-kit/tools-formatting": "npm:^0.1.0" "@types/react": "npm:~19.1.4" - metro: "npm:^0.83.8" - oxc-resolver: "catalog:" + esbuild: "npm:^0.28.2" react: "npm:19.1.4" react-native: "npm:^0.81.6" react-native-macos: "npm:^0.81.0" @@ -14919,7 +14906,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0, esbuild@npm:^0.28.0, esbuild@npm:^0.28.1": +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0, esbuild@npm:^0.28.0, esbuild@npm:^0.28.1, esbuild@npm:^0.28.2": version: 0.28.2 resolution: "esbuild@npm:0.28.2" dependencies: