Skip to content
Open
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
104 changes: 104 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Performance

# On-demand check, independent of the regular CI checks: it never runs on its
# own. Trigger it by adding the `perf` label to a PR, or from the Actions tab
# (optionally with a PR number). Head and base are benchmarked side by side on
# the same runner and the report is written to the job summary.
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
pr_number:
description: PR to benchmark against its base (leave empty to benchmark the selected branch)
required: false
base_ref:
description: Ref to compare the selected branch against (ignored when a PR number is given)
default: main

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: true

jobs:
benchmark:
name: Bundle size, time & memory
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'perf'
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
# Base and head are checked out side by side with identical path depth,
# so neither sits inside the other's `node_modules` resolution chain.
- name: Checkout head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
with:
path: head
fetch-depth: 0
ref: ${{ inputs.pr_number && format('refs/pull/{0}/merge', inputs.pr_number) || '' }}

# For a PR, head is its merge commit and base is that commit's first
# parent, so exactly the PR's changes are measured.
- name: Resolve base
id: base
working-directory: head
env:
IS_PR: ${{ github.event_name == 'pull_request' || inputs.pr_number != '' }}
BASE_REF: ${{ inputs.base_ref || 'main' }}
run: |
if [ "$IS_PR" = "true" ]; then
sha=$(git rev-parse HEAD^1)
else
git fetch origin "$BASE_REF"
sha=$(git merge-base HEAD FETCH_HEAD)
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"

- name: Checkout base
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
with:
path: base
ref: ${{ steps.base.outputs.sha }}

- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # tag=v4.1.0
with:
package_json_file: head/package.json

- name: Use Node.js 24
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # tag=v4.4.0
with:
node-version: '24'
cache: pnpm
cache-dependency-path: |
head/pnpm-lock.yaml
base/pnpm-lock.yaml

- name: Install dependencies (head)
working-directory: head
run: pnpm install --frozen-lockfile

- name: Install dependencies (base)
working-directory: base
run: pnpm install --frozen-lockfile

# Builds both checkouts, then runs head and base builds interleaved.
# Fails on a 🔴 regression; it is not a required check.
- name: Run benchmarks
run: >
node head/tests/perf/src/bench.mjs
--head head
--base base
--runs 5
--out perf-results

- name: Upload results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2
with:
name: perf-results
path: |
perf-results/results.json
perf-results/report.md
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,30 @@ Re.Pack is a toolkit for building and developing React Native applications with
- `apps/tester-app/`: Primary manual/integration validation app.
- `apps/tester-federation/`: Module Federation v1 validation app.
- `apps/tester-federation-v2/`: Module Federation v2 validation app.
- `apps/bench-expo/`, `apps/bench-bare/`: Neutral apps for benchmarking Re.Pack
against Metro (`tests/perf/src/competitors.mjs`).

### Tests

- `tests/integration/`: Integration-level automated coverage.
- `tests/metro-compat/`: Metro compatibility behavior coverage.
- `tests/resolver-cases/`: Resolver behavior and edge-case coverage.
- `tests/perf/`: Bundle size, build time and memory benchmarks (CI: `perf.yml`).

### Performance

Changes to bundling code (`packages/repack/src/{loaders,plugins,modules,commands}`,
plugin packages, bundler config defaults) can regress bundle size, build time
or memory. Before opening a PR for such a change, run
`pnpm bench --base-ref origin/main` (narrow with `--fixtures rspack/ios --runs 3`
while iterating) and read `tests/perf/results/report.md`:

- 🔴 must be fixed, or justified in the PR description if intended.
- 🟡 should be explained in the PR description.
- Treat "made the build output nondeterministic" as a bug.

The same check runs in CI only on demand (add the `perf` label to a PR, or run
the `Performance` workflow manually); it is not part of the regular CI checks.

## Agent Context

Expand Down
1 change: 1 addition & 0 deletions agent_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ works, and is kept in sync with the implementation as it evolves.
| Folder | Topic |
| --- | --- |
| [rspackv2-jul2026](./rspackv2-jul2026/design.md) | Dual Rspack 1.x/2.x support |
| [perf-regressions-oct2026](./perf-regressions-oct2026/design.md) | Bundle size, build time and memory regression guards |
115 changes: 115 additions & 0 deletions agent_context/perf-regressions-oct2026/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Performance Regression Guards — Technical Design

A change can be benchmarked against its base for bundle size, build time and
peak memory, so changes (including AI-generated ones) don't silently regress
performance. It runs locally (`pnpm bench --base-ref origin/main`) or as an
on-demand CI check that is independent of the regular CI checks
(`.github/workflows/perf.yml`, triggered by a `perf` PR label or manually).
Whether it should eventually run on every PR depends on how fast it turns out
to be in CI. Implementation: `tests/perf/`. Usage is in `tests/perf/README.md`.

