diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000..ef0e92ea4 --- /dev/null +++ b/.github/workflows/perf.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index afc7276ab..7a51b40f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/agent_context/README.md b/agent_context/README.md index 35109711a..c10cbc0f3 100644 --- a/agent_context/README.md +++ b/agent_context/README.md @@ -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 | diff --git a/agent_context/perf-regressions-oct2026/design.md b/agent_context/perf-regressions-oct2026/design.md new file mode 100644 index 000000000..ec9bf9875 --- /dev/null +++ b/agent_context/perf-regressions-oct2026/design.md @@ -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). diff --git a/apps/bench-bare/.gitignore b/apps/bench-bare/.gitignore new file mode 100644 index 000000000..0d26bb65c --- /dev/null +++ b/apps/bench-bare/.gitignore @@ -0,0 +1,3 @@ +build/ +src/generated/screens/ +src/generated/components/ diff --git a/apps/bench-bare/app.json b/apps/bench-bare/app.json new file mode 100644 index 000000000..ceca6d27d --- /dev/null +++ b/apps/bench-bare/app.json @@ -0,0 +1,4 @@ +{ + "name": "BenchBare", + "displayName": "BenchBare" +} diff --git a/apps/bench-bare/assets/repack.png b/apps/bench-bare/assets/repack.png new file mode 100644 index 000000000..0a4d65372 Binary files /dev/null and b/apps/bench-bare/assets/repack.png differ diff --git a/apps/bench-bare/babel.config.js b/apps/bench-bare/babel.config.js new file mode 100644 index 000000000..98655448c --- /dev/null +++ b/apps/bench-bare/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['@react-native/babel-preset'], +}; diff --git a/apps/bench-bare/index.js b/apps/bench-bare/index.js new file mode 100644 index 000000000..9250f395b --- /dev/null +++ b/apps/bench-bare/index.js @@ -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); diff --git a/apps/bench-bare/metro.config.js b/apps/bench-bare/metro.config.js new file mode 100644 index 000000000..a526ca9d8 --- /dev/null +++ b/apps/bench-bare/metro.config.js @@ -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')], +}); diff --git a/apps/bench-bare/package.json b/apps/bench-bare/package.json new file mode 100644 index 000000000..d14c9ea0f --- /dev/null +++ b/apps/bench-bare/package.json @@ -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:" + } +} diff --git a/apps/bench-bare/react-native.config.js b/apps/bench-bare/react-native.config.js new file mode 100644 index 000000000..c62c2e12f --- /dev/null +++ b/apps/bench-bare/react-native.config.js @@ -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'), +}; diff --git a/apps/bench-bare/rspack.config.mjs b/apps/bench-bare/rspack.config.mjs new file mode 100644 index 000000000..bc5702b1f --- /dev/null +++ b/apps/bench-bare/rspack.config.mjs @@ -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'), + }, + }, + }, + }), +})); diff --git a/apps/bench-bare/src/App.tsx b/apps/bench-bare/src/App.tsx new file mode 100644 index 000000000..ad7631e8c --- /dev/null +++ b/apps/bench-bare/src/App.tsx @@ -0,0 +1,120 @@ +import { lazy, type ReactNode, Suspense, useState } from 'react'; +import { + Button, + FlatList, + Image, + Pressable, + SafeAreaView, + StatusBar, + StyleSheet, + Text, + View, +} from 'react-native'; +import { screens } from './generated'; + +// Same features as apps/bench-expo, without Expo or a navigation library: a +// details view, a packaged image, a lazily loaded component, and the +// generated screens. +const LazyCard = lazy(() => import('./LazyCard')); + +type Route = + | { name: 'home' | 'details' | 'image' | 'lazy' } + | { name: 'screen'; index: number }; + +function Details({ onBack }: { onBack: () => void }) { + return ( + + React Native benchmark app +