Skip to content

Commit af004f6

Browse files
committed
demo: Add benchmark-react with normalization and ref-stability scenarios
- Browser benchmark comparing @data-client/react (Playwright, customSmallerIsBetter). - Scenarios: mount, update entity/author, ref-stability (item/author ref counts). - Hot-path (CI) vs with-network (local): simulated delay for overfetch comparison. - CI workflow runs hot-path only; reports to rhysd/github-action-benchmark. Made-with: Cursor
1 parent 31fd291 commit af004f6

File tree

22 files changed

+1513
-11
lines changed

22 files changed

+1513
-11
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: Benchmark React
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- master
7+
paths:
8+
- 'packages/react/src/**'
9+
- 'packages/core/src/**'
10+
- 'examples/benchmark-react/**'
11+
- '.github/workflows/benchmark-react.yml'
12+
push:
13+
branches:
14+
- master
15+
paths:
16+
- 'packages/react/src/**'
17+
- 'packages/core/src/**'
18+
- 'examples/benchmark-react/**'
19+
- '.github/workflows/benchmark-react.yml'
20+
21+
jobs:
22+
benchmark-react:
23+
runs-on: ubuntu-latest
24+
25+
steps:
26+
- uses: actions/checkout@v6
27+
with:
28+
fetch-depth: 1
29+
- uses: actions/setup-node@v6
30+
with:
31+
node-version: '24'
32+
cache: 'yarn'
33+
- name: Install packages
34+
run: |
35+
corepack enable
36+
yarn install --immutable
37+
- name: Install Playwright (Chromium + system deps)
38+
run: npx playwright install chromium --with-deps
39+
- name: Build packages
40+
run: yarn build:benchmark-react
41+
- name: Run benchmark
42+
run: |
43+
yarn workspace example-benchmark-react preview &
44+
sleep 10
45+
cd examples/benchmark-react && yarn bench | tee react-bench-output.json
46+
47+
# PR comments on changes
48+
- name: Download previous benchmark data (PR)
49+
if: ${{ github.event_name == 'pull_request' }}
50+
uses: actions/cache@v5
51+
with:
52+
path: ./cache
53+
key: ${{ runner.os }}-benchmark-react-pr-${{ github.run_number }}
54+
restore-keys: |
55+
${{ runner.os }}-benchmark-react
56+
- name: Store benchmark result (PR)
57+
if: ${{ github.event_name == 'pull_request' }}
58+
uses: rhysd/github-action-benchmark@v1
59+
with:
60+
tool: 'customSmallerIsBetter'
61+
output-file-path: examples/benchmark-react/react-bench-output.json
62+
github-token: "${{ secrets.GITHUB_TOKEN }}"
63+
gh-pages-branch: 'gh-pages-bench'
64+
benchmark-data-dir-path: react-bench
65+
alert-threshold: '150%'
66+
comment-always: true
67+
fail-on-alert: false
68+
alert-comment-cc-users: '@ntucker'
69+
save-data-file: false
70+
auto-push: false
71+
72+
# master reports to history
73+
- name: Download previous benchmark data (main)
74+
if: ${{ github.event_name == 'push' }}
75+
uses: actions/cache@v5
76+
with:
77+
path: ./cache
78+
key: ${{ runner.os }}-benchmark-react
79+
- name: Store benchmark result (main)
80+
if: ${{ github.event_name == 'push' }}
81+
uses: rhysd/github-action-benchmark@v1
82+
with:
83+
tool: 'customSmallerIsBetter'
84+
output-file-path: examples/benchmark-react/react-bench-output.json
85+
github-token: "${{ secrets.GITHUB_TOKEN }}"
86+
gh-pages-branch: 'gh-pages-bench'
87+
benchmark-data-dir-path: react-bench
88+
auto-push: true
89+
fail-on-alert: false
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
presets: [['@anansi', { polyfillMethod: false }]],
3+
};