## Prior art (surveyed Oct 2026)

- **No React Native bundler gates on performance publicly.** Metro, Expo CLI,
rnx-kit and the Vite-for-RN efforts have no perf CI.
- **webpack** (closest to Re.Pack: JS plugins/loaders) runs tinybench +
CodSpeed walltime and memory modes on PRs
(`.github/workflows/benchmarks.yml`), deterministic V8 flags, and a separate
output-size job comparing against a main baseline artifact (`code-size.yml`).
- **Rspack** uses CodSpeed simulation for Rust micro-benchmarks, plus an
ecosystem benchmark (`web-infra-dev/rspack-ecosystem-benchmark`) on a
dedicated runner that flags ≥5% time and ≥30% memory (RSS is noisy), and a
binary `size-limit` check.
- **Rolldown, Turbopack, Next.js, Parcel**: CodSpeed or
`github-action-benchmark` with loose thresholds (e.g. 110%), PR comments, and
rarely failing checks — except on size.

The common pattern, adopted here: **hard-fail on deterministic metrics (size),
use loose thresholds with noise controls for time and memory.**

## Design

- **Fixtures** are real apps (`apps/tester-app` with rspack and webpack, ios
and android) bundled through the public `bundle` command, so loaders
(`babel-swc-loader`, assets), `RepackPlugin` and the NativeWind/Reanimated
plugins are all exercised. Defined in `tests/perf/src/config.mjs`.
- **Same-job A/B**: base and head are built and measured on the same machine,
with runs interleaved and order alternated. No stored baselines, no external
service, so it works for fork PRs and locally (`--base-ref`).
- **Process isolation**: each build runs in a fresh process
(`build-once.mjs`) that resolves `@callstack/repack` from the measured
checkout, so one harness can drive any checkout.
- **Memory**: `process.resourceUsage().maxRSS` (exact, includes threads) plus a
`ps`-sampled process-tree peak for webpack's worker processes.
- **Classification** (`report.mjs`): size metrics fail at >1% (warn on any
change); sampled metrics fail only when the median change exceeds the
threshold *and* head/base samples don't overlap. Memory also has an absolute
`failDelta` because Rspack's multi-GB peak hides large leaks in relative terms.

## Pitfalls found while building it

- **Checkout location matters.** A base worktree under `node_modules/` made
`babel-swc-loader` treat app code as third-party (`/node_modules/` checks),
changing output and slowing webpack builds ~60%. A symlinked root (macOS
`/var` → `/private/var`) made asset and chunk names absolute. Base worktrees
live in `realpath(os.tmpdir())`; CI checks out `head/` and `base/` side by
side.
- **Source maps embed absolute paths**, so their size depends on checkout path
length; the repo root is replaced before measuring.
- **webpack output is nondeterministic** with `thread-loader` +
`babel-swc-loader`: the same React Native files get different
`@babel/runtime` helpers (e.g. `classPrivateFieldLoose*` vs `readOnlyError`)
depending on the worker, changing module counts and bundle bytes by a few
KB per run. The harness detects per-metric variation across runs and falls
back to sampled comparison for those metrics. Rspack output is
byte-identical across runs and checkouts.
- **Rspack peak RSS is ~4.5 GB** for tester-app on a 14-core machine (webpack:
~1.2 GB main process), most likely from parallel loader workers scaling with
CPU count.

## Competitor benchmark

`tests/perf/src/competitors.mjs` compares Re.Pack with other bundlers at 0 /
1,000 / 3,000 generated screens (`tests/perf/src/generate-screens.mjs`):

- `apps/bench-expo`: Metro via Expo CLI (optionally with Expo's experimental
tree shaking) vs Re.Pack with `repack-expo`.
- `apps/bench-bare`: Metro via the React Native CLI vs Re.Pack with the
`repack-init` template config.
- Planned: One (Vite native mode), pending a feasibility spike.

Expo app results from Oct 2026 (M4 Pro, iOS):

- **Re.Pack ahead:** JS 16–17% smaller, Hermes bytecode 12–13% smaller, ~3×
less CPU per build, edit-to-bundle 1.5–2.5× faster, dev server memory 37–62%
lower.
- **Metro ahead:**
- Cold builds are 1.3–1.9× faster in wall time (Metro keeps ~8–9 cores busy,
Re.Pack ~1.7).
- Warm builds are 2.4–3.6× faster.
- Dev server start is 1.3–2.0× faster.
- Peak memory is lower at 3,000 screens.

Pitfalls:

- Metro's `--reset-cache` only resets once, so the harness deletes Metro's
temp-dir caches.
- Metro in `CI=1` mode doesn't watch files, so dev servers run without it.
- In a monorepo, Expo serves `/.expo/.virtual-metro-entry.bundle`.
- Bare Metro needs `watchFolders` for pnpm's root `node_modules`; watching the
whole repo made cold builds ~70% slower (8.7 s vs 5.1 s), which would be
unfair to Metro.
- Early smoke runs: on the bare app Re.Pack builds faster than Metro (3.0 s vs
6.8 s cold at 100 screens) and starts its dev server faster (1.6 s vs 5.2 s),
the opposite of the Expo app. The Expo integration's overhead is a lead
for performance work.

## Not covered yet

- Dev server startup, rebuild latency and development-mode bundles in the
per-PR gate (only the competitor benchmark measures them).
- Module Federation testers.
- Instruction-count benchmarks (CodSpeed simulation) for low-noise timing of
JS hot paths; needs CodSpeed set up for the org.
- PR comments (the report is in the job summary; comments from fork PRs need a
`workflow_run` follow-up job).
3 changes: 3 additions & 0 deletions apps/bench-bare/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build/
src/generated/screens/
src/generated/components/
4 changes: 4 additions & 0 deletions apps/bench-bare/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "BenchBare",
"displayName": "BenchBare"
}
Binary file added apps/bench-bare/assets/repack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/bench-bare/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: ['@react-native/babel-preset'],
};
5 changes: 5 additions & 0 deletions apps/bench-bare/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AppRegistry } from 'react-native';
import { name as appName } from './app.json';
import App from './src/App';

AppRegistry.registerComponent(appName, () => App);
9 changes: 9 additions & 0 deletions apps/bench-bare/metro.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const path = require('node:path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

// React Native's default Metro config. The only addition lets Metro see the
// monorepo's root node_modules, where pnpm keeps the actual package files;
// watching the whole repo would make Metro crawl every other app too.
module.exports = mergeConfig(getDefaultConfig(__dirname), {
watchFolders: [path.resolve(__dirname, '../../node_modules')],
});
32 changes: 32 additions & 0 deletions apps/bench-bare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "bench-bare",
"version": "0.0.1",
"private": true,
"description": "Neutral bare React Native app for benchmarking Re.Pack against plain Metro",
"scripts": {
"start": "react-native start",
"start:metro": "BENCH_BUNDLER=metro react-native start",
"bundle:ios": "react-native bundle --platform ios --dev false --entry-file index.js --bundle-output build/repack/main.jsbundle",
"bundle:ios:metro": "BENCH_BUNDLER=metro react-native bundle --platform ios --dev false --entry-file index.js --bundle-output build/metro/main.jsbundle --assets-dest build/metro",
"generate": "node ../../tests/perf/src/generate-screens.mjs bare .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "catalog:",
"react-native": "catalog:"
},
"devDependencies": {
"@babel/core": "catalog:",
"@callstack/repack": "workspace:*",
"@react-native-community/cli": "catalog:testers",
"@react-native-community/cli-platform-android": "catalog:testers",
"@react-native-community/cli-platform-ios": "catalog:testers",
"@react-native/babel-preset": "catalog:testers",
"@react-native/metro-config": "catalog:testers",
"@react-native/typescript-config": "catalog:testers",
"@rspack/core": "catalog:",
"@swc/helpers": "catalog:",
"@types/react": "catalog:testers",
"typescript": "catalog:"
}
}
8 changes: 8 additions & 0 deletions apps/bench-bare/react-native.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// BENCH_BUNDLER=metro keeps the CLI's built-in Metro commands, so the same app
// can be bundled by both Metro and Re.Pack.
module.exports = {
commands:
process.env.BENCH_BUNDLER === 'metro'
? []
: require('@callstack/repack/commands'),
};
47 changes: 47 additions & 0 deletions apps/bench-bare/rspack.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import path from 'node:path';
import * as Repack from '@callstack/repack';

const dirname = Repack.getDirname(import.meta.url);

// The config `@callstack/repack-init` installs (templates/rspack.config.mjs),
// plus an output path for the benchmark and its opt-in persistent cache variant.
export default Repack.defineRspackConfig((env) => ({
context: dirname,
entry: './index.js',
resolve: {
...Repack.getResolveOptions({ enablePackageExports: true }),
},
output: {
path:
process.env.BENCH_OUTPUT_DIR ??
path.join(dirname, 'build', 'rspack', env.platform),
},
module: {
rules: [
{
test: /\.[cm]?[jt]sx?$/,
type: 'javascript/auto',
use: {
loader: '@callstack/repack/babel-swc-loader',
parallel: true,
options: {},
},
},
...Repack.getAssetTransformRules(),
],
},
plugins: [new Repack.RepackPlugin()],
// Metro caches transforms by default; this is the equivalent opt-in.
...(process.env.BENCH_RSPACK_CACHE && {
cache: true,
experiments: {
cache: {
type: 'persistent',
storage: {
type: 'filesystem',
directory: path.join(dirname, 'node_modules/.cache/rspack'),
},
},
},
}),
}));
Loading