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
207 changes: 207 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
name: Benchmark

on:
pull_request:
branches: [main]

permissions:
contents: write
issues: write
pull-requests: write

concurrency:
group: benchmark-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
benchmark:
name: Compare build benchmarks
runs-on: ubuntu-latest
timeout-minutes: 30

env:
BENCHMARK_PROFILE: default
BENCHMARK_ITERATIONS: '5'
BENCHMARK_WARMUP: '1'
BENCHMARK_HISTORY_BRANCH: benchmark-results

steps:
- name: Checkout PR head
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
path: head
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Checkout PR base
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
path: base
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: head/.nvmrc

- name: Setup PNPM
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
with:
package_json_file: head/package.json

- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT"

- name: Setup pnpm cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-benchmark-pnpm-${{ hashFiles('head/pnpm-lock.yaml', 'base/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-benchmark-pnpm-
${{ runner.os }}-pnpm-store-

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

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

- name: Resolve benchmark-only React plugin
id: benchmark-imports
shell: bash
run: |
node -e "const { pathToFileURL } = require('node:url'); console.log('PLUGIN_REACT_IMPORT=' + pathToFileURL(process.argv[1]).href)" \
"$GITHUB_WORKSPACE/head/node_modules/@rsbuild/plugin-react/dist/index.js" >> "$GITHUB_OUTPUT"

- name: Benchmark base
working-directory: base
env:
REACT_ROUTER_BENCHMARK_PLUGIN_REACT_IMPORT: ${{ steps.benchmark-imports.outputs.PLUGIN_REACT_IMPORT }}
run: |
node "$GITHUB_WORKSPACE/head/scripts/bench-builds.mjs" \
--profile "$BENCHMARK_PROFILE" \
--iterations "$BENCHMARK_ITERATIONS" \
--warmup "$BENCHMARK_WARMUP" \
--clean build \
--format both \
--out "$GITHUB_WORKSPACE/benchmark-output/base"

- name: Benchmark head
working-directory: head
run: |
node scripts/bench-builds.mjs \
--profile "$BENCHMARK_PROFILE" \
--iterations "$BENCHMARK_ITERATIONS" \
--warmup "$BENCHMARK_WARMUP" \
--clean build \
--format both \
--out "$GITHUB_WORKSPACE/benchmark-output/head"

- name: Generate benchmark report
working-directory: head
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node scripts/report-benchmark-ci.mjs \
--base "$GITHUB_WORKSPACE/benchmark-output/base/baseline.json" \
--head "$GITHUB_WORKSPACE/benchmark-output/head/baseline.json" \
--out "$GITHUB_WORKSPACE/benchmark-output/report" \
--pr "$PR_NUMBER" \
--base-ref "$BASE_REF" \
--base-sha "$BASE_SHA" \
--head-ref "$HEAD_REF" \
--head-sha "$HEAD_SHA" \
--run-url "$RUN_URL"

- name: Upload benchmark artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: benchmark-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
path: benchmark-output
if-no-files-found: error

- name: Comment benchmark results
if: github.event.pull_request.head.repo.full_name == github.repository
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
marker='<!-- react-router-benchmark-ci -->'
comment_body="$GITHUB_WORKSPACE/benchmark-output/report/comment.md"
body="$(cat "$comment_body")"
comment_id="$(gh api "repos/$REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.body | contains(\"$marker\")) | .id" | tail -n 1)"

if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/$REPOSITORY/issues/comments/$comment_id" \
--field body="$body"
else
gh api --method POST "repos/$REPOSITORY/issues/$PR_NUMBER/comments" \
--field body="$body"
Comment thread
ScriptedAlchemy marked this conversation as resolved.
fi

- name: Persist benchmark history
if: github.event.pull_request.head.repo.full_name == github.repository
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
history_dir="$GITHUB_WORKSPACE/benchmark-history"
remote_url="https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git"

set +e
git ls-remote --exit-code --heads "$remote_url" "$BENCHMARK_HISTORY_BRANCH" > /dev/null 2>&1
branch_status=$?
set -e

if [ "$branch_status" -eq 0 ]; then
git clone --depth 1 --branch "$BENCHMARK_HISTORY_BRANCH" "$remote_url" "$history_dir"
elif [ "$branch_status" -eq 2 ]; then
git clone --depth 1 "$remote_url" "$history_dir"
cd "$history_dir"
git checkout --orphan "$BENCHMARK_HISTORY_BRANCH"
git rm -rf .
else
exit "$branch_status"
fi

cd "$history_dir"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

result_dir="pull-requests/${PR_NUMBER}/${HEAD_SHA}"
latest_dir="pull-requests/${PR_NUMBER}/latest"
mkdir -p "$result_dir" "$latest_dir"
cp "$GITHUB_WORKSPACE/benchmark-output/base/baseline.json" "$result_dir/base.json"
cp "$GITHUB_WORKSPACE/benchmark-output/head/baseline.json" "$result_dir/head.json"
cp "$GITHUB_WORKSPACE/benchmark-output/report/report.json" "$result_dir/report.json"
cp "$GITHUB_WORKSPACE/benchmark-output/report/comment.md" "$result_dir/comment.md"
cp "$result_dir/report.json" "$latest_dir/report.json"
cp "$result_dir/comment.md" "$latest_dir/comment.md"

git add pull-requests
if git diff --cached --quiet; then
echo "Benchmark history is already up to date."
exit 0
fi

git commit -m "bench: record PR ${PR_NUMBER} ${HEAD_SHA:0:7}"
git push origin "$BENCHMARK_HISTORY_BRANCH"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ build
.unpack-cache/
.codex/
.tracedecay/
.benchmark/
task/upstream/
task/output/

Expand Down
75 changes: 75 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Rsbuild React Router Performance Baselines

This directory documents the repeatable benchmark workflow for
`rsbuild-plugin-react-router`. Benchmark artifacts are written to `.benchmark/`,
which is intentionally ignored.

## Commands

```sh
pnpm bench:smoke
pnpm bench:baseline
pnpm bench:full
```

`bench:smoke` is a one-iteration sanity check. `bench:baseline` is the default
comparison point for plugin performance work. `bench:full` adds larger route
counts for stress testing.

All benchmark profiles generate deterministic synthetic React Router apps under
`.benchmark/fixtures/`, build the current plugin package once, then run Rsbuild
production builds against those fixtures.

To capture Rspack tracing output for a benchmark, pass `--rspack-profile`:

```sh
node scripts/bench-builds.mjs --profile=smoke --iterations=1 --warmup=0 --rspack-profile=OVERVIEW
node scripts/bench-builds.mjs --profile=full --filter=synthetic-1024 --iterations=1 --warmup=0 --rspack-profile=ALL
```

Trace directories are moved from fixture roots into
`.benchmark/results/<profile>/rspack-profiles/` and referenced from the JSON
result. `ALL` can produce large traces; use it for targeted runs.
When `--rspack-trace-output` is provided, the benchmark writes one absolute
trace file per run under that directory so Rsbuild does not resolve the path
inside each generated `.rspack-profile-*` directory.

## Baseline Shape

The synthetic fixture keeps app behavior simple and scales route count/export
shape deliberately:

- `ssr-esm`: production SSR build with ESM server output.
- `ssr-esm-split`: same route set with `future.v8_splitRouteModules`.
- `spa`: `ssr: false` route transform path.
- `sourcemaps`: production client sourcemaps enabled.

Routes include plain components, server data exports, client data exports,
split-route candidates, and `.client` / `.server` imports. This targets the
plugin paths that are expensive in large apps: per-route client entries,
`?react-router-route` transforms, client-only stubbing, split-route detection,
and manifest emission.

## Outputs

Each run writes:

- `.benchmark/results/<profile>/baseline.json`
- `.benchmark/results/<profile>/baseline.md`

The JSON includes wall time, optional GNU `/usr/bin/time -v` user/sys/RSS data,
per-run exit status, and Rspack trace artifact paths when tracing is enabled.
The markdown report summarizes the same benchmark-level timing and memory data
without opening the raw JSON.

## Hygiene

Start and end with:

```sh
git status --short
```

Benchmark output should stay inside ignored `.benchmark/`. If you need to clean
generated benchmark data, remove `.benchmark/` directly rather than using a broad
`git clean -fdX`, which can also delete `node_modules/` and TraceDecay indexes.
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
},
"scripts": {
"build": "rslib build",
"bench": "node scripts/bench-builds.mjs",
"bench:ci-report": "node scripts/report-benchmark-ci.mjs",
"bench:compare": "node scripts/compare-benchmarks.mjs",
"bench:smoke": "node scripts/bench-builds.mjs --profile smoke --iterations 1 --warmup 0 --format both --out .benchmark/results/smoke",
"bench:baseline": "node scripts/bench-builds.mjs --profile default --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/baseline",
"bench:full": "node scripts/bench-builds.mjs --profile full --iterations 5 --warmup 1 --clean build --format both --out .benchmark/results/full",
"e2e": "pnpm build && pnpm --filter './examples/{default-template,spa-mode,prerender,custom-node-server,cloudflare,client-only}' test:e2e",
"dev": "rslib build --watch",
"test": "rstest run",
Expand Down Expand Up @@ -88,6 +94,7 @@
"@react-router/dev": "^7.13.0",
"@rsbuild/config": "workspace:*",
"@rsbuild/core": "2.0.15",
"@rsbuild/plugin-react": "2.0.1",
"@rslib/core": "^0.22.1",
"@rspack/core": "2.0.8",
"@swc/helpers": "^0.5.23",
Expand Down
Loading
Loading