examples/benchmark-react/PLAN.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# React Rendering Benchmark – Future Work
2+
3+
Follow this plan in later sessions to extend the benchmark suite.
4+
5+
---
6+
7+
## Session 2: Competitor implementations
8+
9+
Add apps that implement the same `BenchAPI` (`window.__BENCH__`) and use the same presentational components so results are comparable.
10+
11+
- **`apps/tanstack-query/`** (or `src/tanstack-query/` if keeping single webpack entry)
12+
- Use `@tanstack/react-query` with `useQuery` and `queryClient.setQueryData` for cache seeding.
13+
- Same scenarios: mount N items, update single entity.
14+
- **`apps/swr/`**
15+
- Use `swr` with `mutate` for cache seeding.
16+
- **`apps/baseline/`**
17+
- Plain `useState` + `useContext`, no caching library (baseline).
18+
19+
**Deliverables:** Each app exposes the same `window.__BENCH__` interface and uses the same `ItemRow` (or shared) presentational component. Extend webpack to multi-entry so each app is built and served at e.g. `/data-client/`, `/tanstack-query/`, etc. Update `bench/runner.ts` and `bench/scenarios.ts` to iterate over all libraries and report per-library results.
20+
21+
---
22+
23+
## Session 3: Entity update propagation (normalization showcase) — Done
24+
25+
Implemented:
26+
27+
- **`update-shared-author-duration`** — Mount 100 items (sharing 20 authors), update one author; measure duration (ms).
28+
- **Ref-stability scenarios**`ref-stability-item-changed` and `ref-stability-author-changed` report how many components received a new object reference after an update (unit: count; smaller is better). data-client’s normalized cache keeps referential equality for unchanged entities, so these counts stay low (1 and ~25 respectively).
29+
- Shared `refStability` module and `BenchAPI.captureRefSnapshot` / `getRefStabilityReport` / `updateAuthor`; `getAuthor` endpoint and `FIXTURE_AUTHORS` for seeding.
30+
31+
---
32+
33+
## Session 4: Memory and scaling scenarios
34+
35+
Add memory and stress scenarios.
36+
37+
- **Memory under repeated operations:** Cycle mount → unmount N times; measure heap (e.g. `Performance.getMetrics` / JSHeapUsedSize via CDP) to detect growth.
38+
- **Many-subscriber scaling:** Mount 500+ components subscribed to overlapping entities; measure per-update cost (time and/or memory).
39+
- **Optimistic update + rollback:** Optimistic mutation, then simulate error and rollback; measure time to revert DOM.
40+
41+
**Deliverables:** New scenarios in `bench/scenarios.ts`, optional `bench/memory.ts` for CDP heap collection, and report entries for memory metrics (e.g. `customSmallerIsBetter` with unit `bytes` where applicable).
42+
43+
---
44+
45+
## Session 5: Advanced measurement and reporting
46+
47+
- **React Profiler:** Use `<Profiler onRender>` (and/or `performance.measure`) to record React commit duration as a separate metric alongside existing measures.
48+
- **Local HTML report:** Build a small report viewer (e.g. `bench/report-viewer.html` or a small app) that loads saved JSON and displays a table/charts for comparing libraries (similar to krausest results table).
49+
- **Lighthouse-style metrics:** Optionally add FCP, TBT, or other metrics for initial load comparison (e.g. via CDP or Lighthouse CI).
50+
51+
**Deliverables:** Profiler instrumentation in app(s), report viewer, and optionally Lighthouse/load metrics in the runner and report format.
52+
53+
---
54+
55+
## Session 6: Polish and documentation
56+
57+
- **README:** Expand with methodology (what we measure, why), how to add a new library, and how to run locally vs CI.
58+
- **Cursor rule:** Update `.cursor/rules/benchmarking.mdc` (or equivalent) to document the React benchmark: where it lives, how to run it, and how it relates to the existing Node `example-benchmark` suite.
59+
- **AGENTS.md:** If appropriate, add a short mention of the React benchmark and link to this plan or the README.
60+
61+
**Deliverables:** Updated README, rule file, and any AGENTS.md changes.

