From 42114c89cde166f7715a3c776fd8c02b5fef292c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 21 Mar 2026 12:39:15 -0400 Subject: [PATCH 1/5] demo(benchmark-react): replace react-window with renderLimit (#3803) Drop react-window in favor of plain keyed lists with a configurable renderLimit that caps DOM rendering while keeping all data in the store. This decouples store size from DOM pressure and lets React reconcile by key instead of index. Also: - Rename scenarios for clarity (update-entity, update-user, etc.) - Increase default mountCount to 1000 for update/ref-stability scenarios - Make React Compiler the default build (opt-out with REACT_COMPILER=false) Made-with: Cursor --- .cursor/rules/benchmarking.mdc | 8 +-- examples/benchmark-react/.babelrc.js | 2 +- examples/benchmark-react/README.md | 36 ++++++------- examples/benchmark-react/bench/runner.ts | 7 +++ examples/benchmark-react/bench/scenarios.ts | 40 ++++++++++---- examples/benchmark-react/bench/validate.ts | 1 - examples/benchmark-react/package.json | 7 ++- .../benchmark-react/src/data-client/index.tsx | 53 ++++++++----------- .../src/shared/benchHarness.tsx | 3 ++ .../benchmark-react/src/shared/components.tsx | 30 ++++------- examples/benchmark-react/src/shared/types.ts | 4 ++ examples/benchmark-react/src/swr/index.tsx | 53 ++++++++----------- .../src/tanstack-query/index.tsx | 53 ++++++++----------- yarn.lock | 11 ---- 14 files changed, 148 insertions(+), 160 deletions(-) diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 1f69b835ac6e..45ae39d52c99 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -31,7 +31,7 @@ Use this mapping when deciding which React benchmark scenarios are relevant to a - Relevant for: `@data-client/react` hooks, `@data-client/core` store initialization - All libraries -- **Update propagation** (`update-single-entity`, `update-shared-user-500-mounted`, `update-shared-user-10000-mounted`) +- **Update propagation** (`update-entity`, `update-user`, `update-user-10000`) - Exercises: store update → React rerender → DOM mutation - Relevant for: `@data-client/core` dispatch/reducer, `@data-client/react` subscription/selector - All libraries (normalization advantage shows with shared user at scale) @@ -41,7 +41,7 @@ Use this mapping when deciding which React benchmark scenarios are relevant to a - Relevant for: `@data-client/normalizr` denormalize memoization, Entity identity - All libraries (data-client should show fewest changed refs) -- **Sorted/derived view** (`sorted-view-mount-500`, `sorted-view-update-entity`) +- **Sorted/derived view** (`getlist-500-sorted`, `update-entity-sorted`) - Exercises: `Query` schema memoization via `useQuery` (data-client) vs `useMemo` sort (competitors) - Relevant for: `@data-client/endpoint` Query, `@data-client/normalizr` MemoCache, `@data-client/react` useQuery - All libraries @@ -58,8 +58,8 @@ Use this mapping when deciding which React benchmark scenarios are relevant to a | Category | Scenarios | Typical run-to-run spread | |---|---|---| -| **Stable** | `getlist-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | -| **Moderate** | `update-shared-user-*`, `sorted-view-update-*` | 5–10% | +| **Stable** | `getlist-*`, `update-entity`, `ref-stability-*` | 2–5% | +| **Moderate** | `update-user-*`, `update-entity-sorted` | 5–10% | | **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10–25% | Regressions >5% on stable scenarios or >15% on volatile scenarios are worth investigating. diff --git a/examples/benchmark-react/.babelrc.js b/examples/benchmark-react/.babelrc.js index bfd95e4230c7..8b171cb134bd 100644 --- a/examples/benchmark-react/.babelrc.js +++ b/examples/benchmark-react/.babelrc.js @@ -1,5 +1,5 @@ const options = { polyfillMethod: false }; -if (process.env.REACT_COMPILER === 'true') { +if (process.env.REACT_COMPILER !== 'false') { options.reactCompiler = {}; } diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 432135ab0516..3c0b30631f4c 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -28,11 +28,11 @@ The repo has two benchmark suites: **Hot path (CI)** - **Get list** (`getlist-100`, `getlist-500`) — Time to show a ListView component that auto-fetches 100 or 500 issues from the list endpoint, then renders (unit: ms). Exercises the full fetch + normalization + render pipeline. -- **Update single entity** (`update-single-entity`) — Time to update one issue and propagate to the UI (unit: ms). -- **Update shared user (scaling)** (`update-shared-user-500-mounted`, `update-shared-user-10000-mounted`) — Update one shared user with 500 or 10,000 mounted issues to test subscriber scaling. Normalized cache: one store update, all views of that user update. +- **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. +- **Update entity** (`update-entity`) — Time to update one issue and propagate to the UI (unit: ms). +- **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. +- **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. - **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. -- **Sorted view mount** (`sorted-view-mount-500`) — Mount 500 issues through a sorted/derived view. data-client uses `useQuery(sortedIssuesQuery)` with `Query` schema memoization; competitors use `useMemo` + sort. -- **Sorted view update** (`sorted-view-update-entity`) — After mounting a sorted view, update one entity. data-client's `Query` memoization avoids re-sorting when sort keys are unchanged. - **Invalidate and resolve** (`invalidate-and-resolve`) — data-client only; invalidates a cached endpoint and immediately re-resolves. Measures Suspense boundary round-trip. **With network (local comparison)** @@ -55,17 +55,17 @@ These are approximate values to help calibrate expectations. Exact numbers vary | Scenario | data-client | tanstack-query | swr | |---|---|---|---| | `getlist-100` | ~similar | ~similar | ~similar | -| `update-shared-user-500-mounted` | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | +| `update-user` | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | | `ref-stability-issue-changed` (100 mounted) | ~1 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | | `ref-stability-user-changed` (100 mounted) | ~5 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | -| `sorted-view-update-entity` | Fast (Query memoization skips re-sort) | Re-sorts on every issue change | Re-sorts on every issue change | +| `update-entity-sorted` | Fast (Query memoization skips re-sort) | Re-sorts on every issue change | Re-sorts on every issue change | ## Expected variance | Category | Scenarios | Typical run-to-run spread | |---|---|---| -| **Stable** | `getlist-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2-5% | -| **Moderate** | `update-shared-user-*`, `sorted-view-update-*` | 5-10% | +| **Stable** | `getlist-*`, `update-entity`, `ref-stability-*` | 2-5% | +| **Moderate** | `update-user-*`, `update-entity-sorted` | 5-10% | | **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10-25% | Regressions >5% on stable scenarios or >15% on volatile scenarios are worth investigating. @@ -107,24 +107,24 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve Or from repo root after a build: start preview in one terminal, then in another run `yarn workspace example-benchmark-react bench`. -3. **With React Compiler** +3. **Without React Compiler** - To measure the impact of React Compiler, build and bench with it enabled: + The default build includes React Compiler. To measure impact without it: ```bash cd examples/benchmark-react - yarn build:compiler # builds with babel-plugin-react-compiler + yarn build:no-compiler # builds without babel-plugin-react-compiler yarn preview & sleep 5 - yarn bench:compiler # labels results with [compiler] suffix + yarn bench:no-compiler # labels results with [no-compiler] suffix ``` - Or as a single command: `yarn bench:run:compiler`. + Or as a single command: `yarn bench:run:no-compiler`. - Results are labelled `[compiler]` so you can compare side-by-side with a normal run by loading both JSON files into the report viewer's history feature. + Results are labelled `[no-compiler]` so you can compare side-by-side with the default run by loading both JSON files into the report viewer's history feature. - You can also set the env vars directly for custom combinations: - - `REACT_COMPILER=true` — enables the Babel plugin at build time + Env vars for custom combinations: + - `REACT_COMPILER=false` — disables the Babel plugin at build time - `BENCH_LABEL=` — appends `[]` to all result names at bench time - `BENCH_PORT=` — port for `preview` server and bench runner (default `5173`) - `BENCH_BASE_URL=` — full base URL override (takes precedence over `BENCH_PORT`) @@ -163,8 +163,8 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve Scenarios are classified as `small` or `large` based on their cost: - - **Small** (3 warmup + 15 measurement): `getlist-100`, `update-single-entity`, `ref-stability-*`, `invalidate-and-resolve`, `unshift-item`, `delete-item` - - **Large** (1 warmup + 4 measurement): `getlist-500`, `update-shared-user-500-mounted`, `update-shared-user-10000-mounted`, `update-shared-user-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + - **Small** (3 warmup + 15 measurement): `getlist-100`, `update-entity`, `ref-stability-*`, `invalidate-and-resolve`, `unshift-item`, `delete-item` + - **Large** (1 warmup + 4 measurement): `getlist-500`, `getlist-500-sorted`, `update-user`, `update-user-10000`, `update-entity-sorted`, `list-detail-switch` - **Memory** (opt-in, 1 warmup + 3 measurement): `memory-mount-unmount-cycle` — run with `--action memory` When running all scenarios (`yarn bench`), each group runs with its own warmup/measurement count. Use `--size` to run only one group. diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 27d5e678ad61..74ab3d32f978 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -172,6 +172,13 @@ async function runScenario( ); } + if (scenario.renderLimit != null) { + await (bench as any).evaluate( + (api: any, n: number) => api.setRenderLimit(n), + scenario.renderLimit, + ); + } + const isMemory = scenario.action === 'mountUnmountCycle' && scenario.resultMetric === 'heapDelta'; diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 54b4f91de266..8f96b9867e8d 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -56,6 +56,8 @@ interface BaseScenario { onlyLibs?: string[]; /** Result is deterministic (zero variance); run exactly once with no warmup. */ deterministic?: boolean; + /** Cap DOM rendering to first N items while keeping all data in the store. */ + renderLimit?: number; } const BASE_SCENARIOS: BaseScenario[] = [ @@ -73,10 +75,12 @@ const BASE_SCENARIOS: BaseScenario[] = [ size: 'large', }, { - nameSuffix: 'update-single-entity', + nameSuffix: 'update-entity', action: 'updateEntity', args: [1], category: 'hotPath', + mountCount: 1000, + renderLimit: 100, }, { nameSuffix: 'ref-stability-issue-changed', @@ -84,6 +88,8 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: [1], resultMetric: 'issueRefChanged', category: 'hotPath', + mountCount: 1000, + renderLimit: 100, deterministic: true, }, { @@ -92,14 +98,17 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['user0'], resultMetric: 'userRefChanged', category: 'hotPath', + mountCount: 1000, + renderLimit: 100, deterministic: true, }, { - nameSuffix: 'update-shared-user-500-mounted', + nameSuffix: 'update-user', action: 'updateUser', args: ['user0'], category: 'hotPath', - mountCount: 500, + mountCount: 1000, + renderLimit: 100, size: 'large', }, { @@ -111,41 +120,46 @@ const BASE_SCENARIOS: BaseScenario[] = [ size: 'large', }, { - nameSuffix: 'sorted-view-mount-500', + nameSuffix: 'getlist-500-sorted', action: 'mountSortedView', args: [500], category: 'hotPath', size: 'large', }, { - nameSuffix: 'sorted-view-update-entity', + nameSuffix: 'update-entity-sorted', action: 'updateEntity', args: [1], category: 'hotPath', - mountCount: 500, + mountCount: 1000, + renderLimit: 100, preMountAction: 'mountSortedView', size: 'large', }, { nameSuffix: 'list-detail-switch', action: 'listDetailSwitch', - args: [500], + args: [1000], category: 'hotPath', size: 'large', + renderLimit: 100, }, { - nameSuffix: 'update-shared-user-10000-mounted', + nameSuffix: 'update-user-10000', action: 'updateUser', args: ['user0'], category: 'hotPath', mountCount: 10000, size: 'large', + renderLimit: 100, }, { nameSuffix: 'invalidate-and-resolve', action: 'invalidateAndResolve', args: [1], category: 'hotPath', + mountCount: 1000, + renderLimit: 100, onlyLibs: ['data-client'], }, { @@ -153,21 +167,24 @@ const BASE_SCENARIOS: BaseScenario[] = [ action: 'unshiftItem', args: [], category: 'hotPath', - mountCount: 100, + mountCount: 1000, + renderLimit: 100, }, { nameSuffix: 'delete-item', action: 'deleteEntity', args: [1], category: 'hotPath', - mountCount: 100, + mountCount: 1000, + renderLimit: 100, }, { nameSuffix: 'move-item', action: 'moveItem', args: [1], category: 'hotPath', - mountCount: 100, + mountCount: 1000, + renderLimit: 100, preMountAction: 'initDoubleList', }, ]; @@ -188,6 +205,7 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => mountCount: base.mountCount, preMountAction: base.preMountAction, deterministic: base.deterministic, + renderLimit: base.renderLimit, }), ), ); diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 708379dc49fc..f155befaf1e2 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -20,7 +20,6 @@ const BASE_URL = process.env.BENCH_BASE_URL ?? `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; -// react-window virtualises; keep test counts within the visible window const TEST_ISSUE_COUNT = 20; // --------------------------------------------------------------------------- diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 0a2c53aab40a..6e664820d728 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -5,15 +5,15 @@ "description": "React rendering benchmark comparing @data-client/react against other data libraries", "scripts": { "build": "BROWSERSLIST_ENV=2026 webpack --mode=production", - "build:compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=true webpack --mode=production", + "build:no-compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=false webpack --mode=production", "preview": "serve dist -l ${BENCH_PORT:-5173} --no-request-logging", "bench": "npx tsx bench/runner.ts", - "bench:compiler": "BENCH_LABEL=compiler npx tsx bench/runner.ts", + "bench:no-compiler": "BENCH_LABEL=no-compiler npx tsx bench/runner.ts", "bench:small": "npx tsx bench/runner.ts --size small", "bench:large": "npx tsx bench/runner.ts --size large", "bench:dc": "npx tsx bench/runner.ts --lib data-client", "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", - "bench:run:compiler": "yarn build:compiler && (yarn preview &) && sleep 5 && yarn bench:compiler", + "bench:run:no-compiler": "yarn build:no-compiler && (yarn preview &) && sleep 5 && yarn bench:no-compiler", "validate": "npx tsx bench/validate.ts", "validate:run": "yarn build && (yarn preview &) && sleep 5 && yarn validate" }, @@ -28,7 +28,6 @@ "@tanstack/react-query": "5.62.7", "react": "19.2.3", "react-dom": "19.2.3", - "react-window": "^2.2.7", "swr": "2.4.1" }, "devDependencies": { diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index a33c0f261983..ed690ea4cde9 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -13,10 +13,7 @@ import { } from '@shared/benchHarness'; import { DOUBLE_LIST_STYLE, - ISSUE_HEIGHT, IssueRow, - IssuesRow, - LIST_STYLE, PlainIssueList, } from '@shared/components'; import { @@ -33,7 +30,6 @@ import { import { getIssue, patchIssue } from '@shared/server'; import type { Issue } from '@shared/types'; import React, { useCallback } from 'react'; -import { List } from 'react-window'; /** GCPolicy with no interval (won't fire during timing scenarios) and instant * expiry so an explicit sweep() collects all unreferenced data immediately. */ @@ -57,56 +53,50 @@ class BenchGCPolicy extends GCPolicy { const benchGC = new BenchGCPolicy(); /** Renders issues from the list endpoint (models rendering a list fetch response). */ -function ListView({ count }: { count: number }) { +function ListView({ count, limit }: { count: number; limit?: number }) { const { data: issues } = useDLE(IssueResource.getList, { count }); if (!issues) return null; const list = issues as Issue[]; setCurrentIssues(list); - return ( - - ); + return ; } /** Renders issues sorted by title via Query schema (memoized by MemoCache). */ -function SortedListView({ count }: { count: number }) { +function SortedListView({ count, limit }: { count: number; limit?: number }) { const { data: issues } = useDLE(sortedIssuesEndpoint, { count }); if (!issues?.length) return null; return (
- +
); } -function StateListView({ state, count }: { state: string; count: number }) { +function StateListView({ + state, + count, + limit, +}: { + state: string; + count: number; + limit?: number; +}) { const { data: issues } = useDLE(IssueResource.getList, { state, count }); if (!issues) return null; const list = issues as Issue[]; return (
{list.length} - +
); } -function DoubleListView({ count }: { count: number }) { +function DoubleListView({ count, limit }: { count: number; limit?: number }) { return (
- - + +
); } @@ -129,6 +119,7 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + renderLimit, containerRef, measureUpdate, registerAPI, @@ -241,12 +232,14 @@ function BenchmarkHarness() { return (
- {listViewCount != null && } + {listViewCount != null && ( + + )} {showSortedView && sortedViewCount != null && ( - + )} {showDoubleList && doubleListCount != null && ( - + )} {detailIssueNumber != null && ( Loading...
}> diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index d1ea05ecccbe..fb3c561db976 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -80,6 +80,7 @@ export function useBenchState() { const [detailIssueNumber, setDetailIssueNumber] = useState( null, ); + const [renderLimit, setRenderLimit] = useState(); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const apiRef = useRef(null as any); @@ -298,6 +299,7 @@ export function useBenchState() { setNetworkDelay, setMethodDelays, flushPendingMutations, + setRenderLimit, ...libraryActions, } as BenchAPI; }; @@ -321,6 +323,7 @@ export function useBenchState() { showDoubleList, doubleListCount, detailIssueNumber, + renderLimit, containerRef, measureMount, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index aa5853a61e7c..92ecfd64fa42 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -1,11 +1,7 @@ import React from 'react'; -import type { RowComponentProps } from 'react-window'; import type { Issue, User } from './types'; -export const ISSUE_HEIGHT = 30; -export const VISIBLE_COUNT = 40; -export const LIST_STYLE = { height: ISSUE_HEIGHT * VISIBLE_COUNT } as const; export const DOUBLE_LIST_STYLE = { display: 'flex', gap: 8 } as const; function djb2(str: string): number { @@ -78,25 +74,19 @@ export function IssueRow({ issue }: { issue: Issue }) { ); } -/** Generic react-window row that renders an IssueRow from an issues array. */ -export function IssuesRow({ - index, - style, +/** Plain keyed list. React can reconcile inserts/deletes by key without + * re-rendering every row (unlike index-based virtualized lists). */ +export function PlainIssueList({ issues, -}: RowComponentProps<{ issues: Issue[] }>) { - return ( -
- -
- ); -} - -/** Plain (non-virtualized) list keyed by issue number. Renders up to VISIBLE_COUNT issues. */ -export function PlainIssueList({ issues }: { issues: Issue[] }) { + limit, +}: { + issues: Issue[]; + limit?: number; +}) { const visible = - issues.length > VISIBLE_COUNT ? issues.slice(0, VISIBLE_COUNT) : issues; + limit && issues.length > limit ? issues.slice(0, limit) : issues; return ( -
+
{visible.map(issue => ( ))} diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 0eb02b094eea..8b566b1805cc 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -47,6 +47,8 @@ export interface BenchAPI { listDetailSwitch?(count: number): void; /** Trigger store garbage collection (data-client only). Used by memory scenarios to flush unreferenced data before heap measurement. */ triggerGC?(): void; + /** Cap DOM rendering to the first N items while keeping all data in the store. */ + setRenderLimit?(n: number | undefined): void; } declare global { @@ -142,4 +144,6 @@ export interface Scenario { preMountAction?: keyof BenchAPI; /** Result is deterministic (zero variance); run exactly once with no warmup. */ deterministic?: boolean; + /** Cap DOM rendering to first N items while keeping all data in the store. */ + renderLimit?: number; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index f17d81abce24..6d4a1fc635d0 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -5,10 +5,7 @@ import { } from '@shared/benchHarness'; import { DOUBLE_LIST_STYLE, - ISSUE_HEIGHT, IssueRow, - IssuesRow, - LIST_STYLE, PlainIssueList, } from '@shared/components'; import { @@ -21,7 +18,6 @@ import { setCurrentIssues } from '@shared/refStability'; import { UserResource, IssueResource } from '@shared/resources'; import type { Issue } from '@shared/types'; import React, { useCallback, useMemo } from 'react'; -import { List } from 'react-window'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; /** SWR fetcher: dispatches to shared resource fetch methods based on cache key */ @@ -42,19 +38,13 @@ const fetcher = (key: string): Promise => { return Promise.reject(new Error(`Unknown key: ${key}`)); }; -function SortedListView() { +function SortedListView({ limit }: { limit?: number }) { const { data: issues } = useSWR('issues:all', fetcher); const sorted = useMemo(() => (issues ? sortByTitle(issues) : []), [issues]); if (!sorted.length) return null; return (
- +
); } @@ -69,22 +59,22 @@ function DetailView({ number }: { number: number }) { ); } -function ListView({ count }: { count: number }) { +function ListView({ count, limit }: { count: number; limit?: number }) { const { data: issues } = useSWR(`issues:${count}`, fetcher); if (!issues) return null; setCurrentIssues(issues); - return ( - - ); + return ; } -function StateListView({ state, count }: { state: string; count: number }) { +function StateListView({ + state, + count, + limit, +}: { + state: string; + count: number; + limit?: number; +}) { const { data: issues } = useSWR( `issues:state:${state}:${count}`, fetcher, @@ -93,16 +83,16 @@ function StateListView({ state, count }: { state: string; count: number }) { return (
{issues.length} - +
); } -function DoubleListView({ count }: { count: number }) { +function DoubleListView({ count, limit }: { count: number; limit?: number }) { return (
- - + +
); } @@ -115,6 +105,7 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + renderLimit, containerRef, measureUpdate, registerAPI, @@ -196,10 +187,12 @@ function BenchmarkHarness() { return (
- {listViewCount != null && } - {showSortedView && } + {listViewCount != null && ( + + )} + {showSortedView && } {showDoubleList && doubleListCount != null && ( - + )} {detailIssueNumber != null && }
diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 900de1e398f3..9e038044f1f8 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -5,10 +5,7 @@ import { } from '@shared/benchHarness'; import { DOUBLE_LIST_STYLE, - ISSUE_HEIGHT, IssueRow, - IssuesRow, - LIST_STYLE, PlainIssueList, } from '@shared/components'; import { @@ -27,7 +24,6 @@ import { useQueryClient, } from '@tanstack/react-query'; import React, { useCallback, useMemo } from 'react'; -import { List } from 'react-window'; function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { const [type, id] = queryKey as [string, string | number | undefined]; @@ -51,7 +47,7 @@ const queryClient = new QueryClient({ }, }); -function SortedListView() { +function SortedListView({ limit }: { limit?: number }) { const { data: issues } = useQuery({ queryKey: ['issues', 'all'], queryFn, @@ -63,13 +59,7 @@ function SortedListView() { if (!sorted.length) return null; return (
- +
); } @@ -87,7 +77,7 @@ function DetailView({ number }: { number: number }) { ); } -function ListView({ count }: { count: number }) { +function ListView({ count, limit }: { count: number; limit?: number }) { const { data: issues } = useQuery({ queryKey: ['issues', count], queryFn, @@ -95,18 +85,18 @@ function ListView({ count }: { count: number }) { if (!issues) return null; const list = issues as Issue[]; setCurrentIssues(list); - return ( - - ); + return ; } -function StateListView({ state, count }: { state: string; count: number }) { +function StateListView({ + state, + count, + limit, +}: { + state: string; + count: number; + limit?: number; +}) { const { data: issues } = useQuery({ queryKey: ['issues', { state, count }], queryFn, @@ -116,16 +106,16 @@ function StateListView({ state, count }: { state: string; count: number }) { return (
{list.length} - +
); } -function DoubleListView({ count }: { count: number }) { +function DoubleListView({ count, limit }: { count: number; limit?: number }) { return (
- - + +
); } @@ -138,6 +128,7 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + renderLimit, containerRef, measureUpdate, registerAPI, @@ -222,10 +213,12 @@ function BenchmarkHarness() { return (
- {listViewCount != null && } - {showSortedView && } + {listViewCount != null && ( + + )} + {showSortedView && } {showDoubleList && doubleListCount != null && ( - + )} {detailIssueNumber != null && }
diff --git a/yarn.lock b/yarn.lock index bb58c9533d5f..27650ce296d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14654,7 +14654,6 @@ __metadata: playwright: "npm:1.58.2" react: "npm:19.2.3" react-dom: "npm:19.2.3" - react-window: "npm:^2.2.7" serve: "npm:14.2.6" swr: "npm:2.4.1" tsx: "npm:4.21.0" @@ -25056,16 +25055,6 @@ __metadata: languageName: node linkType: hard -"react-window@npm:^2.2.7": - version: 2.2.7 - resolution: "react-window@npm:2.2.7" - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - checksum: 10c0/4eba3bce2083fa53ac674513078fb23d4dc3ad7dfd12ea5733863b583ea1472294df791947a2b58f27bab45138cedf7a63fdc19b0420823bf19749aa10455b81 - languageName: node - linkType: hard - "react@npm:19.2.3": version: 19.2.3 resolution: "react@npm:19.2.3" From ea591a4956574bd1b4ec329e6376b0621674707a Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 21 Mar 2026 13:26:05 -0400 Subject: [PATCH 2/5] demo(benchmark-react): DRY runner and scenario definitions (#3804) Extract shared helpers (runRound, recordResult, warmupCount) to eliminate three copies of the lib-iteration loop. Consolidate three parallel result maps into a single Map. Derive BaseScenario from Scenario via Omit so new fields flow through automatically, and replace the 12-line manual property mapping with destructure + spread. Simplify react-commit eligibility from a 10-action list to !scenario.resultMetric. Remove dead startup code. Skip data-client-only scenarios when benchmarking all frameworks. Update README with measured results table. Made-with: Cursor --- examples/benchmark-react/README.md | 40 +- examples/benchmark-react/bench/runner.ts | 364 +++++++------------ examples/benchmark-react/bench/scenarios.ts | 32 +- examples/benchmark-react/src/shared/types.ts | 2 + 4 files changed, 171 insertions(+), 267 deletions(-) diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 3c0b30631f4c..aa30055075f1 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -50,15 +50,37 @@ The repo has two benchmark suites: ## Expected results -These are approximate values to help calibrate expectations. Exact numbers vary by machine and CPU throttling. - -| Scenario | data-client | tanstack-query | swr | -|---|---|---|---| -| `getlist-100` | ~similar | ~similar | ~similar | -| `update-user` | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | -| `ref-stability-issue-changed` (100 mounted) | ~1 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | -| `ref-stability-user-changed` (100 mounted) | ~5 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | -| `update-entity-sorted` | Fast (Query memoization skips re-sort) | Re-sorts on every issue change | Re-sorts on every issue change | +Illustrative **relative** results with **SWR = 100%** (baseline). For **duration** rows, each value is (library median ms ÷ SWR median ms) × 100 — **lower is faster**. For **ref-stability** rows, the same idea uses the “refs changed” count — **lower is fewer components that saw a new object reference**. Figures are rounded from the **Latest measured results** table below (network simulation on); absolute milliseconds will vary by machine, but **library-to-library ratios** are usually similar. + +| Category | Scenarios (representative) | data-client | tanstack-query | swr | +|---|---|---:|---:|---:| +| Get list (fetch + render) | `getlist-100`, `getlist-500`, `getlist-500-sorted` | ~104% | ~101% | **100%** | +| Mutations (with network sim) | `update-entity`, `unshift-item`, `delete-item`, `move-item` | ~2% | ~104% | **100%** | +| Sorted view: entity update | `update-entity-sorted` | ~2% | ~100% | **100%** | +| Large data: shared user update | `update-user` (1k rows rendered) | ~2% | ~102% | **100%** | +| Large data: shared user update | `update-user-10000` | ~5% | ~122% | **100%** | +| Large data: list ↔ detail | `list-detail-switch` | ~25% | ~106% | **100%** | + + +## Latest measured results (network simulation on) + +Median per metric; range is approximate 95% CI margin from the runner (`stats.ts`). **Network simulation** applies the per-RPC delays in `bench/scenarios.ts` (`NETWORK_SIM_DELAYS`, e.g. `fetchIssueList` 80 ms, `updateUser` 50 ms) so list refetches after an author update pay extra latency compared to normalized propagation. + +Run: **2026-03-21**, Linux (WSL2), `yarn build:benchmark-react`, static preview + `env -u CI npx tsx bench/runner.ts --network-sim true` (all libraries; memory scenarios not included). Numbers are **machine-specific**; use them for relative comparison between libraries, not as absolutes. + +| Scenario | Unit | data-client | tanstack-query | swr | +|---|---|---:|---:|---:| +| `getlist-100` | ms | 89.3 ± 0.11 | 88.9 ± 0.17 | 87.2 ± 0.49 | +| `getlist-500` | ms | 104.8 ± 1.37 | 101.2 ± 0.29 | 99.4 ± 0.69 | +| `update-entity` | ms | 2.1 ± 0.11 | 144.9 ± 0.58 | 143.0 ± 0.23 | +| `update-user` | ms | 3.1 ± 0.29 | 141.9 ± 0.00 | 139.0 ± 0.00 | +| `getlist-500-sorted` | ms | 103.2 ± 0.59 | 98.8 ± 0.39 | 98.9 ± 0.88 | +| `update-entity-sorted` | ms | 2.7 ± 0.00 | 139.7 ± 0.10 | 140.4 ± 0.88 | +| `list-detail-switch` | ms | 165.5 ± 21.69 | 694.4 ± 3.72 | 656.9 ± 26.95 | +| `update-user-10000` | ms | 9.1 ± 0.49 | 239.4 ± 0.59 | 195.7 ± 1.86 | +| `unshift-item` | ms | 3.0 ± 0.07 | 144.1 ± 0.46 | 139.8 ± 0.47 | +| `delete-item` | ms | 2.6 ± 0.07 | 142.2 ± 0.07 | 138.5 ± 0.36 | +| `move-item` | ms | 3.6 ± 0.11 | 154.5 ± 0.82 | 143.9 ± 0.82 | ## Expected variance diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 74ab3d32f978..4788168de3bb 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -1,6 +1,6 @@ /// import { chromium } from 'playwright'; -import type { Page } from 'playwright'; +import type { Browser, Page } from 'playwright'; import { collectMeasures, getMeasureDuration } from './measure.js'; import { collectHeapUsed } from './memory.js'; @@ -63,6 +63,8 @@ function filterScenarios(scenarios: Scenario[]): { networkSim, } = parseArgs(); + const libraries = libs ?? (process.env.CI ? ['data-client'] : [...LIBRARIES]); + let filtered = scenarios; // In CI, restrict to data-client hot-path only (existing behavior) @@ -105,7 +107,11 @@ function filterScenarios(scenarios: Scenario[]): { filtered = filtered.filter(s => s.name.includes(scenarioFilter)); } - const libraries = libs ?? (process.env.CI ? ['data-client'] : [...LIBRARIES]); + // Multi-lib runs: omit scenarios that do not apply to every selected library (e.g. invalidate-and-resolve). + filtered = filtered.filter( + s => + !s.onlyLibs?.length || libraries.every(lib => s.onlyLibs!.includes(lib)), + ); return { filtered, libraries, networkSim }; } @@ -120,9 +126,27 @@ const BASE_URL = const BENCH_LABEL = process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; const USE_TRACE = process.env.BENCH_TRACE === 'true'; +const MEMORY_WARMUP = 1; +const MEMORY_MEASUREMENTS = 3; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ScenarioResult { + value: number; + reactCommit?: number; + traceDuration?: number; +} + +interface ScenarioSamples { + value: number[]; + reactCommit: number[]; + trace: number[]; +} // --------------------------------------------------------------------------- -// Scenario runner (unchanged logic) +// Scenario runner // --------------------------------------------------------------------------- const REF_STABILITY_METRICS = ['issueRefChanged', 'userRefChanged'] as const; @@ -136,12 +160,6 @@ function isRefStabilityScenario(scenario: Scenario): scenario is Scenario & { ); } -interface ScenarioResult { - value: number; - reactCommit?: number; - traceDuration?: number; -} - async function runScenario( page: Page, lib: string, @@ -353,42 +371,6 @@ async function runScenario( }; } -// --------------------------------------------------------------------------- -// Startup -// --------------------------------------------------------------------------- - -interface StartupMetrics { - fcp: number; - taskDuration: number; -} - -async function runStartupScenario( - page: Page, - lib: string, -): Promise { - const cdp = await page.context().newCDPSession(page); - await cdp.send('Performance.enable'); - const appPath = `/${lib}/`; - await page.goto(`${BASE_URL}${appPath}`, { - waitUntil: 'networkidle', - timeout: 120000, - }); - await page.waitForSelector('[data-app-ready]', { - timeout: 120000, - state: 'attached', - }); - const { metrics } = await cdp.send('Performance.getMetrics'); - const fcp = - metrics.find( - (m: { name: string; value: number }) => m.name === 'FirstContentfulPaint', - )?.value ?? 0; - const taskDuration = - metrics.find( - (m: { name: string; value: number }) => m.name === 'TaskDuration', - )?.value ?? 0; - return { fcp, taskDuration }; -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -403,15 +385,73 @@ function shuffle(arr: T[]): T[] { } function scenarioUnit(scenario: Scenario): string { - if ( - scenario.resultMetric === 'issueRefChanged' || - scenario.resultMetric === 'userRefChanged' - ) - return 'count'; + if (isRefStabilityScenario(scenario)) return 'count'; if (scenario.resultMetric === 'heapDelta') return 'bytes'; return 'ms'; } +function recordResult( + samples: Map, + scenario: Scenario, + result: ScenarioResult, +) { + const s = samples.get(scenario.name)!; + s.value.push(result.value); + s.reactCommit.push(result.reactCommit ?? NaN); + s.trace.push(result.traceDuration ?? NaN); +} + +function warmupCount(scenario: Scenario): number { + if (scenario.deterministic) return 0; + if (scenario.category === 'memory') return MEMORY_WARMUP; + return RUN_CONFIG[scenario.size ?? 'small'].warmup; +} + +/** Run each scenario once per matching library (one browser context per lib). */ +async function runRound( + browser: Browser, + scenarios: Scenario[], + libs: string[], + networkSim: boolean, + samples: Map, + opts: { shuffleLibs?: boolean; showProgress?: boolean } = {}, +): Promise { + const orderedLibs = opts.shuffleLibs ? shuffle([...libs]) : libs; + let done = 0; + const total = scenarios.length; + + for (const lib of orderedLibs) { + const libScenarios = scenarios.filter(s => s.name.startsWith(`${lib}:`)); + if (libScenarios.length === 0) continue; + + const context = await browser.newContext(); + const page = await context.newPage(); + + for (const scenario of libScenarios) { + done++; + const prefix = opts.showProgress ? `[${done}/${total}] ` : ''; + try { + const result = await runScenario(page, lib, scenario, networkSim); + recordResult(samples, scenario, result); + const commitSuffix = + result.reactCommit != null ? + ` (commit ${result.reactCommit.toFixed(2)} ms)` + : ''; + process.stderr.write( + ` ${prefix}${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}${commitSuffix}\n`, + ); + } catch (err) { + console.error( + ` ${prefix}${scenario.name} FAILED:`, + err instanceof Error ? err.message : err, + ); + } + } + + await context.close(); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -432,11 +472,9 @@ async function main() { process.exit(1); } - // Separate memory into its own category (run in a distinct phase) const memoryScenarios = SCENARIOS_TO_RUN.filter(s => s.category === 'memory'); const mainScenarios = SCENARIOS_TO_RUN.filter(s => s.category !== 'memory'); - // Group main scenarios by size for differentiated run counts const bySize: Record = { small: [], large: [] }; for (const s of mainScenarios) { bySize[s.size ?? 'small'].push(s); @@ -445,59 +483,37 @@ async function main() { Object.entries(bySize) as [ScenarioSize, Scenario[]][] ).filter(([, arr]) => arr.length > 0); - const results: Record = {}; - const reactCommitResults: Record = {}; - const traceResults: Record = {}; - for (const scenario of SCENARIOS_TO_RUN) { - results[scenario.name] = []; - reactCommitResults[scenario.name] = []; - traceResults[scenario.name] = []; + const samples = new Map(); + for (const s of SCENARIOS_TO_RUN) { + samples.set(s.name, { value: [], reactCommit: [], trace: [] }); } const browser = await chromium.launch({ headless: true }); - // Run deterministic scenarios once (no warmup needed) — main scenarios only + // Deterministic scenarios: run once, no warmup const deterministicNames = new Set(); const deterministicScenarios = mainScenarios.filter(s => s.deterministic); if (deterministicScenarios.length > 0) { process.stderr.write( `\n── Deterministic scenarios (${deterministicScenarios.length}) ──\n`, ); - for (const lib of libraries) { - const libScenarios = deterministicScenarios.filter(s => - s.name.startsWith(`${lib}:`), - ); - if (libScenarios.length === 0) continue; - const context = await browser.newContext(); - const page = await context.newPage(); - for (const scenario of libScenarios) { - try { - const result = await runScenario(page, lib, scenario, networkSim); - results[scenario.name].push(result.value); - reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); - traceResults[scenario.name].push(result.traceDuration ?? NaN); - process.stderr.write( - ` ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}\n`, - ); - } catch (err) { - console.error( - ` ${scenario.name} FAILED:`, - err instanceof Error ? err.message : err, - ); - } - deterministicNames.add(scenario.name); - } - await context.close(); - } + await runRound( + browser, + deterministicScenarios, + libraries, + networkSim, + samples, + ); + for (const s of deterministicScenarios) deterministicNames.add(s.name); } - // Run each size group with adaptive per-scenario convergence + // Adaptive-convergence rounds per size group for (const [size, scenarios] of sizeGroups) { const { warmup, minMeasurement, maxMeasurement, targetMarginPct } = RUN_CONFIG[size]; const nonDeterministic = scenarios.filter( s => !deterministicNames.has(s.name), - ); // main scenarios only (memory runs in its own phase) + ); if (nonDeterministic.length === 0) continue; const maxRounds = warmup + maxMeasurement; @@ -510,52 +526,29 @@ async function main() { const phaseTotal = isMeasure ? maxMeasurement : warmup; const active = nonDeterministic.filter(s => !converged.has(s.name)); if (active.length === 0) break; + process.stderr.write( `\n── ${size} round ${round + 1}/${maxRounds} (${phase} ${phaseRound}/${phaseTotal}, ${active.length}/${nonDeterministic.length} active) ──\n`, ); - let scenarioDone = 0; - for (const lib of shuffle([...libraries])) { - const libScenarios = active.filter(s => s.name.startsWith(`${lib}:`)); - if (libScenarios.length === 0) continue; - - const context = await browser.newContext(); - const page = await context.newPage(); - - for (const scenario of libScenarios) { - try { - const result = await runScenario(page, lib, scenario, networkSim); - results[scenario.name].push(result.value); - reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); - traceResults[scenario.name].push(result.traceDuration ?? NaN); - scenarioDone++; - process.stderr.write( - ` [${scenarioDone}/${active.length}] ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}${result.reactCommit != null ? ` (commit ${result.reactCommit.toFixed(2)} ms)` : ''}\n`, - ); - } catch (err) { - scenarioDone++; - console.error( - ` [${scenarioDone}/${active.length}] ${scenario.name} FAILED:`, - err instanceof Error ? err.message : err, - ); - } - } - await context.close(); - } + await runRound(browser, active, libraries, networkSim, samples, { + shuffleLibs: true, + showProgress: true, + }); // After each measurement round, check per-scenario convergence if (isMeasure) { for (const scenario of active) { if ( isConverged( - results[scenario.name], + samples.get(scenario.name)!.value, warmup, targetMarginPct, minMeasurement, ) ) { converged.add(scenario.name); - const nMeasured = results[scenario.name].length - warmup; + const nMeasured = samples.get(scenario.name)!.value.length - warmup; process.stderr.write( ` [converged] ${scenario.name} after ${nMeasured} measurements\n`, ); @@ -571,77 +564,20 @@ async function main() { } } - // Memory category: run in its own phase (opt-in via --action memory) - const MEMORY_WARMUP = 1; - const MEMORY_MEASUREMENTS = 3; + // Memory: separate phase (opt-in via --action memory) if (memoryScenarios.length > 0) { + const totalRounds = MEMORY_WARMUP + MEMORY_MEASUREMENTS; process.stderr.write( `\n── Memory (${memoryScenarios.length} scenarios, ${MEMORY_WARMUP} warmup + ${MEMORY_MEASUREMENTS} measurements) ──\n`, ); - for (let round = 0; round < MEMORY_WARMUP + MEMORY_MEASUREMENTS; round++) { - const isMeasure = round >= MEMORY_WARMUP; - const phase = isMeasure ? 'measure' : 'warmup'; + for (let round = 0; round < totalRounds; round++) { + const phase = round >= MEMORY_WARMUP ? 'measure' : 'warmup'; process.stderr.write( - `\n── Memory round ${round + 1}/${MEMORY_WARMUP + MEMORY_MEASUREMENTS} (${phase}) ──\n`, + `\n── Memory round ${round + 1}/${totalRounds} (${phase}) ──\n`, ); - for (const lib of shuffle([...libraries])) { - const libScenarios = memoryScenarios.filter(s => - s.name.startsWith(`${lib}:`), - ); - if (libScenarios.length === 0) continue; - const context = await browser.newContext(); - const page = await context.newPage(); - for (const scenario of libScenarios) { - try { - const result = await runScenario(page, lib, scenario, networkSim); - results[scenario.name].push(result.value); - reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); - traceResults[scenario.name].push(result.traceDuration ?? NaN); - process.stderr.write( - ` ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}\n`, - ); - } catch (err) { - console.error( - ` ${scenario.name} FAILED:`, - err instanceof Error ? err.message : err, - ); - } - } - await context.close(); - } - } - } - - // Startup scenarios (fast; only locally) - const startupResults: Record = {}; - const includeStartup = false; // Bench not set up for startup metrics (FCP/task duration) - if (includeStartup) { - for (const lib of libraries) { - startupResults[lib] = { fcp: [], tbt: [] }; - } - const STARTUP_RUNS = 5; - for (let round = 0; round < STARTUP_RUNS; round++) { - process.stderr.write( - `\n── Startup round ${round + 1}/${STARTUP_RUNS} ──\n`, - ); - for (const lib of shuffle([...libraries])) { - const context = await browser.newContext(); - const page = await context.newPage(); - try { - const m = await runStartupScenario(page, lib); - startupResults[lib].fcp.push(m.fcp * 1000); - startupResults[lib].tbt.push(m.taskDuration * 1000); - process.stderr.write( - ` ${lib}: fcp ${(m.fcp * 1000).toFixed(2)} ms, task ${(m.taskDuration * 1000).toFixed(2)} ms\n`, - ); - } catch (err) { - console.error( - ` ${lib} startup FAILED:`, - err instanceof Error ? err.message : err, - ); - } - await context.close(); - } + await runRound(browser, memoryScenarios, libraries, networkSim, samples, { + shuffleLibs: true, + }); } } @@ -652,13 +588,11 @@ async function main() { // --------------------------------------------------------------------------- const report: BenchmarkResult[] = []; for (const scenario of SCENARIOS_TO_RUN) { - const samples = results[scenario.name]; - const warmupRuns = - scenario.deterministic ? 0 - : scenario.category === 'memory' ? MEMORY_WARMUP - : RUN_CONFIG[scenario.size ?? 'small'].warmup; - if (samples.length <= warmupRuns) continue; - const { median, range } = computeStats(samples, warmupRuns); + const s = samples.get(scenario.name)!; + const warmup = warmupCount(scenario); + if (s.value.length <= warmup) continue; + + const { median, range } = computeStats(s.value, warmup); const unit = scenarioUnit(scenario); report.push({ name: scenario.name, @@ -666,22 +600,12 @@ async function main() { value: Math.round(median * 100) / 100, range, }); - const reactSamples = reactCommitResults[scenario.name] - .slice(warmupRuns) + + // React commit times (only meaningful for duration-based scenarios) + const reactSamples = s.reactCommit + .slice(warmup) .filter(x => !Number.isNaN(x)); - if ( - reactSamples.length > 0 && - (scenario.action === 'init' || - scenario.action === 'initDoubleList' || - scenario.action === 'updateEntity' || - scenario.action === 'updateUser' || - scenario.action === 'mountSortedView' || - scenario.action === 'listDetailSwitch' || - scenario.action === 'invalidateAndResolve' || - scenario.action === 'unshiftItem' || - scenario.action === 'deleteEntity' || - scenario.action === 'moveItem') - ) { + if (reactSamples.length > 0 && !scenario.resultMetric) { const { median: rcMedian, range: rcRange } = computeStats( reactSamples, 0, @@ -693,9 +617,9 @@ async function main() { range: rcRange, }); } - const traceSamples = traceResults[scenario.name] - .slice(warmupRuns) - .filter(x => !Number.isNaN(x)); + + // Chrome trace durations (opt-in via BENCH_TRACE=true) + const traceSamples = s.trace.slice(warmup).filter(x => !Number.isNaN(x)); if (traceSamples.length > 0) { const { median: trMedian, range: trRange } = computeStats( traceSamples, @@ -710,30 +634,6 @@ async function main() { } } - if (includeStartup) { - for (const lib of libraries) { - const s = startupResults[lib]; - if (s && s.fcp.length > 0) { - const fcpStats = computeStats(s.fcp, 0); - report.push({ - name: `${lib}: startup-fcp`, - unit: 'ms', - value: Math.round(fcpStats.median * 100) / 100, - range: fcpStats.range, - }); - } - if (s && s.tbt.length > 0) { - const tbtStats = computeStats(s.tbt, 0); - report.push({ - name: `${lib}: startup-task-duration`, - unit: 'ms', - value: Math.round(tbtStats.median * 100) / 100, - range: tbtStats.range, - }); - } - } - } - if (BENCH_LABEL) { for (const entry of report) { entry.name += BENCH_LABEL; diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 8f96b9867e8d..b3701eed25cd 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -42,23 +42,10 @@ export const ACTION_GROUPS: Record = { memory: ['mountUnmountCycle'], }; -interface BaseScenario { +type BaseScenario = Omit & { nameSuffix: string; - action: Scenario['action']; - args: unknown[]; - resultMetric?: Scenario['resultMetric']; category: NonNullable; - size?: ScenarioSize; - mountCount?: number; - /** Use a different BenchAPI method to pre-mount items (e.g. 'mountSortedView' instead of 'mount'). */ - preMountAction?: keyof BenchAPI; - /** Only run for these libraries. Omit to run for all. */ - onlyLibs?: string[]; - /** Result is deterministic (zero variance); run exactly once with no warmup. */ - deterministic?: boolean; - /** Cap DOM rendering to first N items while keeping all data in the store. */ - renderLimit?: number; -} +}; const BASE_SCENARIOS: BaseScenario[] = [ { @@ -195,17 +182,10 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => BASE_SCENARIOS.filter( base => !base.onlyLibs || base.onlyLibs.includes(lib), ).map( - (base): Scenario => ({ - name: `${lib}: ${base.nameSuffix}`, - action: base.action, - args: base.args, - resultMetric: base.resultMetric, - category: base.category, - size: base.size, - mountCount: base.mountCount, - preMountAction: base.preMountAction, - deterministic: base.deterministic, - renderLimit: base.renderLimit, + ({ nameSuffix, onlyLibs, ...rest }): Scenario => ({ + name: `${lib}: ${nameSuffix}`, + ...rest, + ...(onlyLibs ? { onlyLibs: [...onlyLibs] } : {}), }), ), ); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 8b566b1805cc..e761282a6dae 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -146,4 +146,6 @@ export interface Scenario { deterministic?: boolean; /** Cap DOM rendering to first N items while keeping all data in the store. */ renderLimit?: number; + /** If set, scenario applies only to these libs; dropped when any selected library is not listed. */ + onlyLibs?: string[]; } From 9c22178c9f456b3ff90fc8e9fac7fcaa59cced95 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 21 Mar 2026 14:30:39 -0400 Subject: [PATCH 3/5] demo(benchmark-react): improve measurement stability (#3805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce benchmark variance and improve result reliability: - IQR outlier trimming in stats (single GC spikes no longer widen CI) - Force V8 GC via CDP between scenarios and after pre-mount - Shuffle scenario order within each library to eliminate ordering bias - Bump minMeasurement (small: 3→5, large: 2→3) for more reliable convergence - Increase large maxMeasurement in CI (4→6) for more convergence headroom Made-with: Cursor --- examples/benchmark-react/bench/runner.ts | 32 ++++++++--- examples/benchmark-react/bench/scenarios.ts | 16 ++++-- examples/benchmark-react/bench/stats.ts | 59 +++++++++++++++------ 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 4788168de3bb..030ee6675527 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -1,6 +1,6 @@ /// import { chromium } from 'playwright'; -import type { Browser, Page } from 'playwright'; +import type { Browser, CDPSession, Page } from 'playwright'; import { collectMeasures, getMeasureDuration } from './measure.js'; import { collectHeapUsed } from './memory.js'; @@ -165,6 +165,7 @@ async function runScenario( lib: string, scenario: Scenario, networkSim: boolean, + cdp?: CDPSession, ): Promise { const appPath = `/${lib}/`; await page.goto(`${BASE_URL}${appPath}`, { @@ -201,13 +202,13 @@ async function runScenario( scenario.action === 'mountUnmountCycle' && scenario.resultMetric === 'heapDelta'; if (isMemory) { - const cdp = await page.context().newCDPSession(page); + const memoryCdp = await page.context().newCDPSession(page); try { - await cdp.send('Performance.enable'); + await memoryCdp.send('Performance.enable'); } catch { // best-effort } - const heapBefore = await collectHeapUsed(cdp); + const heapBefore = await collectHeapUsed(memoryCdp); await (bench as any).evaluate(async (api: any, a: unknown[]) => { if (api.mountUnmountCycle) await api.mountUnmountCycle(...(a as [number, number])); @@ -228,13 +229,14 @@ async function runScenario( if (api.triggerGC) api.triggerGC(); }); await page.waitForTimeout(100); - const heapAfter = await collectHeapUsed(cdp); + const heapAfter = await collectHeapUsed(memoryCdp); await bench.dispose(); return { value: heapAfter - heapBefore }; } const isUpdate = scenario.action === 'updateEntity' || + scenario.action === 'updateEntityMultiView' || scenario.action === 'updateUser' || scenario.action === 'invalidateAndResolve' || scenario.action === 'unshiftItem' || @@ -270,6 +272,13 @@ async function runScenario( performance.clearMarks(); performance.clearMeasures(); }); + // Force GC after pre-mount so V8 doesn't collect during the timed action + if (cdp) { + try { + await cdp.send('HeapProfiler.collectGarbage'); + } catch {} + await page.waitForTimeout(50); + } } if (isRefStability) { @@ -421,17 +430,25 @@ async function runRound( const total = scenarios.length; for (const lib of orderedLibs) { - const libScenarios = scenarios.filter(s => s.name.startsWith(`${lib}:`)); + let libScenarios = scenarios.filter(s => s.name.startsWith(`${lib}:`)); if (libScenarios.length === 0) continue; + if (opts.shuffleLibs) libScenarios = shuffle(libScenarios); const context = await browser.newContext(); const page = await context.newPage(); + const cdp = await context.newCDPSession(page); for (const scenario of libScenarios) { + // Force GC before each scenario to reduce variance from prior allocations + try { + await cdp.send('HeapProfiler.collectGarbage'); + } catch {} + await page.waitForTimeout(50); + done++; const prefix = opts.showProgress ? `[${done}/${total}] ` : ''; try { - const result = await runScenario(page, lib, scenario, networkSim); + const result = await runScenario(page, lib, scenario, networkSim, cdp); recordResult(samples, scenario, result); const commitSuffix = result.reactCommit != null ? @@ -448,6 +465,7 @@ async function runRound( } } + await cdp.detach().catch(() => {}); await context.close(); } } diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index b3701eed25cd..e90b0efb1c6b 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -23,14 +23,14 @@ export interface RunProfile { export const RUN_CONFIG: Record = { small: { warmup: 3, - minMeasurement: 3, + minMeasurement: 5, maxMeasurement: process.env.CI ? 10 : 20, targetMarginPct: process.env.CI ? 15 : 10, }, large: { warmup: 1, - minMeasurement: 2, - maxMeasurement: process.env.CI ? 4 : 8, + minMeasurement: 3, + maxMeasurement: process.env.CI ? 6 : 10, targetMarginPct: process.env.CI ? 20 : 15, }, }; @@ -123,6 +123,16 @@ const BASE_SCENARIOS: BaseScenario[] = [ preMountAction: 'mountSortedView', size: 'large', }, + { + nameSuffix: 'update-entity-multi-view', + action: 'updateEntityMultiView', + args: [1], + category: 'hotPath', + mountCount: 1000, + renderLimit: 100, + preMountAction: 'initMultiView', + size: 'large', + }, { nameSuffix: 'list-detail-switch', action: 'listDetailSwitch', diff --git a/examples/benchmark-react/bench/stats.ts b/examples/benchmark-react/bench/stats.ts index d9b339abb122..a0aad7ce618c 100644 --- a/examples/benchmark-react/bench/stats.ts +++ b/examples/benchmark-react/bench/stats.ts @@ -1,7 +1,27 @@ +/** + * Remove outliers using the IQR method (1.5×IQR fence). + * Input must be sorted ascending. Falls back to the full array when + * there are fewer than 4 samples or the IQR is zero. + */ +function trimOutliers(sorted: number[]): number[] { + if (sorted.length < 4) return sorted; + const q1 = sorted[Math.floor(sorted.length * 0.25)]; + const q3 = sorted[Math.floor(sorted.length * 0.75)]; + const iqr = q3 - q1; + if (iqr === 0) return sorted; + const lower = q1 - 1.5 * iqr; + const upper = q3 + 1.5 * iqr; + const result = sorted.filter(x => x >= lower && x <= upper); + return result.length >= 2 ? result : sorted; +} + /** * Check whether a scenario's samples have converged: 95% CI margin * is within targetMarginPct of the median. Zero-variance metrics * (e.g. ref-stability counts) converge after minSamples. + * + * Outliers are trimmed via IQR before computing the CI so that a + * single GC spike doesn't prevent convergence. */ export function isConverged( samples: number[], @@ -9,39 +29,46 @@ export function isConverged( targetMarginPct: number, minSamples: number, ): boolean { - const trimmed = samples.slice(warmupCount); - if (trimmed.length < minSamples) return false; - const mean = trimmed.reduce((sum, x) => sum + x, 0) / trimmed.length; + const measured = samples.slice(warmupCount); + if (measured.length < minSamples) return false; + const sorted = [...measured].sort((a, b) => a - b); + const clean = trimOutliers(sorted); + const mean = clean.reduce((sum, x) => sum + x, 0) / clean.length; if (mean === 0) return true; const stdDev = Math.sqrt( - trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (trimmed.length - 1), + clean.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (clean.length - 1), ); - const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); + const margin = 1.96 * (stdDev / Math.sqrt(clean.length)); return (margin / Math.abs(mean)) * 100 <= targetMarginPct; } /** * Compute median, p95, and approximate 95% confidence interval from samples. - * Discards warmup runs. + * Discards warmup runs, then trims IQR outliers for median and CI + * computation. p95 uses the full (untrimmed) sorted data. */ export function computeStats( samples: number[], warmupCount: number, ): { median: number; p95: number; range: string } { - const trimmed = samples.slice(warmupCount); - if (trimmed.length <= 1) { - const v = trimmed[0] ?? 0; + const measured = samples.slice(warmupCount); + if (measured.length <= 1) { + const v = measured[0] ?? 0; return { median: v, p95: v, range: '± 0' }; } - const sorted = [...trimmed].sort((a, b) => a - b); - const median = sorted[Math.floor(sorted.length / 2)] ?? 0; + const sorted = [...measured].sort((a, b) => a - b); + const clean = trimOutliers(sorted); + const median = clean[Math.floor(clean.length / 2)] ?? 0; const p95Idx = Math.floor(sorted.length * 0.95); const p95 = sorted[Math.min(p95Idx, sorted.length - 1)] ?? median; - const mean = trimmed.reduce((sum, x) => sum + x, 0) / trimmed.length; - const stdDev = Math.sqrt( - trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (trimmed.length - 1), - ); - const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); + const mean = clean.reduce((sum, x) => sum + x, 0) / clean.length; + const stdDev = + clean.length > 1 ? + Math.sqrt( + clean.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (clean.length - 1), + ) + : 0; + const margin = 1.96 * (stdDev / Math.sqrt(clean.length)); return { median, p95, From 400837cddc02a8340e1fdbd8a21cf193fcae844c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 21 Mar 2026 15:31:08 -0400 Subject: [PATCH 4/5] internal: Skip Vercel builds on gh-pages* branches (#3807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh-pages-bench has no website/ directory — it only exists for GitHub Pages benchmark rendering. The ignoreCommand was erroring out on these branches, causing Vercel to attempt (and fail) preview deployments. Made-with: Cursor --- website/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/vercel.json b/website/vercel.json index d95689b2d32f..ee2a9de83d7e 100644 --- a/website/vercel.json +++ b/website/vercel.json @@ -1,4 +1,4 @@ { "cleanUrls": true, - "ignoreCommand": "if [ \"$VERCEL_GIT_COMMIT_REF\" = \"master\" ]; then git diff HEAD^ HEAD --quiet -- . ../docs/; else git diff HEAD^ HEAD --quiet -- .; fi" + "ignoreCommand": "case \"$VERCEL_GIT_COMMIT_REF\" in gh-pages*) exit 0;; esac; if [ \"$VERCEL_GIT_COMMIT_REF\" = \"master\" ]; then git diff HEAD^ HEAD --quiet -- . ../docs/; else git diff HEAD^ HEAD --quiet -- .; fi" } From 655b42ec2fc529f46d04d16f098d25b6e4229786 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 21 Mar 2026 15:49:17 -0400 Subject: [PATCH 5/5] demo(benchmark-react): add multi-view entity update scenario (#3806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .cursor/rules/benchmarking.mdc | 5 ++ examples/benchmark-react/README.md | 38 +++++----- examples/benchmark-react/bench/runner.ts | 7 -- .../benchmark-react/src/data-client/index.tsx | 57 ++++++++++++++ .../src/shared/benchHarness.tsx | 75 ++++++++++++++----- .../benchmark-react/src/shared/components.tsx | 22 ++++++ examples/benchmark-react/src/shared/types.ts | 5 ++ examples/benchmark-react/src/swr/index.tsx | 55 ++++++++++++++ .../src/tanstack-query/index.tsx | 57 ++++++++++++++ 9 files changed, 278 insertions(+), 43 deletions(-) diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 45ae39d52c99..cf1fac14c50b 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -41,6 +41,11 @@ Use this mapping when deciding which React benchmark scenarios are relevant to a - Relevant for: `@data-client/normalizr` denormalize memoization, Entity identity - All libraries (data-client should show fewest changed refs) +- **Multi-view entity update** (`update-entity-multi-view`) + - Exercises: cross-query entity propagation — one update to a shared entity reflected in list, detail panel, and pinned cards + - Relevant for: `@data-client/normalizr` normalized cache, `@data-client/core` subscription fan-out + - All libraries (normalization advantage: one store write vs. multiple query invalidations + refetches) + - **Sorted/derived view** (`getlist-500-sorted`, `update-entity-sorted`) - Exercises: `Query` schema memoization via `useQuery` (data-client) vs `useMemo` sort (competitors) - Relevant for: `@data-client/endpoint` Query, `@data-client/normalizr` MemoCache, `@data-client/react` useQuery diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index aa30055075f1..72b37bc9c18c 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -31,6 +31,7 @@ The repo has two benchmark suites: - **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. - **Update entity** (`update-entity`) — Time to update one issue and propagate to the UI (unit: ms). - **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. +- **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. - **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. - **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. - **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 | Category | Scenarios (representative) | data-client | tanstack-query | swr | |---|---|---:|---:|---:| -| Get list (fetch + render) | `getlist-100`, `getlist-500`, `getlist-500-sorted` | ~104% | ~101% | **100%** | -| Mutations (with network sim) | `update-entity`, `unshift-item`, `delete-item`, `move-item` | ~2% | ~104% | **100%** | -| Sorted view: entity update | `update-entity-sorted` | ~2% | ~100% | **100%** | -| Large data: shared user update | `update-user` (1k rows rendered) | ~2% | ~102% | **100%** | -| Large data: shared user update | `update-user-10000` | ~5% | ~122% | **100%** | -| Large data: list ↔ detail | `list-detail-switch` | ~25% | ~106% | **100%** | +| Navigation | `getlist-100`, `getlist-500`, `getlist-500-sorted` | ~103% | ~102% | **100%** | +| Navigation | `list-detail-switch` | ~21% | ~102% | **100%** | +| Mutations | `update-entity`, `update-user`, `update-entity-sorted`, `update-entity-multi-view`, `unshift-item`, `delete-item`, `move-item` | ~2% | ~102% | **100%** | +| Scaling (10k items) | `update-user-10000` | ~5% | ~122% | **100%** | ## Latest measured results (network simulation on) @@ -70,24 +69,25 @@ Run: **2026-03-21**, Linux (WSL2), `yarn build:benchmark-react`, static preview | Scenario | Unit | data-client | tanstack-query | swr | |---|---|---:|---:|---:| -| `getlist-100` | ms | 89.3 ± 0.11 | 88.9 ± 0.17 | 87.2 ± 0.49 | -| `getlist-500` | ms | 104.8 ± 1.37 | 101.2 ± 0.29 | 99.4 ± 0.69 | -| `update-entity` | ms | 2.1 ± 0.11 | 144.9 ± 0.58 | 143.0 ± 0.23 | -| `update-user` | ms | 3.1 ± 0.29 | 141.9 ± 0.00 | 139.0 ± 0.00 | -| `getlist-500-sorted` | ms | 103.2 ± 0.59 | 98.8 ± 0.39 | 98.9 ± 0.88 | -| `update-entity-sorted` | ms | 2.7 ± 0.00 | 139.7 ± 0.10 | 140.4 ± 0.88 | -| `list-detail-switch` | ms | 165.5 ± 21.69 | 694.4 ± 3.72 | 656.9 ± 26.95 | -| `update-user-10000` | ms | 9.1 ± 0.49 | 239.4 ± 0.59 | 195.7 ± 1.86 | -| `unshift-item` | ms | 3.0 ± 0.07 | 144.1 ± 0.46 | 139.8 ± 0.47 | -| `delete-item` | ms | 2.6 ± 0.07 | 142.2 ± 0.07 | 138.5 ± 0.36 | -| `move-item` | ms | 3.6 ± 0.11 | 154.5 ± 0.82 | 143.9 ± 0.82 | +| `getlist-100` | ms | 89.3 ± 0.22 | 88.7 ± 0.15 | 87.5 ± 0.50 | +| `getlist-500` | ms | 102.3 ± 1.25 | 99.9 ± 1.25 | 98.4 ± 1.25 | +| `getlist-500-sorted` | ms | 101.8 ± 1.61 | 99.2 ± 1.29 | 97.9 ± 0.63 | +| `list-detail-switch` | ms | 144.4 ± 21.22 | 689.4 ± 20.83 | 674.5 ± 35.67 | +| `update-entity` | ms | 2.8 ± 0.09 | 142.6 ± 0.31 | 142.4 ± 0.34 | +| `update-user` | ms | 3.0 ± 0.13 | 142.7 ± 0.43 | 139.4 ± 0.51 | +| `update-entity-sorted` | ms | 3.2 ± 0.24 | 141.3 ± 0.07 | 141.4 ± 0.56 | +| `update-entity-multi-view` | ms | 2.8 ± 0.41 | 146.6 ± 7.25 | 145.3 ± 8.21 | +| `update-user-10000` | ms | 10.3 ± 0.82 | 246.0 ± 1.35 | 201.2 ± 0.75 | +| `unshift-item` | ms | 3.5 ± 0.06 | 144.5 ± 0.38 | 139.7 ± 0.07 | +| `delete-item` | ms | 3.2 ± 0.10 | 144.4 ± 0.11 | 139.9 ± 0.11 | +| `move-item` | ms | 3.5 ± 0.13 | 156.4 ± 0.50 | 146.4 ± 0.05 | ## Expected variance | Category | Scenarios | Typical run-to-run spread | |---|---|---| | **Stable** | `getlist-*`, `update-entity`, `ref-stability-*` | 2-5% | -| **Moderate** | `update-user-*`, `update-entity-sorted` | 5-10% | +| **Moderate** | `update-user-*`, `update-entity-sorted`, `update-entity-multi-view` | 5-10% | | **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10-25% | 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 Scenarios are classified as `small` or `large` based on their cost: - **Small** (3 warmup + 15 measurement): `getlist-100`, `update-entity`, `ref-stability-*`, `invalidate-and-resolve`, `unshift-item`, `delete-item` - - **Large** (1 warmup + 4 measurement): `getlist-500`, `getlist-500-sorted`, `update-user`, `update-user-10000`, `update-entity-sorted`, `list-detail-switch` + - **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` - **Memory** (opt-in, 1 warmup + 3 measurement): `memory-mount-unmount-cycle` — run with `--action memory` When running all scenarios (`yarn bench`), each group runs with its own warmup/measurement count. Use `--size` to run only one group. diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 030ee6675527..479f1b7d4fcf 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -272,13 +272,6 @@ async function runScenario( performance.clearMarks(); performance.clearMeasures(); }); - // Force GC after pre-mount so V8 doesn't collect during the timed action - if (cdp) { - try { - await cdp.send('HeapProfiler.collectGarbage'); - } catch {} - await page.waitForTimeout(50); - } } if (isRefStability) { diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index ed690ea4cde9..6c2c57260201 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -14,6 +14,8 @@ import { import { DOUBLE_LIST_STYLE, IssueRow, + PINNED_STRIP_STYLE, + PinnedCardView, PlainIssueList, } from '@shared/components'; import { @@ -110,6 +112,21 @@ function DetailView({ number }: { number: number }) { ); } +function PinnedCard({ number }: { number: number }) { + const issue = useSuspense(IssueResource.get, { number }); + return ; +} + +function PinnedStrip({ numbers }: { numbers: number[] }) { + return ( +
+ {numbers.map(n => ( + + ))} +
+ ); +} + function BenchmarkHarness() { const controller = useController(); const { @@ -119,6 +136,7 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, renderLimit, containerRef, measureUpdate, @@ -220,10 +238,44 @@ function BenchmarkHarness() { [measureUpdate, controller, containerRef, doubleListCount, listViewCount], ); + const updateEntityMultiView = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + const expected = `${issue.title} (updated)`; + measureUpdate( + () => { + controller.fetch( + IssueResource.update, + { number }, + { title: expected }, + ); + }, + () => { + const container = containerRef.current!; + const listTitle = container.querySelector( + `[data-issue-number="${number}"] [data-title]`, + ); + const detailTitle = container.querySelector( + '[data-detail-view] [data-title]', + ); + const pinnedTitle = container.querySelector( + `[data-pinned-number="${number}"] [data-title]`, + ); + return [listTitle, detailTitle, pinnedTitle].every( + el => el?.textContent === expected, + ); + }, + ); + }, + [measureUpdate, controller, containerRef], + ); + registerAPI({ updateEntity, updateUser, invalidateAndResolve, + updateEntityMultiView, unshiftItem, deleteEntity, moveItem, @@ -246,6 +298,11 @@ function BenchmarkHarness() { )} + {pinnedNumbers.length > 0 && ( + Loading pinned...
}> + + + )}
); } diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index fb3c561db976..425ba678f47b 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -80,6 +80,7 @@ export function useBenchState() { const [detailIssueNumber, setDetailIssueNumber] = useState( null, ); + const [pinnedNumbers, setPinnedNumbers] = useState([]); const [renderLimit, setRenderLimit] = useState(); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); @@ -95,29 +96,41 @@ export function useBenchState() { * Measure a mount action via MutationObserver. Ends when expected content * ([data-bench-item] or [data-sorted-list]) appears in the container, * skipping intermediate states like Suspense fallbacks or empty first renders. + * + * Returns a promise that resolves when the mount content is detected. + * Pass `signalComplete: false` to suppress the data-bench-complete attribute + * (useful when the caller needs additional async work before signaling). */ const measureMount = useCallback( - (fn: () => unknown) => { + (fn: () => unknown, { signalComplete = true } = {}): Promise => { const container = containerRef.current!; - const observer = new MutationObserver(() => { - if (container.querySelector('[data-bench-item], [data-sorted-list]')) { + return new Promise(resolve => { + const done = () => { + if (signalComplete) setComplete(); + resolve(); + }; + const observer = new MutationObserver(() => { + if ( + container.querySelector('[data-bench-item], [data-sorted-list]') + ) { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + observer.disconnect(); + clearTimeout(timer); + done(); + } + }); + observer.observe(container, OBSERVE_MUTATIONS); + const timer = setTimeout(() => { + observer.disconnect(); performance.mark('mount-end'); performance.measure('mount-duration', 'mount-start', 'mount-end'); - observer.disconnect(); - clearTimeout(timer); - setComplete(); - } + container.setAttribute('data-bench-timeout', 'true'); + done(); + }, 30000); + performance.mark('mount-start'); + fn(); }); - observer.observe(container, OBSERVE_MUTATIONS); - const timer = setTimeout(() => { - observer.disconnect(); - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - container.setAttribute('data-bench-timeout', 'true'); - setComplete(); - }, 30000); - performance.mark('mount-start'); - fn(); }, [setComplete], ); @@ -195,6 +208,7 @@ export function useBenchState() { setShowDoubleList(false); setDoubleListCount(undefined); setDetailIssueNumber(null); + setPinnedNumbers([]); }, []); const initDoubleList = useCallback( @@ -272,6 +286,31 @@ export function useBenchState() { ], ); + const initMultiView = useCallback( + async (n: number) => { + await seedIssueList(FIXTURE_ISSUES.slice(0, n)); + + setDetailIssueNumber(1); + setPinnedNumbers(Array.from({ length: 10 }, (_, i) => i + 1)); + + await measureMount(() => setListViewCount(n), { + signalComplete: false, + }); + + await waitForElement('[data-detail-view]'); + await waitForElement('[data-pinned-number]'); + setComplete(); + }, + [ + measureMount, + setListViewCount, + setDetailIssueNumber, + setPinnedNumbers, + waitForElement, + setComplete, + ], + ); + const getRenderedCount = useCallback( () => listViewCount ?? 0, [listViewCount], @@ -289,6 +328,7 @@ export function useBenchState() { apiRef.current = { init, initDoubleList, + initMultiView, unmountAll, mountUnmountCycle, mountSortedView, @@ -323,6 +363,7 @@ export function useBenchState() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, renderLimit, containerRef, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 92ecfd64fa42..a8b7bb39729f 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -74,6 +74,28 @@ export function IssueRow({ issue }: { issue: Issue }) { ); } +export const PINNED_STRIP_STYLE = { + display: 'flex', + gap: 4, + flexWrap: 'wrap', +} as const; + +/** + * Compact card for "pinned/bookmarked" issues — structurally different from + * IssueRow. Each card fetches its issue individually by ID (per-library), + * so the multi-view scenario tests cross-query entity propagation. + */ +export function PinnedCardView({ issue }: { issue: Issue }) { + return ( +
+ {issue.title} + + {STATE_ICONS[issue.state] ?? issue.state} + {issue.comments} +
+ ); +} + /** Plain keyed list. React can reconcile inserts/deletes by key without * re-rendering every row (unlike index-based virtualized lists). */ export function PlainIssueList({ diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index e761282a6dae..24e15cf0556d 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -45,6 +45,10 @@ export interface BenchAPI { moveItem?(id: number): void; /** 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). */ listDetailSwitch?(count: number): void; + /** Mount list + detail panel + pinned card strip for multi-view entity propagation. */ + initMultiView?(count: number): void; + /** Update an entity that appears in list + detail + pinned views; waits for all three to reflect the change. */ + updateEntityMultiView?(id: number): void; /** Trigger store garbage collection (data-client only). Used by memory scenarios to flush unreferenced data before heap measurement. */ triggerGC?(): void; /** Cap DOM rendering to the first N items while keeping all data in the store. */ @@ -110,6 +114,7 @@ export interface Issue { export type ScenarioAction = | { action: 'init'; args: [number] } | { action: 'updateEntity'; args: [number] } + | { action: 'updateEntityMultiView'; args: [number] } | { action: 'updateUser'; args: [string] } | { action: 'unmountAll'; args: [] } | { action: 'unshiftItem'; args: [] } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 6d4a1fc635d0..f02f6677140e 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -6,6 +6,8 @@ import { import { DOUBLE_LIST_STYLE, IssueRow, + PINNED_STRIP_STYLE, + PinnedCardView, PlainIssueList, } from '@shared/components'; import { @@ -59,6 +61,22 @@ function DetailView({ number }: { number: number }) { ); } +function PinnedCard({ number }: { number: number }) { + const { data: issue } = useSWR(`issue:${number}`, fetcher); + if (!issue) return null; + return ; +} + +function PinnedStrip({ numbers }: { numbers: number[] }) { + return ( +
+ {numbers.map(n => ( + + ))} +
+ ); +} + function ListView({ count, limit }: { count: number; limit?: number }) { const { data: issues } = useSWR(`issues:${count}`, fetcher); if (!issues) return null; @@ -105,6 +123,7 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, renderLimit, containerRef, measureUpdate, @@ -177,9 +196,44 @@ function BenchmarkHarness() { [measureUpdate, mutate, containerRef], ); + const updateEntityMultiView = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + const expected = `${issue.title} (updated)`; + measureUpdate( + () => + IssueResource.update({ number }, { title: expected }).then(() => + mutate( + key => + typeof key === 'string' && + (key.startsWith('issues:') || key.startsWith('issue:')), + ), + ) as Promise, + () => { + const container = containerRef.current!; + const listTitle = container.querySelector( + `[data-issue-number="${number}"] [data-title]`, + ); + const detailTitle = container.querySelector( + '[data-detail-view] [data-title]', + ); + const pinnedTitle = container.querySelector( + `[data-pinned-number="${number}"] [data-title]`, + ); + return [listTitle, detailTitle, pinnedTitle].every( + el => el?.textContent === expected, + ); + }, + ); + }, + [measureUpdate, mutate, containerRef], + ); + registerAPI({ updateEntity, updateUser, + updateEntityMultiView, unshiftItem, deleteEntity, moveItem, @@ -195,6 +249,7 @@ function BenchmarkHarness() { )} {detailIssueNumber != null && } + {pinnedNumbers.length > 0 && } ); } diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 9e038044f1f8..71d7b45855b8 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -6,6 +6,8 @@ import { import { DOUBLE_LIST_STYLE, IssueRow, + PINNED_STRIP_STYLE, + PinnedCardView, PlainIssueList, } from '@shared/components'; import { @@ -77,6 +79,25 @@ function DetailView({ number }: { number: number }) { ); } +function PinnedCard({ number }: { number: number }) { + const { data: issue } = useQuery({ + queryKey: ['issue', number], + queryFn, + }); + if (!issue) return null; + return ; +} + +function PinnedStrip({ numbers }: { numbers: number[] }) { + return ( +
+ {numbers.map(n => ( + + ))} +
+ ); +} + function ListView({ count, limit }: { count: number; limit?: number }) { const { data: issues } = useQuery({ queryKey: ['issues', count], @@ -128,6 +149,7 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, renderLimit, containerRef, measureUpdate, @@ -203,9 +225,43 @@ function BenchmarkHarness() { [measureUpdate, client, containerRef], ); + const updateEntityMultiView = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + const expected = `${issue.title} (updated)`; + measureUpdate( + () => + IssueResource.update({ number }, { title: expected }).then(() => + Promise.all([ + client.invalidateQueries({ queryKey: ['issues'] }), + client.invalidateQueries({ queryKey: ['issue'] }), + ]), + ), + () => { + const container = containerRef.current!; + const listTitle = container.querySelector( + `[data-issue-number="${number}"] [data-title]`, + ); + const detailTitle = container.querySelector( + '[data-detail-view] [data-title]', + ); + const pinnedTitle = container.querySelector( + `[data-pinned-number="${number}"] [data-title]`, + ); + return [listTitle, detailTitle, pinnedTitle].every( + el => el?.textContent === expected, + ); + }, + ); + }, + [measureUpdate, client, containerRef], + ); + registerAPI({ updateEntity, updateUser, + updateEntityMultiView, unshiftItem, deleteEntity, moveItem, @@ -221,6 +277,7 @@ function BenchmarkHarness() { )} {detailIssueNumber != null && } + {pinnedNumbers.length > 0 && } ); }