diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 1f69b835ac6e..cf1fac14c50b 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,12 @@ 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`) +- **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 - All libraries @@ -58,8 +63,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..72b37bc9c18c 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -28,11 +28,12 @@ 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 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. -- **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)** @@ -50,22 +51,43 @@ The repo has two benchmark suites: ## Expected results -These are approximate values to help calibrate expectations. Exact numbers vary by machine and CPU throttling. +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. -| 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) | -| `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 | +| Category | Scenarios (representative) | data-client | tanstack-query | swr | +|---|---|---:|---:|---:| +| 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) + +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.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-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`, `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. @@ -107,24 +129,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 +185,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`, `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 27d5e678ad61..479f1b7d4fcf 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, CDPSession, 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; // --------------------------------------------------------------------------- -// Scenario runner (unchanged logic) +// Types +// --------------------------------------------------------------------------- + +interface ScenarioResult { + value: number; + reactCommit?: number; + traceDuration?: number; +} + +interface ScenarioSamples { + value: number[]; + reactCommit: number[]; + trace: number[]; +} + +// --------------------------------------------------------------------------- +// Scenario runner // --------------------------------------------------------------------------- const REF_STABILITY_METRICS = ['issueRefChanged', 'userRefChanged'] as const; @@ -136,17 +160,12 @@ function isRefStabilityScenario(scenario: Scenario): scenario is Scenario & { ); } -interface ScenarioResult { - value: number; - reactCommit?: number; - traceDuration?: number; -} - async function runScenario( page: Page, lib: string, scenario: Scenario, networkSim: boolean, + cdp?: CDPSession, ): Promise { const appPath = `/${lib}/`; await page.goto(`${BASE_URL}${appPath}`, { @@ -172,17 +191,24 @@ 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'; 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])); @@ -203,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' || @@ -346,42 +373,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 // --------------------------------------------------------------------------- @@ -396,15 +387,82 @@ 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) { + 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, cdp); + 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 cdp.detach().catch(() => {}); + await context.close(); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -425,11 +483,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); @@ -438,59 +494,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; @@ -503,52 +537,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`, ); @@ -564,77 +575,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, + }); } } @@ -645,13 +599,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, @@ -659,22 +611,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, @@ -686,9 +628,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, @@ -703,30 +645,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 54b4f91de266..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, }, }; @@ -42,21 +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; -} +}; const BASE_SCENARIOS: BaseScenario[] = [ { @@ -73,10 +62,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 +75,8 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: [1], resultMetric: 'issueRefChanged', category: 'hotPath', + mountCount: 1000, + renderLimit: 100, deterministic: true, }, { @@ -92,14 +85,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 +107,56 @@ 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: 'update-entity-multi-view', + action: 'updateEntityMultiView', + args: [1], + category: 'hotPath', + mountCount: 1000, + renderLimit: 100, + preMountAction: 'initMultiView', + 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 +164,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', }, ]; @@ -178,16 +192,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, + ({ nameSuffix, onlyLibs, ...rest }): Scenario => ({ + name: `${lib}: ${nameSuffix}`, + ...rest, + ...(onlyLibs ? { onlyLibs: [...onlyLibs] } : {}), }), ), ); 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, 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..6c2c57260201 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -13,10 +13,9 @@ import { } from '@shared/benchHarness'; import { DOUBLE_LIST_STYLE, - ISSUE_HEIGHT, IssueRow, - IssuesRow, - LIST_STYLE, + PINNED_STRIP_STYLE, + PinnedCardView, PlainIssueList, } from '@shared/components'; import { @@ -33,7 +32,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 +55,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 (
- - + +
); } @@ -120,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 { @@ -129,6 +136,8 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, + renderLimit, containerRef, measureUpdate, registerAPI, @@ -229,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, @@ -241,18 +284,25 @@ function BenchmarkHarness() { return (
- {listViewCount != null && } + {listViewCount != null && ( + + )} {showSortedView && sortedViewCount != null && ( - + )} {showDoubleList && doubleListCount != null && ( - + )} {detailIssueNumber != null && ( Loading...
}> )} + {pinnedNumbers.length > 0 && ( + Loading pinned...}> + + + )} ); } diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index d1ea05ecccbe..425ba678f47b 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -80,6 +80,8 @@ 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); const apiRef = useRef(null as any); @@ -94,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], ); @@ -194,6 +208,7 @@ export function useBenchState() { setShowDoubleList(false); setDoubleListCount(undefined); setDetailIssueNumber(null); + setPinnedNumbers([]); }, []); const initDoubleList = useCallback( @@ -271,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], @@ -288,6 +328,7 @@ export function useBenchState() { apiRef.current = { init, initDoubleList, + initMultiView, unmountAll, mountUnmountCycle, mountSortedView, @@ -298,6 +339,7 @@ export function useBenchState() { setNetworkDelay, setMethodDelays, flushPendingMutations, + setRenderLimit, ...libraryActions, } as BenchAPI; }; @@ -321,6 +363,8 @@ export function useBenchState() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, + renderLimit, containerRef, measureMount, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index aa5853a61e7c..a8b7bb39729f 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,41 @@ export function IssueRow({ issue }: { issue: Issue }) { ); } -/** Generic react-window row that renders an IssueRow from an issues array. */ -export function IssuesRow({ - index, - style, - issues, -}: RowComponentProps<{ issues: 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 (non-virtualized) list keyed by issue number. Renders up to VISIBLE_COUNT issues. */ -export function PlainIssueList({ issues }: { issues: Issue[] }) { +/** Plain keyed list. React can reconcile inserts/deletes by key without + * re-rendering every row (unlike index-based virtualized lists). */ +export function PlainIssueList({ + issues, + 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..24e15cf0556d 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -45,8 +45,14 @@ 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. */ + setRenderLimit?(n: number | undefined): void; } declare global { @@ -108,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: [] } @@ -142,4 +149,8 @@ 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; + /** If set, scenario applies only to these libs; dropped when any selected library is not listed. */ + onlyLibs?: string[]; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index f17d81abce24..f02f6677140e 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -5,10 +5,9 @@ import { } from '@shared/benchHarness'; import { DOUBLE_LIST_STYLE, - ISSUE_HEIGHT, IssueRow, - IssuesRow, - LIST_STYLE, + PINNED_STRIP_STYLE, + PinnedCardView, PlainIssueList, } from '@shared/components'; import { @@ -21,7 +20,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 +40,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 +61,38 @@ function DetailView({ number }: { number: number }) { ); } -function ListView({ count }: { count: 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; 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 +101,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 +123,8 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, + renderLimit, containerRef, measureUpdate, registerAPI, @@ -186,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, @@ -196,12 +241,15 @@ function BenchmarkHarness() { return (
- {listViewCount != null && } - {showSortedView && } + {listViewCount != null && ( + + )} + {showSortedView && } {showDoubleList && doubleListCount != null && ( - + )} {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 900de1e398f3..71d7b45855b8 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -5,10 +5,9 @@ import { } from '@shared/benchHarness'; import { DOUBLE_LIST_STYLE, - ISSUE_HEIGHT, IssueRow, - IssuesRow, - LIST_STYLE, + PINNED_STRIP_STYLE, + PinnedCardView, PlainIssueList, } from '@shared/components'; import { @@ -27,7 +26,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 +49,7 @@ const queryClient = new QueryClient({ }, }); -function SortedListView() { +function SortedListView({ limit }: { limit?: number }) { const { data: issues } = useQuery({ queryKey: ['issues', 'all'], queryFn, @@ -63,13 +61,7 @@ function SortedListView() { if (!sorted.length) return null; return (
- +
); } @@ -87,7 +79,26 @@ function DetailView({ number }: { number: number }) { ); } -function ListView({ count }: { count: 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], queryFn, @@ -95,18 +106,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 +127,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 +149,8 @@ function BenchmarkHarness() { showDoubleList, doubleListCount, detailIssueNumber, + pinnedNumbers, + renderLimit, containerRef, measureUpdate, registerAPI, @@ -212,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, @@ -222,12 +269,15 @@ function BenchmarkHarness() { return (
- {listViewCount != null && } - {showSortedView && } + {listViewCount != null && ( + + )} + {showSortedView && } {showDoubleList && doubleListCount != null && ( - + )} {detailIssueNumber != null && } + {pinnedNumbers.length > 0 && }
); } 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" } 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"