Skip to content

Commit 1f34136

Browse files
demo: Add benchmark-react with normalization and ref-stability scenarios (#3783)
* 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 * more of the plan * More scenarios * more scenarios * add react compiler option * bugbot: dead code * No throttling * yarn lock * bugbot + fix test data client correctness * fair comparisons * ts 6 * bugbot * update website types * improve ci * internal: Bench runs concurrently * better abstractions * CRUD * virtualize * create adds to list; larger high end case * No seeding fix yarn lock * fix test conditions to be more accurate * dynamic accuracy * fix bench measurements * remove unneeded bench * fix measurement by eliminating paint timings from measurement * increase scale, remove redundant * bench name updates * more realistic data * MutationObserver timeout silently fails without signaling completion * Make sorted mount consistent * fix sorted-view-update-entity for some frameworks * change reporting * move scenario * Init scenarios capture wrong react-commit-update measurement * Upgrade benchmark baseline from dual list to triple list Made-with: Cursor * bugbot * server sim * Make author component more expensive * network sim flag; new scenario * bugbot * DRY * Potential fix for code scanning alert no. 83: DOM text reinterpreted as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * delete dead code * movewith docs * review * switch to github data --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 84744e0 commit 1f34136

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+6103
-36
lines changed

.changeset/collection-movewith.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@data-client/endpoint': minor
3+
'@data-client/rest': minor
4+
---
5+
6+
Add `Collection.moveWith()` for custom move schemas
7+
8+
Analogous to [`addWith()`](https://dataclient.io/rest/api/Collection#addWith), `moveWith()` constructs a custom move schema that controls how entities are added to their destination collection. The remove behavior is automatically derived from the collection type (Array or Values).
9+
10+
New exports: `unshift` merge function for convenience.
11+
12+
```ts
13+
import { Collection, unshift } from '@data-client/rest';
14+
15+
class MyCollection extends Collection {
16+
constructor(schema, options) {
17+
super(schema, options);
18+
this.move = this.moveWith(unshift);
19+
}
20+
}
21+
```

.cursor/rules/benchmarking.mdc

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,79 @@
11
---
2-
description: Benchmarking guidelines using examples/benchmark (suite selection, commands, and what packages are exercised)
3-
globs: examples/benchmark/**, .github/workflows/benchmark.yml, packages/normalizr/src/**, packages/core/src/**, packages/endpoint/src/schemas/**
2+
description: Benchmarking guidelines using examples/benchmark and examples/benchmark-react (suite selection, commands, and what packages are exercised)
3+
globs: examples/benchmark/**, examples/benchmark-react/**, .github/workflows/benchmark.yml, .github/workflows/benchmark-react.yml, packages/normalizr/src/**, packages/core/src/**, packages/endpoint/src/schemas/**, packages/react/src/**
44
alwaysApply: false
55
---
66

7-
# Benchmarking (`@examples/benchmark`)
7+
# Benchmarking
88

9-
When working on performance investigations or changes that might impact performance, use **`@examples/benchmark`** as the canonical benchmark harness.
9+
## Node benchmark (`@examples/benchmark`)
10+
11+
When working on performance investigations or changes that might impact **core, normalizr, or endpoint** (no browser, no React), use **`@examples/benchmark`** as the canonical harness.
12+
13+
## React benchmark (`@examples/benchmark-react`)
14+
15+
When working on **`packages/react`** or comparing data-client to other React data libraries (TanStack Query, SWR, baseline), use **`@examples/benchmark-react`**.
16+
17+
- **Where it lives**: `examples/benchmark-react/`
18+
- **How to run**: From repo root: `yarn build:benchmark-react`, then `yarn workspace example-benchmark-react preview &` and in another terminal `cd examples/benchmark-react && yarn bench`
19+
- **What it measures**: Browser-based init/update duration, ref-stability counts, sorted-view (Query memoization), optional memory (heap delta), startup metrics (FCP/TBT), and React Profiler commit times. Compares data-client, TanStack Query, SWR, and a plain React baseline.
20+
- **CI**: `.github/workflows/benchmark-react.yml` runs on changes to `packages/react/src/**`, `packages/core/src/**`, `packages/endpoint/src/schemas/**`, `packages/normalizr/src/**`, or `examples/benchmark-react/**` and reports via `rhysd/github-action-benchmark` (customSmallerIsBetter). CI runs **data-client only** (hot-path scenarios) to track regressions; competitor libraries (TanStack Query, SWR, baseline) are for local comparison only.
21+
- **Report viewer**: Open `examples/benchmark-react/bench/report-viewer.html` in a browser and paste `react-bench-output.json` to view a comparison table and charts. Toggle "React commit" and "Trace" filters. Use "Load history" for time-series.
22+
23+
See `@examples/benchmark-react/README.md` for methodology, adding a new library, and interpreting results.
24+
25+
### Scenarios and what they exercise
26+
27+
Use this mapping when deciding which React benchmark scenarios are relevant to a change:
28+
29+
- **Get list scenarios** (`getlist-100`, `getlist-500`)
30+
- Exercises: full fetch + normalization + render pipeline (ListView auto-fetches from list endpoint)
31+
- Relevant for: `@data-client/react` hooks, `@data-client/core` store initialization
32+
- All libraries
33+
34+
- **Update propagation** (`update-single-entity`, `update-shared-user-500-mounted`, `update-shared-user-10000-mounted`)
35+
- Exercises: store update → React rerender → DOM mutation
36+
- Relevant for: `@data-client/core` dispatch/reducer, `@data-client/react` subscription/selector
37+
- All libraries (normalization advantage shows with shared user at scale)
38+
39+
- **Ref-stability** (`ref-stability-issue-changed`, `ref-stability-user-changed`)
40+
- Exercises: referential equality preservation through normalization
41+
- Relevant for: `@data-client/normalizr` denormalize memoization, Entity identity
42+
- All libraries (data-client should show fewest changed refs)
43+
44+
- **Sorted/derived view** (`sorted-view-mount-500`, `sorted-view-update-entity`)
45+
- Exercises: `Query` schema memoization via `useQuery` (data-client) vs `useMemo` sort (competitors)
46+
- Relevant for: `@data-client/endpoint` Query, `@data-client/normalizr` MemoCache, `@data-client/react` useQuery
47+
- All libraries
48+
49+
- **Optimistic update** (`optimistic-update`) — data-client only
50+
- Exercises: `getOptimisticResponse` + `controller.fetch` pipeline
51+
- Relevant for: `@data-client/core` optimistic dispatch
52+
53+
- **Invalidation** (`invalidate-and-resolve`) — data-client only
54+
- Exercises: `controller.invalidate` → Suspense fallback → `controller.setResponse` re-resolve
55+
- Relevant for: `@data-client/core` invalidation, `@data-client/react` Suspense integration
56+
57+
### Expected variance
58+
59+
| Category | Scenarios | Typical run-to-run spread |
60+
|---|---|---|
61+
| **Stable** | `getlist-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% |
62+
| **Moderate** | `update-shared-user-*`, `sorted-view-update-*` | 5–10% |
63+
| **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10–25% |
64+
65+
Regressions >5% on stable scenarios or >15% on volatile scenarios are worth investigating.
66+
67+
### When to use Node vs React benchmark
68+
69+
- **Core/normalizr/endpoint changes only** (no rendering impact): Run `examples/benchmark` (Node). Faster iteration, no browser needed.
70+
- **React hook or Provider changes**: Run `examples/benchmark-react`. Captures real rendering cost.
71+
- **Schema changes** (Entity, Query, All): Run both — Node benchmark for raw throughput, React benchmark for rendering impact.
72+
- **Performance investigation**: Start with Node benchmark to isolate the JS layer, then validate with React benchmark for end-to-end confirmation.
73+
74+
---
75+
76+
# Node benchmark details (`@examples/benchmark`)
1077

1178
## Optimization workflow
1279

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

.github/workflows/benchmark.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ on:
1919
- 'packages/core/src/**'
2020
- 'examples/benchmark/**'
2121
- '.github/workflows/benchmark.yml'
22+
permissions:
23+
contents: write
24+
pull-requests: write
25+
26+
concurrency:
27+
group: ${{ github.event_name == 'push' && 'gh-pages-bench-node-push' || format('benchmark-node-{0}', github.head_ref) }}
28+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
29+
2230
jobs:
2331
benchmark:
2432

@@ -48,7 +56,7 @@ jobs:
4856
uses: actions/cache@v5
4957
with:
5058
path: ./cache
51-
key: ${{ runner.os }}-benchmark-pr-${{ env.GITHUB_RUN_NUMBER }}
59+
key: ${{ runner.os }}-benchmark-pr-${{ github.run_number }}
5260
restore-keys: |
5361
${{ runner.os }}-benchmark
5462
- name: Store benchmark result (PR)

.github/workflows/bundle_size.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ on:
1717
- 'yarn.lock'
1818
- 'examples/test-bundlesize/**'
1919
- '.github/workflows/bundle_size.yml'
20+
21+
concurrency:
22+
group: bundle-size-${{ github.head_ref || github.ref }}
23+
cancel-in-progress: true
24+
2025
jobs:
2126
build:
2227

.github/workflows/codeql-analysis.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ on:
2727
permissions:
2828
contents: read
2929

30+
concurrency:
31+
group: codeql-${{ github.head_ref || github.ref }}
32+
cancel-in-progress: true
33+
3034
jobs:
3135
analyze:
3236
permissions:

.github/workflows/site-preview.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ on:
1010
- 'website/**'
1111
- 'docs/**'
1212
- '.github/workflows/site-preview.yml'
13+
14+
concurrency:
15+
group: site-preview-${{ github.head_ref || github.ref }}
16+
cancel-in-progress: true
17+
1318
jobs:
1419
deploy:
1520
runs-on: ubuntu-latest

.github/workflows/site-release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ on:
1111
- 'website/**'
1212
- 'docs/**'
1313
- '.github/workflows/site-release.yml'
14+
15+
concurrency:
16+
group: site-release-${{ github.ref }}
17+
cancel-in-progress: true
18+
1419
jobs:
1520
deploy:
1621
runs-on: ubuntu-latest

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v
3737
- **Examples**: `examples/todo-app`, `examples/github-app`, `examples/nextjs`
3838
- **Documentation**: `docs/core/api`, `docs/rest`, `docs/core/guides`
3939
- **Tests**: `packages/*/src/**/__tests__`
40+
- **Benchmarks**: `examples/benchmark` (Node: core/normalizr/endpoint throughput), `examples/benchmark-react` (browser: React rendering and data-library comparison). See `.cursor/rules/benchmarking.mdc` and each example’s README.
4041

4142
## Key Principles
4243

docs/rest/api/Collection.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,44 @@ e.g., `'10' == 10`
601601
boolean;
602602
```
603603

604+
### moveWith(merge): MoveSchema {#moveWith}
605+
606+
Constructs a custom move schema for this collection. This is analogous to [addWith](#addWith)
607+
but for [move](#move) operations. The `merge` function controls how entities are added to
608+
their destination collection, while the remove behavior is automatically derived from
609+
the collection type (Array or Values).
610+
611+
This is useful when you need to control the insertion position of moved items
612+
(e.g., prepending instead of appending).
613+
614+
#### merge(collection, moved)
615+
616+
Controls how the moved entity is added to its destination collection.
617+
618+
The exported [`unshift`](#unshift-merge) merge function places items at the start:
619+
620+
```ts
621+
import { Collection, unshift } from '@data-client/rest';
622+
623+
class MyCollection extends Collection {
624+
constructor(schema, options) {
625+
super(schema, options);
626+
// Prepend moved items instead of appending
627+
// highlight-next-line
628+
this.move = this.moveWith(unshift);
629+
}
630+
}
631+
```
632+
633+
### unshift (merge function) {#unshift-merge}
634+
635+
A merge function that places incoming items at the _start_ of the collection.
636+
Use with [moveWith](#moveWith) or [addWith](#addWith) to control insertion order.
637+
638+
```ts
639+
import { unshift } from '@data-client/rest';
640+
```
641+
604642
## Lifecycle Methods
605643

606644
### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldReorder}

0 commit comments

Comments
 (0)