examples/benchmark-react/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# React Rendering Benchmark
2+
3+
Browser-based benchmark comparing `@data-client/react` (and future: TanStack Query, SWR, baseline) on mount/update scenarios. Built with Webpack via `@anansi/webpack-config`. Results are reported to CI via `rhysd/github-action-benchmark`.
4+
5+
## Scenario categories
6+
7+
- **Hot path (in CI)** — JS-only: mount, update propagation, ref-stability. No simulated network. These run in CI and track regression.
8+
- **With network (comparison only)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Used to compare overfetching: data-client needs one store update (1 × delay); non-normalized libs typically invalidate/refetch multiple queries (N × delay). **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these.
9+
10+
## Scenarios
11+
12+
**Hot path (CI)**
13+
14+
- **Mount** — Time to mount 100 or 500 item rows (unit: ms).
15+
- **Update single entity** — Time to update one item and propagate to the UI (unit: ms).
16+
- **Update shared author** (`update-shared-author-duration`) — 100 components, shared authors; update one author. Measures time to propagate (unit: ms). Normalized cache: one store update, all views of that author update.
17+
- **Ref-stability item/author** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities.
18+
19+
**With network (local comparison)**
20+
21+
- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client uses 1 request; other libs can be compared with higher `simulatedRequestCount` to model overfetching.
22+
23+
## Running locally
24+
25+
1. **Install system dependencies (Linux / WSL)**
26+
Playwright needs system libraries to run Chromium. If you see “Host system is missing dependencies to run browsers”:
27+
28+
```bash
29+
sudo npx playwright install-deps chromium
30+
```
31+
32+
Or install manually (e.g. Debian/Ubuntu):
33+
34+
```bash
35+
sudo apt-get install libnss3 libnspr4 libasound2t64
36+
```
37+
38+
2. **Build and run**
39+
40+
```bash
41+
yarn build:benchmark-react
42+
yarn workspace example-benchmark-react preview &
43+
sleep 5
44+
cd examples/benchmark-react && yarn bench
45+
```
46+
47+
Or from repo root after a build: start preview in one terminal, then in another run `yarn workspace example-benchmark-react bench`.
48+
49+
## Output
50+
51+
The runner prints a JSON array in `customSmallerIsBetter` format (name, unit, value, range) to stdout. In CI this is written to `react-bench-output.json` and sent to the benchmark action.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Page } from 'playwright';
2+
3+
export interface PerformanceMeasure {
4+
name: string;
5+
duration: number;
6+
}
7+
8+
/**
9+
* Collect performance.measure() entries from the page.
10+
*/
11+
export async function collectMeasures(
12+
page: Page,
13+
): Promise<PerformanceMeasure[]> {
14+
return page.evaluate(() => {
15+
const entries = performance.getEntriesByType('measure');
16+
return entries.map(e => ({
17+
name: e.name,
18+
duration: e.duration,
19+
}));
20+
});
21+
}
22+
23+
/**
24+
* Get the duration for a specific measure name (e.g. 'mount-duration', 'update-duration').
25+
*/
26+
export function getMeasureDuration(
27+
measures: PerformanceMeasure[],
28+
name: string,
29+
): number {
30+
const m = measures.find(x => x.name === name);
31+
return m?.duration ?? 0;
32+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Format results as customSmallerIsBetter JSON for rhysd/github-action-benchmark.
3+
*/
4+
export interface BenchmarkResult {
5+
name: string;
6+
unit: string;
7+
value: number;
8+
range: string;
9+
}
10+
11+
export function formatReport(results: BenchmarkResult[]): string {
12+
return JSON.stringify(results, null, 2);
13+
}

0 commit comments

Comments
 (0)