Skip to content

Commit 3c9fa0d

Browse files
authored
Infrastructure: add bundle size checks and pipeline with warnings (#4284)
* add bundle size package * add bundle size pr pipeline
1 parent cfef63e commit 3c9fa0d

11 files changed

Lines changed: 753 additions & 0 deletions

File tree

.github/workflows/pr.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,53 @@ jobs:
3434
- name: Build CI
3535
run: yarn lage buildci
3636

37+
bundle-size:
38+
name: Bundle Size PR
39+
runs-on: windows-latest
40+
timeout-minutes: 30
41+
steps:
42+
- name: Checkout
43+
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
44+
with:
45+
fetch-depth: 0
46+
47+
- name: Set up toolchain
48+
uses: microsoft/react-native-test-app/.github/actions/setup-toolchain@1230975885a9e5d3a2ad58b1483f80b09cec04f2 # 5.2.3
49+
with:
50+
node-version: 22
51+
52+
- name: Install dependencies
53+
run: yarn
54+
55+
- name: Read base branch bundle baseline
56+
shell: powershell
57+
env:
58+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
59+
run: |
60+
$baseline = "$env:RUNNER_TEMP\bundle-size-baseline.json"
61+
$baselinePath = 'apps/bundle-size/baseline.json'
62+
$baselineExists = git ls-tree --name-only $env:BASE_SHA -- $baselinePath
63+
if ($baselineExists) {
64+
git show "$env:BASE_SHA`:apps/bundle-size/baseline.json" | Out-File -FilePath $baseline -Encoding utf8
65+
} else {
66+
Copy-Item $baselinePath $baseline
67+
}
68+
69+
- name: Measure bundle sizes
70+
run: yarn bundle-size --baseline "$env:RUNNER_TEMP\bundle-size-baseline.json"
71+
72+
- name: Publish bundle size summary
73+
run: Get-Content apps\bundle-size\dist\bundle-size\report.md -Raw | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
74+
75+
- name: Upload bundle analysis
76+
if: ${{ always() }}
77+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
78+
with:
79+
name: bundle-size-analysis
80+
path: apps/bundle-size/dist/bundle-size
81+
if-no-files-found: warn
82+
retention-days: 14
83+
3784
android:
3885
name: Android PR
3986
runs-on: ubuntu-latest

apps/bundle-size/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Bundle-size fixture
2+
3+
This private app measures minified, tree-shaken Metro consumer bundles without including a test
4+
app or Storybook catalog. It uses `@rnx-kit/metro-config`,
5+
`@rnx-kit/metro-resolver-symlinks` with `oxc-resolver`, and
6+
`@rnx-kit/metro-serializer-esbuild`. Each target scenario is compared with the same React Native
7+
shell on macOS, Win32, and Windows.
8+
9+
Run every configured scenario from the repository root:
10+
11+
```sh
12+
yarn bundle-size
13+
```
14+
15+
Limit a local run to one or more platforms:
16+
17+
```sh
18+
yarn bundle-size --platform windows
19+
yarn bundle-size --platform macos --platform win32
20+
```
21+
22+
Results, source maps, and esbuild metafiles are written to the ignored `dist/bundle-size`
23+
directory. The runner passes `--tree-shake true` and `--metafile <scenario>.meta.json` to
24+
`rnx-cli bundle`. The JSON report contains raw bytes, gzip bytes, contributing esbuild input
25+
counts, Metro source-map counts, workspace package contribution bytes, and shell deltas. Raw
26+
bytes are the primary comparison; gzip and module attribution are diagnostic signals.
27+
28+
The Babel configuration preserves ESM for the serializer while explicitly lowering JSX. The
29+
explicit JSX transform is required because the desktop React Native packages publish JSX in
30+
`.js` files, which esbuild otherwise parses with its plain JavaScript loader.
31+
32+
## Baselines and pull requests
33+
34+
`baseline.json` is the reviewed comparison point for pull requests. Ordinary measurement writes
35+
`dist/bundle-size/report.md` with advisory deltas and never changes the baseline. Update it only
36+
for an intentional bundle change:
37+
38+
```sh
39+
yarn bundle-size:update
40+
```
41+
42+
Review the baseline diff together with the implementation that caused it. The PR workflow adds
43+
the Markdown comparison to its job summary and uploads the complete `dist/bundle-size` directory,
44+
including the esbuild metafiles, for investigation.
45+
46+
## Adding a package or submodule
47+
48+
1. Add the package to this fixture's dependencies so the pnpm linker exposes it to Metro.
49+
2. Add a scenario to `scenarios.json` with a stable name, module specifier, and either `exports`
50+
or `namespace: true`.
51+
3. Run `yarn bundle-size` and inspect the shell delta and generated esbuild metafile. The
52+
metafile can also be loaded into the esbuild bundle analyzer.
53+
54+
Use a supported public package or subpath export when measuring consumer cost. A source-relative
55+
module path is useful for diagnostics, but it does not prove the cost of the published API.

apps/bundle-size/babel.config.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const env = process.env.BABEL_ENV || process.env.NODE_ENV;
2+
3+
module.exports = {
4+
presets: [
5+
[
6+
'module:@react-native/babel-preset',
7+
{
8+
disableImportExportTransform: env === 'production' && Boolean(process.env.RNX_METRO_SERIALIZER_ESBUILD),
9+
},
10+
],
11+
],
12+
plugins: [['@babel/plugin-transform-react-jsx', { runtime: 'automatic' }]],
13+
};

apps/bundle-size/baseline.json

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
{
2+
"schemaVersion": 1,
3+
"results": [
4+
{
5+
"platform": "macos",
6+
"scenario": "shell",
7+
"rawBytes": 820522,
8+
"gzipBytes": 236436,
9+
"moduleCount": 497,
10+
"metroModuleCount": 495,
11+
"metafileInputCount": 497,
12+
"workspaceModules": {},
13+
"workspaceBytes": {}
14+
},
15+
{
16+
"platform": "macos",
17+
"scenario": "components-button",
18+
"rawBytes": 1088756,
19+
"gzipBytes": 301160,
20+
"moduleCount": 753,
21+
"metroModuleCount": 748,
22+
"metafileInputCount": 753,
23+
"workspaceModules": {
24+
"@fluentui-react-native/components": 98,
25+
"@fluentui-react-native/design": 20,
26+
"@fluentui-react-native/framework-base": 26
27+
},
28+
"workspaceBytes": {
29+
"@fluentui-react-native/components": 120566,
30+
"@fluentui-react-native/design": 26777,
31+
"@fluentui-react-native/framework-base": 12813
32+
}
33+
},
34+
{
35+
"platform": "macos",
36+
"scenario": "components-catalog",
37+
"rawBytes": 1090777,
38+
"gzipBytes": 301702,
39+
"moduleCount": 753,
40+
"metroModuleCount": 748,
41+
"metafileInputCount": 753,
42+
"workspaceModules": {
43+
"@fluentui-react-native/components": 98,
44+
"@fluentui-react-native/design": 20,
45+
"@fluentui-react-native/framework-base": 26
46+
},
47+
"workspaceBytes": {
48+
"@fluentui-react-native/components": 122538,
49+
"@fluentui-react-native/design": 26777,
50+
"@fluentui-react-native/framework-base": 12813
51+
}
52+
},
53+
{
54+
"platform": "win32",
55+
"scenario": "shell",
56+
"rawBytes": 825849,
57+
"gzipBytes": 236958,
58+
"moduleCount": 497,
59+
"metroModuleCount": 495,
60+
"metafileInputCount": 497,
61+
"workspaceModules": {},
62+
"workspaceBytes": {}
63+
},
64+
{
65+
"platform": "win32",
66+
"scenario": "components-button",
67+
"rawBytes": 1094123,
68+
"gzipBytes": 301643,
69+
"moduleCount": 754,
70+
"metroModuleCount": 749,
71+
"metafileInputCount": 754,
72+
"workspaceModules": {
73+
"@fluentui-react-native/components": 98,
74+
"@fluentui-react-native/design": 21,
75+
"@fluentui-react-native/framework-base": 26
76+
},
77+
"workspaceBytes": {
78+
"@fluentui-react-native/components": 120582,
79+
"@fluentui-react-native/design": 26814,
80+
"@fluentui-react-native/framework-base": 12813
81+
}
82+
},
83+
{
84+
"platform": "win32",
85+
"scenario": "components-catalog",
86+
"rawBytes": 1096141,
87+
"gzipBytes": 302170,
88+
"moduleCount": 754,
89+
"metroModuleCount": 749,
90+
"metafileInputCount": 754,
91+
"workspaceModules": {
92+
"@fluentui-react-native/components": 98,
93+
"@fluentui-react-native/design": 21,
94+
"@fluentui-react-native/framework-base": 26
95+
},
96+
"workspaceBytes": {
97+
"@fluentui-react-native/components": 122554,
98+
"@fluentui-react-native/design": 26814,
99+
"@fluentui-react-native/framework-base": 12813
100+
}
101+
},
102+
{
103+
"platform": "windows",
104+
"scenario": "shell",
105+
"rawBytes": 829655,
106+
"gzipBytes": 237915,
107+
"moduleCount": 502,
108+
"metroModuleCount": 500,
109+
"metafileInputCount": 502,
110+
"workspaceModules": {},
111+
"workspaceBytes": {}
112+
},
113+
{
114+
"platform": "windows",
115+
"scenario": "components-button",
116+
"rawBytes": 1097727,
117+
"gzipBytes": 302564,
118+
"moduleCount": 759,
119+
"metroModuleCount": 754,
120+
"metafileInputCount": 759,
121+
"workspaceModules": {
122+
"@fluentui-react-native/components": 98,
123+
"@fluentui-react-native/design": 21,
124+
"@fluentui-react-native/framework-base": 26
125+
},
126+
"workspaceBytes": {
127+
"@fluentui-react-native/components": 120529,
128+
"@fluentui-react-native/design": 26849,
129+
"@fluentui-react-native/framework-base": 12813
130+
}
131+
},
132+
{
133+
"platform": "windows",
134+
"scenario": "components-catalog",
135+
"rawBytes": 1099748,
136+
"gzipBytes": 303151,
137+
"moduleCount": 759,
138+
"metroModuleCount": 754,
139+
"metafileInputCount": 759,
140+
"workspaceModules": {
141+
"@fluentui-react-native/components": 98,
142+
"@fluentui-react-native/design": 21,
143+
"@fluentui-react-native/framework-base": 26
144+
},
145+
"workspaceBytes": {
146+
"@fluentui-react-native/components": 122501,
147+
"@fluentui-react-native/design": 26849,
148+
"@fluentui-react-native/framework-base": 12813
149+
}
150+
}
151+
]
152+
}

apps/bundle-size/metro.config.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const { makeMetroConfig } = require('@rnx-kit/metro-config');
2+
const MetroSymlinksResolver = require('@rnx-kit/metro-resolver-symlinks');
3+
4+
const symlinkResolver = MetroSymlinksResolver({
5+
resolver: 'oxc-resolver',
6+
});
7+
8+
function resolvePlatformModule(moduleName, platform) {
9+
if (platform !== 'win32') {
10+
return moduleName;
11+
}
12+
13+
if (moduleName === 'react-native') {
14+
return '@office-iss/react-native-win32';
15+
}
16+
17+
if (moduleName.startsWith('react-native/')) {
18+
return `@office-iss/react-native-win32/${moduleName.slice('react-native/'.length)}`;
19+
}
20+
21+
return moduleName;
22+
}
23+
24+
module.exports = makeMetroConfig({
25+
resolver: {
26+
resolveRequest: (context, moduleName, platform) => symlinkResolver(context, resolvePlatformModule(moduleName, platform), platform),
27+
unstable_enablePackageExports: true,
28+
unstable_conditionNames: ['react-native', 'import', 'require'],
29+
disableHierarchicalLookup: true,
30+
enableSymlinks: true,
31+
},
32+
});

apps/bundle-size/package.json

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"name": "@fluentui-react-native/bundle-size",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Deterministic Metro consumer-bundle measurements",
6+
"license": "MIT",
7+
"scripts": {
8+
"format": "fluentui-scripts format",
9+
"lint": "fluentui-scripts lint",
10+
"measure": "node scripts/measure.mjs",
11+
"update-baseline": "node scripts/measure.mjs --update-baseline"
12+
},
13+
"dependencies": {
14+
"@fluentui-react-native/components": "workspace:*",
15+
"@office-iss/react-native-win32": "^0.81.0",
16+
"@types/react": "~19.1.4",
17+
"react": "19.1.4",
18+
"react-native": "^0.81.6",
19+
"react-native-macos": "^0.81.0",
20+
"react-native-svg": "^15.12.1",
21+
"react-native-windows": "^0.81.0"
22+
},
23+
"devDependencies": {
24+
"@babel/core": "catalog:",
25+
"@babel/plugin-transform-react-jsx": "catalog:",
26+
"@fluentui-react-native/scripts": "workspace:*",
27+
"@react-native-community/cli": "^20.0.0",
28+
"@react-native-community/cli-platform-android": "^20.0.0",
29+
"@react-native-community/cli-platform-ios": "^20.0.0",
30+
"@react-native-windows/cli": "^0.81.0",
31+
"@react-native/babel-preset": "^0.81.0",
32+
"@react-native/metro-config": "^0.81.0",
33+
"@rnx-kit/cli": "catalog:",
34+
"@rnx-kit/metro-config": "catalog:",
35+
"@rnx-kit/metro-resolver-symlinks": "catalog:",
36+
"@rnx-kit/metro-serializer-esbuild": "catalog:",
37+
"metro": "^0.83.1",
38+
"oxc-resolver": "catalog:"
39+
},
40+
"rnx-kit": {
41+
"kitType": "app",
42+
"bundle": [
43+
{
44+
"id": "measure",
45+
"entryFile": "src/bootstrap.js",
46+
"treeShake": true,
47+
"targets": [
48+
"macos",
49+
"win32",
50+
"windows"
51+
]
52+
}
53+
],
54+
"alignDeps": {
55+
"requirements": {
56+
"production": [
57+
"react-native@0.81"
58+
]
59+
},
60+
"capabilities": [
61+
"babel-preset-react-native",
62+
"community/cli",
63+
"core",
64+
"core-macos",
65+
"core-win32",
66+
"core-windows",
67+
"core/metro-config",
68+
"metro"
69+
]
70+
},
71+
"extends": "@fluentui-react-native/scripts/kit-config"
72+
}
73+
}

0 commit comments

Comments
 (0)