Skip to content

Commit 655b42e

Browse files
authored
demo(benchmark-react): add multi-view entity update scenario (#3806)
* demo(benchmark-react): add multi-view entity update scenario Add `update-entity-multi-view` benchmark where the same issue entity is displayed across three structurally different component trees (list row, detail panel, pinned card strip). A single entity update must propagate to all three views, exercising normalized cache cross-query propagation vs. multi-query invalidation + refetch. Made-with: Cursor * demo(benchmark-react): fix post-mount GC inflating data-client times Remove forced HeapProfiler.collectGarbage after pre-mount. The full GC promoted all recently-allocated entities into V8's old generation, causing write-barrier overhead during the timed action that disproportionately penalized data-client's CPU-bound optimistic updates (~1.8x inflation) while leaving network-bound libraries unaffected. Also re-measure all scenarios and reorganize the README summary table into Navigation / Mutations / Scaling categories. Made-with: Cursor * demo(benchmark-react): fix initMultiView double-setComplete race measureMount's MutationObserver called setComplete() (setting data-bench-complete) as soon as list items appeared, before the detail panel and pinned card views were ready. The runner could see this premature signal, proceed to the timed update phase, and then receive a stale second setComplete() — corrupting the measurement. Refactor measureMount to return a Promise and accept { signalComplete: false } so initMultiView can suppress the early completion signal and call setComplete() once after all three views are ready. Made-with: Cursor
1 parent 400837c commit 655b42e

File tree

9 files changed

+278
-43
lines changed

9 files changed

+278
-43
lines changed

.cursor/rules/benchmarking.mdc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ Use this mapping when deciding which React benchmark scenarios are relevant to a
4141
- Relevant for: `@data-client/normalizr` denormalize memoization, Entity identity
4242
- All libraries (data-client should show fewest changed refs)
4343

44+
- **Multi-view entity update** (`update-entity-multi-view`)
45+
- Exercises: cross-query entity propagation — one update to a shared entity reflected in list, detail panel, and pinned cards
46+
- Relevant for: `@data-client/normalizr` normalized cache, `@data-client/core` subscription fan-out
47+
- All libraries (normalization advantage: one store write vs. multiple query invalidations + refetches)
48+
4449
- **Sorted/derived view** (`getlist-500-sorted`, `update-entity-sorted`)
4550
- Exercises: `Query` schema memoization via `useQuery` (data-client) vs `useMemo` sort (competitors)
4651
- Relevant for: `@data-client/endpoint` Query, `@data-client/normalizr` MemoCache, `@data-client/react` useQuery

examples/benchmark-react/README.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The repo has two benchmark suites:
3131
- **Get list sorted** (`getlist-500-sorted`) — Mount 500 issues through a sorted/derived view. data-client uses `useQuery(sortedIssuesQuery)` with `Query` schema memoization; competitors use `useMemo` + sort.
3232
- **Update entity** (`update-entity`) — Time to update one issue and propagate to the UI (unit: ms).
3333
- **Update entity sorted** (`update-entity-sorted`) — After mounting a sorted view, update one entity. data-client's `Query` memoization avoids re-sorting when sort keys are unchanged.
34+
- **Update entity multi-view** (`update-entity-multi-view`) — Update one issue that appears simultaneously in a list, a detail panel, and a pinned-cards strip. Exercises cross-query entity propagation: normalized cache updates once and all three views reflect the change; non-normalized libraries must invalidate and refetch each query independently.
3435
- **Update user (scaling)** (`update-user`, `update-user-10000`) — Update one shared user with 1,000 or 10,000 mounted issues to test subscriber scaling. Normalized cache: one store update, all views of that user update.
3536
- **Ref-stability** (`ref-stability-issue-changed`, `ref-stability-user-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.
3637
- **Invalidate and resolve** (`invalidate-and-resolve`) — data-client only; invalidates a cached endpoint and immediately re-resolves. Measures Suspense boundary round-trip.
@@ -54,12 +55,10 @@ Illustrative **relative** results with **SWR = 100%** (baseline). For **duration
5455

5556
| Category | Scenarios (representative) | data-client | tanstack-query | swr |
5657
|---|---|---:|---:|---:|
57-
| Get list (fetch + render) | `getlist-100`, `getlist-500`, `getlist-500-sorted` | ~104% | ~101% | **100%** |
58-
| Mutations (with network sim) | `update-entity`, `unshift-item`, `delete-item`, `move-item` | ~2% | ~104% | **100%** |
59-
| Sorted view: entity update | `update-entity-sorted` | ~2% | ~100% | **100%** |
60-
| Large data: shared user update | `update-user` (1k rows rendered) | ~2% | ~102% | **100%** |
61-
| Large data: shared user update | `update-user-10000` | ~5% | ~122% | **100%** |
62-
| Large data: list ↔ detail | `list-detail-switch` | ~25% | ~106% | **100%** |
58+
| Navigation | `getlist-100`, `getlist-500`, `getlist-500-sorted` | ~103% | ~102% | **100%** |
59+
| Navigation | `list-detail-switch` | ~21% | ~102% | **100%** |
60+
| Mutations | `update-entity`, `update-user`, `update-entity-sorted`, `update-entity-multi-view`, `unshift-item`, `delete-item`, `move-item` | ~2% | ~102% | **100%** |
61+
| Scaling (10k items) | `update-user-10000` | ~5% | ~122% | **100%** |
6362

6463

6564
## Latest measured results (network simulation on)
@@ -70,24 +69,25 @@ Run: **2026-03-21**, Linux (WSL2), `yarn build:benchmark-react`, static preview
7069

7170
| Scenario | Unit | data-client | tanstack-query | swr |
7271
|---|---|---:|---:|---:|
73-
| `getlist-100` | ms | 89.3 ± 0.11 | 88.9 ± 0.17 | 87.2 ± 0.49 |
74-
| `getlist-500` | ms | 104.8 ± 1.37 | 101.2 ± 0.29 | 99.4 ± 0.69 |
75-
| `update-entity` | ms | 2.1 ± 0.11 | 144.9 ± 0.58 | 143.0 ± 0.23 |
76-
| `update-user` | ms | 3.1 ± 0.29 | 141.9 ± 0.00 | 139.0 ± 0.00 |
77-
| `getlist-500-sorted` | ms | 103.2 ± 0.59 | 98.8 ± 0.39 | 98.9 ± 0.88 |
78-
| `update-entity-sorted` | ms | 2.7 ± 0.00 | 139.7 ± 0.10 | 140.4 ± 0.88 |
79-
| `list-detail-switch` | ms | 165.5 ± 21.69 | 694.4 ± 3.72 | 656.9 ± 26.95 |
80-
| `update-user-10000` | ms | 9.1 ± 0.49 | 239.4 ± 0.59 | 195.7 ± 1.86 |
81-
| `unshift-item` | ms | 3.0 ± 0.07 | 144.1 ± 0.46 | 139.8 ± 0.47 |
82-
| `delete-item` | ms | 2.6 ± 0.07 | 142.2 ± 0.07 | 138.5 ± 0.36 |
83-
| `move-item` | ms | 3.6 ± 0.11 | 154.5 ± 0.82 | 143.9 ± 0.82 |
72+
| `getlist-100` | ms | 89.3 ± 0.22 | 88.7 ± 0.15 | 87.5 ± 0.50 |
73+
| `getlist-500` | ms | 102.3 ± 1.25 | 99.9 ± 1.25 | 98.4 ± 1.25 |
74+
| `getlist-500-sorted` | ms | 101.8 ± 1.61 | 99.2 ± 1.29 | 97.9 ± 0.63 |
75+
| `list-detail-switch` | ms | 144.4 ± 21.22 | 689.4 ± 20.83 | 674.5 ± 35.67 |
76+
| `update-entity` | ms | 2.8 ± 0.09 | 142.6 ± 0.31 | 142.4 ± 0.34 |
77+
| `update-user` | ms | 3.0 ± 0.13 | 142.7 ± 0.43 | 139.4 ± 0.51 |
78+
| `update-entity-sorted` | ms | 3.2 ± 0.24 | 141.3 ± 0.07 | 141.4 ± 0.56 |
79+
| `update-entity-multi-view` | ms | 2.8 ± 0.41 | 146.6 ± 7.25 | 145.3 ± 8.21 |
80+
| `update-user-10000` | ms | 10.3 ± 0.82 | 246.0 ± 1.35 | 201.2 ± 0.75 |
81+
| `unshift-item` | ms | 3.5 ± 0.06 | 144.5 ± 0.38 | 139.7 ± 0.07 |
82+
| `delete-item` | ms | 3.2 ± 0.10 | 144.4 ± 0.11 | 139.9 ± 0.11 |
83+
| `move-item` | ms | 3.5 ± 0.13 | 156.4 ± 0.50 | 146.4 ± 0.05 |
8484

8585
## Expected variance
8686

8787
| Category | Scenarios | Typical run-to-run spread |
8888
|---|---|---|
8989
| **Stable** | `getlist-*`, `update-entity`, `ref-stability-*` | 2-5% |
90-
| **Moderate** | `update-user-*`, `update-entity-sorted` | 5-10% |
90+
| **Moderate** | `update-user-*`, `update-entity-sorted`, `update-entity-multi-view` | 5-10% |
9191
| **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10-25% |
9292

9393
Regressions >5% on stable scenarios or >15% on volatile scenarios are worth investigating.
@@ -186,7 +186,7 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve
186186
Scenarios are classified as `small` or `large` based on their cost:
187187

188188
- **Small** (3 warmup + 15 measurement): `getlist-100`, `update-entity`, `ref-stability-*`, `invalidate-and-resolve`, `unshift-item`, `delete-item`
189-
- **Large** (1 warmup + 4 measurement): `getlist-500`, `getlist-500-sorted`, `update-user`, `update-user-10000`, `update-entity-sorted`, `list-detail-switch`
189+
- **Large** (1 warmup + 4 measurement): `getlist-500`, `getlist-500-sorted`, `update-user`, `update-user-10000`, `update-entity-sorted`, `update-entity-multi-view`, `list-detail-switch`
190190
- **Memory** (opt-in, 1 warmup + 3 measurement): `memory-mount-unmount-cycle` — run with `--action memory`
191191

192192
When running all scenarios (`yarn bench`), each group runs with its own warmup/measurement count. Use `--size` to run only one group.

examples/benchmark-react/bench/runner.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,6 @@ async function runScenario(
272272
performance.clearMarks();
273273
performance.clearMeasures();
274274
});
275-
// Force GC after pre-mount so V8 doesn't collect during the timed action
276-
if (cdp) {
277-
try {
278-
await cdp.send('HeapProfiler.collectGarbage');
279-
} catch {}
280-
await page.waitForTimeout(50);
281-
}
282275
}
283276

284277
if (isRefStability) {

examples/benchmark-react/src/data-client/index.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
import {
1515
DOUBLE_LIST_STYLE,
1616
IssueRow,
17+
PINNED_STRIP_STYLE,
18+
PinnedCardView,
1719
PlainIssueList,
1820
} from '@shared/components';
1921
import {
@@ -110,6 +112,21 @@ function DetailView({ number }: { number: number }) {
110112
);
111113
}
112114

115+
function PinnedCard({ number }: { number: number }) {
116+
const issue = useSuspense(IssueResource.get, { number });
117+
return <PinnedCardView issue={issue as Issue} />;
118+
}
119+
120+
function PinnedStrip({ numbers }: { numbers: number[] }) {
121+
return (
122+
<div data-pinned-strip style={PINNED_STRIP_STYLE}>
123+
{numbers.map(n => (
124+
<PinnedCard key={n} number={n} />
125+
))}
126+
</div>
127+
);
128+
}
129+
113130
function BenchmarkHarness() {
114131
const controller = useController();
115132
const {
@@ -119,6 +136,7 @@ function BenchmarkHarness() {
119136
showDoubleList,
120137
doubleListCount,
121138
detailIssueNumber,
139+
pinnedNumbers,
122140
renderLimit,
123141
containerRef,
124142
measureUpdate,
@@ -220,10 +238,44 @@ function BenchmarkHarness() {
220238
[measureUpdate, controller, containerRef, doubleListCount, listViewCount],
221239
);
222240

241+
const updateEntityMultiView = useCallback(
242+
(number: number) => {
243+
const issue = FIXTURE_ISSUES_BY_NUMBER.get(number);
244+
if (!issue) return;
245+
const expected = `${issue.title} (updated)`;
246+
measureUpdate(
247+
() => {
248+
controller.fetch(
249+
IssueResource.update,
250+
{ number },
251+
{ title: expected },
252+
);
253+
},
254+
() => {
255+
const container = containerRef.current!;
256+
const listTitle = container.querySelector(
257+
`[data-issue-number="${number}"] [data-title]`,
258+
);
259+
const detailTitle = container.querySelector(
260+
'[data-detail-view] [data-title]',
261+
);
262+
const pinnedTitle = container.querySelector(
263+
`[data-pinned-number="${number}"] [data-title]`,
264+
);
265+
return [listTitle, detailTitle, pinnedTitle].every(
266+
el => el?.textContent === expected,
267+
);
268+
},
269+
);
270+
},
271+
[measureUpdate, controller, containerRef],
272+
);
273+
223274
registerAPI({
224275
updateEntity,
225276
updateUser,
226277
invalidateAndResolve,
278+
updateEntityMultiView,
227279
unshiftItem,
228280
deleteEntity,
229281
moveItem,
@@ -246,6 +298,11 @@ function BenchmarkHarness() {
246298
<DetailView number={detailIssueNumber} />
247299
</React.Suspense>
248300
)}
301+
{pinnedNumbers.length > 0 && (
302+
<React.Suspense fallback={<div>Loading pinned...</div>}>
303+
<PinnedStrip numbers={pinnedNumbers} />
304+
</React.Suspense>
305+
)}
249306
</div>
250307
);
251308
}

examples/benchmark-react/src/shared/benchHarness.tsx

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function useBenchState() {
8080
const [detailIssueNumber, setDetailIssueNumber] = useState<number | null>(
8181
null,
8282
);
83+
const [pinnedNumbers, setPinnedNumbers] = useState<number[]>([]);
8384
const [renderLimit, setRenderLimit] = useState<number | undefined>();
8485
const containerRef = useRef<HTMLDivElement>(null);
8586
const completeResolveRef = useRef<(() => void) | null>(null);
@@ -95,29 +96,41 @@ export function useBenchState() {
9596
* Measure a mount action via MutationObserver. Ends when expected content
9697
* ([data-bench-item] or [data-sorted-list]) appears in the container,
9798
* skipping intermediate states like Suspense fallbacks or empty first renders.
99+
*
100+
* Returns a promise that resolves when the mount content is detected.
101+
* Pass `signalComplete: false` to suppress the data-bench-complete attribute
102+
* (useful when the caller needs additional async work before signaling).
98103
*/
99104
const measureMount = useCallback(
100-
(fn: () => unknown) => {
105+
(fn: () => unknown, { signalComplete = true } = {}): Promise<void> => {
101106
const container = containerRef.current!;
102-
const observer = new MutationObserver(() => {
103-
if (container.querySelector('[data-bench-item], [data-sorted-list]')) {
107+
return new Promise<void>(resolve => {
108+
const done = () => {
109+
if (signalComplete) setComplete();
110+
resolve();
111+
};
112+
const observer = new MutationObserver(() => {
113+
if (
114+
container.querySelector('[data-bench-item], [data-sorted-list]')
115+
) {
116+
performance.mark('mount-end');
117+
performance.measure('mount-duration', 'mount-start', 'mount-end');
118+
observer.disconnect();
119+
clearTimeout(timer);
120+
done();
121+
}
122+
});
123+
observer.observe(container, OBSERVE_MUTATIONS);
124+
const timer = setTimeout(() => {
125+
observer.disconnect();
104126
performance.mark('mount-end');
105127
performance.measure('mount-duration', 'mount-start', 'mount-end');
106-
observer.disconnect();
107-
clearTimeout(timer);
108-
setComplete();
109-
}
128+
container.setAttribute('data-bench-timeout', 'true');
129+
done();
130+
}, 30000);
131+
performance.mark('mount-start');
132+
fn();
110133
});
111-
observer.observe(container, OBSERVE_MUTATIONS);
112-
const timer = setTimeout(() => {
113-
observer.disconnect();
114-
performance.mark('mount-end');
115-
performance.measure('mount-duration', 'mount-start', 'mount-end');
116-
container.setAttribute('data-bench-timeout', 'true');
117-
setComplete();
118-
}, 30000);
119-
performance.mark('mount-start');
120-
fn();
121134
},
122135
[setComplete],
123136
);
@@ -195,6 +208,7 @@ export function useBenchState() {
195208
setShowDoubleList(false);
196209
setDoubleListCount(undefined);
197210
setDetailIssueNumber(null);
211+
setPinnedNumbers([]);
198212
}, []);
199213

200214
const initDoubleList = useCallback(
@@ -272,6 +286,31 @@ export function useBenchState() {
272286
],
273287
);
274288

289+
const initMultiView = useCallback(
290+
async (n: number) => {
291+
await seedIssueList(FIXTURE_ISSUES.slice(0, n));
292+
293+
setDetailIssueNumber(1);
294+
setPinnedNumbers(Array.from({ length: 10 }, (_, i) => i + 1));
295+
296+
await measureMount(() => setListViewCount(n), {
297+
signalComplete: false,
298+
});
299+
300+
await waitForElement('[data-detail-view]');
301+
await waitForElement('[data-pinned-number]');
302+
setComplete();
303+
},
304+
[
305+
measureMount,
306+
setListViewCount,
307+
setDetailIssueNumber,
308+
setPinnedNumbers,
309+
waitForElement,
310+
setComplete,
311+
],
312+
);
313+
275314
const getRenderedCount = useCallback(
276315
() => listViewCount ?? 0,
277316
[listViewCount],
@@ -289,6 +328,7 @@ export function useBenchState() {
289328
apiRef.current = {
290329
init,
291330
initDoubleList,
331+
initMultiView,
292332
unmountAll,
293333
mountUnmountCycle,
294334
mountSortedView,
@@ -323,6 +363,7 @@ export function useBenchState() {
323363
showDoubleList,
324364
doubleListCount,
325365
detailIssueNumber,
366+
pinnedNumbers,
326367
renderLimit,
327368
containerRef,
328369

examples/benchmark-react/src/shared/components.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,28 @@ export function IssueRow({ issue }: { issue: Issue }) {
7474
);
7575
}
7676

77+
export const PINNED_STRIP_STYLE = {
78+
display: 'flex',
79+
gap: 4,
80+
flexWrap: 'wrap',
81+
} as const;
82+
83+
/**
84+
* Compact card for "pinned/bookmarked" issues — structurally different from
85+
* IssueRow. Each card fetches its issue individually by ID (per-library),
86+
* so the multi-view scenario tests cross-query entity propagation.
87+
*/
88+
export function PinnedCardView({ issue }: { issue: Issue }) {
89+
return (
90+
<div data-pinned-number={issue.number} data-bench-item>
91+
<span data-title>{issue.title}</span>
92+
<UserView user={issue.user} />
93+
<span data-state>{STATE_ICONS[issue.state] ?? issue.state}</span>
94+
<span data-comments>{issue.comments}</span>
95+
</div>
96+
);
97+
}
98+
7799
/** Plain keyed list. React can reconcile inserts/deletes by key without
78100
* re-rendering every row (unlike index-based virtualized lists). */
79101
export function PlainIssueList({

examples/benchmark-react/src/shared/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export interface BenchAPI {
4545
moveItem?(id: number): void;
4646
/** Switch between sorted list view and individual issue detail views 10 times (20 renders). Exercises normalized cache lookup (data-client) vs per-navigation fetch (others). */
4747
listDetailSwitch?(count: number): void;
48+
/** Mount list + detail panel + pinned card strip for multi-view entity propagation. */
49+
initMultiView?(count: number): void;
50+
/** Update an entity that appears in list + detail + pinned views; waits for all three to reflect the change. */
51+
updateEntityMultiView?(id: number): void;
4852
/** Trigger store garbage collection (data-client only). Used by memory scenarios to flush unreferenced data before heap measurement. */
4953
triggerGC?(): void;
5054
/** Cap DOM rendering to the first N items while keeping all data in the store. */
@@ -110,6 +114,7 @@ export interface Issue {
110114
export type ScenarioAction =
111115
| { action: 'init'; args: [number] }
112116
| { action: 'updateEntity'; args: [number] }
117+
| { action: 'updateEntityMultiView'; args: [number] }
113118
| { action: 'updateUser'; args: [string] }
114119
| { action: 'unmountAll'; args: [] }
115120
| { action: 'unshiftItem'; args: [] }

0 commit comments

Comments
 (0)