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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions apps/bundle-size/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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 <scenario>.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.

## 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.
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.
13 changes: 13 additions & 0 deletions apps/bundle-size/babel.config.js
Original file line number Diff line number Diff line change
@@ -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' }]],
};
152 changes: 152 additions & 0 deletions apps/bundle-size/baseline.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
32 changes: 32 additions & 0 deletions apps/bundle-size/metro.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
});
73 changes: 73 additions & 0 deletions apps/bundle-size/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"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",
"update-baseline": "node scripts/measure.mjs --update-baseline"
},
"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"
}
}
Loading
Loading