From 49432519f2d4143f93aeabaae8de047034e015b2 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 8 Mar 2026 23:29:09 -0400 Subject: [PATCH 01/46] demo: Add benchmark-react with normalization and ref-stability scenarios - Browser benchmark comparing @data-client/react (Playwright, customSmallerIsBetter). - Scenarios: mount, update entity/author, ref-stability (item/author ref counts). - Hot-path (CI) vs with-network (local): simulated delay for overfetch comparison. - CI workflow runs hot-path only; reports to rhysd/github-action-benchmark. Made-with: Cursor --- .github/workflows/benchmark-react.yml | 89 +++ examples/benchmark-react/.babelrc.js | 3 + examples/benchmark-react/PLAN.md | 61 ++ examples/benchmark-react/README.md | 51 ++ examples/benchmark-react/bench/measure.ts | 32 + examples/benchmark-react/bench/report.ts | 13 + examples/benchmark-react/bench/runner.ts | 178 +++++ examples/benchmark-react/bench/scenarios.ts | 61 ++ examples/benchmark-react/bench/stats.ts | 26 + examples/benchmark-react/bench/tracing.ts | 53 ++ examples/benchmark-react/package.json | 40 + examples/benchmark-react/playwright.config.ts | 17 + examples/benchmark-react/src/index.tsx | 181 +++++ examples/benchmark-react/src/resources.ts | 57 ++ .../benchmark-react/src/shared/components.tsx | 16 + examples/benchmark-react/src/shared/data.ts | 41 + .../src/shared/refStability.ts | 61 ++ examples/benchmark-react/src/shared/types.ts | 74 ++ examples/benchmark-react/tsconfig.json | 22 + examples/benchmark-react/webpack.config.cjs | 23 + package.json | 2 + yarn.lock | 756 +++++++++++++++++- 22 files changed, 1844 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/benchmark-react.yml create mode 100644 examples/benchmark-react/.babelrc.js create mode 100644 examples/benchmark-react/PLAN.md create mode 100644 examples/benchmark-react/README.md create mode 100644 examples/benchmark-react/bench/measure.ts create mode 100644 examples/benchmark-react/bench/report.ts create mode 100644 examples/benchmark-react/bench/runner.ts create mode 100644 examples/benchmark-react/bench/scenarios.ts create mode 100644 examples/benchmark-react/bench/stats.ts create mode 100644 examples/benchmark-react/bench/tracing.ts create mode 100644 examples/benchmark-react/package.json create mode 100644 examples/benchmark-react/playwright.config.ts create mode 100644 examples/benchmark-react/src/index.tsx create mode 100644 examples/benchmark-react/src/resources.ts create mode 100644 examples/benchmark-react/src/shared/components.tsx create mode 100644 examples/benchmark-react/src/shared/data.ts create mode 100644 examples/benchmark-react/src/shared/refStability.ts create mode 100644 examples/benchmark-react/src/shared/types.ts create mode 100644 examples/benchmark-react/tsconfig.json create mode 100644 examples/benchmark-react/webpack.config.cjs diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml new file mode 100644 index 000000000000..83d110d80a98 --- /dev/null +++ b/.github/workflows/benchmark-react.yml @@ -0,0 +1,89 @@ +name: Benchmark React + +on: + pull_request: + branches: + - master + paths: + - 'packages/react/src/**' + - 'packages/core/src/**' + - 'examples/benchmark-react/**' + - '.github/workflows/benchmark-react.yml' + push: + branches: + - master + paths: + - 'packages/react/src/**' + - 'packages/core/src/**' + - 'examples/benchmark-react/**' + - '.github/workflows/benchmark-react.yml' + +jobs: + benchmark-react: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'yarn' + - name: Install packages + run: | + corepack enable + yarn install --immutable + - name: Install Playwright (Chromium + system deps) + run: npx playwright install chromium --with-deps + - name: Build packages + run: yarn build:benchmark-react + - name: Run benchmark + run: | + yarn workspace example-benchmark-react preview & + sleep 10 + cd examples/benchmark-react && yarn bench | tee react-bench-output.json + + # PR comments on changes + - name: Download previous benchmark data (PR) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/cache@v5 + with: + path: ./cache + key: ${{ runner.os }}-benchmark-react-pr-${{ github.run_number }} + restore-keys: | + ${{ runner.os }}-benchmark-react + - name: Store benchmark result (PR) + if: ${{ github.event_name == 'pull_request' }} + uses: rhysd/github-action-benchmark@v1 + with: + tool: 'customSmallerIsBetter' + output-file-path: examples/benchmark-react/react-bench-output.json + github-token: "${{ secrets.GITHUB_TOKEN }}" + gh-pages-branch: 'gh-pages-bench' + benchmark-data-dir-path: react-bench + alert-threshold: '150%' + comment-always: true + fail-on-alert: false + alert-comment-cc-users: '@ntucker' + save-data-file: false + auto-push: false + + # master reports to history + - name: Download previous benchmark data (main) + if: ${{ github.event_name == 'push' }} + uses: actions/cache@v5 + with: + path: ./cache + key: ${{ runner.os }}-benchmark-react + - name: Store benchmark result (main) + if: ${{ github.event_name == 'push' }} + uses: rhysd/github-action-benchmark@v1 + with: + tool: 'customSmallerIsBetter' + output-file-path: examples/benchmark-react/react-bench-output.json + github-token: "${{ secrets.GITHUB_TOKEN }}" + gh-pages-branch: 'gh-pages-bench' + benchmark-data-dir-path: react-bench + auto-push: true + fail-on-alert: false diff --git a/examples/benchmark-react/.babelrc.js b/examples/benchmark-react/.babelrc.js new file mode 100644 index 000000000000..3993a0467ba7 --- /dev/null +++ b/examples/benchmark-react/.babelrc.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [['@anansi', { polyfillMethod: false }]], +}; diff --git a/examples/benchmark-react/PLAN.md b/examples/benchmark-react/PLAN.md new file mode 100644 index 000000000000..1d64702f7ead --- /dev/null +++ b/examples/benchmark-react/PLAN.md @@ -0,0 +1,61 @@ +# React Rendering Benchmark – Future Work + +Follow this plan in later sessions to extend the benchmark suite. + +--- + +## Session 2: Competitor implementations + +Add apps that implement the same `BenchAPI` (`window.__BENCH__`) and use the same presentational components so results are comparable. + +- **`apps/tanstack-query/`** (or `src/tanstack-query/` if keeping single webpack entry) + - Use `@tanstack/react-query` with `useQuery` and `queryClient.setQueryData` for cache seeding. + - Same scenarios: mount N items, update single entity. +- **`apps/swr/`** + - Use `swr` with `mutate` for cache seeding. +- **`apps/baseline/`** + - Plain `useState` + `useContext`, no caching library (baseline). + +**Deliverables:** Each app exposes the same `window.__BENCH__` interface and uses the same `ItemRow` (or shared) presentational component. Extend webpack to multi-entry so each app is built and served at e.g. `/data-client/`, `/tanstack-query/`, etc. Update `bench/runner.ts` and `bench/scenarios.ts` to iterate over all libraries and report per-library results. + +--- + +## Session 3: Entity update propagation (normalization showcase) — Done + +Implemented: + +- **`update-shared-author-duration`** — Mount 100 items (sharing 20 authors), update one author; measure duration (ms). +- **Ref-stability scenarios** — `ref-stability-item-changed` and `ref-stability-author-changed` report how many components received a new object reference after an update (unit: count; smaller is better). data-client’s normalized cache keeps referential equality for unchanged entities, so these counts stay low (1 and ~25 respectively). +- Shared `refStability` module and `BenchAPI.captureRefSnapshot` / `getRefStabilityReport` / `updateAuthor`; `getAuthor` endpoint and `FIXTURE_AUTHORS` for seeding. + +--- + +## Session 4: Memory and scaling scenarios + +Add memory and stress scenarios. + +- **Memory under repeated operations:** Cycle mount → unmount N times; measure heap (e.g. `Performance.getMetrics` / JSHeapUsedSize via CDP) to detect growth. +- **Many-subscriber scaling:** Mount 500+ components subscribed to overlapping entities; measure per-update cost (time and/or memory). +- **Optimistic update + rollback:** Optimistic mutation, then simulate error and rollback; measure time to revert DOM. + +**Deliverables:** New scenarios in `bench/scenarios.ts`, optional `bench/memory.ts` for CDP heap collection, and report entries for memory metrics (e.g. `customSmallerIsBetter` with unit `bytes` where applicable). + +--- + +## Session 5: Advanced measurement and reporting + +- **React Profiler:** Use `` (and/or `performance.measure`) to record React commit duration as a separate metric alongside existing measures. +- **Local HTML report:** Build a small report viewer (e.g. `bench/report-viewer.html` or a small app) that loads saved JSON and displays a table/charts for comparing libraries (similar to krausest results table). +- **Lighthouse-style metrics:** Optionally add FCP, TBT, or other metrics for initial load comparison (e.g. via CDP or Lighthouse CI). + +**Deliverables:** Profiler instrumentation in app(s), report viewer, and optionally Lighthouse/load metrics in the runner and report format. + +--- + +## Session 6: Polish and documentation + +- **README:** Expand with methodology (what we measure, why), how to add a new library, and how to run locally vs CI. +- **Cursor rule:** Update `.cursor/rules/benchmarking.mdc` (or equivalent) to document the React benchmark: where it lives, how to run it, and how it relates to the existing Node `example-benchmark` suite. +- **AGENTS.md:** If appropriate, add a short mention of the React benchmark and link to this plan or the README. + +**Deliverables:** Updated README, rule file, and any AGENTS.md changes. diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md new file mode 100644 index 000000000000..ce14bffd0e7e --- /dev/null +++ b/examples/benchmark-react/README.md @@ -0,0 +1,51 @@ +# React Rendering Benchmark + +Browser-based benchmark comparing `@data-client/react` (and future: TanStack Query, SWR, baseline) on mount/update scenarios. Built with Webpack via `@anansi/webpack-config`. Results are reported to CI via `rhysd/github-action-benchmark`. + +## Scenario categories + +- **Hot path (in CI)** — JS-only: mount, update propagation, ref-stability. No simulated network. These run in CI and track regression. +- **With network (comparison only)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Used to compare overfetching: data-client needs one store update (1 × delay); non-normalized libs typically invalidate/refetch multiple queries (N × delay). **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. + +## Scenarios + +**Hot path (CI)** + +- **Mount** — Time to mount 100 or 500 item rows (unit: ms). +- **Update single entity** — Time to update one item and propagate to the UI (unit: ms). +- **Update shared author** (`update-shared-author-duration`) — 100 components, shared authors; update one author. Measures time to propagate (unit: ms). Normalized cache: one store update, all views of that author update. +- **Ref-stability item/author** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. + +**With network (local comparison)** + +- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client uses 1 request; other libs can be compared with higher `simulatedRequestCount` to model overfetching. + +## Running locally + +1. **Install system dependencies (Linux / WSL)** + Playwright needs system libraries to run Chromium. If you see “Host system is missing dependencies to run browsers”: + + ```bash + sudo npx playwright install-deps chromium + ``` + + Or install manually (e.g. Debian/Ubuntu): + + ```bash + sudo apt-get install libnss3 libnspr4 libasound2t64 + ``` + +2. **Build and run** + + ```bash + yarn build:benchmark-react + yarn workspace example-benchmark-react preview & + sleep 5 + cd examples/benchmark-react && yarn bench + ``` + + Or from repo root after a build: start preview in one terminal, then in another run `yarn workspace example-benchmark-react bench`. + +## Output + +The runner prints a JSON array in `customSmallerIsBetter` format (name, unit, value, range) to stdout. In CI this is written to `react-bench-output.json` and sent to the benchmark action. diff --git a/examples/benchmark-react/bench/measure.ts b/examples/benchmark-react/bench/measure.ts new file mode 100644 index 000000000000..e7b3c5f259df --- /dev/null +++ b/examples/benchmark-react/bench/measure.ts @@ -0,0 +1,32 @@ +import type { Page } from 'playwright'; + +export interface PerformanceMeasure { + name: string; + duration: number; +} + +/** + * Collect performance.measure() entries from the page. + */ +export async function collectMeasures( + page: Page, +): Promise { + return page.evaluate(() => { + const entries = performance.getEntriesByType('measure'); + return entries.map(e => ({ + name: e.name, + duration: e.duration, + })); + }); +} + +/** + * Get the duration for a specific measure name (e.g. 'mount-duration', 'update-duration'). + */ +export function getMeasureDuration( + measures: PerformanceMeasure[], + name: string, +): number { + const m = measures.find(x => x.name === name); + return m?.duration ?? 0; +} diff --git a/examples/benchmark-react/bench/report.ts b/examples/benchmark-react/bench/report.ts new file mode 100644 index 000000000000..849b3d88712e --- /dev/null +++ b/examples/benchmark-react/bench/report.ts @@ -0,0 +1,13 @@ +/** + * Format results as customSmallerIsBetter JSON for rhysd/github-action-benchmark. + */ +export interface BenchmarkResult { + name: string; + unit: string; + value: number; + range: string; +} + +export function formatReport(results: BenchmarkResult[]): string { + return JSON.stringify(results, null, 2); +} diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts new file mode 100644 index 000000000000..9da2d50de91b --- /dev/null +++ b/examples/benchmark-react/bench/runner.ts @@ -0,0 +1,178 @@ +/// +import { chromium } from 'playwright'; +import type { Page } from 'playwright'; + +import { collectMeasures, getMeasureDuration } from './measure.js'; +import { formatReport, type BenchmarkResult } from './report.js'; +import { + SCENARIOS, + WARMUP_RUNS, + MEASUREMENT_RUNS, + LIBRARIES, +} from './scenarios.js'; +import { computeStats } from './stats.js'; + +const BASE_URL = process.env.BENCH_BASE_URL ?? 'http://localhost:5173'; +/** In CI we only run hot-path scenarios; with-network is for local comparison. */ +const SCENARIOS_TO_RUN = + process.env.CI ? + SCENARIOS.filter(s => s.category !== 'withNetwork') + : SCENARIOS; +const TOTAL_RUNS = WARMUP_RUNS + MEASUREMENT_RUNS; + +const REF_STABILITY_METRICS = ['itemRefChanged', 'authorRefChanged'] as const; + +function isRefStabilityScenario( + scenario: (typeof SCENARIOS_TO_RUN)[0], +): scenario is (typeof SCENARIOS_TO_RUN)[0] & { + resultMetric: (typeof REF_STABILITY_METRICS)[number]; +} { + return ( + scenario.resultMetric === 'itemRefChanged' || + scenario.resultMetric === 'authorRefChanged' + ); +} + +async function runScenario( + page: Page, + lib: string, + scenario: (typeof SCENARIOS_TO_RUN)[0], +): Promise { + const appPath = '/'; + await page.goto(`${BASE_URL}${appPath}`, { waitUntil: 'networkidle' }); + await page.waitForSelector('[data-app-ready]', { + timeout: 10000, + state: 'attached', + }); + + const harness = page.locator('[data-bench-harness]'); + await harness.waitFor({ state: 'attached' }); + + const bench = await page.evaluateHandle('window.__BENCH__'); + if (!bench) throw new Error('window.__BENCH__ not found'); + + const isUpdate = + scenario.action === 'updateEntity' || scenario.action === 'updateAuthor'; + const isRefStability = isRefStabilityScenario(scenario); + + if (isUpdate || isRefStability) { + await harness.evaluate(el => el.removeAttribute('data-bench-complete')); + await (bench as any).evaluate((api: any) => api.mount(100)); + await page.waitForSelector('[data-bench-complete]', { + timeout: 5000, + state: 'attached', + }); + await page.evaluate(() => { + performance.clearMarks(); + performance.clearMeasures(); + }); + } + + if (isRefStability) { + await (bench as any).evaluate((api: any) => api.captureRefSnapshot()); + } + + await harness.evaluate(el => el.removeAttribute('data-bench-complete')); + await (bench as any).evaluate((api: any, s: any) => { + api[s.action](...s.args); + }, scenario); + + await page.waitForSelector('[data-bench-complete]', { + timeout: 10000, + state: 'attached', + }); + + if (isRefStability && scenario.resultMetric) { + const report = await (bench as any).evaluate((api: any) => + api.getRefStabilityReport(), + ); + await bench.dispose(); + return report[scenario.resultMetric] as number; + } + + const measures = await collectMeasures(page); + const duration = + scenario.action === 'mount' ? + getMeasureDuration(measures, 'mount-duration') + : getMeasureDuration(measures, 'update-duration'); + + await bench.dispose(); + return duration; +} + +function shuffle(arr: T[]): T[] { + const out = [...arr]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +async function main() { + const results: Record = {}; + for (const scenario of SCENARIOS_TO_RUN) { + results[scenario.name] = []; + } + + const browser = await chromium.launch({ headless: true }); + const libraries = [...LIBRARIES]; + + for (let round = 0; round < TOTAL_RUNS; round++) { + for (const lib of shuffle(libraries)) { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + const cdp = await context.newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + } catch { + // CDP throttling is best-effort + } + + for (const scenario of SCENARIOS_TO_RUN) { + if (!scenario.name.startsWith(`${lib}:`)) continue; + try { + const duration = await runScenario(page, lib, scenario); + results[scenario.name].push(duration); + } catch (err) { + console.error( + `Scenario ${scenario.name} failed:`, + err instanceof Error ? err.message : err, + ); + } + } + + await context.close(); + } + } + + await browser.close(); + + const report: BenchmarkResult[] = []; + for (const scenario of SCENARIOS_TO_RUN) { + const samples = results[scenario.name]; + if (samples.length === 0) continue; + const { median, range } = computeStats(samples, WARMUP_RUNS); + const unit = + ( + scenario.resultMetric === 'itemRefChanged' || + scenario.resultMetric === 'authorRefChanged' + ) ? + 'count' + : 'ms'; + report.push({ + name: scenario.name, + unit, + value: Math.round(median * 100) / 100, + range, + }); + } + + process.stdout.write(formatReport(report)); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts new file mode 100644 index 000000000000..e213239d134b --- /dev/null +++ b/examples/benchmark-react/bench/scenarios.ts @@ -0,0 +1,61 @@ +import type { Scenario } from '../src/shared/types.js'; + +/** Simulated network delay (ms) per request for with-network scenarios. Consistent for comparability. */ +export const SIMULATED_NETWORK_DELAY_MS = 50; + +export const SCENARIOS: Scenario[] = [ + { + name: 'data-client: mount-100-items', + action: 'mount', + args: [100], + category: 'hotPath', + }, + { + name: 'data-client: mount-500-items', + action: 'mount', + args: [500], + category: 'hotPath', + }, + { + name: 'data-client: update-single-entity', + action: 'updateEntity', + args: ['item-0'], + category: 'hotPath', + }, + { + name: 'data-client: update-shared-author-duration', + action: 'updateAuthor', + args: ['author-0'], + category: 'hotPath', + }, + { + name: 'data-client: ref-stability-item-changed', + action: 'updateEntity', + args: ['item-0'], + resultMetric: 'itemRefChanged', + category: 'hotPath', + }, + { + name: 'data-client: ref-stability-author-changed', + action: 'updateAuthor', + args: ['author-0'], + resultMetric: 'authorRefChanged', + category: 'hotPath', + }, + { + name: 'data-client: update-shared-author-with-network', + action: 'updateAuthor', + args: [ + 'author-0', + { + simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, + simulatedRequestCount: 1, + }, + ], + category: 'withNetwork', + }, +]; + +export const WARMUP_RUNS = 2; +export const MEASUREMENT_RUNS = process.env.CI ? 5 : 20; +export const LIBRARIES = ['data-client'] as const; diff --git a/examples/benchmark-react/bench/stats.ts b/examples/benchmark-react/bench/stats.ts new file mode 100644 index 000000000000..b00e0cf28426 --- /dev/null +++ b/examples/benchmark-react/bench/stats.ts @@ -0,0 +1,26 @@ +/** + * Compute median, p95, and approximate 95% confidence interval from samples. + * Discards warmup runs. + */ +export function computeStats( + samples: number[], + warmupCount: number, +): { median: number; p95: number; range: string } { + const trimmed = samples.slice(warmupCount); + if (trimmed.length === 0) { + return { median: 0, p95: 0, range: '± 0' }; + } + const sorted = [...trimmed].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] ?? 0; + const p95Idx = Math.floor(sorted.length * 0.95); + const p95 = sorted[Math.min(p95Idx, sorted.length - 1)] ?? median; + const stdDev = Math.sqrt( + trimmed.reduce((sum, x) => sum + (x - median) ** 2, 0) / trimmed.length, + ); + const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); + return { + median, + p95, + range: `± ${margin.toFixed(2)}`, + }; +} diff --git a/examples/benchmark-react/bench/tracing.ts b/examples/benchmark-react/bench/tracing.ts new file mode 100644 index 000000000000..d54d58b7ec74 --- /dev/null +++ b/examples/benchmark-react/bench/tracing.ts @@ -0,0 +1,53 @@ +/** + * Parse Chrome trace JSON to extract duration from first relevant event to last Paint. + * Fallback: returns 0 if parsing fails - caller can use performance.measure instead. + */ +export function parseTraceDuration(traceBuffer: Buffer): number { + try { + const text = traceBuffer.toString('utf-8'); + const events = parseTraceEvents(text); + if (events.length === 0) return 0; + + const paintEvents = events.filter( + e => e.name === 'Paint' || e.cat?.includes('blink'), + ); + const scriptEvents = events.filter( + e => + e.name === 'FunctionCall' || + e.name?.includes('EvaluateScript') || + e.cat?.includes('devtools.timeline'), + ); + + const firstTs = Math.min(...events.map(e => e.ts).filter(Boolean)); + const lastPaint = + paintEvents.length ? + Math.max(...paintEvents.map(e => e.ts + (e.dur ?? 0))) + : Math.max(...events.map(e => e.ts + (e.dur ?? 0))); + + return (lastPaint - firstTs) / 1000; + } catch { + return 0; + } +} + +interface TraceEvent { + ts: number; + dur?: number; + name?: string; + cat?: string; +} + +function parseTraceEvents(text: string): TraceEvent[] { + const events: TraceEvent[] = []; + const lines = text.trim().split('\n'); + for (const line of lines) { + if (line.startsWith('[') || line.startsWith(']')) continue; + try { + const obj = JSON.parse(line.replace(/,$/, '')) as TraceEvent; + if (obj.ts != null) events.push(obj); + } catch { + // skip malformed lines + } + } + return events; +} diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json new file mode 100644 index 000000000000..2943b4ed5a1e --- /dev/null +++ b/examples/benchmark-react/package.json @@ -0,0 +1,40 @@ +{ + "name": "example-benchmark-react", + "version": "0.1.0", + "private": true, + "description": "React rendering benchmark comparing @data-client/react against other data libraries", + "scripts": { + "build": "webpack --mode=production", + "preview": "serve dist -l 5173", + "bench": "npx tsx bench/runner.ts", + "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@data-client/core": "workspace:*", + "@data-client/endpoint": "workspace:*", + "@data-client/react": "workspace:*", + "@data-client/rest": "workspace:*", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@anansi/babel-preset": "6.2.23", + "@anansi/browserslist-config": "^1.4.3", + "@anansi/webpack-config": "21.1.14", + "@babel/core": "^7.22.15", + "@playwright/test": "1.49.1", + "@types/node": "24.11.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "playwright": "1.49.1", + "serve": "14.2.4", + "tsx": "4.19.2", + "typescript": "5.9.3", + "webpack": "5.105.3", + "webpack-cli": "6.0.1" + }, + "browserslist": "extends @anansi/browserslist-config" +} diff --git a/examples/benchmark-react/playwright.config.ts b/examples/benchmark-react/playwright.config.ts new file mode 100644 index 000000000000..2cd29248182f --- /dev/null +++ b/examples/benchmark-react/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './bench', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: 'list', + use: { + baseURL: 'http://localhost:5173', + trace: 'off', + screenshot: 'off', + video: 'off', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/examples/benchmark-react/src/index.tsx b/examples/benchmark-react/src/index.tsx new file mode 100644 index 000000000000..1cabcf6d5183 --- /dev/null +++ b/examples/benchmark-react/src/index.tsx @@ -0,0 +1,181 @@ +import { DataProvider, useCache, useController } from '@data-client/react'; +import { mockInitialState } from '@data-client/react/mock'; +import { ItemRow } from '@shared/components'; +import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import type { Item, UpdateAuthorOptions } from '@shared/types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { getAuthor, getItem, getItemList } from './resources'; + +const initialState = mockInitialState([ + { endpoint: getItemList, args: [], response: FIXTURE_ITEMS }, + ...FIXTURE_ITEMS.map(item => ({ + endpoint: getItem, + args: [{ id: item.id }] as [{ id: string }], + response: item, + })), + ...FIXTURE_AUTHORS.map(author => ({ + endpoint: getAuthor, + args: [{ id: author.id }] as [{ id: string }], + response: author, + })), +]); + +function ItemView({ id }: { id: string }) { + const item = useCache(getItem, { id }); + if (!item) return null; + const itemAsItem = item as unknown as Item; + registerRefs(id, itemAsItem, itemAsItem.author); + return ; +} + +function BenchmarkHarness() { + const [count, setCount] = useState(0); + const containerRef = useRef(null); + const controller = useController(); + + const setComplete = useCallback(() => { + containerRef.current?.setAttribute('data-bench-complete', 'true'); + }, []); + + const mount = useCallback( + (n: number) => { + performance.mark('mount-start'); + setCount(n); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + + const updateEntity = useCallback( + (id: string) => { + performance.mark('update-start'); + const item = FIXTURE_ITEMS.find(i => i.id === id); + if (item) { + controller.setResponse( + getItem, + { id }, + { + ...item, + label: `${item.label} (updated)`, + }, + ); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }); + }, + [controller, setComplete], + ); + + const updateAuthor = useCallback( + (authorId: string, options?: UpdateAuthorOptions) => { + performance.mark('update-start'); + const delayMs = options?.simulateNetworkDelayMs ?? 0; + const requestCount = options?.simulatedRequestCount ?? 1; + const totalDelayMs = delayMs * requestCount; + + const doUpdate = () => { + const author = FIXTURE_AUTHORS.find(a => a.id === authorId); + if (author) { + controller.setResponse( + getAuthor, + { id: authorId }, + { + ...author, + name: `${author.name} (updated)`, + }, + ); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure( + 'update-duration', + 'update-start', + 'update-end', + ); + setComplete(); + }); + }); + }; + + if (totalDelayMs > 0) { + setTimeout(doUpdate, totalDelayMs); + } else { + doUpdate(); + } + }, + [controller, setComplete], + ); + + const unmountAll = useCallback(() => { + setCount(0); + }, []); + + const getRenderedCount = useCallback(() => count, [count]); + + const captureRefSnapshot = useCallback(() => { + captureSnapshot(); + }, []); + + const getRefStabilityReport = useCallback(() => getReport(), []); + + useEffect(() => { + window.__BENCH__ = { + mount, + updateEntity, + updateAuthor, + unmountAll, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + }; + return () => { + delete window.__BENCH__; + }; + }, [ + mount, + updateEntity, + updateAuthor, + unmountAll, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + ]); + + useEffect(() => { + document.body.setAttribute('data-app-ready', 'true'); + }, []); + + const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); + + return ( +
+
+ {ids.map(id => ( + + ))} +
+
+ ); +} + +const rootEl = document.getElementById('root') ?? document.body; +createRoot(rootEl).render( + + + , +); diff --git a/examples/benchmark-react/src/resources.ts b/examples/benchmark-react/src/resources.ts new file mode 100644 index 000000000000..7f1a62ee0545 --- /dev/null +++ b/examples/benchmark-react/src/resources.ts @@ -0,0 +1,57 @@ +import { Entity, Endpoint } from '@data-client/endpoint'; +import type { Author, Item } from '@shared/types'; + +/** Author entity - shared across items for normalization */ +export class AuthorEntity extends Entity { + id = ''; + login = ''; + name = ''; + + pk() { + return this.id; + } + + static key = 'AuthorEntity'; +} + +/** Item entity with nested author */ +export class ItemEntity extends Entity { + id = ''; + label = ''; + author = AuthorEntity; + + pk() { + return this.id; + } + + static key = 'ItemEntity'; +} + +/** Endpoint to get a single author by id */ +export const getAuthor = new Endpoint( + (params: { id: string }) => + Promise.reject(new Error('Not implemented - use fixtures')), + { + schema: AuthorEntity, + key: (params: { id: string }) => `author:${params.id}`, + }, +); + +/** Endpoint to get a single item by id */ +export const getItem = new Endpoint( + (params: { id: string }) => + Promise.reject(new Error('Not implemented - use fixtures')), + { + schema: ItemEntity, + key: (params: { id: string }) => `item:${params.id}`, + }, +); + +/** Endpoint to get item list - used for fixture seeding */ +export const getItemList = new Endpoint( + () => Promise.reject(new Error('Not implemented - use fixtures')), + { + schema: [ItemEntity], + key: () => 'item:list', + }, +); diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx new file mode 100644 index 000000000000..b7d34f25b250 --- /dev/null +++ b/examples/benchmark-react/src/shared/components.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import type { Item } from './types'; + +/** + * Pure presentational component - no data-fetching logic. + * Each library app wraps this with its own data-fetching hook. + */ +export function ItemRow({ item }: { item: Item }) { + return ( +
+ {item.label} + {item.author.login} +
+ ); +} diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts new file mode 100644 index 000000000000..81617b1da684 --- /dev/null +++ b/examples/benchmark-react/src/shared/data.ts @@ -0,0 +1,41 @@ +import type { Author, Item } from './types'; + +/** + * Generate authors - shared across items to stress normalization. + * Fewer authors than items means many items share the same author reference. + */ +export function generateAuthors(count: number): Author[] { + const authors: Author[] = []; + for (let i = 0; i < count; i++) { + authors.push({ + id: `author-${i}`, + login: `user${i}`, + name: `User ${i}`, + }); + } + return authors; +} + +/** + * Generate items with nested author entities (shared references). + * Items cycle through authors so many items share the same author. + */ +export function generateItems(count: number, authorCount = 10): Item[] { + const authors = generateAuthors(authorCount); + const items: Item[] = []; + for (let i = 0; i < count; i++) { + const author = authors[i % authorCount]; + items.push({ + id: `item-${i}`, + label: `Item ${i}`, + author: { ...author }, + }); + } + return items; +} + +/** Pre-generated fixture for benchmark - 500 items, 20 shared authors */ +export const FIXTURE_ITEMS = generateItems(500, 20); + +/** Unique authors from fixture (for seeding and updateAuthor scenarios) */ +export const FIXTURE_AUTHORS = generateAuthors(20); diff --git a/examples/benchmark-react/src/shared/refStability.ts b/examples/benchmark-react/src/shared/refStability.ts new file mode 100644 index 000000000000..055bea63c2a0 --- /dev/null +++ b/examples/benchmark-react/src/shared/refStability.ts @@ -0,0 +1,61 @@ +import type { Author, Item, RefStabilityReport } from './types'; + +const currentRefs: Record = {}; +let snapshotRefs: Record | null = null; + +/** + * Register current (item, author) refs for a row. Call from each row on render. + */ +export function registerRefs(id: string, item: Item, author: Author): void { + currentRefs[id] = { item, author }; +} + +/** + * Copy current refs into snapshot. Call after mount, before running an update. + */ +export function captureSnapshot(): void { + snapshotRefs = { ...currentRefs }; +} + +/** + * Compare current refs to snapshot and return counts. Call after update completes. + */ +export function getReport(): RefStabilityReport { + let itemRefUnchanged = 0; + let itemRefChanged = 0; + let authorRefUnchanged = 0; + let authorRefChanged = 0; + + if (!snapshotRefs) { + return { + itemRefUnchanged: 0, + itemRefChanged: 0, + authorRefUnchanged: 0, + authorRefChanged: 0, + }; + } + + for (const id of Object.keys(currentRefs)) { + const current = currentRefs[id]; + const snap = snapshotRefs[id]; + if (!current || !snap) continue; + + if (current.item === snap.item) { + itemRefUnchanged++; + } else { + itemRefChanged++; + } + if (current.author === snap.author) { + authorRefUnchanged++; + } else { + authorRefChanged++; + } + } + + return { + itemRefUnchanged, + itemRefChanged, + authorRefUnchanged, + authorRefChanged, + }; +} diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts new file mode 100644 index 000000000000..39f22e64a0a0 --- /dev/null +++ b/examples/benchmark-react/src/shared/types.ts @@ -0,0 +1,74 @@ +/** + * Ref-stability report: counts of components that kept vs received new refs after an update. + * Smaller "changed" counts = better (normalization keeps referential equality for unchanged entities). + */ +export interface RefStabilityReport { + itemRefUnchanged: number; + itemRefChanged: number; + authorRefUnchanged: number; + authorRefChanged: number; +} + +/** + * Options for updateAuthor when simulating network (for comparison scenarios). + * Consistent delay so results are comparable across libraries. + */ +export interface UpdateAuthorOptions { + /** Simulated delay per "request" in ms (e.g. 50). */ + simulateNetworkDelayMs?: number; + /** Number of simulated round-trips (data-client = 1; non-normalized libs may need more). */ + simulatedRequestCount?: number; +} + +/** + * Benchmark API interface exposed by each library app on window.__BENCH__ + */ +export interface BenchAPI { + mount(count: number): void; + updateEntity(id: string): void; + updateAuthor(id: string, options?: UpdateAuthorOptions): void; + unmountAll(): void; + getRenderedCount(): number; + captureRefSnapshot(): void; + getRefStabilityReport(): RefStabilityReport; +} + +declare global { + interface Window { + __BENCH__?: BenchAPI; + } +} + +export interface Author { + id: string; + login: string; + name: string; +} + +export interface Item { + id: string; + label: string; + author: Author; +} + +export type ScenarioAction = + | { action: 'mount'; args: [number] } + | { action: 'updateEntity'; args: [string] } + | { action: 'updateAuthor'; args: [string] } + | { action: 'updateAuthor'; args: [string, UpdateAuthorOptions] } + | { action: 'unmountAll'; args: [] }; + +export type ResultMetric = 'duration' | 'itemRefChanged' | 'authorRefChanged'; + +/** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. */ +export type ScenarioCategory = 'hotPath' | 'withNetwork'; + +export interface Scenario { + name: string; + action: keyof BenchAPI; + args: unknown[]; + /** Which value to report; default 'duration'. Ref-stability scenarios use itemRefChanged/authorRefChanged. */ + resultMetric?: ResultMetric; + /** hotPath (default) = run in CI. withNetwork = comparison only, not CI. */ + category?: ScenarioCategory; +} diff --git a/examples/benchmark-react/tsconfig.json b/examples/benchmark-react/tsconfig.json new file mode 100644 index 000000000000..a26a1575613f --- /dev/null +++ b/examples/benchmark-react/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["./src/shared/*"] + }, + "types": ["@anansi/webpack-config/types"] + }, + "include": ["src/**/*", "bench/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/benchmark-react/webpack.config.cjs b/examples/benchmark-react/webpack.config.cjs new file mode 100644 index 000000000000..fc6d6b6d39d9 --- /dev/null +++ b/examples/benchmark-react/webpack.config.cjs @@ -0,0 +1,23 @@ +const path = require('path'); +const { makeConfig } = require('@anansi/webpack-config'); + +const options = { + basePath: 'src', + buildDir: 'dist/', + globalStyleDir: 'style', + sassOptions: false, +}; + +const generateConfig = makeConfig(options); + +module.exports = (env, argv) => { + const config = generateConfig(env, argv); + config.resolve = config.resolve || {}; + config.resolve.alias = { + ...config.resolve.alias, + '@shared': path.resolve(__dirname, 'src/shared'), + }; + return config; +}; + +module.exports.options = options; diff --git a/package.json b/package.json index 9f113c70ca4a..c51dcf686793 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "workspaces": [ "packages/*", "examples/benchmark", + "examples/benchmark-react", "examples/test-bundlesize", "examples/normalizr-*", "examples/coin-app", @@ -28,6 +29,7 @@ "ci:build:esmodule": "yarn workspaces foreach -WptivR --from @data-client/react --from @data-client/rest --from @data-client/graphql run build:lib && yarn workspace @data-client/normalizr run build:js:node && yarn workspace @data-client/endpoint run build:js:node", "ci:build:bundlesize": "yarn workspaces foreach -Wptiv --no-private run build:lib && yarn workspace test-bundlesize run build:sizecompare", "build:benchmark": "yarn workspaces foreach -WptivR --from @data-client/core --from @data-client/endpoint --from @data-client/normalizr run build:lib && yarn workspace example-benchmark run build", + "build:benchmark-react": "yarn workspaces foreach -WptivR --from @data-client/core --from @data-client/endpoint --from @data-client/react run build:lib && yarn workspace example-benchmark-react run build", "build:copy:ambient": "mkdirp ./packages/endpoint/lib && copyfiles --flat ./packages/endpoint/src/schema.d.ts ./packages/endpoint/lib/ && copyfiles --flat ./packages/endpoint/src/endpoint.d.ts ./packages/endpoint/lib/ && mkdirp ./packages/rest/lib && copyfiles --flat ./packages/rest/src/RestEndpoint.d.ts ./packages/rest/lib && copyfiles --flat ./packages/rest/src/next/RestEndpoint.d.ts ./packages/rest/lib/next && mkdirp ./packages/react/lib && copyfiles --flat ./packages/react/src/server/redux/redux.d.ts ./packages/react/lib/server/redux", "copy:websitetypes": "./scripts/copywebsitetypes.sh", "test": "NODE_ENV=test run jest", diff --git a/yarn.lock b/yarn.lock index 3407b5aa37f7..ba010c6ec527 100644 --- a/yarn.lock +++ b/yarn.lock @@ -263,6 +263,69 @@ __metadata: languageName: node linkType: hard +"@anansi/babel-preset@npm:6.2.23": + version: 6.2.23 + resolution: "@anansi/babel-preset@npm:6.2.23" + dependencies: + "@anansi/ts-utils": "npm:^0.3.9" + "@babel/plugin-proposal-decorators": "npm:^7.29.0" + "@babel/plugin-proposal-export-default-from": "npm:^7.27.1" + "@babel/plugin-proposal-record-and-tuple": "npm:^7.27.1" + "@babel/plugin-syntax-function-bind": "npm:^7.28.6" + "@babel/plugin-transform-object-assign": "npm:^7.27.1" + "@babel/plugin-transform-react-constant-elements": "npm:^7.27.1" + "@babel/plugin-transform-react-inline-elements": "npm:^7.27.1" + "@babel/plugin-transform-runtime": "npm:^7.29.0" + "@babel/plugin-transform-typescript": "npm:^7.28.6" + "@babel/preset-env": "npm:^7.29.0" + "@babel/preset-react": "npm:^7.28.5" + babel-plugin-macros: "npm:^3.1.0" + babel-plugin-module-resolver: "npm:^5.0.2" + babel-plugin-polyfill-corejs3: "npm:^0.14.0" + babel-plugin-react-compiler: "npm:1.0.0" + babel-plugin-root-import: "npm:^6.6.0" + babel-plugin-transform-import-meta: "npm:^2.3.3" + babel-plugin-transform-react-remove-prop-types: "npm:^0.4.24" + core-js-compat: "npm:^3.40.0" + glob-to-regexp: "npm:^0.4.1" + path: "npm:^0.12.7" + peerDependencies: + "@babel/core": ^7.12.0 + "@babel/runtime": ^7.7.0 + "@babel/runtime-corejs2": ^7.10.0 + "@babel/runtime-corejs3": ^7.10.0 + babel-minify: ^0.5.1 + core-js: "*" + core-js-pure: "*" + lodash: "*" + ramda: "*" + react-refresh: "*" + typescript: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + "@babel/runtime": + optional: true + "@babel/runtime-corejs2": + optional: true + "@babel/runtime-corejs3": + optional: true + babel-minify: + optional: true + core-js: + optional: true + core-js-pure: + optional: true + lodash: + optional: true + ramda: + optional: true + react-refresh: + optional: true + typescript: + optional: true + checksum: 10c0/b7d67ac3f7a13895d4cf9518ced7bd13b12284d2119b7f3e026d148480c6450fe59ea1d7cbf4e683e0bbfa5eb1d1c2be91e3973a8b47222e109549ef27f2a4f1 + languageName: node + linkType: hard + "@anansi/babel-preset@npm:6.2.24, @anansi/babel-preset@npm:^6.2.19": version: 6.2.24 resolution: "@anansi/babel-preset@npm:6.2.24" @@ -523,6 +586,113 @@ __metadata: languageName: node linkType: hard +"@anansi/webpack-config@npm:21.1.14": + version: 21.1.14 + resolution: "@anansi/webpack-config@npm:21.1.14" + dependencies: + "@babel/runtime-corejs3": "npm:^7.26.0" + "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.6.2" + "@svgr/webpack": "npm:^8.1.0" + "@types/sass-loader": "npm:^8.0.10" + "@types/webpack-bundle-analyzer": "npm:^4.7.0" + "@vue/preload-webpack-plugin": "npm:^2.0.0" + "@wyw-in-js/webpack-loader": "npm:^1.0.6" + assert: "npm:^2.1.0" + autoprefixer: "npm:^10.4.27" + babel-loader: "npm:^10.0.0" + browserify-zlib: "npm:^0.2.0" + buffer: "npm:^6.0.3" + circular-dependency-plugin: "npm:^5.2.2" + clean-webpack-plugin: "npm:^4.0.0" + console-browserify: "npm:^1.2.0" + constants-browserify: "npm:^1.0.0" + core-js-pure: "npm:^3.40.0" + crypto-browserify: "npm:^3.12.1" + css-loader: "npm:^7.1.4" + css-minimizer-webpack-plugin: "npm:^7.0.4" + domain-browser: "npm:^5.7.0" + duplicate-package-checker-webpack-plugin: "npm:^3.0.0" + events: "npm:^3.3.0" + file-loader: "npm:^6.2.0" + html-loader: "npm:^5.1.0" + html-webpack-plugin: "npm:^5.6.6" + https-browserify: "npm:^1.0.0" + is-wsl: "npm:^2.2.0" + mini-css-extract-plugin: "npm:^2.10.0" + mkdirp: "npm:^3.0.1" + os-browserify: "npm:^0.3.0" + path: "npm:^0.12.7" + path-browserify: "npm:^1.0.1" + postcss: "npm:^8.5.6" + postcss-loader: "npm:^8.2.1" + postcss-preset-env: "npm:^10.6.1" + process: "npm:^0.11.10" + querystring-es3: "npm:^0.2.1" + ramda: "npm:^0.32.0" + react-dev-utils: "npm:^12.0.1" + react-error-overlay: "npm:6.0.9" + readable-stream: "npm:^4.7.0" + sass-loader: "npm:^16.0.7" + sass-resources-loader: "npm:^2.2.5" + semver: "npm:^7.7.4" + stream-browserify: "npm:^3.0.0" + stream-http: "npm:^3.2.0" + string_decoder: "npm:^1.3.0" + strip-ansi: "npm:^6.0.1" + svgo: "npm:^4.0.0" + svgo-loader: "npm:^4.0.0" + terser-webpack-plugin: "npm:^5.3.16" + timers-browserify: "npm:^2.0.12" + tsconfig-paths-webpack-plugin: "npm:^4.2.0" + tty-browserify: "npm:^0.0.1" + url: "npm:^0.11.4" + util: "npm:^0.12.5" + vm-browserify: "npm:^1.1.2" + webpack-bundle-analyzer: "npm:^5.2.0" + webpack-node-externals: "npm:^3.0.0" + webpack-remove-empty-scripts: "npm:^1.1.1" + webpack-stats-plugin: "npm:^1.1.3" + worker-loader: "npm:^3.0.8" + peerDependencies: + "@babel/core": ^6 || ^7 || ^8 + "@hot-loader/react-dom": ^16.0.0 || ^17.0.0 + "@storybook/react": ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + "@types/react": "*" + react: "*" + react-refresh: "*" + sass: ^1.3.0 + sass-embedded: ^1.70.0 + svgo: ^3.0.0 || ^4.0.0 + webpack: ^5.61.0 + webpack-cli: ^4.1.0 || ^5.0.0 || ^6.0.0 + webpack-dev-server: ^4.8.0 || ^5.0.0 + peerDependenciesMeta: + "@babel/core": + optional: true + "@hot-loader/react-dom": + optional: true + "@storybook/react": + optional: true + "@types/react": + optional: true + react: + optional: true + react-refresh: + optional: true + sass: + optional: true + sass-embedded: + optional: true + svgo: + optional: true + webpack-cli: + optional: true + webpack-dev-server: + optional: true + checksum: 10c0/594ee7ee5697c080cd495fc3a61396feca7d8f2a4198ea2e71de44c95be1741da5bdd15d4813b9d67d06224b38f424bdeb909df2b3bd555c1a0224113afb3fcf + languageName: node + linkType: hard + "@anansi/webpack-config@npm:21.1.15": version: 21.1.15 resolution: "@anansi/webpack-config@npm:21.1.15" @@ -4210,6 +4380,174 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/aix-ppc64@npm:0.23.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-arm64@npm:0.23.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-arm@npm:0.23.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-x64@npm:0.23.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/darwin-arm64@npm:0.23.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/darwin-x64@npm:0.23.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/freebsd-arm64@npm:0.23.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/freebsd-x64@npm:0.23.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-arm64@npm:0.23.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-arm@npm:0.23.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-ia32@npm:0.23.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-loong64@npm:0.23.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-mips64el@npm:0.23.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-ppc64@npm:0.23.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-riscv64@npm:0.23.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-s390x@npm:0.23.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-x64@npm:0.23.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/netbsd-x64@npm:0.23.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/openbsd-arm64@npm:0.23.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/openbsd-x64@npm:0.23.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/sunos-x64@npm:0.23.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-arm64@npm:0.23.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-ia32@npm:0.23.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-x64@npm:0.23.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -6130,6 +6468,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:1.49.1": + version: 1.49.1 + resolution: "@playwright/test@npm:1.49.1" + dependencies: + playwright: "npm:1.49.1" + bin: + playwright: cli.js + checksum: 10c0/2fca0bb7b334f7a23c7c5dfa5dbe37b47794c56f39b747c8d74a2f95c339e7902a296f2f1dd32c47bdd723cfa92cee05219f1a5876725dc89a1871b9137a286d + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:^0.6.2": version: 0.6.2 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.6.2" @@ -7941,6 +8290,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:24.11.0": + version: 24.11.0 + resolution: "@types/node@npm:24.11.0" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/4fb7390259e3b158d25dbecf52de8ce70fa18a4ed0949c9444bb6384517c361fa19781e6821ca8c18dc5f6af43eab72e9e159e07000e6b1286d082e8585d8c41 + languageName: node + linkType: hard + "@types/node@npm:24.12.0, @types/node@npm:^24.0.0": version: 24.12.0 resolution: "@types/node@npm:24.12.0" @@ -9096,7 +9454,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.4, accepts@npm:~1.3.8": +"accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -9269,6 +9627,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:8.12.0": + version: 8.12.0 + resolution: "ajv@npm:8.12.0" + dependencies: + fast-deep-equal: "npm:^3.1.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + uri-js: "npm:^4.2.2" + checksum: 10c0/ac4f72adf727ee425e049bc9d8b31d4a57e1c90da8d28bcd23d60781b12fcd6fc3d68db5df16994c57b78b94eed7988f5a6b482fd376dc5b084125e20a0a622e + languageName: node + linkType: hard + "ajv@npm:8.18.0, ajv@npm:^8.0.0, ajv@npm:^8.9.0": version: 8.18.0 resolution: "ajv@npm:8.18.0" @@ -9821,6 +10191,18 @@ __metadata: languageName: node linkType: hard +"babel-loader@npm:^10.0.0": + version: 10.0.0 + resolution: "babel-loader@npm:10.0.0" + dependencies: + find-up: "npm:^5.0.0" + peerDependencies: + "@babel/core": ^7.12.0 + webpack: ">=5.61.0" + checksum: 10c0/882dfacde3ee24b432ad57e468832cd0821e2a410f6c5b75ff945f069a8956592b28c6c357df5bb03db73d2741ec3db5febb106ac0bb3591c3d4288f2cf4df0e + languageName: node + linkType: hard + "babel-loader@npm:^10.1.0": version: 10.1.1 resolution: "babel-loader@npm:10.1.1" @@ -11489,7 +11871,7 @@ __metadata: languageName: node linkType: hard -"compressible@npm:~2.0.18": +"compressible@npm:~2.0.16, compressible@npm:~2.0.18": version: 2.0.18 resolution: "compressible@npm:2.0.18" dependencies: @@ -11498,6 +11880,21 @@ __metadata: languageName: node linkType: hard +"compression@npm:1.7.4": + version: 1.7.4 + resolution: "compression@npm:1.7.4" + dependencies: + accepts: "npm:~1.3.5" + bytes: "npm:3.0.0" + compressible: "npm:~2.0.16" + debug: "npm:2.6.9" + on-headers: "npm:~1.0.2" + safe-buffer: "npm:5.1.2" + vary: "npm:~1.1.2" + checksum: 10c0/138db836202a406d8a14156a5564fb1700632a76b6e7d1546939472895a5304f2b23c80d7a22bf44c767e87a26e070dbc342ea63bb45ee9c863354fa5556bbbc + languageName: node + linkType: hard + "compression@npm:1.8.1, compression@npm:^1.8.1": version: 1.8.1 resolution: "compression@npm:1.8.1" @@ -13514,7 +13911,7 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.20.0, enhanced-resolve@npm:^5.7.0": +"enhanced-resolve@npm:^5.19.0, enhanced-resolve@npm:^5.20.0, enhanced-resolve@npm:^5.7.0": version: 5.20.0 resolution: "enhanced-resolve@npm:5.20.0" dependencies: @@ -13765,6 +14162,89 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.23.0": + version: 0.23.1 + resolution: "esbuild@npm:0.23.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.23.1" + "@esbuild/android-arm": "npm:0.23.1" + "@esbuild/android-arm64": "npm:0.23.1" + "@esbuild/android-x64": "npm:0.23.1" + "@esbuild/darwin-arm64": "npm:0.23.1" + "@esbuild/darwin-x64": "npm:0.23.1" + "@esbuild/freebsd-arm64": "npm:0.23.1" + "@esbuild/freebsd-x64": "npm:0.23.1" + "@esbuild/linux-arm": "npm:0.23.1" + "@esbuild/linux-arm64": "npm:0.23.1" + "@esbuild/linux-ia32": "npm:0.23.1" + "@esbuild/linux-loong64": "npm:0.23.1" + "@esbuild/linux-mips64el": "npm:0.23.1" + "@esbuild/linux-ppc64": "npm:0.23.1" + "@esbuild/linux-riscv64": "npm:0.23.1" + "@esbuild/linux-s390x": "npm:0.23.1" + "@esbuild/linux-x64": "npm:0.23.1" + "@esbuild/netbsd-x64": "npm:0.23.1" + "@esbuild/openbsd-arm64": "npm:0.23.1" + "@esbuild/openbsd-x64": "npm:0.23.1" + "@esbuild/sunos-x64": "npm:0.23.1" + "@esbuild/win32-arm64": "npm:0.23.1" + "@esbuild/win32-ia32": "npm:0.23.1" + "@esbuild/win32-x64": "npm:0.23.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/08c2ed1105cc3c5e3a24a771e35532fe6089dd24a39c10097899072cef4a99f20860e41e9294e000d86380f353b04d8c50af482483d7f69f5208481cce61eec7 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -14334,6 +14814,33 @@ __metadata: languageName: node linkType: hard +"example-benchmark-react@workspace:examples/benchmark-react": + version: 0.0.0-use.local + resolution: "example-benchmark-react@workspace:examples/benchmark-react" + dependencies: + "@anansi/babel-preset": "npm:6.2.23" + "@anansi/browserslist-config": "npm:^1.4.3" + "@anansi/webpack-config": "npm:21.1.14" + "@babel/core": "npm:^7.22.15" + "@data-client/core": "workspace:*" + "@data-client/endpoint": "workspace:*" + "@data-client/react": "workspace:*" + "@data-client/rest": "workspace:*" + "@playwright/test": "npm:1.49.1" + "@types/node": "npm:24.11.0" + "@types/react": "npm:19.2.14" + "@types/react-dom": "npm:19.2.3" + playwright: "npm:1.49.1" + react: "npm:19.2.3" + react-dom: "npm:19.2.3" + serve: "npm:14.2.4" + tsx: "npm:4.19.2" + typescript: "npm:5.9.3" + webpack: "npm:5.105.3" + webpack-cli: "npm:6.0.1" + languageName: unknown + linkType: soft + "example-benchmark@workspace:examples/benchmark": version: 0.0.0-use.local resolution: "example-benchmark@workspace:examples/benchmark" @@ -15151,7 +15658,17 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2": +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -15161,7 +15678,16 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -15326,6 +15852,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.7.5": + version: 4.13.6 + resolution: "get-tsconfig@npm:4.13.6" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/bab6937302f542f97217cbe7cbbdfa7e85a56a377bc7a73e69224c1f0b7c9ae8365918e55752ae8648265903f506c1705f63c0de1d4bab1ec2830fef3e539a1a + languageName: node + linkType: hard + "github-slugger@npm:^1.5.0": version: 1.5.0 resolution: "github-slugger@npm:1.5.0" @@ -20477,6 +21012,18 @@ __metadata: languageName: node linkType: hard +"mini-css-extract-plugin@npm:^2.10.0": + version: 2.10.0 + resolution: "mini-css-extract-plugin@npm:2.10.0" + dependencies: + schema-utils: "npm:^4.0.0" + tapable: "npm:^2.2.1" + peerDependencies: + webpack: ^5.0.0 + checksum: 10c0/5fb0654471f4fb695629d96324d327d4d3a05069a95b83770d070e8f48a07d5b5f8da61adabdd56e3119cf6b17eef058c4ba6ab4cd31a7b2af48624621fe0520 + languageName: node + linkType: hard + "mini-css-extract-plugin@npm:^2.10.1, mini-css-extract-plugin@npm:^2.9.2": version: 2.10.1 resolution: "mini-css-extract-plugin@npm:2.10.1" @@ -20512,6 +21059,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:3.1.2": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + "minimatch@npm:3.1.5, minimatch@npm:^3.0.3, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5": version: 3.1.5 resolution: "minimatch@npm:3.1.5" @@ -21608,6 +22164,13 @@ __metadata: languageName: node linkType: hard +"on-headers@npm:~1.0.2": + version: 1.0.2 + resolution: "on-headers@npm:1.0.2" + checksum: 10c0/f649e65c197bf31505a4c0444875db0258e198292f34b884d73c2f751e91792ef96bb5cf89aa0f4fecc2e4dc662461dda606b1274b0e564f539cae5d2f5fc32f + languageName: node + linkType: hard + "on-headers@npm:~1.1.0": version: 1.1.0 resolution: "on-headers@npm:1.1.0" @@ -22614,6 +23177,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.49.1": + version: 1.49.1 + resolution: "playwright-core@npm:1.49.1" + bin: + playwright-core: cli.js + checksum: 10c0/990b619c75715cd98b2c10c1180a126e3a454b247063b8352bc67792fe01183ec07f31d30c8714c3768cefed12886d1d64ac06da701f2baafc2cad9b439e3919 + languageName: node + linkType: hard + +"playwright@npm:1.49.1": + version: 1.49.1 + resolution: "playwright@npm:1.49.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.49.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/2368762c898920d4a0a5788b153dead45f9c36c3f5cf4d2af5228d0b8ea65823e3bbe998877950a2b9bb23a211e4633996f854c6188769dc81a25543ac818ab5 + languageName: node + linkType: hard + "plugin-error@npm:^2.0.0, plugin-error@npm:^2.0.1": version: 2.0.1 resolution: "plugin-error@npm:2.0.1" @@ -23799,6 +24386,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.6": + version: 8.5.6 + resolution: "postcss@npm:8.5.6" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -25775,6 +26373,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 + languageName: node + linkType: hard + "safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -25782,13 +26387,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": - version: 5.1.2 - resolution: "safe-buffer@npm:5.1.2" - checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 - languageName: node - linkType: hard - "safe-push-apply@npm:^1.0.0": version: 1.0.0 resolution: "safe-push-apply@npm:1.0.0" @@ -26085,6 +26683,21 @@ __metadata: languageName: node linkType: hard +"serve-handler@npm:6.1.6": + version: 6.1.6 + resolution: "serve-handler@npm:6.1.6" + dependencies: + bytes: "npm:3.0.0" + content-disposition: "npm:0.5.2" + mime-types: "npm:2.1.18" + minimatch: "npm:3.1.2" + path-is-inside: "npm:1.0.2" + path-to-regexp: "npm:3.3.0" + range-parser: "npm:1.2.0" + checksum: 10c0/1e1cb6bbc51ee32bc1505f2e0605bdc2e96605c522277c977b67f83be9d66bd1eec8604388714a4d728e036d86b629bc9aec02120ea030d3d2c3899d44696503 + languageName: node + linkType: hard + "serve-handler@npm:6.1.7, serve-handler@npm:^6.1.6": version: 6.1.7 resolution: "serve-handler@npm:6.1.7" @@ -26127,6 +26740,27 @@ __metadata: languageName: node linkType: hard +"serve@npm:14.2.4": + version: 14.2.4 + resolution: "serve@npm:14.2.4" + dependencies: + "@zeit/schemas": "npm:2.36.0" + ajv: "npm:8.12.0" + arg: "npm:5.0.2" + boxen: "npm:7.0.0" + chalk: "npm:5.0.1" + chalk-template: "npm:0.4.0" + clipboardy: "npm:3.0.0" + compression: "npm:1.7.4" + is-port-reachable: "npm:4.0.0" + serve-handler: "npm:6.1.6" + update-check: "npm:1.5.4" + bin: + serve: build/main.js + checksum: 10c0/93abecd6214228d529065040f7c0cbe541c1cc321c6a94b8a968f45a519bd9c46a9fd5e45a9b24a1f5736c5b547b8fa60d5414ebc78f870e29431b64165c1d06 + languageName: node + linkType: hard + "serve@npm:14.2.6": version: 14.2.6 resolution: "serve@npm:14.2.6" @@ -27414,7 +28048,7 @@ __metadata: languageName: node linkType: hard -"svgo@npm:^4.0.1": +"svgo@npm:^4.0.0, svgo@npm:^4.0.1": version: 4.0.1 resolution: "svgo@npm:4.0.1" dependencies: @@ -27540,6 +28174,28 @@ __metadata: languageName: node linkType: hard +"terser-webpack-plugin@npm:^5.3.16": + version: 5.3.16 + resolution: "terser-webpack-plugin@npm:5.3.16" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + jest-worker: "npm:^27.4.5" + schema-utils: "npm:^4.3.0" + serialize-javascript: "npm:^6.0.2" + terser: "npm:^5.31.1" + peerDependencies: + webpack: ^5.1.0 + peerDependenciesMeta: + "@swc/core": + optional: true + esbuild: + optional: true + uglify-js: + optional: true + checksum: 10c0/39e37c5b3015c1a5354a3633f77235677bfa06eac2608ce26d258b1d1a74070a99910319a6f2f2c437eb61dc321f66434febe01d78e73fa96b4d4393b813f4cf + languageName: node + linkType: hard + "terser-webpack-plugin@npm:^5.3.17, terser-webpack-plugin@npm:^5.3.9, terser-webpack-plugin@npm:^5.4.0": version: 5.4.0 resolution: "terser-webpack-plugin@npm:5.4.0" @@ -27981,6 +28637,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:4.19.2": + version: 4.19.2 + resolution: "tsx@npm:4.19.2" + dependencies: + esbuild: "npm:~0.23.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/63164b889b1d170403e4d8753a6755dec371f220f5ce29a8e88f1f4d6085a784a12d8dc2ee669116611f2c72757ac9beaa3eea5c452796f541bdd2dc11753721 + languageName: node + linkType: hard + "tsyringe@npm:^4.10.0": version: 4.10.0 resolution: "tsyringe@npm:4.10.0" @@ -28204,6 +28876,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + "typescript@npm:6.0.1-rc": version: 6.0.1-rc resolution: "typescript@npm:6.0.1-rc" @@ -28224,6 +28906,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A6.0.1-rc#optional!builtin": version: 6.0.1-rc resolution: "typescript@patch:typescript@npm%3A6.0.1-rc#optional!builtin::version=6.0.1-rc&hash=5786d5" @@ -29326,6 +30018,44 @@ __metadata: languageName: node linkType: hard +"webpack@npm:5.105.3": + version: 5.105.3 + resolution: "webpack@npm:5.105.3" + dependencies: + "@types/eslint-scope": "npm:^3.7.7" + "@types/estree": "npm:^1.0.8" + "@types/json-schema": "npm:^7.0.15" + "@webassemblyjs/ast": "npm:^1.14.1" + "@webassemblyjs/wasm-edit": "npm:^1.14.1" + "@webassemblyjs/wasm-parser": "npm:^1.14.1" + acorn: "npm:^8.16.0" + acorn-import-phases: "npm:^1.0.3" + browserslist: "npm:^4.28.1" + chrome-trace-event: "npm:^1.0.2" + enhanced-resolve: "npm:^5.19.0" + es-module-lexer: "npm:^2.0.0" + eslint-scope: "npm:5.1.1" + events: "npm:^3.2.0" + glob-to-regexp: "npm:^0.4.1" + graceful-fs: "npm:^4.2.11" + json-parse-even-better-errors: "npm:^2.3.1" + loader-runner: "npm:^4.3.1" + mime-types: "npm:^2.1.27" + neo-async: "npm:^2.6.2" + schema-utils: "npm:^4.3.3" + tapable: "npm:^2.3.0" + terser-webpack-plugin: "npm:^5.3.16" + watchpack: "npm:^2.5.1" + webpack-sources: "npm:^3.3.4" + peerDependenciesMeta: + webpack-cli: + optional: true + bin: + webpack: bin/webpack.js + checksum: 10c0/a9a9a3a369d14f24348bd932feb07eef74a9fe194ad16704dc4f125b6055fe51a5ef1c6fdb3aa398c6f180f59aa14cb1b360695d8ea862a5ed130103ebd255e0 + languageName: node + linkType: hard + "webpackbar@npm:^6.0.1": version: 6.0.1 resolution: "webpackbar@npm:6.0.1" From eb261cb17ee3854b6b0b5876c95a473fd454c7d9 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 00:08:40 -0400 Subject: [PATCH 02/46] more of the plan --- .cursor/rules/benchmarking.mdc | 26 +- AGENTS.md | 1 + examples/AGENTS.md | 2 +- examples/benchmark-react/README.md | 43 +++- examples/benchmark-react/bench/memory.ts | 14 ++ .../benchmark-react/bench/report-viewer.html | 160 ++++++++++++ examples/benchmark-react/bench/runner.ts | 146 +++++++++-- examples/benchmark-react/bench/scenarios.ts | 66 ++++- examples/benchmark-react/bench/stats.ts | 3 +- examples/benchmark-react/package.json | 8 +- .../benchmark-react/src/baseline/index.tsx | 209 ++++++++++++++++ .../src/{ => data-client}/index.tsx | 61 ++++- .../src/{ => data-client}/resources.ts | 1 + examples/benchmark-react/src/shared/types.ts | 20 +- examples/benchmark-react/src/swr/index.tsx | 222 +++++++++++++++++ .../src/tanstack-query/index.tsx | 228 ++++++++++++++++++ examples/benchmark-react/webpack.config.cjs | 30 +++ yarn.lock | 54 +++-- 18 files changed, 1235 insertions(+), 59 deletions(-) create mode 100644 examples/benchmark-react/bench/memory.ts create mode 100644 examples/benchmark-react/bench/report-viewer.html create mode 100644 examples/benchmark-react/src/baseline/index.tsx rename examples/benchmark-react/src/{ => data-client}/index.tsx (74%) rename examples/benchmark-react/src/{ => data-client}/resources.ts (96%) create mode 100644 examples/benchmark-react/src/swr/index.tsx create mode 100644 examples/benchmark-react/src/tanstack-query/index.tsx diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 473d27d63351..01c5c08fe469 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -1,12 +1,30 @@ --- -description: Benchmarking guidelines using examples/benchmark (suite selection, commands, and what packages are exercised) -globs: examples/benchmark/**, .github/workflows/benchmark.yml, packages/normalizr/src/**, packages/core/src/**, packages/endpoint/src/schemas/** +description: Benchmarking guidelines using examples/benchmark and examples/benchmark-react (suite selection, commands, and what packages are exercised) +globs: examples/benchmark/**, examples/benchmark-react/**, .github/workflows/benchmark.yml, .github/workflows/benchmark-react.yml, packages/normalizr/src/**, packages/core/src/**, packages/endpoint/src/schemas/**, packages/react/src/** alwaysApply: false --- -# Benchmarking (`@examples/benchmark`) +# Benchmarking -When working on performance investigations or changes that might impact performance, use **`@examples/benchmark`** as the canonical benchmark harness. +## Node benchmark (`@examples/benchmark`) + +When working on performance investigations or changes that might impact **core, normalizr, or endpoint** (no browser, no React), use **`@examples/benchmark`** as the canonical harness. + +## React benchmark (`@examples/benchmark-react`) + +When working on **`packages/react`** or comparing data-client to other React data libraries (TanStack Query, SWR, baseline), use **`@examples/benchmark-react`**. + +- **Where it lives**: `examples/benchmark-react/` +- **How to run**: From repo root: `yarn build:benchmark-react`, then `yarn workspace example-benchmark-react preview &` and in another terminal `cd examples/benchmark-react && yarn bench` +- **What it measures**: Browser-based mount/update duration, ref-stability counts, optional memory (heap delta) and React Profiler commit times. Compares data-client, TanStack Query, SWR, and a plain React baseline. +- **CI**: `.github/workflows/benchmark-react.yml` runs on changes to `packages/react/src/**`, `packages/core/src/**`, or `examples/benchmark-react/**` and reports via `rhysd/github-action-benchmark` (customSmallerIsBetter). Hot-path scenarios only in CI; with-network and memory scenarios run locally. +- **Report viewer**: Open `examples/benchmark-react/bench/report-viewer.html` in a browser and paste `react-bench-output.json` to view a comparison table and charts. + +See `@examples/benchmark-react/README.md` for methodology, adding a new library, and interpreting results. + +--- + +# Node benchmark details (`@examples/benchmark`) ## Optimization workflow diff --git a/AGENTS.md b/AGENTS.md index d7e66057e216..4c7cca3fda45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v - **Examples**: `examples/todo-app`, `examples/github-app`, `examples/nextjs` - **Documentation**: `docs/core/api`, `docs/rest`, `docs/core/guides` - **Tests**: `packages/*/src/**/__tests__` +- **Benchmarks**: `examples/benchmark` (Node: core/normalizr/endpoint throughput), `examples/benchmark-react` (browser: React rendering and data-library comparison). See `.cursor/rules/benchmarking.mdc` and each example’s README. ## Key Principles diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 523b0ddda5c3..94cecb86a05e 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -4,7 +4,7 @@ ### Monorepo workspace members -`benchmark`, `test-bundlesize`, `normalizr-*`, `coin-app` — listed in root `package.json` workspaces, managed by yarn. Most use `workspace:*` for `@data-client/*` deps. +`benchmark`, `benchmark-react`, `test-bundlesize`, `normalizr-*`, `coin-app` — listed in root `package.json` workspaces, managed by yarn. Most use `workspace:*` for `@data-client/*` deps. ### Standalone (StackBlitz demos) diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index ce14bffd0e7e..a9b921919ed5 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -1,6 +1,19 @@ # React Rendering Benchmark -Browser-based benchmark comparing `@data-client/react` (and future: TanStack Query, SWR, baseline) on mount/update scenarios. Built with Webpack via `@anansi/webpack-config`. Results are reported to CI via `rhysd/github-action-benchmark`. +Browser-based benchmark comparing `@data-client/react`, TanStack Query, SWR, and a plain React baseline on mount/update scenarios. Built with Webpack via `@anansi/webpack-config`. Results are reported to CI via `rhysd/github-action-benchmark`. + +## Comparison to Node benchmarks + +The repo has two benchmark suites: + +- **`examples/benchmark`** (Node) — Measures the JS engine only: `normalize`/`denormalize`, `Controller.setResponse`/`getResponse`, reducer throughput. No browser, no React. Use it to validate core and normalizr changes. +- **`examples/benchmark-react`** (this app) — Measures the full React rendering pipeline: same operations driven in a real browser, with layout and paint. Use it to validate `@data-client/react` and compare against other data libraries. + +## Methodology + +- **What we measure:** Wall-clock time from triggering an action (e.g. `mount(100)` or `updateAuthor('author-0')`) until the harness sets `data-bench-complete` (after two `requestAnimationFrame` callbacks). Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. +- **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update) and ref stability (fewer new object references). See [js-framework-benchmark “How the duration is measured”](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. +- **Statistical:** Warmup runs are discarded; we report median and 95% CI. Libraries are interleaved per round to reduce environmental variance. ## Scenario categories @@ -20,6 +33,28 @@ Browser-based benchmark comparing `@data-client/react` (and future: TanStack Que - **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client uses 1 request; other libs can be compared with higher `simulatedRequestCount` to model overfetching. +**Memory (local only)** + +- **Memory mount/unmount cycle** (`memory-mount-unmount-cycle`) — Mount 500 items, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. + +**Other** + +- **Optimistic update rollback** — data-client only; measures time to apply and then roll back an optimistic update. + +## Interpreting results + +- **Lower is better** for duration (ms), ref-stability counts, and heap delta (bytes). +- **Expected variance:** Duration and ref-stability are usually within a few percent run-to-run; heap and “react commit” can vary more. Regressions > 5–10% on stable scenarios are worth investigating. +- **Ref-stability:** data-client’s normalized cache keeps referential equality for unchanged entities, so `itemRefChanged` and `authorRefChanged` should stay low (e.g. 1 and ~25 for “update one author” with 100 rows). Non-normalized libs typically show higher counts. + +## Adding a new library + +1. Add a new app under `src//index.tsx` (e.g. `src/urql/index.tsx`). +2. Implement the same `BenchAPI` interface on `window.__BENCH__`: `mount`, `updateEntity`, `updateAuthor`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `optimisticRollback`. Use the shared presentational `ItemRow` from `@shared/components` and fixtures from `@shared/data`. +3. Add the library to `LIBRARIES` in `bench/scenarios.ts`. +4. Add a webpack entry in `webpack.config.cjs` for the new app and an `HtmlWebpackPlugin` entry so the app is served at `//`. +5. Add the dependency to `package.json` and run `yarn install`. + ## Running locally 1. **Install system dependencies (Linux / WSL)** @@ -49,3 +84,9 @@ Browser-based benchmark comparing `@data-client/react` (and future: TanStack Que ## Output The runner prints a JSON array in `customSmallerIsBetter` format (name, unit, value, range) to stdout. In CI this is written to `react-bench-output.json` and sent to the benchmark action. + +To view results locally, open `bench/report-viewer.html` in a browser and paste the JSON (or upload `react-bench-output.json`) to see a comparison table and bar chart. + +## Optional: Chrome trace + +Set `BENCH_TRACE=true` when running the bench to enable Chrome tracing for duration scenarios. Trace files are written to disk; parsing and reporting trace duration is best-effort and may require additional tooling for the trace zip format. diff --git a/examples/benchmark-react/bench/memory.ts b/examples/benchmark-react/bench/memory.ts new file mode 100644 index 000000000000..0002b2b8506f --- /dev/null +++ b/examples/benchmark-react/bench/memory.ts @@ -0,0 +1,14 @@ +import type { CDPSession } from 'playwright'; + +/** + * Collect JS heap used size (bytes) via Chrome DevTools Protocol. + * Call HeapProfiler.collectGarbage first to reduce variance. + */ +export async function collectHeapUsed(cdp: CDPSession): Promise { + await cdp.send('HeapProfiler.collectGarbage'); + const { metrics } = await cdp.send('Performance.getMetrics'); + const m = metrics.find( + (metric: { name: string }) => metric.name === 'JSHeapUsedSize', + ); + return m?.value ?? 0; +} diff --git a/examples/benchmark-react/bench/report-viewer.html b/examples/benchmark-react/bench/report-viewer.html new file mode 100644 index 000000000000..c0d730bd69c9 --- /dev/null +++ b/examples/benchmark-react/bench/report-viewer.html @@ -0,0 +1,160 @@ + + + + + + React benchmark report + + + + +

React benchmark report

+
+ + +

+ +
+ +
+

+
+
+ + + + diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 9da2d50de91b..df349291946c 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -3,6 +3,7 @@ import { chromium } from 'playwright'; import type { Page } from 'playwright'; import { collectMeasures, getMeasureDuration } from './measure.js'; +import { collectHeapUsed } from './memory.js'; import { formatReport, type BenchmarkResult } from './report.js'; import { SCENARIOS, @@ -11,12 +12,15 @@ import { LIBRARIES, } from './scenarios.js'; import { computeStats } from './stats.js'; +import { parseTraceDuration } from './tracing.js'; const BASE_URL = process.env.BENCH_BASE_URL ?? 'http://localhost:5173'; -/** In CI we only run hot-path scenarios; with-network is for local comparison. */ +/** In CI we only run hot-path scenarios; with-network and memory are for local comparison. */ const SCENARIOS_TO_RUN = process.env.CI ? - SCENARIOS.filter(s => s.category !== 'withNetwork') + SCENARIOS.filter( + s => s.category !== 'withNetwork' && s.category !== 'memory', + ) : SCENARIOS; const TOTAL_RUNS = WARMUP_RUNS + MEASUREMENT_RUNS; @@ -33,12 +37,20 @@ function isRefStabilityScenario( ); } +const USE_TRACE = process.env.BENCH_TRACE === 'true'; + +interface ScenarioResult { + value: number; + reactCommit?: number; + traceDuration?: number; +} + async function runScenario( page: Page, lib: string, scenario: (typeof SCENARIOS_TO_RUN)[0], -): Promise { - const appPath = '/'; +): Promise { + const appPath = `/${lib}/`; await page.goto(`${BASE_URL}${appPath}`, { waitUntil: 'networkidle' }); await page.waitForSelector('[data-app-ready]', { timeout: 10000, @@ -51,13 +63,44 @@ async function runScenario( const bench = await page.evaluateHandle('window.__BENCH__'); if (!bench) throw new Error('window.__BENCH__ not found'); + const isMemory = + scenario.action === 'mountUnmountCycle' && + scenario.resultMetric === 'heapDelta'; + if (isMemory) { + const cdp = await page.context().newCDPSession(page); + try { + await cdp.send('Performance.enable'); + } catch { + // best-effort + } + const heapBefore = await collectHeapUsed(cdp); + await (bench as any).evaluate(async (api: any, a: unknown[]) => { + if (api.mountUnmountCycle) + await api.mountUnmountCycle(...(a as [number, number])); + }, scenario.args); + await page.waitForSelector('[data-bench-complete]', { + timeout: 60000, + state: 'attached', + }); + const heapAfter = await collectHeapUsed(cdp); + await bench.dispose(); + return { value: heapAfter - heapBefore }; + } + const isUpdate = - scenario.action === 'updateEntity' || scenario.action === 'updateAuthor'; + scenario.action === 'updateEntity' || + scenario.action === 'updateAuthor' || + scenario.action === 'optimisticRollback'; const isRefStability = isRefStabilityScenario(scenario); + const mountCount = + scenario.mountCount ?? (scenario.action === 'optimisticRollback' ? 1 : 100); if (isUpdate || isRefStability) { await harness.evaluate(el => el.removeAttribute('data-bench-complete')); - await (bench as any).evaluate((api: any) => api.mount(100)); + await (bench as any).evaluate( + (api: any, n: number) => api.mount(n), + mountCount, + ); await page.waitForSelector('[data-bench-complete]', { timeout: 5000, state: 'attached', @@ -73,6 +116,11 @@ async function runScenario( } await harness.evaluate(el => el.removeAttribute('data-bench-complete')); + if (USE_TRACE && !isRefStability) { + await (page as any).tracing.start({ + categories: ['devtools.timeline', 'blink'], + }); + } await (bench as any).evaluate((api: any, s: any) => { api[s.action](...s.args); }, scenario); @@ -82,12 +130,22 @@ async function runScenario( state: 'attached', }); + let traceDuration: number | undefined; + if (USE_TRACE && !isRefStability) { + try { + const buf = await (page as any).tracing.stop(); + traceDuration = parseTraceDuration(buf); + } catch { + traceDuration = undefined; + } + } + if (isRefStability && scenario.resultMetric) { const report = await (bench as any).evaluate((api: any) => api.getRefStabilityReport(), ); await bench.dispose(); - return report[scenario.resultMetric] as number; + return { value: report[scenario.resultMetric] as number }; } const measures = await collectMeasures(page); @@ -95,9 +153,17 @@ async function runScenario( scenario.action === 'mount' ? getMeasureDuration(measures, 'mount-duration') : getMeasureDuration(measures, 'update-duration'); + const reactCommit = + scenario.action === 'mount' ? + getMeasureDuration(measures, 'react-commit-mount') + : getMeasureDuration(measures, 'react-commit-update'); await bench.dispose(); - return duration; + return { + value: duration, + reactCommit: reactCommit > 0 ? reactCommit : undefined, + traceDuration, + }; } function shuffle(arr: T[]): T[] { @@ -111,8 +177,12 @@ function shuffle(arr: T[]): T[] { async function main() { 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 browser = await chromium.launch({ headless: true }); @@ -132,9 +202,17 @@ async function main() { for (const scenario of SCENARIOS_TO_RUN) { if (!scenario.name.startsWith(`${lib}:`)) continue; + if (scenario.action === 'optimisticRollback' && lib !== 'data-client') + continue; try { - const duration = await runScenario(page, lib, scenario); - results[scenario.name].push(duration); + const result = await runScenario(page, lib, scenario); + results[scenario.name].push(result.value); + if (result.reactCommit != null) { + reactCommitResults[scenario.name].push(result.reactCommit); + } + if (result.traceDuration != null) { + traceResults[scenario.name].push(result.traceDuration); + } } catch (err) { console.error( `Scenario ${scenario.name} failed:`, @@ -154,19 +232,53 @@ async function main() { const samples = results[scenario.name]; if (samples.length === 0) continue; const { median, range } = computeStats(samples, WARMUP_RUNS); - const unit = - ( - scenario.resultMetric === 'itemRefChanged' || - scenario.resultMetric === 'authorRefChanged' - ) ? - 'count' - : 'ms'; + let unit = 'ms'; + if ( + scenario.resultMetric === 'itemRefChanged' || + scenario.resultMetric === 'authorRefChanged' + ) { + unit = 'count'; + } else if (scenario.resultMetric === 'heapDelta') { + unit = 'bytes'; + } report.push({ name: scenario.name, unit, value: Math.round(median * 100) / 100, range, }); + const reactSamples = reactCommitResults[scenario.name]; + if ( + reactSamples.length > 0 && + (scenario.action === 'mount' || + scenario.action === 'updateEntity' || + scenario.action === 'updateAuthor' || + scenario.action === 'optimisticRollback') + ) { + const { median: rcMedian, range: rcRange } = computeStats( + reactSamples, + WARMUP_RUNS, + ); + report.push({ + name: `${scenario.name} (react commit)`, + unit: 'ms', + value: Math.round(rcMedian * 100) / 100, + range: rcRange, + }); + } + const traceSamples = traceResults[scenario.name]; + if (traceSamples.length > 0) { + const { median: trMedian, range: trRange } = computeStats( + traceSamples, + WARMUP_RUNS, + ); + report.push({ + name: `${scenario.name} (trace)`, + unit: 'ms', + value: Math.round(trMedian * 100) / 100, + range: trRange, + }); + } } process.stdout.write(formatReport(report)); diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index e213239d134b..637e37c943e6 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -3,47 +3,56 @@ import type { Scenario } from '../src/shared/types.js'; /** Simulated network delay (ms) per request for with-network scenarios. Consistent for comparability. */ export const SIMULATED_NETWORK_DELAY_MS = 50; -export const SCENARIOS: Scenario[] = [ +interface BaseScenario { + nameSuffix: string; + action: Scenario['action']; + args: unknown[]; + resultMetric?: Scenario['resultMetric']; + category: NonNullable; + mountCount?: number; +} + +const BASE_SCENARIOS: BaseScenario[] = [ { - name: 'data-client: mount-100-items', + nameSuffix: 'mount-100-items', action: 'mount', args: [100], category: 'hotPath', }, { - name: 'data-client: mount-500-items', + nameSuffix: 'mount-500-items', action: 'mount', args: [500], category: 'hotPath', }, { - name: 'data-client: update-single-entity', + nameSuffix: 'update-single-entity', action: 'updateEntity', args: ['item-0'], category: 'hotPath', }, { - name: 'data-client: update-shared-author-duration', + nameSuffix: 'update-shared-author-duration', action: 'updateAuthor', args: ['author-0'], category: 'hotPath', }, { - name: 'data-client: ref-stability-item-changed', + nameSuffix: 'ref-stability-item-changed', action: 'updateEntity', args: ['item-0'], resultMetric: 'itemRefChanged', category: 'hotPath', }, { - name: 'data-client: ref-stability-author-changed', + nameSuffix: 'ref-stability-author-changed', action: 'updateAuthor', args: ['author-0'], resultMetric: 'authorRefChanged', category: 'hotPath', }, { - name: 'data-client: update-shared-author-with-network', + nameSuffix: 'update-shared-author-with-network', action: 'updateAuthor', args: [ 'author-0', @@ -54,8 +63,47 @@ export const SCENARIOS: Scenario[] = [ ], category: 'withNetwork', }, + { + nameSuffix: 'update-shared-author-500-mounted', + action: 'updateAuthor', + args: ['author-0'], + category: 'hotPath', + mountCount: 500, + }, + { + nameSuffix: 'memory-mount-unmount-cycle', + action: 'mountUnmountCycle', + args: [500, 10], + resultMetric: 'heapDelta', + category: 'memory', + }, + { + nameSuffix: 'optimistic-update-rollback', + action: 'optimisticRollback', + args: [], + category: 'hotPath', + }, ]; +export const LIBRARIES = [ + 'data-client', + 'tanstack-query', + 'swr', + 'baseline', +] as const; + +export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => + BASE_SCENARIOS.map( + (base): Scenario => ({ + name: `${lib}: ${base.nameSuffix}`, + action: base.action, + args: base.args, + resultMetric: base.resultMetric, + category: base.category, + mountCount: base.mountCount, + }), + ), +); + export const WARMUP_RUNS = 2; export const MEASUREMENT_RUNS = process.env.CI ? 5 : 20; -export const LIBRARIES = ['data-client'] as const; diff --git a/examples/benchmark-react/bench/stats.ts b/examples/benchmark-react/bench/stats.ts index b00e0cf28426..2ac242c41e9c 100644 --- a/examples/benchmark-react/bench/stats.ts +++ b/examples/benchmark-react/bench/stats.ts @@ -14,8 +14,9 @@ export function computeStats( const median = sorted[Math.floor(sorted.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 - median) ** 2, 0) / trimmed.length, + trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / trimmed.length, ); const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); return { diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 2943b4ed5a1e..4eee16846f27 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -17,19 +17,21 @@ "@data-client/endpoint": "workspace:*", "@data-client/react": "workspace:*", "@data-client/rest": "workspace:*", + "@tanstack/react-query": "5.62.7", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "swr": "2.3.6" }, "devDependencies": { "@anansi/babel-preset": "6.2.23", "@anansi/browserslist-config": "^1.4.3", "@anansi/webpack-config": "21.1.14", "@babel/core": "^7.22.15", - "@playwright/test": "1.49.1", + "@playwright/test": "1.58.2", "@types/node": "24.11.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", - "playwright": "1.49.1", + "playwright": "1.58.2", "serve": "14.2.4", "tsx": "4.19.2", "typescript": "5.9.3", diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx new file mode 100644 index 000000000000..577fae905bd0 --- /dev/null +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -0,0 +1,209 @@ +import { ItemRow } from '@shared/components'; +import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import type { Item, UpdateAuthorOptions } from '@shared/types'; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { createRoot } from 'react-dom/client'; + +const ItemsContext = React.createContext<{ + items: Item[]; + setItems: React.Dispatch>; +}>(null as any); + +function ItemView({ id }: { id: string }) { + const { items } = useContext(ItemsContext); + const item = items.find(i => i.id === id); + if (!item) return null; + registerRefs(id, item, item.author); + return ; +} + +function BenchmarkHarness() { + const [items, setItems] = useState([]); + const [count, setCount] = useState(0); + const containerRef = useRef(null); + const completeResolveRef = useRef<(() => void) | null>(null); + + const setComplete = useCallback(() => { + completeResolveRef.current?.(); + completeResolveRef.current = null; + containerRef.current?.setAttribute('data-bench-complete', 'true'); + }, []); + + const mount = useCallback( + (n: number) => { + performance.mark('mount-start'); + setCount(n); + setItems(FIXTURE_ITEMS.slice(0, n)); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + + const updateEntity = useCallback( + (id: string) => { + performance.mark('update-start'); + setItems(prev => + prev.map(item => + item.id === id ? { ...item, label: `${item.label} (updated)` } : item, + ), + ); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + + const updateAuthor = useCallback( + (authorId: string, options?: UpdateAuthorOptions) => { + performance.mark('update-start'); + const delayMs = options?.simulateNetworkDelayMs ?? 0; + const requestCount = options?.simulatedRequestCount ?? 1; + const totalDelayMs = delayMs * requestCount; + + const doUpdate = () => { + const author = FIXTURE_AUTHORS.find(a => a.id === authorId); + if (author) { + const newAuthor = { + ...author, + name: `${author.name} (updated)`, + }; + setItems(prev => + prev.map(item => + item.author.id === authorId ? + { ...item, author: newAuthor } + : item, + ), + ); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure( + 'update-duration', + 'update-start', + 'update-end', + ); + setComplete(); + }); + }); + }; + + if (totalDelayMs > 0) { + setTimeout(doUpdate, totalDelayMs); + } else { + doUpdate(); + } + }, + [setComplete], + ); + + const unmountAll = useCallback(() => { + setCount(0); + setItems([]); + }, []); + + const mountUnmountCycle = useCallback( + async (n: number, cycles: number) => { + for (let i = 0; i < cycles; i++) { + const p = new Promise(r => { + completeResolveRef.current = r; + }); + mount(n); + await p; + unmountAll(); + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ); + } + setComplete(); + }, + [mount, unmountAll, setComplete], + ); + + const getRenderedCount = useCallback(() => count, [count]); + + const captureRefSnapshot = useCallback(() => { + captureSnapshot(); + }, []); + + const getRefStabilityReport = useCallback(() => getReport(), []); + + useEffect(() => { + window.__BENCH__ = { + mount, + updateEntity, + updateAuthor, + unmountAll, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + mountUnmountCycle, + }; + return () => { + delete window.__BENCH__; + }; + }, [ + mount, + updateEntity, + updateAuthor, + unmountAll, + mountUnmountCycle, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + ]); + + useEffect(() => { + document.body.setAttribute('data-app-ready', 'true'); + }, []); + + const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); + + return ( + +
+
+ {ids.map(id => ( + + ))} +
+
+
+ ); +} + +function onProfilerRender( + _id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, +) { + performance.measure(`react-commit-${phase}`, { + start: performance.now() - actualDuration, + duration: actualDuration, + }); +} + +const rootEl = document.getElementById('root') ?? document.body; +createRoot(rootEl).render( + + + , +); diff --git a/examples/benchmark-react/src/index.tsx b/examples/benchmark-react/src/data-client/index.tsx similarity index 74% rename from examples/benchmark-react/src/index.tsx rename to examples/benchmark-react/src/data-client/index.tsx index 1cabcf6d5183..994766b1933c 100644 --- a/examples/benchmark-react/src/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -34,9 +34,12 @@ function ItemView({ id }: { id: string }) { function BenchmarkHarness() { const [count, setCount] = useState(0); const containerRef = useRef(null); + const completeResolveRef = useRef<(() => void) | null>(null); const controller = useController(); const setComplete = useCallback(() => { + completeResolveRef.current?.(); + completeResolveRef.current = null; containerRef.current?.setAttribute('data-bench-complete', 'true'); }, []); @@ -125,6 +128,45 @@ function BenchmarkHarness() { setCount(0); }, []); + const optimisticRollback = useCallback(() => { + const item = FIXTURE_ITEMS[0]; + if (!item) return; + performance.mark('update-start'); + controller.setResponse( + getItem, + { id: item.id }, + { + ...item, + label: `${item.label} (rolled back)`, + }, + ); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }); + }, [controller, setComplete]); + + const mountUnmountCycle = useCallback( + async (n: number, cycles: number) => { + for (let i = 0; i < cycles; i++) { + const p = new Promise(r => { + completeResolveRef.current = r; + }); + mount(n); + await p; + unmountAll(); + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ); + } + setComplete(); + }, + [mount, unmountAll, setComplete], + ); + const getRenderedCount = useCallback(() => count, [count]); const captureRefSnapshot = useCallback(() => { @@ -142,6 +184,8 @@ function BenchmarkHarness() { getRenderedCount, captureRefSnapshot, getRefStabilityReport, + mountUnmountCycle, + optimisticRollback, }; return () => { delete window.__BENCH__; @@ -151,6 +195,8 @@ function BenchmarkHarness() { updateEntity, updateAuthor, unmountAll, + mountUnmountCycle, + optimisticRollback, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -173,9 +219,22 @@ function BenchmarkHarness() { ); } +function onProfilerRender( + id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, +) { + performance.measure(`react-commit-${phase}`, { + start: performance.now() - actualDuration, + duration: actualDuration, + }); +} + const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( - + + + , ); diff --git a/examples/benchmark-react/src/resources.ts b/examples/benchmark-react/src/data-client/resources.ts similarity index 96% rename from examples/benchmark-react/src/resources.ts rename to examples/benchmark-react/src/data-client/resources.ts index 7f1a62ee0545..0a07a38319b6 100644 --- a/examples/benchmark-react/src/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -25,6 +25,7 @@ export class ItemEntity extends Entity { } static key = 'ItemEntity'; + static schema = { author: AuthorEntity }; } /** Endpoint to get a single author by id */ diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 39f22e64a0a0..f3d75d78b0b2 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -31,6 +31,10 @@ export interface BenchAPI { getRenderedCount(): number; captureRefSnapshot(): void; getRefStabilityReport(): RefStabilityReport; + /** For memory scenarios: mount n items, unmount, repeat cycles times; resolves when done. */ + mountUnmountCycle?(count: number, cycles: number): Promise; + /** Optimistic update then rollback; sets data-bench-complete when rollback is painted. Optional (data-client only). */ + optimisticRollback?(): void; } declare global { @@ -58,17 +62,23 @@ export type ScenarioAction = | { action: 'updateAuthor'; args: [string, UpdateAuthorOptions] } | { action: 'unmountAll'; args: [] }; -export type ResultMetric = 'duration' | 'itemRefChanged' | 'authorRefChanged'; +export type ResultMetric = + | 'duration' + | 'itemRefChanged' + | 'authorRefChanged' + | 'heapDelta'; -/** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. */ -export type ScenarioCategory = 'hotPath' | 'withNetwork'; +/** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. memory = heap delta, not CI. */ +export type ScenarioCategory = 'hotPath' | 'withNetwork' | 'memory'; export interface Scenario { name: string; action: keyof BenchAPI; args: unknown[]; - /** Which value to report; default 'duration'. Ref-stability scenarios use itemRefChanged/authorRefChanged. */ + /** Which value to report; default 'duration'. Ref-stability use itemRefChanged/authorRefChanged; memory use heapDelta. */ resultMetric?: ResultMetric; - /** hotPath (default) = run in CI. withNetwork = comparison only, not CI. */ + /** hotPath (default) = run in CI. withNetwork = comparison only. memory = heap delta. */ category?: ScenarioCategory; + /** For update scenarios: number of items to mount before running the update (default 100). */ + mountCount?: number; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx new file mode 100644 index 000000000000..87108f02fe80 --- /dev/null +++ b/examples/benchmark-react/src/swr/index.tsx @@ -0,0 +1,222 @@ +import { ItemRow } from '@shared/components'; +import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import type { Item, UpdateAuthorOptions } from '@shared/types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import useSWR, { SWRConfig, useSWRConfig } from 'swr'; + +const fetcher = () => Promise.reject(new Error('Not implemented - use cache')); + +const cache = new Map< + string, + { data: unknown; isLoading: boolean; isValidating: boolean; error: undefined } +>(); +for (const author of FIXTURE_AUTHORS) { + cache.set(`author:${author.id}`, { + data: author, + isLoading: false, + isValidating: false, + error: undefined, + }); +} +for (const item of FIXTURE_ITEMS) { + cache.set(`item:${item.id}`, { + data: item, + isLoading: false, + isValidating: false, + error: undefined, + }); +} + +function ItemView({ id }: { id: string }) { + const { data: item } = useSWR(`item:${id}`, fetcher); + if (!item) return null; + registerRefs(id, item, item.author); + return ; +} + +function BenchmarkHarness() { + const [count, setCount] = useState(0); + const containerRef = useRef(null); + const completeResolveRef = useRef<(() => void) | null>(null); + const { mutate } = useSWRConfig(); + + const setComplete = useCallback(() => { + completeResolveRef.current?.(); + completeResolveRef.current = null; + containerRef.current?.setAttribute('data-bench-complete', 'true'); + }, []); + + const mount = useCallback( + (n: number) => { + performance.mark('mount-start'); + setCount(n); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + + const updateEntity = useCallback( + (id: string) => { + performance.mark('update-start'); + const item = FIXTURE_ITEMS.find(i => i.id === id); + if (item) { + void mutate(`item:${id}`, { + ...item, + label: `${item.label} (updated)`, + }); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }); + }, + [mutate, setComplete], + ); + + const updateAuthor = useCallback( + (authorId: string, options?: UpdateAuthorOptions) => { + performance.mark('update-start'); + const delayMs = options?.simulateNetworkDelayMs ?? 0; + const requestCount = options?.simulatedRequestCount ?? 1; + const totalDelayMs = delayMs * requestCount; + + const doUpdate = () => { + const author = FIXTURE_AUTHORS.find(a => a.id === authorId); + if (author) { + const newAuthor = { + ...author, + name: `${author.name} (updated)`, + }; + void mutate(`author:${authorId}`, newAuthor); + for (const item of FIXTURE_ITEMS) { + if (item.author.id === authorId) { + void mutate(`item:${item.id}`, (prev: Item | undefined) => + prev ? { ...prev, author: newAuthor } : prev, + ); + } + } + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure( + 'update-duration', + 'update-start', + 'update-end', + ); + setComplete(); + }); + }); + }; + + if (totalDelayMs > 0) { + setTimeout(doUpdate, totalDelayMs); + } else { + doUpdate(); + } + }, + [mutate, setComplete], + ); + + const unmountAll = useCallback(() => { + setCount(0); + }, []); + + const mountUnmountCycle = useCallback( + async (n: number, cycles: number) => { + for (let i = 0; i < cycles; i++) { + const p = new Promise(r => { + completeResolveRef.current = r; + }); + mount(n); + await p; + unmountAll(); + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ); + } + setComplete(); + }, + [mount, unmountAll, setComplete], + ); + + const getRenderedCount = useCallback(() => count, [count]); + + const captureRefSnapshot = useCallback(() => { + captureSnapshot(); + }, []); + + const getRefStabilityReport = useCallback(() => getReport(), []); + + useEffect(() => { + window.__BENCH__ = { + mount, + updateEntity, + updateAuthor, + unmountAll, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + mountUnmountCycle, + }; + return () => { + delete window.__BENCH__; + }; + }, [ + mount, + updateEntity, + updateAuthor, + unmountAll, + mountUnmountCycle, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + ]); + + useEffect(() => { + document.body.setAttribute('data-app-ready', 'true'); + }, []); + + const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); + + return ( +
+
+ {ids.map(id => ( + + ))} +
+
+ ); +} + +function onProfilerRender( + _id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, +) { + performance.measure(`react-commit-${phase}`, { + start: performance.now() - actualDuration, + duration: actualDuration, + }); +} + +const rootEl = document.getElementById('root') ?? document.body; +createRoot(rootEl).render( + cache as any }}> + + + + , +); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx new file mode 100644 index 000000000000..bd3c5333fe00 --- /dev/null +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -0,0 +1,228 @@ +import { ItemRow } from '@shared/components'; +import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import type { Item, UpdateAuthorOptions } from '@shared/types'; +import { + QueryClient, + QueryClientProvider, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +function seedCache(queryClient: QueryClient) { + for (const author of FIXTURE_AUTHORS) { + queryClient.setQueryData(['author', author.id], author); + } + for (const item of FIXTURE_ITEMS) { + queryClient.setQueryData(['item', item.id], item); + } +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: Infinity, + }, + }, +}); +seedCache(queryClient); + +function ItemView({ id }: { id: string }) { + const { data: item } = useQuery({ + queryKey: ['item', id], + queryFn: () => FIXTURE_ITEMS.find(i => i.id === id) as Item, + initialData: () => FIXTURE_ITEMS.find(i => i.id === id) as Item, + }); + if (!item) return null; + const itemAsItem = item as Item; + registerRefs(id, itemAsItem, itemAsItem.author); + return ; +} + +function BenchmarkHarness() { + const [count, setCount] = useState(0); + const containerRef = useRef(null); + const completeResolveRef = useRef<(() => void) | null>(null); + const client = useQueryClient(); + + const setComplete = useCallback(() => { + completeResolveRef.current?.(); + completeResolveRef.current = null; + containerRef.current?.setAttribute('data-bench-complete', 'true'); + }, []); + + const mount = useCallback( + (n: number) => { + performance.mark('mount-start'); + setCount(n); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + + const updateEntity = useCallback( + (id: string) => { + performance.mark('update-start'); + const item = FIXTURE_ITEMS.find(i => i.id === id); + if (item) { + client.setQueryData(['item', id], { + ...item, + label: `${item.label} (updated)`, + }); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }); + }, + [client, setComplete], + ); + + const updateAuthor = useCallback( + (authorId: string, options?: UpdateAuthorOptions) => { + performance.mark('update-start'); + const delayMs = options?.simulateNetworkDelayMs ?? 0; + const requestCount = options?.simulatedRequestCount ?? 1; + const totalDelayMs = delayMs * requestCount; + + const doUpdate = () => { + const author = FIXTURE_AUTHORS.find(a => a.id === authorId); + if (author) { + const newAuthor = { + ...author, + name: `${author.name} (updated)`, + }; + client.setQueryData(['author', authorId], newAuthor); + for (const item of FIXTURE_ITEMS) { + if (item.author.id === authorId) { + client.setQueryData(['item', item.id], (old: Item | undefined) => + old ? { ...old, author: newAuthor } : old, + ); + } + } + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure( + 'update-duration', + 'update-start', + 'update-end', + ); + setComplete(); + }); + }); + }; + + if (totalDelayMs > 0) { + setTimeout(doUpdate, totalDelayMs); + } else { + doUpdate(); + } + }, + [client, setComplete], + ); + + const unmountAll = useCallback(() => { + setCount(0); + }, []); + + const mountUnmountCycle = useCallback( + async (n: number, cycles: number) => { + for (let i = 0; i < cycles; i++) { + const p = new Promise(r => { + completeResolveRef.current = r; + }); + mount(n); + await p; + unmountAll(); + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ); + } + setComplete(); + }, + [mount, unmountAll, setComplete], + ); + + const getRenderedCount = useCallback(() => count, [count]); + + const captureRefSnapshot = useCallback(() => { + captureSnapshot(); + }, []); + + const getRefStabilityReport = useCallback(() => getReport(), []); + + useEffect(() => { + window.__BENCH__ = { + mount, + updateEntity, + updateAuthor, + unmountAll, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + mountUnmountCycle, + }; + return () => { + delete window.__BENCH__; + }; + }, [ + mount, + updateEntity, + updateAuthor, + unmountAll, + mountUnmountCycle, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + ]); + + useEffect(() => { + document.body.setAttribute('data-app-ready', 'true'); + }, []); + + const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); + + return ( +
+
+ {ids.map(id => ( + + ))} +
+
+ ); +} + +function onProfilerRender( + _id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, +) { + performance.measure(`react-commit-${phase}`, { + start: performance.now() - actualDuration, + duration: actualDuration, + }); +} + +const rootEl = document.getElementById('root') ?? document.body; +createRoot(rootEl).render( + + + + + , +); diff --git a/examples/benchmark-react/webpack.config.cjs b/examples/benchmark-react/webpack.config.cjs index fc6d6b6d39d9..674cd9de8d84 100644 --- a/examples/benchmark-react/webpack.config.cjs +++ b/examples/benchmark-react/webpack.config.cjs @@ -1,11 +1,21 @@ const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); const { makeConfig } = require('@anansi/webpack-config'); +const LIBRARIES = ['data-client', 'tanstack-query', 'swr', 'baseline']; + +const entries = {}; +for (const lib of LIBRARIES) { + entries[lib] = `./src/${lib}/index.tsx`; +} + const options = { + rootPath: __dirname, basePath: 'src', buildDir: 'dist/', globalStyleDir: 'style', sassOptions: false, + nohash: true, }; const generateConfig = makeConfig(options); @@ -16,7 +26,27 @@ module.exports = (env, argv) => { config.resolve.alias = { ...config.resolve.alias, '@shared': path.resolve(__dirname, 'src/shared'), + 'swr': require.resolve('swr'), }; + + config.entry = entries; + config.output.filename = '[name].js'; + config.output.chunkFilename = '[name].chunk.js'; + + config.plugins = config.plugins.filter( + (p) => p.constructor.name !== 'HtmlWebpackPlugin', + ); + for (const lib of LIBRARIES) { + config.plugins.push( + new HtmlWebpackPlugin({ + title: `Benchmark: ${lib}`, + filename: path.join(lib, 'index.html'), + chunks: [lib], + scriptLoading: 'defer', + }), + ); + } + return config; }; diff --git a/yarn.lock b/yarn.lock index ba010c6ec527..538efb279b7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6468,14 +6468,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:1.49.1": - version: 1.49.1 - resolution: "@playwright/test@npm:1.49.1" +"@playwright/test@npm:1.58.2": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" dependencies: - playwright: "npm:1.49.1" + playwright: "npm:1.58.2" bin: playwright: cli.js - checksum: 10c0/2fca0bb7b334f7a23c7c5dfa5dbe37b47794c56f39b747c8d74a2f95c339e7902a296f2f1dd32c47bdd723cfa92cee05219f1a5876725dc89a1871b9137a286d + checksum: 10c0/2164c03ad97c3653ff02e8818a71f3b2bbc344ac07924c9d8e31cd57505d6d37596015a41f51396b3ed8de6840f59143eaa9c21bf65515963da20740119811da languageName: node linkType: hard @@ -7457,6 +7457,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.62.7": + version: 5.62.7 + resolution: "@tanstack/query-core@npm:5.62.7" + checksum: 10c0/d7aa83571b56e7bf1b608b8f56c90a9e9e759ba5a8899686182e8961c94432d1b6437b066a228765250498a3009623a30c5ca17b2714ddb30e2d727adea9aa0c + languageName: node + linkType: hard + +"@tanstack/react-query@npm:5.62.7": + version: 5.62.7 + resolution: "@tanstack/react-query@npm:5.62.7" + dependencies: + "@tanstack/query-core": "npm:5.62.7" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/4a99bf3f3cdce6a24b6356377697e4b8cc31b0b160942a32d0619b928aec44c42dad61070d9d127512e6139bc4e62e7fe0722054f8152e21a1b8743a1dbd39e0 + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.1.0, @testing-library/dom@npm:^10.4.0, @testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -14826,14 +14844,16 @@ __metadata: "@data-client/endpoint": "workspace:*" "@data-client/react": "workspace:*" "@data-client/rest": "workspace:*" - "@playwright/test": "npm:1.49.1" + "@playwright/test": "npm:1.58.2" + "@tanstack/react-query": "npm:5.62.7" "@types/node": "npm:24.11.0" "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" - playwright: "npm:1.49.1" + playwright: "npm:1.58.2" react: "npm:19.2.3" react-dom: "npm:19.2.3" serve: "npm:14.2.4" + swr: "npm:2.3.6" tsx: "npm:4.19.2" typescript: "npm:5.9.3" webpack: "npm:5.105.3" @@ -23177,27 +23197,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.49.1": - version: 1.49.1 - resolution: "playwright-core@npm:1.49.1" +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" bin: playwright-core: cli.js - checksum: 10c0/990b619c75715cd98b2c10c1180a126e3a454b247063b8352bc67792fe01183ec07f31d30c8714c3768cefed12886d1d64ac06da701f2baafc2cad9b439e3919 + checksum: 10c0/5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b languageName: node linkType: hard -"playwright@npm:1.49.1": - version: 1.49.1 - resolution: "playwright@npm:1.49.1" +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.49.1" + playwright-core: "npm:1.58.2" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/2368762c898920d4a0a5788b153dead45f9c36c3f5cf4d2af5228d0b8ea65823e3bbe998877950a2b9bb23a211e4633996f854c6188769dc81a25543ac818ab5 + checksum: 10c0/d060d9b7cc124bd8b5dffebaab5e84f6b34654a553758fe7b19cc598dfbee93f6ecfbdc1832b40a6380ae04eade86ef3285ba03aa0b136799e83402246dc0727 languageName: node linkType: hard @@ -28065,7 +28085,7 @@ __metadata: languageName: node linkType: hard -"swr@npm:^2.2.5": +"swr@npm:2.3.6, swr@npm:^2.2.5": version: 2.3.6 resolution: "swr@npm:2.3.6" dependencies: From ddd4c8fa8cd1e218ec06ed072d8efb47ee7ab497 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 00:31:40 -0400 Subject: [PATCH 03/46] More scenarios --- examples/benchmark-react/PLAN.md | 83 ++++++++++--------- examples/benchmark-react/bench/runner.ts | 35 ++++---- examples/benchmark-react/bench/scenarios.ts | 51 ++++++++++-- .../benchmark-react/src/baseline/index.tsx | 38 +++++++-- .../benchmark-react/src/data-client/index.tsx | 73 +++++++++++----- .../src/data-client/resources.ts | 36 +++++--- examples/benchmark-react/src/shared/data.ts | 28 +++++++ examples/benchmark-react/src/shared/types.ts | 9 +- examples/benchmark-react/src/swr/index.tsx | 50 +++++++++-- .../src/tanstack-query/index.tsx | 44 ++++++++-- 10 files changed, 326 insertions(+), 121 deletions(-) diff --git a/examples/benchmark-react/PLAN.md b/examples/benchmark-react/PLAN.md index 1d64702f7ead..c8d9331d7016 100644 --- a/examples/benchmark-react/PLAN.md +++ b/examples/benchmark-react/PLAN.md @@ -1,61 +1,68 @@ -# React Rendering Benchmark – Future Work - -Follow this plan in later sessions to extend the benchmark suite. +# React Rendering Benchmark – Progress & Future Work --- -## Session 2: Competitor implementations +## Completed -Add apps that implement the same `BenchAPI` (`window.__BENCH__`) and use the same presentational components so results are comparable. +### Session 1: Core harness + data-client implementation -- **`apps/tanstack-query/`** (or `src/tanstack-query/` if keeping single webpack entry) - - Use `@tanstack/react-query` with `useQuery` and `queryClient.setQueryData` for cache seeding. - - Same scenarios: mount N items, update single entity. -- **`apps/swr/`** - - Use `swr` with `mutate` for cache seeding. -- **`apps/baseline/`** - - Plain `useState` + `useContext`, no caching library (baseline). +- Playwright-based runner with Chrome tracing, CDP heap measurement, CPU throttling +- `performance.measure()` + React Profiler `onRender` for dual JS/React metrics +- Interleaved library execution with shuffled ordering for statistical fairness +- `customSmallerIsBetter` JSON output for `rhysd/github-action-benchmark` CI integration +- Report viewer (`bench/report-viewer.html`) with Chart.js comparison table -**Deliverables:** Each app exposes the same `window.__BENCH__` interface and uses the same `ItemRow` (or shared) presentational component. Extend webpack to multi-entry so each app is built and served at e.g. `/data-client/`, `/tanstack-query/`, etc. Update `bench/runner.ts` and `bench/scenarios.ts` to iterate over all libraries and report per-library results. +### Session 2: Competitor implementations ---- +- `tanstack-query/`, `swr/`, `baseline/` apps with identical `BenchAPI` interface +- Shared presentational `ItemRow` component and fixture data across all libraries +- Multi-entry webpack config serving each library at its own path -## Session 3: Entity update propagation (normalization showcase) — Done +### Session 3: Entity update propagation (normalization showcase) -Implemented: +- **`update-shared-author-duration`** — Mount 100 items (sharing 20 authors), update one author; measure duration (ms) +- **Ref-stability scenarios** — `ref-stability-item-changed` and `ref-stability-author-changed` report component reference change counts (smaller is better) +- Shared `refStability` module and `BenchAPI.captureRefSnapshot` / `getRefStabilityReport` / `updateAuthor` -- **`update-shared-author-duration`** — Mount 100 items (sharing 20 authors), update one author; measure duration (ms). -- **Ref-stability scenarios** — `ref-stability-item-changed` and `ref-stability-author-changed` report how many components received a new object reference after an update (unit: count; smaller is better). data-client’s normalized cache keeps referential equality for unchanged entities, so these counts stay low (1 and ~25 respectively). -- Shared `refStability` module and `BenchAPI.captureRefSnapshot` / `getRefStabilityReport` / `updateAuthor`; `getAuthor` endpoint and `FIXTURE_AUTHORS` for seeding. +### Session 4: Best practices fixes + new scenarios ---- +**Best practices fixes:** +- `ItemEntity.author` default changed from `AuthorEntity` (class) to `AuthorEntity.fromJS()` (idiomatic Entity default) +- Removed `as unknown as Item` cast in data-client `ItemView` (entity `fromJS()` fix resolved the type mismatch) +- `optimisticRollback` replaced with real `optimisticUpdate` using `getOptimisticResponse` on an endpoint with `sideEffect: true`; `controller.fetch()` applies the optimistic response immediately -## Session 4: Memory and scaling scenarios +**New scenarios:** +- **`bulk-ingest-500`** — Generate fresh data at runtime, ingest into cache, and render 500 items. For data-client this exercises the full normalization pipeline; competitors seed per-item cache entries. Uses `generateFreshData()` with distinct `fresh-*` IDs to avoid pre-seeded cache hits +- **`withNetwork` per-library request counts** — Non-normalized libraries now simulate 5 network round-trips (one per affected item query sharing the updated author), while data-client simulates 1 (normalization propagates automatically) -Add memory and stress scenarios. +**Refactors:** +- All implementations switched from `count: number` state to `ids: string[]` state for cleaner bulk-ingest support +- Scenario generation supports `perLibArgs` overrides and `onlyLibs` filtering -- **Memory under repeated operations:** Cycle mount → unmount N times; measure heap (e.g. `Performance.getMetrics` / JSHeapUsedSize via CDP) to detect growth. -- **Many-subscriber scaling:** Mount 500+ components subscribed to overlapping entities; measure per-update cost (time and/or memory). -- **Optimistic update + rollback:** Optimistic mutation, then simulate error and rollback; measure time to revert DOM. +--- -**Deliverables:** New scenarios in `bench/scenarios.ts`, optional `bench/memory.ts` for CDP heap collection, and report entries for memory metrics (e.g. `customSmallerIsBetter` with unit `bytes` where applicable). +## Future Work ---- +### Session 5: `useQuery` + `Query` scenario (derived/computed data) -## Session 5: Advanced measurement and reporting +Add a scenario that demonstrates `Query` schema memoization: +- Mount a component showing items sorted/filtered via `useQuery(sortedQuery)` (data-client) vs inline `useMemo` sort (competitors) +- Update one entity and measure rerender cost of the derived view +- Requires adding a `SortedView` component to each library implementation -- **React Profiler:** Use `` (and/or `performance.measure`) to record React commit duration as a separate metric alongside existing measures. -- **Local HTML report:** Build a small report viewer (e.g. `bench/report-viewer.html` or a small app) that loads saved JSON and displays a table/charts for comparing libraries (similar to krausest results table). -- **Lighthouse-style metrics:** Optionally add FCP, TBT, or other metrics for initial load comparison (e.g. via CDP or Lighthouse CI). +### Session 6: Memory and scaling scenarios -**Deliverables:** Profiler instrumentation in app(s), report viewer, and optionally Lighthouse/load metrics in the runner and report format. +- **Many-subscriber scaling:** Mount 500+ components subscribed to overlapping entities; measure per-update cost +- **Cache invalidation cycle:** `controller.invalidate()` → Suspense fallback → re-resolve; measure full cycle time ---- +### Session 7: Advanced measurement and reporting -## Session 6: Polish and documentation +- **React Profiler as primary metric:** Use `` duration as a separate reported metric alongside `performance.measure()` +- **Local HTML report:** Enhance report viewer with time-series charts for CI history +- **Lighthouse-style metrics:** FCP, TBT for initial load comparison -- **README:** Expand with methodology (what we measure, why), how to add a new library, and how to run locally vs CI. -- **Cursor rule:** Update `.cursor/rules/benchmarking.mdc` (or equivalent) to document the React benchmark: where it lives, how to run it, and how it relates to the existing Node `example-benchmark` suite. -- **AGENTS.md:** If appropriate, add a short mention of the React benchmark and link to this plan or the README. +### Session 8: Polish and documentation -**Deliverables:** Updated README, rule file, and any AGENTS.md changes. +- **README:** Expand with methodology (what we measure, why), how to add a new library, and how to run locally vs CI +- **Cursor rule:** Update `.cursor/rules/benchmarking.mdc` to document the React benchmark +- **CI workflow:** Add GitHub Actions workflow for React benchmark (separate from existing Benchmark.js workflow) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index df349291946c..573f5f891190 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -90,11 +90,12 @@ async function runScenario( const isUpdate = scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || - scenario.action === 'optimisticRollback'; + scenario.action === 'optimisticUpdate'; const isRefStability = isRefStabilityScenario(scenario); + const isBulkIngest = scenario.action === 'bulkIngest'; const mountCount = - scenario.mountCount ?? (scenario.action === 'optimisticRollback' ? 1 : 100); + scenario.mountCount ?? (scenario.action === 'optimisticUpdate' ? 1 : 100); if (isUpdate || isRefStability) { await harness.evaluate(el => el.removeAttribute('data-bench-complete')); await (bench as any).evaluate( @@ -149,12 +150,13 @@ async function runScenario( } const measures = await collectMeasures(page); + const isMountLike = scenario.action === 'mount' || isBulkIngest; const duration = - scenario.action === 'mount' ? + isMountLike ? getMeasureDuration(measures, 'mount-duration') : getMeasureDuration(measures, 'update-duration'); const reactCommit = - scenario.action === 'mount' ? + isMountLike ? getMeasureDuration(measures, 'react-commit-mount') : getMeasureDuration(measures, 'react-commit-update'); @@ -202,17 +204,11 @@ async function main() { for (const scenario of SCENARIOS_TO_RUN) { if (!scenario.name.startsWith(`${lib}:`)) continue; - if (scenario.action === 'optimisticRollback' && lib !== 'data-client') - continue; try { const result = await runScenario(page, lib, scenario); results[scenario.name].push(result.value); - if (result.reactCommit != null) { - reactCommitResults[scenario.name].push(result.reactCommit); - } - if (result.traceDuration != null) { - traceResults[scenario.name].push(result.traceDuration); - } + reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); + traceResults[scenario.name].push(result.traceDuration ?? NaN); } catch (err) { console.error( `Scenario ${scenario.name} failed:`, @@ -247,17 +243,20 @@ async function main() { value: Math.round(median * 100) / 100, range, }); - const reactSamples = reactCommitResults[scenario.name]; + const reactSamples = reactCommitResults[scenario.name] + .slice(WARMUP_RUNS) + .filter(x => !Number.isNaN(x)); if ( reactSamples.length > 0 && (scenario.action === 'mount' || scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || - scenario.action === 'optimisticRollback') + scenario.action === 'optimisticUpdate' || + scenario.action === 'bulkIngest') ) { const { median: rcMedian, range: rcRange } = computeStats( reactSamples, - WARMUP_RUNS, + 0, ); report.push({ name: `${scenario.name} (react commit)`, @@ -266,11 +265,13 @@ async function main() { range: rcRange, }); } - const traceSamples = traceResults[scenario.name]; + const traceSamples = traceResults[scenario.name] + .slice(WARMUP_RUNS) + .filter(x => !Number.isNaN(x)); if (traceSamples.length > 0) { const { median: trMedian, range: trRange } = computeStats( traceSamples, - WARMUP_RUNS, + 0, ); report.push({ name: `${scenario.name} (trace)`, diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 637e37c943e6..8de83d21cabd 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,8 +1,13 @@ import type { Scenario } from '../src/shared/types.js'; -/** Simulated network delay (ms) per request for with-network scenarios. Consistent for comparability. */ export const SIMULATED_NETWORK_DELAY_MS = 50; +/** + * With 100 mounted items and 20 authors, each author is shared by 5 items. + * Non-normalized libs must refetch each affected item query individually. + */ +const ITEMS_PER_AUTHOR_100 = 5; + interface BaseScenario { nameSuffix: string; action: Scenario['action']; @@ -10,6 +15,10 @@ interface BaseScenario { resultMetric?: Scenario['resultMetric']; category: NonNullable; mountCount?: number; + /** Override args per library (e.g. different request counts for withNetwork). */ + perLibArgs?: Partial>; + /** Only run for these libraries. Omit to run for all. */ + onlyLibs?: string[]; } const BASE_SCENARIOS: BaseScenario[] = [ @@ -61,6 +70,29 @@ const BASE_SCENARIOS: BaseScenario[] = [ simulatedRequestCount: 1, }, ], + perLibArgs: { + 'tanstack-query': [ + 'author-0', + { + simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, + simulatedRequestCount: ITEMS_PER_AUTHOR_100, + }, + ], + swr: [ + 'author-0', + { + simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, + simulatedRequestCount: ITEMS_PER_AUTHOR_100, + }, + ], + baseline: [ + 'author-0', + { + simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, + simulatedRequestCount: ITEMS_PER_AUTHOR_100, + }, + ], + }, category: 'withNetwork', }, { @@ -78,10 +110,17 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'memory', }, { - nameSuffix: 'optimistic-update-rollback', - action: 'optimisticRollback', + nameSuffix: 'optimistic-update', + action: 'optimisticUpdate', args: [], category: 'hotPath', + onlyLibs: ['data-client'], + }, + { + nameSuffix: 'bulk-ingest-500', + action: 'bulkIngest', + args: [500], + category: 'hotPath', }, ]; @@ -93,11 +132,13 @@ export const LIBRARIES = [ ] as const; export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => - BASE_SCENARIOS.map( + BASE_SCENARIOS.filter( + base => !base.onlyLibs || base.onlyLibs.includes(lib), + ).map( (base): Scenario => ({ name: `${lib}: ${base.nameSuffix}`, action: base.action, - args: base.args, + args: base.perLibArgs?.[lib] ?? base.args, resultMetric: base.resultMetric, category: base.category, mountCount: base.mountCount, diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 577fae905bd0..d0ca181c48e7 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -1,5 +1,9 @@ import { ItemRow } from '@shared/components'; -import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { + FIXTURE_AUTHORS, + FIXTURE_ITEMS, + generateFreshData, +} from '@shared/data'; import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { @@ -26,7 +30,7 @@ function ItemView({ id }: { id: string }) { function BenchmarkHarness() { const [items, setItems] = useState([]); - const [count, setCount] = useState(0); + const [ids, setIds] = useState([]); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); @@ -39,8 +43,9 @@ function BenchmarkHarness() { const mount = useCallback( (n: number) => { performance.mark('mount-start'); - setCount(n); - setItems(FIXTURE_ITEMS.slice(0, n)); + const sliced = FIXTURE_ITEMS.slice(0, n); + setItems(sliced); + setIds(sliced.map(i => i.id)); requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('mount-end'); @@ -116,10 +121,27 @@ function BenchmarkHarness() { ); const unmountAll = useCallback(() => { - setCount(0); + setIds([]); setItems([]); }, []); + const bulkIngest = useCallback( + (n: number) => { + performance.mark('mount-start'); + const { items: freshItems } = generateFreshData(n); + setItems(freshItems); + setIds(freshItems.map(i => i.id)); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -138,7 +160,7 @@ function BenchmarkHarness() { [mount, unmountAll, setComplete], ); - const getRenderedCount = useCallback(() => count, [count]); + const getRenderedCount = useCallback(() => ids.length, [ids]); const captureRefSnapshot = useCallback(() => { captureSnapshot(); @@ -156,6 +178,7 @@ function BenchmarkHarness() { captureRefSnapshot, getRefStabilityReport, mountUnmountCycle, + bulkIngest, }; return () => { delete window.__BENCH__; @@ -166,6 +189,7 @@ function BenchmarkHarness() { updateAuthor, unmountAll, mountUnmountCycle, + bulkIngest, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -175,8 +199,6 @@ function BenchmarkHarness() { document.body.setAttribute('data-app-ready', 'true'); }, []); - const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); - return (
diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 994766b1933c..ccaaee8809ca 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,13 +1,22 @@ import { DataProvider, useCache, useController } from '@data-client/react'; import { mockInitialState } from '@data-client/react/mock'; import { ItemRow } from '@shared/components'; -import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { + FIXTURE_AUTHORS, + FIXTURE_ITEMS, + generateFreshData, +} from '@shared/data'; import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { getAuthor, getItem, getItemList } from './resources'; +import { + getAuthor, + getItem, + getItemList, + updateItemOptimistic, +} from './resources'; const initialState = mockInitialState([ { endpoint: getItemList, args: [], response: FIXTURE_ITEMS }, @@ -26,13 +35,12 @@ const initialState = mockInitialState([ function ItemView({ id }: { id: string }) { const item = useCache(getItem, { id }); if (!item) return null; - const itemAsItem = item as unknown as Item; - registerRefs(id, itemAsItem, itemAsItem.author); - return ; + registerRefs(id, item as Item, item.author as Item['author']); + return ; } function BenchmarkHarness() { - const [count, setCount] = useState(0); + const [ids, setIds] = useState([]); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const controller = useController(); @@ -46,7 +54,7 @@ function BenchmarkHarness() { const mount = useCallback( (n: number) => { performance.mark('mount-start'); - setCount(n); + setIds(FIXTURE_ITEMS.slice(0, n).map(i => i.id)); requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('mount-end'); @@ -125,21 +133,17 @@ function BenchmarkHarness() { ); const unmountAll = useCallback(() => { - setCount(0); + setIds([]); }, []); - const optimisticRollback = useCallback(() => { + const optimisticUpdate = useCallback(() => { const item = FIXTURE_ITEMS[0]; if (!item) return; performance.mark('update-start'); - controller.setResponse( - getItem, - { id: item.id }, - { - ...item, - label: `${item.label} (rolled back)`, - }, - ); + controller.fetch(updateItemOptimistic, { + id: item.id, + label: `${item.label} (optimistic)`, + }); requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('update-end'); @@ -149,6 +153,29 @@ function BenchmarkHarness() { }); }, [controller, setComplete]); + const bulkIngest = useCallback( + (n: number) => { + performance.mark('mount-start'); + const { items, authors } = generateFreshData(n); + controller.setResponse(getItemList, items); + for (const item of items) { + controller.setResponse(getItem, { id: item.id }, item); + } + for (const author of authors) { + controller.setResponse(getAuthor, { id: author.id }, author); + } + setIds(items.map(i => i.id)); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [controller, setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -167,7 +194,7 @@ function BenchmarkHarness() { [mount, unmountAll, setComplete], ); - const getRenderedCount = useCallback(() => count, [count]); + const getRenderedCount = useCallback(() => ids.length, [ids]); const captureRefSnapshot = useCallback(() => { captureSnapshot(); @@ -185,7 +212,8 @@ function BenchmarkHarness() { captureRefSnapshot, getRefStabilityReport, mountUnmountCycle, - optimisticRollback, + optimisticUpdate, + bulkIngest, }; return () => { delete window.__BENCH__; @@ -196,7 +224,8 @@ function BenchmarkHarness() { updateAuthor, unmountAll, mountUnmountCycle, - optimisticRollback, + optimisticUpdate, + bulkIngest, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -206,8 +235,6 @@ function BenchmarkHarness() { document.body.setAttribute('data-app-ready', 'true'); }, []); - const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); - return (
@@ -220,7 +247,7 @@ function BenchmarkHarness() { } function onProfilerRender( - id: string, + _id: string, phase: 'mount' | 'update' | 'nested-update', actualDuration: number, ) { diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts index 0a07a38319b6..3c3d65cfce42 100644 --- a/examples/benchmark-react/src/data-client/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -1,7 +1,5 @@ import { Entity, Endpoint } from '@data-client/endpoint'; -import type { Author, Item } from '@shared/types'; -/** Author entity - shared across items for normalization */ export class AuthorEntity extends Entity { id = ''; login = ''; @@ -14,11 +12,10 @@ export class AuthorEntity extends Entity { static key = 'AuthorEntity'; } -/** Item entity with nested author */ export class ItemEntity extends Entity { id = ''; label = ''; - author = AuthorEntity; + author = AuthorEntity.fromJS(); pk() { return this.id; @@ -28,31 +25,46 @@ export class ItemEntity extends Entity { static schema = { author: AuthorEntity }; } -/** Endpoint to get a single author by id */ export const getAuthor = new Endpoint( - (params: { id: string }) => + ({ id: _id }: { id: string }) => Promise.reject(new Error('Not implemented - use fixtures')), { schema: AuthorEntity, - key: (params: { id: string }) => `author:${params.id}`, + key: ({ id }: { id: string }) => `author:${id}`, }, ); -/** Endpoint to get a single item by id */ export const getItem = new Endpoint( - (params: { id: string }) => + ({ id: _id }: { id: string }) => Promise.reject(new Error('Not implemented - use fixtures')), { schema: ItemEntity, - key: (params: { id: string }) => `item:${params.id}`, + key: ({ id }: { id: string }) => `item:${id}`, }, ); -/** Endpoint to get item list - used for fixture seeding */ export const getItemList = new Endpoint( - () => Promise.reject(new Error('Not implemented - use fixtures')), + () => Promise.reject(new Error('Not implemented - use fixtures')), { schema: [ItemEntity], key: () => 'item:list', }, ); + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const neverResolve = () => new Promise(() => {}); + +/** Optimistic mutation - fetch never resolves; getOptimisticResponse applies immediately */ +export const updateItemOptimistic = new Endpoint( + (_params: { id: string; label: string }) => neverResolve(), + { + schema: ItemEntity, + sideEffect: true, + key: ({ id }: { id: string; label: string }) => `item-update:${id}`, + getOptimisticResponse(snap: any, params: { id: string; label: string }) { + const existing = snap.get(ItemEntity, { id: params.id }); + if (!existing) throw snap.abort; + return { ...existing, label: params.label }; + }, + }, +); diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 81617b1da684..02c753e1027d 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -39,3 +39,31 @@ export const FIXTURE_ITEMS = generateItems(500, 20); /** Unique authors from fixture (for seeding and updateAuthor scenarios) */ export const FIXTURE_AUTHORS = generateAuthors(20); + +/** + * Generate fresh items/authors with distinct IDs for bulk ingestion scenarios. + * Uses `fresh-` prefix so these don't collide with pre-seeded FIXTURE data. + */ +export function generateFreshData( + itemCount: number, + authorCount = 20, +): { items: Item[]; authors: Author[] } { + const authors: Author[] = []; + for (let i = 0; i < authorCount; i++) { + authors.push({ + id: `fresh-author-${i}`, + login: `freshuser${i}`, + name: `Fresh User ${i}`, + }); + } + const items: Item[] = []; + for (let i = 0; i < itemCount; i++) { + const author = authors[i % authorCount]; + items.push({ + id: `fresh-item-${i}`, + label: `Fresh Item ${i}`, + author: { ...author }, + }); + } + return { items, authors }; +} diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index f3d75d78b0b2..1198dbba313c 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -33,8 +33,10 @@ export interface BenchAPI { getRefStabilityReport(): RefStabilityReport; /** For memory scenarios: mount n items, unmount, repeat cycles times; resolves when done. */ mountUnmountCycle?(count: number, cycles: number): Promise; - /** Optimistic update then rollback; sets data-bench-complete when rollback is painted. Optional (data-client only). */ - optimisticRollback?(): void; + /** Optimistic update via getOptimisticResponse; sets data-bench-complete when painted. data-client only. */ + optimisticUpdate?(): void; + /** Ingest fresh data into an empty cache at runtime, then render. Measures normalization + rendering pipeline. */ + bulkIngest?(count: number): void; } declare global { @@ -60,7 +62,8 @@ export type ScenarioAction = | { action: 'updateEntity'; args: [string] } | { action: 'updateAuthor'; args: [string] } | { action: 'updateAuthor'; args: [string, UpdateAuthorOptions] } - | { action: 'unmountAll'; args: [] }; + | { action: 'unmountAll'; args: [] } + | { action: 'bulkIngest'; args: [number] }; export type ResultMetric = | 'duration' diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 87108f02fe80..0725fb81ea3c 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,5 +1,9 @@ import { ItemRow } from '@shared/components'; -import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { + FIXTURE_AUTHORS, + FIXTURE_ITEMS, + generateFreshData, +} from '@shared/data'; import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -37,7 +41,7 @@ function ItemView({ id }: { id: string }) { } function BenchmarkHarness() { - const [count, setCount] = useState(0); + const [ids, setIds] = useState([]); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const { mutate } = useSWRConfig(); @@ -51,7 +55,7 @@ function BenchmarkHarness() { const mount = useCallback( (n: number) => { performance.mark('mount-start'); - setCount(n); + setIds(FIXTURE_ITEMS.slice(0, n).map(i => i.id)); requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('mount-end'); @@ -130,9 +134,41 @@ function BenchmarkHarness() { ); const unmountAll = useCallback(() => { - setCount(0); + setIds([]); }, []); + const bulkIngest = useCallback( + (n: number) => { + performance.mark('mount-start'); + const { items, authors } = generateFreshData(n); + for (const author of authors) { + cache.set(`author:${author.id}`, { + data: author, + isLoading: false, + isValidating: false, + error: undefined, + }); + } + for (const item of items) { + cache.set(`item:${item.id}`, { + data: item, + isLoading: false, + isValidating: false, + error: undefined, + }); + } + setIds(items.map(i => i.id)); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -151,7 +187,7 @@ function BenchmarkHarness() { [mount, unmountAll, setComplete], ); - const getRenderedCount = useCallback(() => count, [count]); + const getRenderedCount = useCallback(() => ids.length, [ids]); const captureRefSnapshot = useCallback(() => { captureSnapshot(); @@ -169,6 +205,7 @@ function BenchmarkHarness() { captureRefSnapshot, getRefStabilityReport, mountUnmountCycle, + bulkIngest, }; return () => { delete window.__BENCH__; @@ -179,6 +216,7 @@ function BenchmarkHarness() { updateAuthor, unmountAll, mountUnmountCycle, + bulkIngest, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -188,8 +226,6 @@ function BenchmarkHarness() { document.body.setAttribute('data-app-ready', 'true'); }, []); - const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); - return (
diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index bd3c5333fe00..afeb6b25f845 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,5 +1,9 @@ import { ItemRow } from '@shared/components'; -import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from '@shared/data'; +import { + FIXTURE_AUTHORS, + FIXTURE_ITEMS, + generateFreshData, +} from '@shared/data'; import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import { @@ -34,7 +38,9 @@ function ItemView({ id }: { id: string }) { const { data: item } = useQuery({ queryKey: ['item', id], queryFn: () => FIXTURE_ITEMS.find(i => i.id === id) as Item, - initialData: () => FIXTURE_ITEMS.find(i => i.id === id) as Item, + initialData: () => + (FIXTURE_ITEMS.find(i => i.id === id) ?? + queryClient.getQueryData(['item', id])) as Item, }); if (!item) return null; const itemAsItem = item as Item; @@ -43,7 +49,7 @@ function ItemView({ id }: { id: string }) { } function BenchmarkHarness() { - const [count, setCount] = useState(0); + const [ids, setIds] = useState([]); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const client = useQueryClient(); @@ -57,7 +63,7 @@ function BenchmarkHarness() { const mount = useCallback( (n: number) => { performance.mark('mount-start'); - setCount(n); + setIds(FIXTURE_ITEMS.slice(0, n).map(i => i.id)); requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('mount-end'); @@ -136,9 +142,31 @@ function BenchmarkHarness() { ); const unmountAll = useCallback(() => { - setCount(0); + setIds([]); }, []); + const bulkIngest = useCallback( + (n: number) => { + performance.mark('mount-start'); + const { items, authors } = generateFreshData(n); + for (const author of authors) { + client.setQueryData(['author', author.id], author); + } + for (const item of items) { + client.setQueryData(['item', item.id], item); + } + setIds(items.map(i => i.id)); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [client, setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -157,7 +185,7 @@ function BenchmarkHarness() { [mount, unmountAll, setComplete], ); - const getRenderedCount = useCallback(() => count, [count]); + const getRenderedCount = useCallback(() => ids.length, [ids]); const captureRefSnapshot = useCallback(() => { captureSnapshot(); @@ -175,6 +203,7 @@ function BenchmarkHarness() { captureRefSnapshot, getRefStabilityReport, mountUnmountCycle, + bulkIngest, }; return () => { delete window.__BENCH__; @@ -185,6 +214,7 @@ function BenchmarkHarness() { updateAuthor, unmountAll, mountUnmountCycle, + bulkIngest, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -194,8 +224,6 @@ function BenchmarkHarness() { document.body.setAttribute('data-app-ready', 'true'); }, []); - const ids = FIXTURE_ITEMS.slice(0, count).map(i => i.id); - return (
From 4080f7bdbef36a589116796219ae36373a0919d4 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 08:26:41 -0400 Subject: [PATCH 04/46] more scenarios --- .cursor/rules/benchmarking.mdc | 55 ++++- .github/workflows/benchmark-react.yml | 8 + examples/benchmark-react/PLAN.md | 37 +-- examples/benchmark-react/README.md | 62 +++-- .../benchmark-react/bench/report-viewer.html | 228 +++++++++++++----- examples/benchmark-react/bench/runner.ts | 117 ++++++++- examples/benchmark-react/bench/scenarios.ts | 43 ++++ .../benchmark-react/src/baseline/index.tsx | 37 +++ .../benchmark-react/src/data-client/index.tsx | 74 +++++- .../src/data-client/resources.ts | 12 +- examples/benchmark-react/src/shared/data.ts | 4 +- examples/benchmark-react/src/shared/types.ts | 12 +- examples/benchmark-react/src/swr/index.tsx | 55 ++++- .../src/tanstack-query/index.tsx | 50 +++- 14 files changed, 677 insertions(+), 117 deletions(-) diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 01c5c08fe469..12839e76aed3 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -16,12 +16,61 @@ When working on **`packages/react`** or comparing data-client to other React dat - **Where it lives**: `examples/benchmark-react/` - **How to run**: From repo root: `yarn build:benchmark-react`, then `yarn workspace example-benchmark-react preview &` and in another terminal `cd examples/benchmark-react && yarn bench` -- **What it measures**: Browser-based mount/update duration, ref-stability counts, optional memory (heap delta) and React Profiler commit times. Compares data-client, TanStack Query, SWR, and a plain React baseline. -- **CI**: `.github/workflows/benchmark-react.yml` runs on changes to `packages/react/src/**`, `packages/core/src/**`, or `examples/benchmark-react/**` and reports via `rhysd/github-action-benchmark` (customSmallerIsBetter). Hot-path scenarios only in CI; with-network and memory scenarios run locally. -- **Report viewer**: Open `examples/benchmark-react/bench/report-viewer.html` in a browser and paste `react-bench-output.json` to view a comparison table and charts. +- **What it measures**: Browser-based mount/update duration, ref-stability counts, sorted-view (Query memoization), optional memory (heap delta), startup metrics (FCP/TBT), and React Profiler commit times. Compares data-client, TanStack Query, SWR, and a plain React baseline. +- **CI**: `.github/workflows/benchmark-react.yml` runs on changes to `packages/react/src/**`, `packages/core/src/**`, `packages/endpoint/src/schemas/**`, `packages/normalizr/src/**`, or `examples/benchmark-react/**` and reports via `rhysd/github-action-benchmark` (customSmallerIsBetter). CI runs **data-client only** (hot-path scenarios) to track regressions; competitor libraries (TanStack Query, SWR, baseline) are for local comparison only. +- **Report viewer**: Open `examples/benchmark-react/bench/report-viewer.html` in a browser and paste `react-bench-output.json` to view a comparison table and charts. Toggle "React commit" and "Trace" filters. Use "Load history" for time-series. See `@examples/benchmark-react/README.md` for methodology, adding a new library, and interpreting results. +### Scenarios and what they exercise + +Use this mapping when deciding which React benchmark scenarios are relevant to a change: + +- **Mount scenarios** (`mount-100-items`, `mount-500-items`, `bulk-ingest-500`) + - Exercises: initial render with pre-populated cache + - Relevant for: `@data-client/react` hooks, `@data-client/core` store initialization + - All libraries + +- **Update propagation** (`update-single-entity`, `update-shared-author-duration`, `update-shared-author-500-mounted`, `update-shared-author-1000-mounted`) + - Exercises: store update → React rerender → DOM paint + - Relevant for: `@data-client/core` dispatch/reducer, `@data-client/react` subscription/selector + - All libraries (normalization advantage shows with shared author) + +- **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) + - Exercises: referential equality preservation through normalization + - 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`) + - 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 + +- **Optimistic update** (`optimistic-update`) — data-client only + - Exercises: `getOptimisticResponse` + `controller.fetch` pipeline + - Relevant for: `@data-client/core` optimistic dispatch + +- **Invalidation** (`invalidate-and-resolve`) — data-client only + - Exercises: `controller.invalidate` → Suspense fallback → `controller.setResponse` re-resolve + - Relevant for: `@data-client/core` invalidation, `@data-client/react` Suspense integration + +### Expected variance + +| Category | Scenarios | Typical run-to-run spread | +|---|---|---| +| **Stable** | `mount-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | +| **Moderate** | `update-shared-author-*`, `bulk-ingest-*`, `sorted-view-update-*` | 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. + +### When to use Node vs React benchmark + +- **Core/normalizr/endpoint changes only** (no rendering impact): Run `examples/benchmark` (Node). Faster iteration, no browser needed. +- **React hook or Provider changes**: Run `examples/benchmark-react`. Captures real rendering cost. +- **Schema changes** (Entity, Query, All): Run both — Node benchmark for raw throughput, React benchmark for rendering impact. +- **Performance investigation**: Start with Node benchmark to isolate the JS layer, then validate with React benchmark for end-to-end confirmation. + --- # Node benchmark details (`@examples/benchmark`) diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 83d110d80a98..18aae19db51f 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -7,6 +7,8 @@ on: paths: - 'packages/react/src/**' - 'packages/core/src/**' + - 'packages/endpoint/src/schemas/**' + - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' - '.github/workflows/benchmark-react.yml' push: @@ -15,9 +17,15 @@ on: paths: - 'packages/react/src/**' - 'packages/core/src/**' + - 'packages/endpoint/src/schemas/**' + - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' - '.github/workflows/benchmark-react.yml' +concurrency: + group: benchmark-react-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: benchmark-react: runs-on: ubuntu-latest diff --git a/examples/benchmark-react/PLAN.md b/examples/benchmark-react/PLAN.md index c8d9331d7016..da920e68659a 100644 --- a/examples/benchmark-react/PLAN.md +++ b/examples/benchmark-react/PLAN.md @@ -39,30 +39,35 @@ - All implementations switched from `count: number` state to `ids: string[]` state for cleaner bulk-ingest support - Scenario generation supports `perLibArgs` overrides and `onlyLibs` filtering ---- - -## Future Work - ### Session 5: `useQuery` + `Query` scenario (derived/computed data) -Add a scenario that demonstrates `Query` schema memoization: -- Mount a component showing items sorted/filtered via `useQuery(sortedQuery)` (data-client) vs inline `useMemo` sort (competitors) -- Update one entity and measure rerender cost of the derived view -- Requires adding a `SortedView` component to each library implementation +- **`sortedItemsQuery`** — `Query` schema in `resources.ts` using `new Query(new All(ItemEntity), ...)` for globally memoized sorted views +- **`SortedListView`** component added to all 4 library implementations: data-client uses `useQuery(sortedItemsQuery)` (no `useMemo`), competitors use `useMemo(() => [...items].sort(...), [items])` +- **`mountSortedView`** — New `BenchAPI` method seeds cache and renders sorted view +- **`sorted-view-mount-500`** — Scenario measuring mount cost of sorted derived view +- **`sorted-view-update-entity`** — Uses `preMountAction: 'mountSortedView'` to pre-mount sorted view, then updates one entity; data-client `Query` memoization avoids re-sort when sort keys unchanged +- Runner updated with `preMountAction` support and `mountSortedView` in `isMountLike` check ### Session 6: Memory and scaling scenarios -- **Many-subscriber scaling:** Mount 500+ components subscribed to overlapping entities; measure per-update cost -- **Cache invalidation cycle:** `controller.invalidate()` → Suspense fallback → re-resolve; measure full cycle time +- **`update-shared-author-1000-mounted`** — 1000 components subscribed to overlapping entities; measures per-update scaling cost +- **Fixture data expanded** to 1000 items (from 500) with 20 shared authors for stronger normalization signal +- **`invalidateAndResolve`** — New `BenchAPI` method (data-client only): `controller.invalidate(getItem, { id })` followed by immediate `controller.setResponse` re-resolve; measures Suspense boundary round-trip +- **`invalidate-and-resolve`** scenario added with `onlyLibs: ['data-client']` ### Session 7: Advanced measurement and reporting -- **React Profiler as primary metric:** Use `` duration as a separate reported metric alongside `performance.measure()` -- **Local HTML report:** Enhance report viewer with time-series charts for CI history -- **Lighthouse-style metrics:** FCP, TBT for initial load comparison +- **Report viewer enhanced** (`bench/report-viewer.html`): + - Metric filtering: checkboxes for "Base metrics", "React commit", and "Trace" suffix entries + - Time-series charts: upload multiple JSON files from consecutive runs to render a Chart.js line chart showing trends over time + - Chart instances properly destroyed on re-render +- **Startup metrics** via CDP `Performance.getMetrics`: + - `startup-fcp` (FirstContentfulPaint) and `startup-task-duration` (TaskDuration, proxy for TBT) reported per library + - Startup category excluded from CI; runs locally only (5 rounds per library) + - `getStartupScenarios()` function in scenarios.ts ### Session 8: Polish and documentation -- **README:** Expand with methodology (what we measure, why), how to add a new library, and how to run locally vs CI -- **Cursor rule:** Update `.cursor/rules/benchmarking.mdc` to document the React benchmark -- **CI workflow:** Add GitHub Actions workflow for React benchmark (separate from existing Benchmark.js workflow) +- **README expanded** with: all new scenarios documented, expected results table, expected variance table, metric categories explained, report viewer toggle/history instructions, updated "Adding a new library" section +- **Cursor rule updated** (`.cursor/rules/benchmarking.mdc`): React benchmark scenario list with what they exercise and when to use them, expected variance table, "when to use Node vs React benchmark" guidance +- **CI workflow refined** (`.github/workflows/benchmark-react.yml`): added `packages/endpoint/src/schemas/**` and `packages/normalizr/src/**` to path triggers, added `concurrency` group to prevent parallel benchmark runs on the same branch diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index a9b921919ed5..7f6e73c33e16 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -12,53 +12,87 @@ The repo has two benchmark suites: ## Methodology - **What we measure:** Wall-clock time from triggering an action (e.g. `mount(100)` or `updateAuthor('author-0')`) until the harness sets `data-bench-complete` (after two `requestAnimationFrame` callbacks). Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. -- **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update) and ref stability (fewer new object references). See [js-framework-benchmark “How the duration is measured”](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. +- **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update), ref stability (fewer new object references), and derived-view memoization (`Query` schema avoids re-sorting when entities haven't changed). See [js-framework-benchmark "How the duration is measured"](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. - **Statistical:** Warmup runs are discarded; we report median and 95% CI. Libraries are interleaved per round to reduce environmental variance. +- **CPU throttling:** 4x CPU slowdown via CDP to amplify small differences on fast CI machines. ## Scenario categories -- **Hot path (in CI)** — JS-only: mount, update propagation, ref-stability. No simulated network. These run in CI and track regression. -- **With network (comparison only)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Used to compare overfetching: data-client needs one store update (1 × delay); non-normalized libs typically invalidate/refetch multiple queries (N × delay). **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. +- **Hot path (in CI, data-client only)** — JS-only: mount, update propagation, ref-stability, sorted-view, bulk-ingest. No simulated network. CI runs only `data-client` scenarios to track our own regressions; competitor libraries are benchmarked locally for comparison. +- **With network (local comparison)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Used to compare overfetching: data-client needs one store update (1 × delay); non-normalized libs typically invalidate/refetch multiple queries (N × delay). **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. +- **Memory (local only)** — Heap delta after repeated mount/unmount cycles. +- **Startup (local only)** — FCP and task duration via CDP `Performance.getMetrics`. ## Scenarios **Hot path (CI)** -- **Mount** — Time to mount 100 or 500 item rows (unit: ms). -- **Update single entity** — Time to update one item and propagate to the UI (unit: ms). +- **Mount** (`mount-100-items`, `mount-500-items`) — Time to mount 100 or 500 item rows (unit: ms). +- **Update single entity** (`update-single-entity`) — Time to update one item and propagate to the UI (unit: ms). - **Update shared author** (`update-shared-author-duration`) — 100 components, shared authors; update one author. Measures time to propagate (unit: ms). Normalized cache: one store update, all views of that author update. -- **Ref-stability item/author** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. +- **Update shared author (scaling)** (`update-shared-author-500-mounted`, `update-shared-author-1000-mounted`) — Same update with 500/1000 mounted components to test subscriber scaling. +- **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. +- **Sorted view mount** (`sorted-view-mount-500`) — Mount 500 items through a sorted/derived view. data-client uses `useQuery(sortedItemsQuery)` 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. +- **Bulk ingest** (`bulk-ingest-500`) — Generate fresh data at runtime, ingest into cache, and render. Exercises the full normalization pipeline. +- **Optimistic update** (`optimistic-update`) — data-client only; applies an optimistic mutation via `getOptimisticResponse`. +- **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)** -- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client uses 1 request; other libs can be compared with higher `simulatedRequestCount` to model overfetching. +- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client uses 1 request; other libs use N requests (one per affected item query) to model overfetching. **Memory (local only)** - **Memory mount/unmount cycle** (`memory-mount-unmount-cycle`) — Mount 500 items, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. -**Other** +**Startup (local only)** -- **Optimistic update rollback** — data-client only; measures time to apply and then roll back an optimistic update. +- **Startup FCP** (`startup-fcp`) — First Contentful Paint time via CDP `Performance.getMetrics`. +- **Startup task duration** (`startup-task-duration`) — Total main-thread task duration via CDP (proxy for TBT). + +## Expected results + +These are approximate values to help calibrate expectations. Exact numbers vary by machine and CPU throttling. + +| Scenario | data-client | tanstack-query | swr | baseline | +|---|---|---|---|---| +| `mount-100-items` | ~similar | ~similar | ~similar | ~similar | +| `update-shared-author-duration` (100 mounted) | Low (one store write propagates) | Higher (N cache writes) | Higher (N cache writes) | Higher (full array map) | +| `ref-stability-item-changed` (100 mounted) | ~1 changed | ~1 changed | ~1 changed | ~100 changed | +| `ref-stability-author-changed` (100 mounted) | ~5 changed | ~100 changed | ~100 changed | ~100 changed | +| `sorted-view-update-entity` | Fast (Query memoization skips re-sort) | Re-sorts on every item change | Re-sorts on every item change | Re-sorts on every item change | +| `bulk-ingest-500` | Normalization pipeline + render | Per-item cache seed + render | Per-item cache seed + render | Set state + render | + +## Expected variance + +| Category | Scenarios | Typical run-to-run spread | +|---|---|---| +| **Stable** | `mount-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2-5% | +| **Moderate** | `update-shared-author-*`, `bulk-ingest-*`, `sorted-view-update-*` | 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. ## Interpreting results - **Lower is better** for duration (ms), ref-stability counts, and heap delta (bytes). -- **Expected variance:** Duration and ref-stability are usually within a few percent run-to-run; heap and “react commit” can vary more. Regressions > 5–10% on stable scenarios are worth investigating. -- **Ref-stability:** data-client’s normalized cache keeps referential equality for unchanged entities, so `itemRefChanged` and `authorRefChanged` should stay low (e.g. 1 and ~25 for “update one author” with 100 rows). Non-normalized libs typically show higher counts. +- **Ref-stability:** data-client's normalized cache keeps referential equality for unchanged entities, so `itemRefChanged` and `authorRefChanged` should stay low. Non-normalized libs typically show higher counts because they create new object references for every cache write. +- **React commit:** Reported as `(react commit)` suffix entries. These measure React Profiler `actualDuration` and isolate React reconciliation cost from layout/paint. +- **Report viewer:** Toggle the "Base metrics", "React commit", and "Trace" checkboxes to filter the comparison table. Use "Load history" to compare multiple runs over time. ## Adding a new library 1. Add a new app under `src//index.tsx` (e.g. `src/urql/index.tsx`). -2. Implement the same `BenchAPI` interface on `window.__BENCH__`: `mount`, `updateEntity`, `updateAuthor`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `optimisticRollback`. Use the shared presentational `ItemRow` from `@shared/components` and fixtures from `@shared/data`. +2. Implement the `BenchAPI` interface on `window.__BENCH__`: `mount`, `updateEntity`, `updateAuthor`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `bulkIngest`, `mountSortedView`. Use the shared presentational `ItemRow` from `@shared/components` and fixtures from `@shared/data`. 3. Add the library to `LIBRARIES` in `bench/scenarios.ts`. 4. Add a webpack entry in `webpack.config.cjs` for the new app and an `HtmlWebpackPlugin` entry so the app is served at `//`. 5. Add the dependency to `package.json` and run `yarn install`. ## Running locally -1. **Install system dependencies (Linux / WSL)** - Playwright needs system libraries to run Chromium. If you see “Host system is missing dependencies to run browsers”: +1. **Install system dependencies (Linux / WSL)** + Playwright needs system libraries to run Chromium. If you see "Host system is missing dependencies to run browsers": ```bash sudo npx playwright install-deps chromium diff --git a/examples/benchmark-react/bench/report-viewer.html b/examples/benchmark-react/bench/report-viewer.html index c0d730bd69c9..94535a1e89a3 100644 --- a/examples/benchmark-react/bench/report-viewer.html +++ b/examples/benchmark-react/bench/report-viewer.html @@ -8,8 +8,11 @@

React benchmark report

+



- +
+ + + + +

+
+

Time-series (load multiple runs)

+

Upload multiple react-bench-output.json files from consecutive runs to see trends.

+ + +
+
+ diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 573f5f891190..56db7792b025 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -15,11 +15,18 @@ import { computeStats } from './stats.js'; import { parseTraceDuration } from './tracing.js'; const BASE_URL = process.env.BENCH_BASE_URL ?? 'http://localhost:5173'; -/** In CI we only run hot-path scenarios; with-network and memory are for local comparison. */ +/** + * In CI we only run data-client hot-path scenarios to track our own regressions. + * Competitor libraries (tanstack-query, swr, baseline) are for local comparison only. + */ const SCENARIOS_TO_RUN = process.env.CI ? SCENARIOS.filter( - s => s.category !== 'withNetwork' && s.category !== 'memory', + s => + s.name.startsWith('data-client:') && + s.category !== 'withNetwork' && + s.category !== 'memory' && + s.category !== 'startup', ) : SCENARIOS; const TOTAL_RUNS = WARMUP_RUNS + MEASUREMENT_RUNS; @@ -90,20 +97,23 @@ async function runScenario( const isUpdate = scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || - scenario.action === 'optimisticUpdate'; + scenario.action === 'optimisticUpdate' || + scenario.action === 'invalidateAndResolve'; const isRefStability = isRefStabilityScenario(scenario); const isBulkIngest = scenario.action === 'bulkIngest'; const mountCount = scenario.mountCount ?? (scenario.action === 'optimisticUpdate' ? 1 : 100); if (isUpdate || isRefStability) { + const preMountAction = scenario.preMountAction ?? 'mount'; await harness.evaluate(el => el.removeAttribute('data-bench-complete')); await (bench as any).evaluate( - (api: any, n: number) => api.mount(n), + (api: any, action: string, n: number) => api[action](n), + preMountAction, mountCount, ); await page.waitForSelector('[data-bench-complete]', { - timeout: 5000, + timeout: 10000, state: 'attached', }); await page.evaluate(() => { @@ -150,15 +160,17 @@ async function runScenario( } const measures = await collectMeasures(page); - const isMountLike = scenario.action === 'mount' || isBulkIngest; + const isMountLike = + scenario.action === 'mount' || + isBulkIngest || + scenario.action === 'mountSortedView'; const duration = isMountLike ? getMeasureDuration(measures, 'mount-duration') : getMeasureDuration(measures, 'update-duration'); - const reactCommit = - isMountLike ? - getMeasureDuration(measures, 'react-commit-mount') - : getMeasureDuration(measures, 'react-commit-update'); + // Both mount-like and update scenarios trigger state updates (setItems/etc.), + // so React Profiler always fires with phase: 'update' for the measured action. + const reactCommit = getMeasureDuration(measures, 'react-commit-update'); await bench.dispose(); return { @@ -168,6 +180,35 @@ async function runScenario( }; } +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' }); + await page.waitForSelector('[data-app-ready]', { + timeout: 10000, + 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 }; +} + function shuffle(arr: T[]): T[] { const out = [...arr]; for (let i = out.length - 1; i > 0; i--) { @@ -188,7 +229,7 @@ async function main() { } const browser = await chromium.launch({ headless: true }); - const libraries = [...LIBRARIES]; + const libraries = process.env.CI ? ['data-client'] : [...LIBRARIES]; for (let round = 0; round < TOTAL_RUNS; round++) { for (const lib of shuffle(libraries)) { @@ -221,6 +262,32 @@ async function main() { } } + const startupResults: Record = {}; + const includeStartup = !process.env.CI; + if (includeStartup) { + for (const lib of LIBRARIES) { + startupResults[lib] = { fcp: [], tbt: [] }; + } + const STARTUP_RUNS = 5; + for (let round = 0; round < STARTUP_RUNS; round++) { + 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); + } catch (err) { + console.error( + `Startup ${lib} failed:`, + err instanceof Error ? err.message : err, + ); + } + await context.close(); + } + } + } + await browser.close(); const report: BenchmarkResult[] = []; @@ -252,7 +319,9 @@ async function main() { scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || scenario.action === 'optimisticUpdate' || - scenario.action === 'bulkIngest') + scenario.action === 'bulkIngest' || + scenario.action === 'mountSortedView' || + scenario.action === 'invalidateAndResolve') ) { const { median: rcMedian, range: rcRange } = computeStats( reactSamples, @@ -282,6 +351,30 @@ 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, + }); + } + } + } + process.stdout.write(formatReport(report)); } diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 8de83d21cabd..4b5ab28786e4 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -15,6 +15,8 @@ interface BaseScenario { resultMetric?: Scenario['resultMetric']; category: NonNullable; mountCount?: number; + /** Use a different BenchAPI method to pre-mount items (e.g. 'mountSortedView' instead of 'mount'). */ + preMountAction?: keyof import('../src/shared/types.js').BenchAPI; /** Override args per library (e.g. different request counts for withNetwork). */ perLibArgs?: Partial>; /** Only run for these libraries. Omit to run for all. */ @@ -122,8 +124,48 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: [500], category: 'hotPath', }, + { + nameSuffix: 'sorted-view-mount-500', + action: 'mountSortedView', + args: [500], + category: 'hotPath', + }, + { + nameSuffix: 'sorted-view-update-entity', + action: 'updateEntity', + args: ['item-0'], + category: 'hotPath', + mountCount: 500, + preMountAction: 'mountSortedView', + }, + { + nameSuffix: 'update-shared-author-1000-mounted', + action: 'updateAuthor', + args: ['author-0'], + category: 'hotPath', + mountCount: 1000, + }, + { + nameSuffix: 'invalidate-and-resolve', + action: 'invalidateAndResolve', + args: ['item-0'], + category: 'hotPath', + onlyLibs: ['data-client'], + }, ]; +/** Startup scenarios measure page load metrics via CDP (no BenchAPI interaction). */ +export function getStartupScenarios(): Scenario[] { + return LIBRARIES.map( + (lib): Scenario => ({ + name: `${lib}: startup`, + action: 'mount', + args: [], + category: 'startup', + }), + ); +} + export const LIBRARIES = [ 'data-client', 'tanstack-query', @@ -142,6 +184,7 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => resultMetric: base.resultMetric, category: base.category, mountCount: base.mountCount, + preMountAction: base.preMountAction, }), ), ); diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index d0ca181c48e7..9d29f349e584 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -28,9 +29,25 @@ function ItemView({ id }: { id: string }) { return ; } +function SortedListView() { + const { items } = useContext(ItemsContext); + const sorted = useMemo( + () => [...items].sort((a, b) => a.label.localeCompare(b.label)), + [items], + ); + return ( +
+ {sorted.map(item => ( + + ))} +
+ ); +} + function BenchmarkHarness() { const [items, setItems] = useState([]); const [ids, setIds] = useState([]); + const [showSortedView, setShowSortedView] = useState(false); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); @@ -142,6 +159,23 @@ function BenchmarkHarness() { [setComplete], ); + const mountSortedView = useCallback( + (n: number) => { + performance.mark('mount-start'); + const sliced = FIXTURE_ITEMS.slice(0, n); + setItems(sliced); + setShowSortedView(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -179,6 +213,7 @@ function BenchmarkHarness() { getRefStabilityReport, mountUnmountCycle, bulkIngest, + mountSortedView, }; return () => { delete window.__BENCH__; @@ -190,6 +225,7 @@ function BenchmarkHarness() { unmountAll, mountUnmountCycle, bulkIngest, + mountSortedView, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -207,6 +243,7 @@ function BenchmarkHarness() { ))}
+ {showSortedView && }
); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index ccaaee8809ca..e20cd5c321cc 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,4 +1,9 @@ -import { DataProvider, useCache, useController } from '@data-client/react'; +import { + DataProvider, + useCache, + useController, + useQuery, +} from '@data-client/react'; import { mockInitialState } from '@data-client/react/mock'; import { ItemRow } from '@shared/components'; import { @@ -15,6 +20,7 @@ import { getAuthor, getItem, getItemList, + sortedItemsQuery, updateItemOptimistic, } from './resources'; @@ -39,8 +45,22 @@ function ItemView({ id }: { id: string }) { return ; } +/** Renders items sorted by label via Query schema (memoized by MemoCache). */ +function SortedListView() { + const items = useQuery(sortedItemsQuery); + if (!items) return null; + return ( +
+ {items.map((item: any) => ( + + ))} +
+ ); +} + function BenchmarkHarness() { const [ids, setIds] = useState([]); + const [showSortedView, setShowSortedView] = useState(false); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const controller = useController(); @@ -176,6 +196,53 @@ function BenchmarkHarness() { [controller, setComplete], ); + const mountSortedView = useCallback( + (n: number) => { + performance.mark('mount-start'); + for (const item of FIXTURE_ITEMS.slice(0, n)) { + controller.setResponse(getItem, { id: item.id }, item); + } + for (const author of FIXTURE_AUTHORS) { + controller.setResponse(getAuthor, { id: author.id }, author); + } + setShowSortedView(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [controller, setComplete], + ); + + const invalidateAndResolve = useCallback( + (id: string) => { + performance.mark('update-start'); + const item = FIXTURE_ITEMS.find(i => i.id === id); + if (item) { + controller.invalidate(getItem, { id }); + controller.setResponse( + getItem, + { id }, + { + ...item, + label: `${item.label} (resolved)`, + }, + ); + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }); + }, + [controller, setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -214,6 +281,8 @@ function BenchmarkHarness() { mountUnmountCycle, optimisticUpdate, bulkIngest, + mountSortedView, + invalidateAndResolve, }; return () => { delete window.__BENCH__; @@ -226,6 +295,8 @@ function BenchmarkHarness() { mountUnmountCycle, optimisticUpdate, bulkIngest, + mountSortedView, + invalidateAndResolve, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -242,6 +313,7 @@ function BenchmarkHarness() { ))}
+ {showSortedView && }
); } diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts index 3c3d65cfce42..6a9b5da46e45 100644 --- a/examples/benchmark-react/src/data-client/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -1,4 +1,4 @@ -import { Entity, Endpoint } from '@data-client/endpoint'; +import { Entity, Endpoint, All, Query } from '@data-client/endpoint'; export class AuthorEntity extends Entity { id = ''; @@ -51,6 +51,16 @@ export const getItemList = new Endpoint( }, ); +/** Derived sorted view via Query schema -- globally memoized by MemoCache */ +export const sortedItemsQuery = new Query( + new All(ItemEntity), + (entries: any[]) => { + return [...entries].sort((a: any, b: any) => + a.label.localeCompare(b.label), + ); + }, +); + // eslint-disable-next-line @typescript-eslint/no-empty-function const neverResolve = () => new Promise(() => {}); diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 02c753e1027d..b8ea5c40287d 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -34,8 +34,8 @@ export function generateItems(count: number, authorCount = 10): Item[] { return items; } -/** Pre-generated fixture for benchmark - 500 items, 20 shared authors */ -export const FIXTURE_ITEMS = generateItems(500, 20); +/** Pre-generated fixture for benchmark - 1000 items, 20 shared authors */ +export const FIXTURE_ITEMS = generateItems(1000, 20); /** Unique authors from fixture (for seeding and updateAuthor scenarios) */ export const FIXTURE_AUTHORS = generateAuthors(20); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 1198dbba313c..f5d3eb92f082 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -37,6 +37,10 @@ export interface BenchAPI { optimisticUpdate?(): void; /** Ingest fresh data into an empty cache at runtime, then render. Measures normalization + rendering pipeline. */ bulkIngest?(count: number): void; + /** Mount a sorted/derived view of items. Exercises Query memoization (data-client) vs useMemo sort (others). */ + mountSortedView?(count: number): void; + /** Invalidate a cached endpoint and immediately re-resolve. Measures Suspense round-trip. data-client only. */ + invalidateAndResolve?(id: string): void; } declare global { @@ -71,8 +75,8 @@ export type ResultMetric = | 'authorRefChanged' | 'heapDelta'; -/** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. memory = heap delta, not CI. */ -export type ScenarioCategory = 'hotPath' | 'withNetwork' | 'memory'; +/** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. memory = heap delta, not CI. startup = page load metrics, not CI. */ +export type ScenarioCategory = 'hotPath' | 'withNetwork' | 'memory' | 'startup'; export interface Scenario { name: string; @@ -80,8 +84,10 @@ export interface Scenario { args: unknown[]; /** Which value to report; default 'duration'. Ref-stability use itemRefChanged/authorRefChanged; memory use heapDelta. */ resultMetric?: ResultMetric; - /** hotPath (default) = run in CI. withNetwork = comparison only. memory = heap delta. */ + /** hotPath (default) = run in CI. withNetwork = comparison only. memory = heap delta. startup = page load metrics. */ category?: ScenarioCategory; /** For update scenarios: number of items to mount before running the update (default 100). */ mountCount?: number; + /** Use a different BenchAPI method to pre-mount (e.g. 'mountSortedView' instead of 'mount'). */ + preMountAction?: keyof BenchAPI; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 0725fb81ea3c..148d5cbf6bc8 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -6,7 +6,13 @@ import { } from '@shared/data'; import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { createRoot } from 'react-dom/client'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; @@ -32,6 +38,12 @@ for (const item of FIXTURE_ITEMS) { error: undefined, }); } +cache.set('items:all', { + data: FIXTURE_ITEMS, + isLoading: false, + isValidating: false, + error: undefined, +}); function ItemView({ id }: { id: string }) { const { data: item } = useSWR(`item:${id}`, fetcher); @@ -40,8 +52,25 @@ function ItemView({ id }: { id: string }) { return ; } +function SortedListView() { + const { data: items } = useSWR('items:all', fetcher); + const sorted = useMemo( + () => + items ? [...items].sort((a, b) => a.label.localeCompare(b.label)) : [], + [items], + ); + return ( +
+ {sorted.map(item => ( + + ))} +
+ ); +} + function BenchmarkHarness() { const [ids, setIds] = useState([]); + const [showSortedView, setShowSortedView] = useState(false); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const { mutate } = useSWRConfig(); @@ -169,6 +198,27 @@ function BenchmarkHarness() { [setComplete], ); + const mountSortedView = useCallback( + (n: number) => { + performance.mark('mount-start'); + cache.set('items:all', { + data: FIXTURE_ITEMS.slice(0, n), + isLoading: false, + isValidating: false, + error: undefined, + }); + setShowSortedView(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -206,6 +256,7 @@ function BenchmarkHarness() { getRefStabilityReport, mountUnmountCycle, bulkIngest, + mountSortedView, }; return () => { delete window.__BENCH__; @@ -217,6 +268,7 @@ function BenchmarkHarness() { unmountAll, mountUnmountCycle, bulkIngest, + mountSortedView, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -233,6 +285,7 @@ function BenchmarkHarness() { ))}
+ {showSortedView && }
); } diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index afeb6b25f845..6a6f1a834a02 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -12,7 +12,13 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { createRoot } from 'react-dom/client'; function seedCache(queryClient: QueryClient) { @@ -22,6 +28,7 @@ function seedCache(queryClient: QueryClient) { for (const item of FIXTURE_ITEMS) { queryClient.setQueryData(['item', item.id], item); } + queryClient.setQueryData(['items', 'all'], FIXTURE_ITEMS); } const queryClient = new QueryClient({ @@ -48,8 +55,30 @@ function ItemView({ id }: { id: string }) { return ; } +function SortedListView() { + const { data: items } = useQuery({ + queryKey: ['items', 'all'], + queryFn: () => FIXTURE_ITEMS, + initialData: () => + queryClient.getQueryData(['items', 'all']) ?? FIXTURE_ITEMS, + }); + const sorted = useMemo( + () => + items ? [...items].sort((a, b) => a.label.localeCompare(b.label)) : [], + [items], + ); + return ( +
+ {sorted.map(item => ( + + ))} +
+ ); +} + function BenchmarkHarness() { const [ids, setIds] = useState([]); + const [showSortedView, setShowSortedView] = useState(false); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const client = useQueryClient(); @@ -167,6 +196,22 @@ function BenchmarkHarness() { [client, setComplete], ); + const mountSortedView = useCallback( + (n: number) => { + performance.mark('mount-start'); + client.setQueryData(['items', 'all'], FIXTURE_ITEMS.slice(0, n)); + setShowSortedView(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }); + }, + [client, setComplete], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -204,6 +249,7 @@ function BenchmarkHarness() { getRefStabilityReport, mountUnmountCycle, bulkIngest, + mountSortedView, }; return () => { delete window.__BENCH__; @@ -215,6 +261,7 @@ function BenchmarkHarness() { unmountAll, mountUnmountCycle, bulkIngest, + mountSortedView, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -231,6 +278,7 @@ function BenchmarkHarness() { ))}
+ {showSortedView && } ); } From 678b3599cf31b44db2aab33f6933b140e308c8e2 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 08:43:51 -0400 Subject: [PATCH 05/46] add react compiler option --- examples/benchmark-react/.babelrc.js | 7 ++++++- examples/benchmark-react/README.md | 20 ++++++++++++++++++++ examples/benchmark-react/bench/runner.ts | 7 +++++++ examples/benchmark-react/package.json | 5 ++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/examples/benchmark-react/.babelrc.js b/examples/benchmark-react/.babelrc.js index 3993a0467ba7..bfd95e4230c7 100644 --- a/examples/benchmark-react/.babelrc.js +++ b/examples/benchmark-react/.babelrc.js @@ -1,3 +1,8 @@ +const options = { polyfillMethod: false }; +if (process.env.REACT_COMPILER === 'true') { + options.reactCompiler = {}; +} + module.exports = { - presets: [['@anansi', { polyfillMethod: false }]], + presets: [['@anansi', options]], }; diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 7f6e73c33e16..61246c3c0900 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -115,6 +115,26 @@ 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** + + To measure the impact of React Compiler, build and bench with it enabled: + + ```bash + cd examples/benchmark-react + yarn build:compiler # builds with babel-plugin-react-compiler + yarn preview & + sleep 5 + yarn bench:compiler # labels results with [compiler] suffix + ``` + + Or as a single command: `yarn bench:run: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. + + You can also set the env vars directly for custom combinations: + - `REACT_COMPILER=true` — enables the Babel plugin at build time + - `BENCH_LABEL=` — appends `[]` to all result names at bench time + ## Output The runner prints a JSON array in `customSmallerIsBetter` format (name, unit, value, range) to stdout. In CI this is written to `react-bench-output.json` and sent to the benchmark action. diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 56db7792b025..1b74ff4617da 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -15,6 +15,8 @@ import { computeStats } from './stats.js'; import { parseTraceDuration } from './tracing.js'; const BASE_URL = process.env.BENCH_BASE_URL ?? 'http://localhost:5173'; +const BENCH_LABEL = + process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; /** * In CI we only run data-client hot-path scenarios to track our own regressions. * Competitor libraries (tanstack-query, swr, baseline) are for local comparison only. @@ -375,6 +377,11 @@ async function main() { } } + if (BENCH_LABEL) { + for (const entry of report) { + entry.name += BENCH_LABEL; + } + } process.stdout.write(formatReport(report)); } diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 4eee16846f27..c9240429c358 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -5,9 +5,12 @@ "description": "React rendering benchmark comparing @data-client/react against other data libraries", "scripts": { "build": "webpack --mode=production", + "build:compiler": "REACT_COMPILER=true webpack --mode=production", "preview": "serve dist -l 5173", "bench": "npx tsx bench/runner.ts", - "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench" + "bench:compiler": "BENCH_LABEL=compiler npx tsx bench/runner.ts", + "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", + "bench:run:compiler": "yarn build:compiler && (yarn preview &) && sleep 5 && yarn bench:compiler" }, "engines": { "node": ">=22" From 972621e87bdc435b6d535485e48979e46e6de4ec Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 08:47:49 -0400 Subject: [PATCH 06/46] bugbot: dead code --- examples/benchmark-react/bench/scenarios.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 4b5ab28786e4..9eff0e84cb14 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -154,18 +154,6 @@ const BASE_SCENARIOS: BaseScenario[] = [ }, ]; -/** Startup scenarios measure page load metrics via CDP (no BenchAPI interaction). */ -export function getStartupScenarios(): Scenario[] { - return LIBRARIES.map( - (lib): Scenario => ({ - name: `${lib}: startup`, - action: 'mount', - args: [], - category: 'startup', - }), - ); -} - export const LIBRARIES = [ 'data-client', 'tanstack-query', From 4498e1c025e10895aa71fdfcb3b3edd43531a710 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 10:03:39 -0400 Subject: [PATCH 07/46] No throttling --- examples/benchmark-react/PLAN.md | 73 --------------------- examples/benchmark-react/README.md | 10 +-- examples/benchmark-react/bench/runner.ts | 7 -- examples/benchmark-react/bench/scenarios.ts | 4 +- examples/benchmark-react/package.json | 2 +- 5 files changed, 6 insertions(+), 90 deletions(-) delete mode 100644 examples/benchmark-react/PLAN.md diff --git a/examples/benchmark-react/PLAN.md b/examples/benchmark-react/PLAN.md deleted file mode 100644 index da920e68659a..000000000000 --- a/examples/benchmark-react/PLAN.md +++ /dev/null @@ -1,73 +0,0 @@ -# React Rendering Benchmark – Progress & Future Work - ---- - -## Completed - -### Session 1: Core harness + data-client implementation - -- Playwright-based runner with Chrome tracing, CDP heap measurement, CPU throttling -- `performance.measure()` + React Profiler `onRender` for dual JS/React metrics -- Interleaved library execution with shuffled ordering for statistical fairness -- `customSmallerIsBetter` JSON output for `rhysd/github-action-benchmark` CI integration -- Report viewer (`bench/report-viewer.html`) with Chart.js comparison table - -### Session 2: Competitor implementations - -- `tanstack-query/`, `swr/`, `baseline/` apps with identical `BenchAPI` interface -- Shared presentational `ItemRow` component and fixture data across all libraries -- Multi-entry webpack config serving each library at its own path - -### Session 3: Entity update propagation (normalization showcase) - -- **`update-shared-author-duration`** — Mount 100 items (sharing 20 authors), update one author; measure duration (ms) -- **Ref-stability scenarios** — `ref-stability-item-changed` and `ref-stability-author-changed` report component reference change counts (smaller is better) -- Shared `refStability` module and `BenchAPI.captureRefSnapshot` / `getRefStabilityReport` / `updateAuthor` - -### Session 4: Best practices fixes + new scenarios - -**Best practices fixes:** -- `ItemEntity.author` default changed from `AuthorEntity` (class) to `AuthorEntity.fromJS()` (idiomatic Entity default) -- Removed `as unknown as Item` cast in data-client `ItemView` (entity `fromJS()` fix resolved the type mismatch) -- `optimisticRollback` replaced with real `optimisticUpdate` using `getOptimisticResponse` on an endpoint with `sideEffect: true`; `controller.fetch()` applies the optimistic response immediately - -**New scenarios:** -- **`bulk-ingest-500`** — Generate fresh data at runtime, ingest into cache, and render 500 items. For data-client this exercises the full normalization pipeline; competitors seed per-item cache entries. Uses `generateFreshData()` with distinct `fresh-*` IDs to avoid pre-seeded cache hits -- **`withNetwork` per-library request counts** — Non-normalized libraries now simulate 5 network round-trips (one per affected item query sharing the updated author), while data-client simulates 1 (normalization propagates automatically) - -**Refactors:** -- All implementations switched from `count: number` state to `ids: string[]` state for cleaner bulk-ingest support -- Scenario generation supports `perLibArgs` overrides and `onlyLibs` filtering - -### Session 5: `useQuery` + `Query` scenario (derived/computed data) - -- **`sortedItemsQuery`** — `Query` schema in `resources.ts` using `new Query(new All(ItemEntity), ...)` for globally memoized sorted views -- **`SortedListView`** component added to all 4 library implementations: data-client uses `useQuery(sortedItemsQuery)` (no `useMemo`), competitors use `useMemo(() => [...items].sort(...), [items])` -- **`mountSortedView`** — New `BenchAPI` method seeds cache and renders sorted view -- **`sorted-view-mount-500`** — Scenario measuring mount cost of sorted derived view -- **`sorted-view-update-entity`** — Uses `preMountAction: 'mountSortedView'` to pre-mount sorted view, then updates one entity; data-client `Query` memoization avoids re-sort when sort keys unchanged -- Runner updated with `preMountAction` support and `mountSortedView` in `isMountLike` check - -### Session 6: Memory and scaling scenarios - -- **`update-shared-author-1000-mounted`** — 1000 components subscribed to overlapping entities; measures per-update scaling cost -- **Fixture data expanded** to 1000 items (from 500) with 20 shared authors for stronger normalization signal -- **`invalidateAndResolve`** — New `BenchAPI` method (data-client only): `controller.invalidate(getItem, { id })` followed by immediate `controller.setResponse` re-resolve; measures Suspense boundary round-trip -- **`invalidate-and-resolve`** scenario added with `onlyLibs: ['data-client']` - -### Session 7: Advanced measurement and reporting - -- **Report viewer enhanced** (`bench/report-viewer.html`): - - Metric filtering: checkboxes for "Base metrics", "React commit", and "Trace" suffix entries - - Time-series charts: upload multiple JSON files from consecutive runs to render a Chart.js line chart showing trends over time - - Chart instances properly destroyed on re-render -- **Startup metrics** via CDP `Performance.getMetrics`: - - `startup-fcp` (FirstContentfulPaint) and `startup-task-duration` (TaskDuration, proxy for TBT) reported per library - - Startup category excluded from CI; runs locally only (5 rounds per library) - - `getStartupScenarios()` function in scenarios.ts - -### Session 8: Polish and documentation - -- **README expanded** with: all new scenarios documented, expected results table, expected variance table, metric categories explained, report viewer toggle/history instructions, updated "Adding a new library" section -- **Cursor rule updated** (`.cursor/rules/benchmarking.mdc`): React benchmark scenario list with what they exercise and when to use them, expected variance table, "when to use Node vs React benchmark" guidance -- **CI workflow refined** (`.github/workflows/benchmark-react.yml`): added `packages/endpoint/src/schemas/**` and `packages/normalizr/src/**` to path triggers, added `concurrency` group to prevent parallel benchmark runs on the same branch diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 61246c3c0900..5f66f12d9445 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -14,7 +14,7 @@ The repo has two benchmark suites: - **What we measure:** Wall-clock time from triggering an action (e.g. `mount(100)` or `updateAuthor('author-0')`) until the harness sets `data-bench-complete` (after two `requestAnimationFrame` callbacks). Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. - **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update), ref stability (fewer new object references), and derived-view memoization (`Query` schema avoids re-sorting when entities haven't changed). See [js-framework-benchmark "How the duration is measured"](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. - **Statistical:** Warmup runs are discarded; we report median and 95% CI. Libraries are interleaved per round to reduce environmental variance. -- **CPU throttling:** 4x CPU slowdown via CDP to amplify small differences on fast CI machines. +- **No CPU throttling:** Runs at native speed with more samples (3 warmup + 30 measurement locally, 15 in CI) for statistical significance rather than artificial slowdown. ## Scenario categories @@ -95,14 +95,10 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve Playwright needs system libraries to run Chromium. If you see "Host system is missing dependencies to run browsers": ```bash - sudo npx playwright install-deps chromium + sudo env PATH="$PATH" npx playwright install-deps chromium ``` - Or install manually (e.g. Debian/Ubuntu): - - ```bash - sudo apt-get install libnss3 libnspr4 libasound2t64 - ``` + The `env PATH="$PATH"` is needed because `sudo` doesn't inherit your shell's PATH (where nvm-managed node/npx live). 2. **Build and run** diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 1b74ff4617da..6dcf6f900f91 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -238,13 +238,6 @@ async function main() { const context = await browser.newContext(); const page = await context.newPage(); - try { - const cdp = await context.newCDPSession(page); - await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); - } catch { - // CDP throttling is best-effort - } - for (const scenario of SCENARIOS_TO_RUN) { if (!scenario.name.startsWith(`${lib}:`)) continue; try { diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 9eff0e84cb14..0df11d0edfc0 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -177,5 +177,5 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => ), ); -export const WARMUP_RUNS = 2; -export const MEASUREMENT_RUNS = process.env.CI ? 5 : 20; +export const WARMUP_RUNS = 3; +export const MEASUREMENT_RUNS = process.env.CI ? 10 : 15; diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index c9240429c358..ef2af3c45785 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -35,7 +35,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "playwright": "1.58.2", - "serve": "14.2.4", + "serve": "14.2.6", "tsx": "4.19.2", "typescript": "5.9.3", "webpack": "5.105.3", From f3d53273bc2b3c16fd172545cae466e8ee71ab35 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 23:01:16 -0400 Subject: [PATCH 08/46] yarn lock --- yarn.lock | 99 ++++++------------------------------------------------- 1 file changed, 10 insertions(+), 89 deletions(-) diff --git a/yarn.lock b/yarn.lock index 538efb279b7c..1fbf99fc6a26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9472,7 +9472,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.8": +"accepts@npm:~1.3.4, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -9645,18 +9645,6 @@ __metadata: languageName: node linkType: hard -"ajv@npm:8.12.0": - version: 8.12.0 - resolution: "ajv@npm:8.12.0" - dependencies: - fast-deep-equal: "npm:^3.1.1" - json-schema-traverse: "npm:^1.0.0" - require-from-string: "npm:^2.0.2" - uri-js: "npm:^4.2.2" - checksum: 10c0/ac4f72adf727ee425e049bc9d8b31d4a57e1c90da8d28bcd23d60781b12fcd6fc3d68db5df16994c57b78b94eed7988f5a6b482fd376dc5b084125e20a0a622e - languageName: node - linkType: hard - "ajv@npm:8.18.0, ajv@npm:^8.0.0, ajv@npm:^8.9.0": version: 8.18.0 resolution: "ajv@npm:8.18.0" @@ -11889,7 +11877,7 @@ __metadata: languageName: node linkType: hard -"compressible@npm:~2.0.16, compressible@npm:~2.0.18": +"compressible@npm:~2.0.18": version: 2.0.18 resolution: "compressible@npm:2.0.18" dependencies: @@ -11898,21 +11886,6 @@ __metadata: languageName: node linkType: hard -"compression@npm:1.7.4": - version: 1.7.4 - resolution: "compression@npm:1.7.4" - dependencies: - accepts: "npm:~1.3.5" - bytes: "npm:3.0.0" - compressible: "npm:~2.0.16" - debug: "npm:2.6.9" - on-headers: "npm:~1.0.2" - safe-buffer: "npm:5.1.2" - vary: "npm:~1.1.2" - checksum: 10c0/138db836202a406d8a14156a5564fb1700632a76b6e7d1546939472895a5304f2b23c80d7a22bf44c767e87a26e070dbc342ea63bb45ee9c863354fa5556bbbc - languageName: node - linkType: hard - "compression@npm:1.8.1, compression@npm:^1.8.1": version: 1.8.1 resolution: "compression@npm:1.8.1" @@ -14852,7 +14825,7 @@ __metadata: playwright: "npm:1.58.2" react: "npm:19.2.3" react-dom: "npm:19.2.3" - serve: "npm:14.2.4" + serve: "npm:14.2.6" swr: "npm:2.3.6" tsx: "npm:4.19.2" typescript: "npm:5.9.3" @@ -21079,15 +21052,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:3.1.2": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 - languageName: node - linkType: hard - "minimatch@npm:3.1.5, minimatch@npm:^3.0.3, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5": version: 3.1.5 resolution: "minimatch@npm:3.1.5" @@ -22184,13 +22148,6 @@ __metadata: languageName: node linkType: hard -"on-headers@npm:~1.0.2": - version: 1.0.2 - resolution: "on-headers@npm:1.0.2" - checksum: 10c0/f649e65c197bf31505a4c0444875db0258e198292f34b884d73c2f751e91792ef96bb5cf89aa0f4fecc2e4dc662461dda606b1274b0e564f539cae5d2f5fc32f - languageName: node - linkType: hard - "on-headers@npm:~1.1.0": version: 1.1.0 resolution: "on-headers@npm:1.1.0" @@ -26393,13 +26350,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": - version: 5.1.2 - resolution: "safe-buffer@npm:5.1.2" - checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 - languageName: node - linkType: hard - "safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -26407,6 +26357,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 + languageName: node + linkType: hard + "safe-push-apply@npm:^1.0.0": version: 1.0.0 resolution: "safe-push-apply@npm:1.0.0" @@ -26703,21 +26660,6 @@ __metadata: languageName: node linkType: hard -"serve-handler@npm:6.1.6": - version: 6.1.6 - resolution: "serve-handler@npm:6.1.6" - dependencies: - bytes: "npm:3.0.0" - content-disposition: "npm:0.5.2" - mime-types: "npm:2.1.18" - minimatch: "npm:3.1.2" - path-is-inside: "npm:1.0.2" - path-to-regexp: "npm:3.3.0" - range-parser: "npm:1.2.0" - checksum: 10c0/1e1cb6bbc51ee32bc1505f2e0605bdc2e96605c522277c977b67f83be9d66bd1eec8604388714a4d728e036d86b629bc9aec02120ea030d3d2c3899d44696503 - languageName: node - linkType: hard - "serve-handler@npm:6.1.7, serve-handler@npm:^6.1.6": version: 6.1.7 resolution: "serve-handler@npm:6.1.7" @@ -26760,27 +26702,6 @@ __metadata: languageName: node linkType: hard -"serve@npm:14.2.4": - version: 14.2.4 - resolution: "serve@npm:14.2.4" - dependencies: - "@zeit/schemas": "npm:2.36.0" - ajv: "npm:8.12.0" - arg: "npm:5.0.2" - boxen: "npm:7.0.0" - chalk: "npm:5.0.1" - chalk-template: "npm:0.4.0" - clipboardy: "npm:3.0.0" - compression: "npm:1.7.4" - is-port-reachable: "npm:4.0.0" - serve-handler: "npm:6.1.6" - update-check: "npm:1.5.4" - bin: - serve: build/main.js - checksum: 10c0/93abecd6214228d529065040f7c0cbe541c1cc321c6a94b8a968f45a519bd9c46a9fd5e45a9b24a1f5736c5b547b8fa60d5414ebc78f870e29431b64165c1d06 - languageName: node - linkType: hard - "serve@npm:14.2.6": version: 14.2.6 resolution: "serve@npm:14.2.6" From 53e85ee6ebf94a53197dd674872f90eb01983edc Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 9 Mar 2026 23:39:53 -0400 Subject: [PATCH 09/46] bugbot + fix test data client correctness --- examples/benchmark-react/bench/runner.ts | 28 +++++++++++--- examples/benchmark-react/bench/tracing.ts | 2 +- .../benchmark-react/src/data-client/index.tsx | 37 +++++++++++-------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 6dcf6f900f91..460ec83dda33 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -129,9 +129,17 @@ async function runScenario( } await harness.evaluate(el => el.removeAttribute('data-bench-complete')); - if (USE_TRACE && !isRefStability) { - await (page as any).tracing.start({ - categories: ['devtools.timeline', 'blink'], + const cdpTracing = + USE_TRACE && !isRefStability ? + await page.context().newCDPSession(page) + : undefined; + const traceChunks: object[] = []; + if (cdpTracing) { + cdpTracing.on('Tracing.dataCollected', (params: { value: object[] }) => { + traceChunks.push(...params.value); + }); + await cdpTracing.send('Tracing.start', { + categories: 'devtools.timeline,blink', }); } await (bench as any).evaluate((api: any, s: any) => { @@ -144,12 +152,20 @@ async function runScenario( }); let traceDuration: number | undefined; - if (USE_TRACE && !isRefStability) { + if (cdpTracing) { try { - const buf = await (page as any).tracing.stop(); - traceDuration = parseTraceDuration(buf); + const done = new Promise(resolve => { + cdpTracing!.on('Tracing.tracingComplete', () => resolve()); + }); + await cdpTracing.send('Tracing.end'); + await done; + const traceJson = + '[\n' + traceChunks.map(e => JSON.stringify(e)).join(',\n') + '\n]'; + traceDuration = parseTraceDuration(Buffer.from(traceJson)); } catch { traceDuration = undefined; + } finally { + await cdpTracing.detach().catch(() => {}); } } diff --git a/examples/benchmark-react/bench/tracing.ts b/examples/benchmark-react/bench/tracing.ts index d54d58b7ec74..d23a783cb6c0 100644 --- a/examples/benchmark-react/bench/tracing.ts +++ b/examples/benchmark-react/bench/tracing.ts @@ -18,7 +18,7 @@ export function parseTraceDuration(traceBuffer: Buffer): number { e.cat?.includes('devtools.timeline'), ); - const firstTs = Math.min(...events.map(e => e.ts).filter(Boolean)); + const firstTs = Math.min(...events.map(e => e.ts).filter(x => x != null)); const lastPaint = paintEvents.length ? Math.max(...paintEvents.map(e => e.ts + (e.dur ?? 0))) diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index e20cd5c321cc..c756cca091d6 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -45,6 +45,19 @@ function ItemView({ id }: { id: string }) { return ; } +/** Renders items from the list endpoint (models rendering a list fetch response). */ +function ListView() { + const items = useCache(getItemList); + if (!items) return null; + return ( + <> + {(items as Item[]).map(item => ( + + ))} + + ); +} + /** Renders items sorted by label via Query schema (memoized by MemoCache). */ function SortedListView() { const items = useQuery(sortedItemsQuery); @@ -60,6 +73,7 @@ function SortedListView() { function BenchmarkHarness() { const [ids, setIds] = useState([]); + const [showListView, setShowListView] = useState(false); const [showSortedView, setShowSortedView] = useState(false); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); @@ -154,6 +168,8 @@ function BenchmarkHarness() { const unmountAll = useCallback(() => { setIds([]); + setShowListView(false); + setShowSortedView(false); }, []); const optimisticUpdate = useCallback(() => { @@ -176,15 +192,9 @@ function BenchmarkHarness() { const bulkIngest = useCallback( (n: number) => { performance.mark('mount-start'); - const { items, authors } = generateFreshData(n); + const { items } = generateFreshData(n); controller.setResponse(getItemList, items); - for (const item of items) { - controller.setResponse(getItem, { id: item.id }, item); - } - for (const author of authors) { - controller.setResponse(getAuthor, { id: author.id }, author); - } - setIds(items.map(i => i.id)); + setShowListView(true); requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('mount-end'); @@ -197,14 +207,8 @@ function BenchmarkHarness() { ); const mountSortedView = useCallback( - (n: number) => { + (_n: number) => { performance.mark('mount-start'); - for (const item of FIXTURE_ITEMS.slice(0, n)) { - controller.setResponse(getItem, { id: item.id }, item); - } - for (const author of FIXTURE_AUTHORS) { - controller.setResponse(getAuthor, { id: author.id }, author); - } setShowSortedView(true); requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -214,7 +218,7 @@ function BenchmarkHarness() { }); }); }, - [controller, setComplete], + [setComplete], ); const invalidateAndResolve = useCallback( @@ -313,6 +317,7 @@ function BenchmarkHarness() { ))} + {showListView && } {showSortedView && } ); From 54a70e786d4a9f342c935b2f59b7edffc5ea3b63 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 10 Mar 2026 09:00:04 -0400 Subject: [PATCH 10/46] fair comparisons --- examples/benchmark-react/src/data-client/index.tsx | 11 +++++++---- examples/benchmark-react/src/data-client/resources.ts | 5 +++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index c756cca091d6..39a20b9d8464 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -59,8 +59,8 @@ function ListView() { } /** Renders items sorted by label via Query schema (memoized by MemoCache). */ -function SortedListView() { - const items = useQuery(sortedItemsQuery); +function SortedListView({ count }: { count?: number }) { + const items = useQuery(sortedItemsQuery, { limit: count }); if (!items) return null; return (
@@ -75,6 +75,7 @@ function BenchmarkHarness() { const [ids, setIds] = useState([]); const [showListView, setShowListView] = useState(false); const [showSortedView, setShowSortedView] = useState(false); + const [sortedViewCount, setSortedViewCount] = useState(); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const controller = useController(); @@ -170,6 +171,7 @@ function BenchmarkHarness() { setIds([]); setShowListView(false); setShowSortedView(false); + setSortedViewCount(undefined); }, []); const optimisticUpdate = useCallback(() => { @@ -207,8 +209,9 @@ function BenchmarkHarness() { ); const mountSortedView = useCallback( - (_n: number) => { + (n: number) => { performance.mark('mount-start'); + setSortedViewCount(n); setShowSortedView(true); requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -318,7 +321,7 @@ function BenchmarkHarness() { ))}
{showListView && } - {showSortedView && } + {showSortedView && } ); } diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts index 6a9b5da46e45..18bc92f2f8b0 100644 --- a/examples/benchmark-react/src/data-client/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -54,10 +54,11 @@ export const getItemList = new Endpoint( /** Derived sorted view via Query schema -- globally memoized by MemoCache */ export const sortedItemsQuery = new Query( new All(ItemEntity), - (entries: any[]) => { - return [...entries].sort((a: any, b: any) => + (entries: any[], { limit }: { limit?: number } = {}) => { + const sorted = [...entries].sort((a: any, b: any) => a.label.localeCompare(b.label), ); + return limit ? sorted.slice(0, limit) : sorted; }, ); From 944f3badbafa0e63d640a90ce510d754a8ee0a89 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 10 Mar 2026 09:29:20 -0400 Subject: [PATCH 11/46] ts 6 --- examples/benchmark-react/package.json | 2 +- examples/benchmark-react/tsconfig.json | 1 - yarn.lock | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index ef2af3c45785..4a2b5cf2ceff 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -37,7 +37,7 @@ "playwright": "1.58.2", "serve": "14.2.6", "tsx": "4.19.2", - "typescript": "5.9.3", + "typescript": "6.0.1-rc", "webpack": "5.105.3", "webpack-cli": "6.0.1" }, diff --git a/examples/benchmark-react/tsconfig.json b/examples/benchmark-react/tsconfig.json index a26a1575613f..c7f0e2350887 100644 --- a/examples/benchmark-react/tsconfig.json +++ b/examples/benchmark-react/tsconfig.json @@ -11,7 +11,6 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "baseUrl": ".", "paths": { "@shared/*": ["./src/shared/*"] }, diff --git a/yarn.lock b/yarn.lock index 1fbf99fc6a26..47a87ed36fa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14828,7 +14828,7 @@ __metadata: serve: "npm:14.2.6" swr: "npm:2.3.6" tsx: "npm:4.19.2" - typescript: "npm:5.9.3" + typescript: "npm:6.0.1-rc" webpack: "npm:5.105.3" webpack-cli: "npm:6.0.1" languageName: unknown From 393050c16e8fbaadaa8daf2cd85b587534290fb9 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 10 Mar 2026 09:31:09 -0400 Subject: [PATCH 12/46] bugbot --- examples/benchmark-react/bench/runner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 460ec83dda33..f3d8a76d6a93 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -70,7 +70,8 @@ async function runScenario( await harness.waitFor({ state: 'attached' }); const bench = await page.evaluateHandle('window.__BENCH__'); - if (!bench) throw new Error('window.__BENCH__ not found'); + if (await bench.evaluate(b => b == null)) + throw new Error('window.__BENCH__ not found'); const isMemory = scenario.action === 'mountUnmountCycle' && From b9317e526e30272c2247b24e86084125571d8b5c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 10 Mar 2026 09:32:56 -0400 Subject: [PATCH 13/46] update website types --- .../editor-types/@data-client/rest.d.ts | 40 ++++++++++++++----- .../Playground/editor-types/globals.d.ts | 40 ++++++++++++++----- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/website/src/components/Playground/editor-types/@data-client/rest.d.ts b/website/src/components/Playground/editor-types/@data-client/rest.d.ts index 45cd235cfc04..42e05b0dbac1 100644 --- a/website/src/components/Playground/editor-types/@data-client/rest.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/rest.d.ts @@ -1417,19 +1417,25 @@ type RestEndpointExtendOptions = 'path' extends keyof O ? RestType<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument<'body' extends keyof O ? O : E, 'method' extends keyof O ? O['method'] : E['method']>, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { +}, F extends FetchFunction> = 'path' extends keyof O ? RestType<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument<'body' extends keyof O ? O : E, 'method' extends keyof O ? O['method'] : E['method']>, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { path: Exclude; body: 'body' extends keyof O ? O['body'] : E['body']; searchParams: 'searchParams' extends keyof O ? O['searchParams'] : E['searchParams']; method: 'method' extends keyof O ? O['method'] : E['method']; paginationField: 'paginationField' extends keyof O ? O['paginationField'] : E['paginationField']; -}> : 'body' extends keyof O ? RestType<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { +}> : 'body' extends keyof O ? RestType<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { path: E['path']; body: O['body']; searchParams: 'searchParams' extends keyof O ? O['searchParams'] : E['searchParams']; method: 'method' extends keyof O ? O['method'] : E['method']; paginationField: 'paginationField' extends keyof O ? O['paginationField'] : Extract; -}> : 'searchParams' extends keyof O ? RestType> : O['searchParams'] & PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { +}> : 'searchParams' extends keyof O ? RestType<[ + O['searchParams'] +] extends [undefined] ? PathArgs> : O['searchParams'] & PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { path: E['path']; body: E['body']; searchParams: O['searchParams']; @@ -1521,7 +1527,9 @@ type AddEndpoint = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { +}> = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { method: 'POST'; }>; type RemoveEndpoint = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { +}> = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { method: 'PATCH'; }>; type MoveEndpoint): string; update?: EndpointUpdateFunction; } -type RestEndpointConstructorOptions = RestEndpointOptions : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument = RestEndpointOptions : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument, O['process'] extends {} ? ReturnType : any>, O['schema']>; /** Simplifies endpoint definitions that follow REST patterns * * @see https://dataclient.io/rest/api/RestEndpoint */ -interface RestEndpoint$1 extends RestInstance : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument extends RestInstance : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument, O['process'] extends {} ? ReturnType : any>, 'schema' extends keyof O ? O['schema'] : undefined, 'sideEffect' extends keyof O ? Extract : MethodToSide, 'method' extends keyof O ? O : O & { method: O extends { @@ -1649,7 +1663,9 @@ type GetEndpoint = RestTypeNoBody<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], undefined, any, O & { +}> = RestTypeNoBody<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], undefined, any, O & { method: 'GET'; }>; type MutateEndpoint = RestTypeWithBody<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], true, O['body'], any, O & { +}> = RestTypeWithBody<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], true, O['body'], any, O & { body: any; method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; }>; @@ -1791,7 +1809,9 @@ interface Resource; schema: Collection<[ O['schema'] - ], ParamToArgs> : O['searchParams'] & PathArgs>>>; + ], ParamToArgs<[ + O['searchParams'] + ] extends [undefined] ? KeysToArgs> : O['searchParams'] & PathArgs>>>; body: 'body' extends keyof O ? O['body'] : Partial>; searchParams: O['searchParams']; movePath: O['path']; diff --git a/website/src/components/Playground/editor-types/globals.d.ts b/website/src/components/Playground/editor-types/globals.d.ts index e6887582d259..a338038e8e41 100644 --- a/website/src/components/Playground/editor-types/globals.d.ts +++ b/website/src/components/Playground/editor-types/globals.d.ts @@ -1421,19 +1421,25 @@ type RestEndpointExtendOptions = 'path' extends keyof O ? RestType<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument<'body' extends keyof O ? O : E, 'method' extends keyof O ? O['method'] : E['method']>, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { +}, F extends FetchFunction> = 'path' extends keyof O ? RestType<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument<'body' extends keyof O ? O : E, 'method' extends keyof O ? O['method'] : E['method']>, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { path: Exclude; body: 'body' extends keyof O ? O['body'] : E['body']; searchParams: 'searchParams' extends keyof O ? O['searchParams'] : E['searchParams']; method: 'method' extends keyof O ? O['method'] : E['method']; paginationField: 'paginationField' extends keyof O ? O['paginationField'] : E['paginationField']; -}> : 'body' extends keyof O ? RestType<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { +}> : 'body' extends keyof O ? RestType<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs> : O['searchParams'] & PathArgs> : PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { path: E['path']; body: O['body']; searchParams: 'searchParams' extends keyof O ? O['searchParams'] : E['searchParams']; method: 'method' extends keyof O ? O['method'] : E['method']; paginationField: 'paginationField' extends keyof O ? O['paginationField'] : Extract; -}> : 'searchParams' extends keyof O ? RestType> : O['searchParams'] & PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { +}> : 'searchParams' extends keyof O ? RestType<[ + O['searchParams'] +] extends [undefined] ? PathArgs> : O['searchParams'] & PathArgs>, OptionsToBodyArgument, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect'], O['process'] extends {} ? ReturnType : ResolveType, { path: E['path']; body: E['body']; searchParams: O['searchParams']; @@ -1525,7 +1531,9 @@ type AddEndpoint = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { +}> = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { method: 'POST'; }>; type RemoveEndpoint = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { +}> = RestInstanceBase> : O['searchParams'] & PathArgs> : PathArgs>, O['body'], ResolveType>, S, true, Omit & { method: 'PATCH'; }>; type MoveEndpoint): string; update?: EndpointUpdateFunction; } -type RestEndpointConstructorOptions = RestEndpointOptions : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument = RestEndpointOptions : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument, O['process'] extends {} ? ReturnType : any>, O['schema']>; /** Simplifies endpoint definitions that follow REST patterns * * @see https://dataclient.io/rest/api/RestEndpoint */ -interface RestEndpoint$1 extends RestInstance : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument extends RestInstance : O['searchParams'] & PathArgs : PathArgs, OptionsToBodyArgument, O['process'] extends {} ? ReturnType : any>, 'schema' extends keyof O ? O['schema'] : undefined, 'sideEffect' extends keyof O ? Extract : MethodToSide, 'method' extends keyof O ? O : O & { method: O extends { @@ -1653,7 +1667,9 @@ type GetEndpoint = RestTypeNoBody<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], undefined, any, O & { +}> = RestTypeNoBody<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], undefined, any, O & { method: 'GET'; }>; type MutateEndpoint = RestTypeWithBody<'searchParams' extends keyof O ? O['searchParams'] extends undefined ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], true, O['body'], any, O & { +}> = RestTypeWithBody<'searchParams' extends keyof O ? [ + O['searchParams'] +] extends [undefined] ? PathArgs : O['searchParams'] & PathArgs : PathArgs, O['schema'], true, O['body'], any, O & { body: any; method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; }>; @@ -1795,7 +1813,9 @@ interface Resource; schema: Collection<[ O['schema'] - ], ParamToArgs> : O['searchParams'] & PathArgs>>>; + ], ParamToArgs<[ + O['searchParams'] + ] extends [undefined] ? KeysToArgs> : O['searchParams'] & PathArgs>>>; body: 'body' extends keyof O ? O['body'] : Partial>; searchParams: O['searchParams']; movePath: O['path']; From 1530cabeaf6aa8461f45f1d114821452cd283e98 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 10 Mar 2026 09:57:31 -0400 Subject: [PATCH 14/46] improve ci --- .github/workflows/benchmark-react.yml | 4 ++++ .github/workflows/benchmark.yml | 4 ++++ examples/benchmark-react/package.json | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 18aae19db51f..8aa16cd80f89 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -26,6 +26,10 @@ concurrency: group: benchmark-react-${{ github.head_ref || github.ref }} cancel-in-progress: true +permissions: + contents: write + pull-requests: write + jobs: benchmark-react: runs-on: ubuntu-latest diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e80305dd3521..4593e2266dd4 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -19,6 +19,10 @@ on: - 'packages/core/src/**' - 'examples/benchmark/**' - '.github/workflows/benchmark.yml' +permissions: + contents: write + pull-requests: write + jobs: benchmark: diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 4a2b5cf2ceff..34d4cbd1d706 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "webpack --mode=production", "build:compiler": "REACT_COMPILER=true webpack --mode=production", - "preview": "serve dist -l 5173", + "preview": "serve dist -l 5173 --no-request-logging", "bench": "npx tsx bench/runner.ts", "bench:compiler": "BENCH_LABEL=compiler npx tsx bench/runner.ts", "bench:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", From 7090705e9ae94e3155e110e9fc788a14b6434914 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 11 Mar 2026 09:45:34 -0400 Subject: [PATCH 15/46] internal: Bench runs concurrently --- .github/workflows/benchmark-react.yml | 4 ++-- .github/workflows/benchmark.yml | 6 +++++- .github/workflows/bundle_size.yml | 5 +++++ .github/workflows/codeql-analysis.yml | 4 ++++ .github/workflows/site-preview.yml | 5 +++++ .github/workflows/site-release.yml | 5 +++++ 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 8aa16cd80f89..5f34534bf6ce 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -23,8 +23,8 @@ on: - '.github/workflows/benchmark-react.yml' concurrency: - group: benchmark-react-${{ github.head_ref || github.ref }} - cancel-in-progress: true + group: ${{ github.event_name == 'push' && 'gh-pages-bench-push' || format('benchmark-react-{0}', github.head_ref) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: write diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4593e2266dd4..9fb1d4d5d7b0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -23,6 +23,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.event_name == 'push' && 'gh-pages-bench-push' || format('benchmark-node-{0}', github.head_ref) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: benchmark: @@ -52,7 +56,7 @@ jobs: uses: actions/cache@v5 with: path: ./cache - key: ${{ runner.os }}-benchmark-pr-${{ env.GITHUB_RUN_NUMBER }} + key: ${{ runner.os }}-benchmark-pr-${{ github.run_number }} restore-keys: | ${{ runner.os }}-benchmark - name: Store benchmark result (PR) diff --git a/.github/workflows/bundle_size.yml b/.github/workflows/bundle_size.yml index ae980bacd4d9..27192a85a95f 100644 --- a/.github/workflows/bundle_size.yml +++ b/.github/workflows/bundle_size.yml @@ -17,6 +17,11 @@ on: - 'yarn.lock' - 'examples/test-bundlesize/**' - '.github/workflows/bundle_size.yml' + +concurrency: + group: bundle-size-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: build: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index be1511b88c56..dd39975b55b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,6 +27,10 @@ on: permissions: contents: read +concurrency: + group: codeql-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: analyze: permissions: diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 968a06396f21..b30cb21de2dd 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -10,6 +10,11 @@ on: - 'website/**' - 'docs/**' - '.github/workflows/site-preview.yml' + +concurrency: + group: site-preview-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/site-release.yml b/.github/workflows/site-release.yml index 8b84e0883fce..e359219faa57 100644 --- a/.github/workflows/site-release.yml +++ b/.github/workflows/site-release.yml @@ -11,6 +11,11 @@ on: - 'website/**' - 'docs/**' - '.github/workflows/site-release.yml' + +concurrency: + group: site-release-${{ github.ref }} + cancel-in-progress: true + jobs: deploy: runs-on: ubuntu-latest From 26062625f56ce5c600fc02c81c11c5bb1daa5535 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 11 Mar 2026 22:32:11 -0400 Subject: [PATCH 16/46] better abstractions --- .github/workflows/benchmark-react.yml | 2 +- .github/workflows/benchmark.yml | 2 +- examples/benchmark-react/README.md | 2 + examples/benchmark-react/bench/runner.ts | 9 +- examples/benchmark-react/package.json | 2 +- .../benchmark-react/src/baseline/index.tsx | 214 ++++---------- .../benchmark-react/src/data-client/index.tsx | 261 ++++------------- .../src/data-client/resources.ts | 3 + .../src/shared/benchHarness.tsx | 194 +++++++++++++ examples/benchmark-react/src/shared/data.ts | 8 + examples/benchmark-react/src/swr/index.tsx | 264 +++++------------- .../src/tanstack-query/index.tsx | 230 ++++----------- 12 files changed, 455 insertions(+), 736 deletions(-) create mode 100644 examples/benchmark-react/src/shared/benchHarness.tsx diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 5f34534bf6ce..0940bcc3aed7 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -23,7 +23,7 @@ on: - '.github/workflows/benchmark-react.yml' concurrency: - group: ${{ github.event_name == 'push' && 'gh-pages-bench-push' || format('benchmark-react-{0}', github.head_ref) }} + group: ${{ github.event_name == 'push' && 'gh-pages-bench-react-push' || format('benchmark-react-{0}', github.head_ref) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 9fb1d4d5d7b0..344bfd6921fe 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -24,7 +24,7 @@ permissions: pull-requests: write concurrency: - group: ${{ github.event_name == 'push' && 'gh-pages-bench-push' || format('benchmark-node-{0}', github.head_ref) }} + group: ${{ github.event_name == 'push' && 'gh-pages-bench-node-push' || format('benchmark-node-{0}', github.head_ref) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 5f66f12d9445..270dad87ff3d 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -130,6 +130,8 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve You can also set the env vars directly for custom combinations: - `REACT_COMPILER=true` — enables 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`) ## Output diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index f3d8a76d6a93..aa01f884c7ab 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -14,7 +14,9 @@ import { import { computeStats } from './stats.js'; import { parseTraceDuration } from './tracing.js'; -const BASE_URL = process.env.BENCH_BASE_URL ?? 'http://localhost:5173'; +const BASE_URL = + process.env.BENCH_BASE_URL ?? + `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; const BENCH_LABEL = process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; /** @@ -111,9 +113,8 @@ async function runScenario( const preMountAction = scenario.preMountAction ?? 'mount'; await harness.evaluate(el => el.removeAttribute('data-bench-complete')); await (bench as any).evaluate( - (api: any, action: string, n: number) => api[action](n), - preMountAction, - mountCount, + (api: any, [action, n]: [string, number]) => api[action](n), + [preMountAction, mountCount], ); await page.waitForSelector('[data-bench-complete]', { timeout: 10000, diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 34d4cbd1d706..bc5383f2a708 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "webpack --mode=production", "build:compiler": "REACT_COMPILER=true webpack --mode=production", - "preview": "serve dist -l 5173 --no-request-logging", + "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:run": "yarn build && (yarn preview &) && sleep 5 && yarn bench", diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 9d29f349e584..7c79a8f1ee26 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -1,19 +1,17 @@ +import { + onProfilerRender, + useBenchState, + waitForPaint, +} from '@shared/benchHarness'; import { ItemRow } from '@shared/components'; import { - FIXTURE_AUTHORS, + FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, generateFreshData, } from '@shared/data'; -import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import { registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; const ItemsContext = React.createContext<{ @@ -46,134 +44,90 @@ function SortedListView() { function BenchmarkHarness() { const [items, setItems] = useState([]); - const [ids, setIds] = useState([]); - const [showSortedView, setShowSortedView] = useState(false); - const containerRef = useRef(null); - const completeResolveRef = useRef<(() => void) | null>(null); - - const setComplete = useCallback(() => { - completeResolveRef.current?.(); - completeResolveRef.current = null; - containerRef.current?.setAttribute('data-bench-complete', 'true'); - }, []); + const { + ids, + showSortedView, + containerRef, + measureMount, + measureUpdate, + measureUpdateWithDelay, + setComplete, + completeResolveRef, + setIds, + setShowSortedView, + unmountAll: unmountBase, + registerAPI, + } = useBenchState(); const mount = useCallback( (n: number) => { - performance.mark('mount-start'); const sliced = FIXTURE_ITEMS.slice(0, n); - setItems(sliced); - setIds(sliced.map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + const slicedIds = sliced.map(i => i.id); + measureMount(() => { + setItems(sliced); + setIds(slicedIds); }); }, - [setComplete], + [measureMount, setIds], ); const updateEntity = useCallback( (id: string) => { - performance.mark('update-start'); - setItems(prev => - prev.map(item => - item.id === id ? { ...item, label: `${item.label} (updated)` } : item, - ), - ); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); - }); + measureUpdate(() => { + setItems(prev => + prev.map(item => + item.id === id ? + { ...item, label: `${item.label} (updated)` } + : item, + ), + ); }); }, - [setComplete], + [measureUpdate], ); const updateAuthor = useCallback( (authorId: string, options?: UpdateAuthorOptions) => { - performance.mark('update-start'); - const delayMs = options?.simulateNetworkDelayMs ?? 0; - const requestCount = options?.simulatedRequestCount ?? 1; - const totalDelayMs = delayMs * requestCount; - - const doUpdate = () => { - const author = FIXTURE_AUTHORS.find(a => a.id === authorId); - if (author) { - const newAuthor = { - ...author, - name: `${author.name} (updated)`, - }; - setItems(prev => - prev.map(item => - item.author.id === authorId ? - { ...item, author: newAuthor } - : item, - ), - ); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure( - 'update-duration', - 'update-start', - 'update-end', - ); - setComplete(); - }); - }); - }; - - if (totalDelayMs > 0) { - setTimeout(doUpdate, totalDelayMs); - } else { - doUpdate(); - } + const author = FIXTURE_AUTHORS_BY_ID.get(authorId); + if (!author) return; + const newAuthor = { ...author, name: `${author.name} (updated)` }; + measureUpdateWithDelay(options, () => { + setItems(prev => + prev.map(item => + item.author.id === authorId ? { ...item, author: newAuthor } : item, + ), + ); + }); }, - [setComplete], + [measureUpdateWithDelay], ); const unmountAll = useCallback(() => { - setIds([]); + unmountBase(); setItems([]); - }, []); + }, [unmountBase]); const bulkIngest = useCallback( (n: number) => { - performance.mark('mount-start'); const { items: freshItems } = generateFreshData(n); - setItems(freshItems); - setIds(freshItems.map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + const freshIds = freshItems.map(i => i.id); + measureMount(() => { + setItems(freshItems); + setIds(freshIds); }); }, - [setComplete], + [measureMount, setIds], ); const mountSortedView = useCallback( (n: number) => { - performance.mark('mount-start'); const sliced = FIXTURE_ITEMS.slice(0, n); - setItems(sliced); - setShowSortedView(true); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + measureMount(() => { + setItems(sliced); + setShowSortedView(true); }); }, - [setComplete], + [measureMount, setShowSortedView], ); const mountUnmountCycle = useCallback( @@ -185,40 +139,14 @@ function BenchmarkHarness() { mount(n); await p; unmountAll(); - await new Promise(r => - requestAnimationFrame(() => requestAnimationFrame(() => r())), - ); + await waitForPaint(); } setComplete(); }, - [mount, unmountAll, setComplete], + [mount, unmountAll, setComplete, completeResolveRef], ); - const getRenderedCount = useCallback(() => ids.length, [ids]); - - const captureRefSnapshot = useCallback(() => { - captureSnapshot(); - }, []); - - const getRefStabilityReport = useCallback(() => getReport(), []); - - useEffect(() => { - window.__BENCH__ = { - mount, - updateEntity, - updateAuthor, - unmountAll, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - mountUnmountCycle, - bulkIngest, - mountSortedView, - }; - return () => { - delete window.__BENCH__; - }; - }, [ + registerAPI({ mount, updateEntity, updateAuthor, @@ -226,14 +154,7 @@ function BenchmarkHarness() { mountUnmountCycle, bulkIngest, mountSortedView, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - ]); - - useEffect(() => { - document.body.setAttribute('data-app-ready', 'true'); - }, []); + }); return ( @@ -249,17 +170,6 @@ function BenchmarkHarness() { ); } -function onProfilerRender( - _id: string, - phase: 'mount' | 'update' | 'nested-update', - actualDuration: number, -) { - performance.measure(`react-commit-${phase}`, { - start: performance.now() - actualDuration, - duration: actualDuration, - }); -} - const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 39a20b9d8464..de625880e84c 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -5,21 +5,25 @@ import { useQuery, } from '@data-client/react'; import { mockInitialState } from '@data-client/react/mock'; +import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { ItemRow } from '@shared/components'; import { - FIXTURE_AUTHORS, + FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, + FIXTURE_ITEMS_BY_ID, generateFreshData, } from '@shared/data'; -import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import { registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { + AuthorEntity, getAuthor, getItem, getItemList, + ItemEntity, sortedItemsQuery, updateItemOptimistic, } from './resources'; @@ -31,7 +35,7 @@ const initialState = mockInitialState([ args: [{ id: item.id }] as [{ id: string }], response: item, })), - ...FIXTURE_AUTHORS.map(author => ({ + ...[...FIXTURE_AUTHORS_BY_ID.values()].map(author => ({ endpoint: getAuthor, args: [{ id: author.id }] as [{ id: string }], response: author, @@ -72,246 +76,114 @@ function SortedListView({ count }: { count?: number }) { } function BenchmarkHarness() { - const [ids, setIds] = useState([]); - const [showListView, setShowListView] = useState(false); - const [showSortedView, setShowSortedView] = useState(false); - const [sortedViewCount, setSortedViewCount] = useState(); - const containerRef = useRef(null); - const completeResolveRef = useRef<(() => void) | null>(null); const controller = useController(); - - const setComplete = useCallback(() => { - completeResolveRef.current?.(); - completeResolveRef.current = null; - containerRef.current?.setAttribute('data-bench-complete', 'true'); - }, []); - - const mount = useCallback( - (n: number) => { - performance.mark('mount-start'); - setIds(FIXTURE_ITEMS.slice(0, n).map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); - }); - }, - [setComplete], - ); + const { + ids, + showSortedView, + sortedViewCount, + containerRef, + measureMount, + measureUpdate, + measureUpdateWithDelay, + setShowSortedView, + setSortedViewCount, + unmountAll: unmountBase, + registerAPI, + } = useBenchState(); + const [showListView, setShowListView] = useState(false); const updateEntity = useCallback( (id: string) => { - performance.mark('update-start'); - const item = FIXTURE_ITEMS.find(i => i.id === id); - if (item) { - controller.setResponse( - getItem, + const item = FIXTURE_ITEMS_BY_ID.get(id); + if (!item) return; + measureUpdate(() => { + controller.set( + ItemEntity, { id }, - { - ...item, - label: `${item.label} (updated)`, - }, + { ...item, label: `${item.label} (updated)` }, ); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); - }); }); }, - [controller, setComplete], + [measureUpdate, controller], ); const updateAuthor = useCallback( (authorId: string, options?: UpdateAuthorOptions) => { - performance.mark('update-start'); - const delayMs = options?.simulateNetworkDelayMs ?? 0; - const requestCount = options?.simulatedRequestCount ?? 1; - const totalDelayMs = delayMs * requestCount; - - const doUpdate = () => { - const author = FIXTURE_AUTHORS.find(a => a.id === authorId); - if (author) { - controller.setResponse( - getAuthor, - { id: authorId }, - { - ...author, - name: `${author.name} (updated)`, - }, - ); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure( - 'update-duration', - 'update-start', - 'update-end', - ); - setComplete(); - }); - }); - }; - - if (totalDelayMs > 0) { - setTimeout(doUpdate, totalDelayMs); - } else { - doUpdate(); - } + const author = FIXTURE_AUTHORS_BY_ID.get(authorId); + if (!author) return; + measureUpdateWithDelay(options, () => { + controller.set( + AuthorEntity, + { id: authorId }, + { ...author, name: `${author.name} (updated)` }, + ); + }); }, - [controller, setComplete], + [measureUpdateWithDelay, controller], ); const unmountAll = useCallback(() => { - setIds([]); + unmountBase(); setShowListView(false); - setShowSortedView(false); - setSortedViewCount(undefined); - }, []); + }, [unmountBase]); const optimisticUpdate = useCallback(() => { const item = FIXTURE_ITEMS[0]; if (!item) return; - performance.mark('update-start'); - controller.fetch(updateItemOptimistic, { - id: item.id, - label: `${item.label} (optimistic)`, - }); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); + measureUpdate(() => { + controller.fetch(updateItemOptimistic, { + id: item.id, + label: `${item.label} (optimistic)`, }); }); - }, [controller, setComplete]); + }, [measureUpdate, controller]); const bulkIngest = useCallback( (n: number) => { - performance.mark('mount-start'); const { items } = generateFreshData(n); - controller.setResponse(getItemList, items); - setShowListView(true); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + measureMount(() => { + controller.setResponse(getItemList, items); + setShowListView(true); }); }, - [controller, setComplete], + [measureMount, controller], ); const mountSortedView = useCallback( (n: number) => { - performance.mark('mount-start'); - setSortedViewCount(n); - setShowSortedView(true); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + measureMount(() => { + setSortedViewCount(n); + setShowSortedView(true); }); }, - [setComplete], + [measureMount, setSortedViewCount, setShowSortedView], ); const invalidateAndResolve = useCallback( (id: string) => { - performance.mark('update-start'); - const item = FIXTURE_ITEMS.find(i => i.id === id); - if (item) { + const item = FIXTURE_ITEMS_BY_ID.get(id); + if (!item) return; + measureUpdate(() => { controller.invalidate(getItem, { id }); controller.setResponse( getItem, { id }, - { - ...item, - label: `${item.label} (resolved)`, - }, + { ...item, label: `${item.label} (resolved)` }, ); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); - }); }); }, - [controller, setComplete], + [measureUpdate, controller], ); - const mountUnmountCycle = useCallback( - async (n: number, cycles: number) => { - for (let i = 0; i < cycles; i++) { - const p = new Promise(r => { - completeResolveRef.current = r; - }); - mount(n); - await p; - unmountAll(); - await new Promise(r => - requestAnimationFrame(() => requestAnimationFrame(() => r())), - ); - } - setComplete(); - }, - [mount, unmountAll, setComplete], - ); - - const getRenderedCount = useCallback(() => ids.length, [ids]); - - const captureRefSnapshot = useCallback(() => { - captureSnapshot(); - }, []); - - const getRefStabilityReport = useCallback(() => getReport(), []); - - useEffect(() => { - window.__BENCH__ = { - mount, - updateEntity, - updateAuthor, - unmountAll, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - mountUnmountCycle, - optimisticUpdate, - bulkIngest, - mountSortedView, - invalidateAndResolve, - }; - return () => { - delete window.__BENCH__; - }; - }, [ - mount, + registerAPI({ updateEntity, updateAuthor, unmountAll, - mountUnmountCycle, optimisticUpdate, bulkIngest, mountSortedView, invalidateAndResolve, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - ]); - - useEffect(() => { - document.body.setAttribute('data-app-ready', 'true'); - }, []); + }); return (
@@ -326,17 +198,6 @@ function BenchmarkHarness() { ); } -function onProfilerRender( - _id: string, - phase: 'mount' | 'update' | 'nested-update', - actualDuration: number, -) { - performance.measure(`react-commit-${phase}`, { - start: performance.now() - actualDuration, - duration: actualDuration, - }); -} - const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts index 18bc92f2f8b0..7c878aa502f2 100644 --- a/examples/benchmark-react/src/data-client/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -31,6 +31,7 @@ export const getAuthor = new Endpoint( { schema: AuthorEntity, key: ({ id }: { id: string }) => `author:${id}`, + dataExpiryLength: Infinity, }, ); @@ -40,6 +41,7 @@ export const getItem = new Endpoint( { schema: ItemEntity, key: ({ id }: { id: string }) => `item:${id}`, + dataExpiryLength: Infinity, }, ); @@ -48,6 +50,7 @@ export const getItemList = new Endpoint( { schema: [ItemEntity], key: () => 'item:list', + dataExpiryLength: Infinity, }, ); diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx new file mode 100644 index 000000000000..838e872f1452 --- /dev/null +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { FIXTURE_ITEMS } from './data'; +import { captureSnapshot, getReport } from './refStability'; +import type { BenchAPI, UpdateAuthorOptions } from './types'; + +export function afterPaint(fn: () => void): void { + requestAnimationFrame(() => requestAnimationFrame(fn)); +} + +export function waitForPaint(): Promise { + return new Promise(r => afterPaint(r)); +} + +export function onProfilerRender( + _id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, +) { + performance.measure(`react-commit-${phase}`, { + start: performance.now() - actualDuration, + duration: actualDuration, + }); +} + +/** + * Actions that each library must provide (not supplied by the shared harness). + * All other BenchAPI methods can be optionally overridden or added. + */ +type LibraryActions = Pick & + Partial>; + +/** + * Shared benchmark harness state, measurement helpers, and API registration. + * + * Standard BenchAPI actions (mount, unmountAll, mountUnmountCycle, + * getRenderedCount, captureRefSnapshot, getRefStabilityReport) are provided + * as defaults via `registerAPI`. Libraries only need to supply their + * specific actions and any overrides. + * + * `registerAPI` uses a Proxy so adding new BenchAPI methods never requires + * updating dependency arrays or registration boilerplate. + */ +export function useBenchState() { + const [ids, setIds] = useState([]); + const [showSortedView, setShowSortedView] = useState(false); + const [sortedViewCount, setSortedViewCount] = useState(); + const containerRef = useRef(null); + const completeResolveRef = useRef<(() => void) | null>(null); + const apiRef = useRef(null as any); + + const setComplete = useCallback(() => { + completeResolveRef.current?.(); + completeResolveRef.current = null; + containerRef.current?.setAttribute('data-bench-complete', 'true'); + }, []); + + const measureMount = useCallback( + (fn: () => void) => { + performance.mark('mount-start'); + fn(); + afterPaint(() => { + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }); + }, + [setComplete], + ); + + const measureUpdate = useCallback( + (fn: () => void) => { + performance.mark('update-start'); + fn(); + afterPaint(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }, + [setComplete], + ); + + /** Like measureUpdate, but marks start before the delay and runs fn after it. */ + const measureUpdateWithDelay = useCallback( + (options: UpdateAuthorOptions | undefined, fn: () => void) => { + performance.mark('update-start'); + const delayMs = options?.simulateNetworkDelayMs ?? 0; + const requestCount = options?.simulatedRequestCount ?? 1; + const totalDelayMs = delayMs * requestCount; + + const doUpdate = () => { + fn(); + afterPaint(() => { + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + setComplete(); + }); + }; + + if (totalDelayMs > 0) { + setTimeout(doUpdate, totalDelayMs); + } else { + doUpdate(); + } + }, + [setComplete], + ); + + const mount = useCallback( + (n: number) => { + const slicedIds = FIXTURE_ITEMS.slice(0, n).map(i => i.id); + measureMount(() => setIds(slicedIds)); + }, + [measureMount], + ); + + const unmountAll = useCallback(() => { + setIds([]); + setShowSortedView(false); + setSortedViewCount(undefined); + }, []); + + const mountUnmountCycle = useCallback( + async (n: number, cycles: number) => { + for (let i = 0; i < cycles; i++) { + const p = new Promise(r => { + completeResolveRef.current = r; + }); + mount(n); + await p; + unmountAll(); + await waitForPaint(); + } + setComplete(); + }, + [mount, unmountAll, setComplete], + ); + + const getRenderedCount = useCallback(() => ids.length, [ids]); + const captureRefSnapshot = useCallback(() => captureSnapshot(), []); + const getRefStabilityReport = useCallback(() => getReport(), []); + + /** + * Register the BenchAPI on window.__BENCH__ with standard actions as defaults. + * Call during render (after defining library-specific actions). + * Libraries only pass their own actions + any overrides; standard actions + * (mount, unmountAll, etc.) are included automatically. + */ + const registerAPI = (libraryActions: LibraryActions) => { + apiRef.current = { + mount, + unmountAll, + mountUnmountCycle, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + ...libraryActions, + } as BenchAPI; + }; + + useEffect(() => { + window.__BENCH__ = new Proxy({} as BenchAPI, { + get(_, prop) { + return (apiRef.current as any)?.[prop]; + }, + }); + document.body.setAttribute('data-app-ready', 'true'); + return () => { + delete window.__BENCH__; + }; + }, []); + + return { + ids, + showSortedView, + sortedViewCount, + containerRef, + + measureMount, + measureUpdate, + measureUpdateWithDelay, + setComplete, + completeResolveRef, + + setIds, + setShowSortedView, + setSortedViewCount, + + mount, + unmountAll, + registerAPI, + }; +} diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index b8ea5c40287d..d06ae41e6a25 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -40,6 +40,14 @@ export const FIXTURE_ITEMS = generateItems(1000, 20); /** Unique authors from fixture (for seeding and updateAuthor scenarios) */ export const FIXTURE_AUTHORS = generateAuthors(20); +/** O(1) item lookup by id (avoids linear scans inside measurement regions) */ +export const FIXTURE_ITEMS_BY_ID = new Map(FIXTURE_ITEMS.map(i => [i.id, i])); + +/** O(1) author lookup by id */ +export const FIXTURE_AUTHORS_BY_ID = new Map( + FIXTURE_AUTHORS.map(a => [a.id, a]), +); + /** * Generate fresh items/authors with distinct IDs for bulk ingestion scenarios. * Uses `fresh-` prefix so these don't collide with pre-seeded FIXTURE data. diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 148d5cbf6bc8..8afccdcccab9 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,18 +1,15 @@ +import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { ItemRow } from '@shared/components'; import { FIXTURE_AUTHORS, + FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, + FIXTURE_ITEMS_BY_ID, generateFreshData, } from '@shared/data'; -import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import { registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; @@ -69,214 +66,94 @@ function SortedListView() { } function BenchmarkHarness() { - const [ids, setIds] = useState([]); - const [showSortedView, setShowSortedView] = useState(false); - const containerRef = useRef(null); - const completeResolveRef = useRef<(() => void) | null>(null); const { mutate } = useSWRConfig(); - - const setComplete = useCallback(() => { - completeResolveRef.current?.(); - completeResolveRef.current = null; - containerRef.current?.setAttribute('data-bench-complete', 'true'); - }, []); - - const mount = useCallback( - (n: number) => { - performance.mark('mount-start'); - setIds(FIXTURE_ITEMS.slice(0, n).map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); - }); - }, - [setComplete], - ); + const { + ids, + showSortedView, + containerRef, + measureMount, + measureUpdate, + measureUpdateWithDelay, + setIds, + setShowSortedView, + registerAPI, + } = useBenchState(); const updateEntity = useCallback( (id: string) => { - performance.mark('update-start'); - const item = FIXTURE_ITEMS.find(i => i.id === id); - if (item) { + const item = FIXTURE_ITEMS_BY_ID.get(id); + if (!item) return; + measureUpdate(() => { void mutate(`item:${id}`, { ...item, label: `${item.label} (updated)`, }); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); - }); }); }, - [mutate, setComplete], + [measureUpdate, mutate], ); const updateAuthor = useCallback( (authorId: string, options?: UpdateAuthorOptions) => { - performance.mark('update-start'); - const delayMs = options?.simulateNetworkDelayMs ?? 0; - const requestCount = options?.simulatedRequestCount ?? 1; - const totalDelayMs = delayMs * requestCount; - - const doUpdate = () => { - const author = FIXTURE_AUTHORS.find(a => a.id === authorId); - if (author) { - const newAuthor = { - ...author, - name: `${author.name} (updated)`, - }; - void mutate(`author:${authorId}`, newAuthor); - for (const item of FIXTURE_ITEMS) { - if (item.author.id === authorId) { - void mutate(`item:${item.id}`, (prev: Item | undefined) => - prev ? { ...prev, author: newAuthor } : prev, - ); - } + const author = FIXTURE_AUTHORS_BY_ID.get(authorId); + if (!author) return; + const newAuthor = { ...author, name: `${author.name} (updated)` }; + measureUpdateWithDelay(options, () => { + void mutate(`author:${authorId}`, newAuthor); + for (const item of FIXTURE_ITEMS) { + if (item.author.id === authorId) { + void mutate(`item:${item.id}`, (prev: Item | undefined) => + prev ? { ...prev, author: newAuthor } : prev, + ); } } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure( - 'update-duration', - 'update-start', - 'update-end', - ); - setComplete(); - }); - }); - }; - - if (totalDelayMs > 0) { - setTimeout(doUpdate, totalDelayMs); - } else { - doUpdate(); - } + }); }, - [mutate, setComplete], + [measureUpdateWithDelay, mutate], ); - const unmountAll = useCallback(() => { - setIds([]); - }, []); - const bulkIngest = useCallback( (n: number) => { - performance.mark('mount-start'); const { items, authors } = generateFreshData(n); - for (const author of authors) { - cache.set(`author:${author.id}`, { - data: author, - isLoading: false, - isValidating: false, - error: undefined, - }); - } - for (const item of items) { - cache.set(`item:${item.id}`, { - data: item, - isLoading: false, - isValidating: false, - error: undefined, - }); - } - setIds(items.map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + measureMount(() => { + for (const author of authors) { + cache.set(`author:${author.id}`, { + data: author, + isLoading: false, + isValidating: false, + error: undefined, + }); + } + for (const item of items) { + cache.set(`item:${item.id}`, { + data: item, + isLoading: false, + isValidating: false, + error: undefined, + }); + } + setIds(items.map(i => i.id)); }); }, - [setComplete], + [measureMount, setIds], ); const mountSortedView = useCallback( (n: number) => { - performance.mark('mount-start'); - cache.set('items:all', { - data: FIXTURE_ITEMS.slice(0, n), - isLoading: false, - isValidating: false, - error: undefined, - }); - setShowSortedView(true); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); + measureMount(() => { + cache.set('items:all', { + data: FIXTURE_ITEMS.slice(0, n), + isLoading: false, + isValidating: false, + error: undefined, }); + setShowSortedView(true); }); }, - [setComplete], + [measureMount, setShowSortedView], ); - const mountUnmountCycle = useCallback( - async (n: number, cycles: number) => { - for (let i = 0; i < cycles; i++) { - const p = new Promise(r => { - completeResolveRef.current = r; - }); - mount(n); - await p; - unmountAll(); - await new Promise(r => - requestAnimationFrame(() => requestAnimationFrame(() => r())), - ); - } - setComplete(); - }, - [mount, unmountAll, setComplete], - ); - - const getRenderedCount = useCallback(() => ids.length, [ids]); - - const captureRefSnapshot = useCallback(() => { - captureSnapshot(); - }, []); - - const getRefStabilityReport = useCallback(() => getReport(), []); - - useEffect(() => { - window.__BENCH__ = { - mount, - updateEntity, - updateAuthor, - unmountAll, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - mountUnmountCycle, - bulkIngest, - mountSortedView, - }; - return () => { - delete window.__BENCH__; - }; - }, [ - mount, - updateEntity, - updateAuthor, - unmountAll, - mountUnmountCycle, - bulkIngest, - mountSortedView, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - ]); - - useEffect(() => { - document.body.setAttribute('data-app-ready', 'true'); - }, []); + registerAPI({ updateEntity, updateAuthor, bulkIngest, mountSortedView }); return (
@@ -290,20 +167,17 @@ function BenchmarkHarness() { ); } -function onProfilerRender( - _id: string, - phase: 'mount' | 'update' | 'nested-update', - actualDuration: number, -) { - performance.measure(`react-commit-${phase}`, { - start: performance.now() - actualDuration, - duration: actualDuration, - }); -} - const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( - cache as any }}> + cache as any, + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: false, + }} + > diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 6a6f1a834a02..646a2da30b2a 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,10 +1,13 @@ +import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { ItemRow } from '@shared/components'; import { FIXTURE_AUTHORS, + FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, + FIXTURE_ITEMS_BY_ID, generateFreshData, } from '@shared/data'; -import { captureSnapshot, getReport, registerRefs } from '@shared/refStability'; +import { registerRefs } from '@shared/refStability'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import { QueryClient, @@ -12,13 +15,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; function seedCache(queryClient: QueryClient) { @@ -44,9 +41,9 @@ seedCache(queryClient); function ItemView({ id }: { id: string }) { const { data: item } = useQuery({ queryKey: ['item', id], - queryFn: () => FIXTURE_ITEMS.find(i => i.id === id) as Item, + queryFn: () => FIXTURE_ITEMS_BY_ID.get(id) as Item, initialData: () => - (FIXTURE_ITEMS.find(i => i.id === id) ?? + (FIXTURE_ITEMS_BY_ID.get(id) ?? queryClient.getQueryData(['item', id])) as Item, }); if (!item) return null; @@ -77,199 +74,79 @@ function SortedListView() { } function BenchmarkHarness() { - const [ids, setIds] = useState([]); - const [showSortedView, setShowSortedView] = useState(false); - const containerRef = useRef(null); - const completeResolveRef = useRef<(() => void) | null>(null); const client = useQueryClient(); - - const setComplete = useCallback(() => { - completeResolveRef.current?.(); - completeResolveRef.current = null; - containerRef.current?.setAttribute('data-bench-complete', 'true'); - }, []); - - const mount = useCallback( - (n: number) => { - performance.mark('mount-start'); - setIds(FIXTURE_ITEMS.slice(0, n).map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); - }); - }, - [setComplete], - ); + const { + ids, + showSortedView, + containerRef, + measureMount, + measureUpdate, + measureUpdateWithDelay, + setIds, + setShowSortedView, + registerAPI, + } = useBenchState(); const updateEntity = useCallback( (id: string) => { - performance.mark('update-start'); - const item = FIXTURE_ITEMS.find(i => i.id === id); - if (item) { + const item = FIXTURE_ITEMS_BY_ID.get(id); + if (!item) return; + measureUpdate(() => { client.setQueryData(['item', id], { ...item, label: `${item.label} (updated)`, }); - } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); - }); }); }, - [client, setComplete], + [measureUpdate, client], ); const updateAuthor = useCallback( (authorId: string, options?: UpdateAuthorOptions) => { - performance.mark('update-start'); - const delayMs = options?.simulateNetworkDelayMs ?? 0; - const requestCount = options?.simulatedRequestCount ?? 1; - const totalDelayMs = delayMs * requestCount; - - const doUpdate = () => { - const author = FIXTURE_AUTHORS.find(a => a.id === authorId); - if (author) { - const newAuthor = { - ...author, - name: `${author.name} (updated)`, - }; - client.setQueryData(['author', authorId], newAuthor); - for (const item of FIXTURE_ITEMS) { - if (item.author.id === authorId) { - client.setQueryData(['item', item.id], (old: Item | undefined) => - old ? { ...old, author: newAuthor } : old, - ); - } + const author = FIXTURE_AUTHORS_BY_ID.get(authorId); + if (!author) return; + const newAuthor = { ...author, name: `${author.name} (updated)` }; + measureUpdateWithDelay(options, () => { + client.setQueryData(['author', authorId], newAuthor); + for (const item of FIXTURE_ITEMS) { + if (item.author.id === authorId) { + client.setQueryData(['item', item.id], (old: Item | undefined) => + old ? { ...old, author: newAuthor } : old, + ); } } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('update-end'); - performance.measure( - 'update-duration', - 'update-start', - 'update-end', - ); - setComplete(); - }); - }); - }; - - if (totalDelayMs > 0) { - setTimeout(doUpdate, totalDelayMs); - } else { - doUpdate(); - } + }); }, - [client, setComplete], + [measureUpdateWithDelay, client], ); - const unmountAll = useCallback(() => { - setIds([]); - }, []); - const bulkIngest = useCallback( (n: number) => { - performance.mark('mount-start'); const { items, authors } = generateFreshData(n); - for (const author of authors) { - client.setQueryData(['author', author.id], author); - } - for (const item of items) { - client.setQueryData(['item', item.id], item); - } - setIds(items.map(i => i.id)); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + measureMount(() => { + for (const author of authors) { + client.setQueryData(['author', author.id], author); + } + for (const item of items) { + client.setQueryData(['item', item.id], item); + } + setIds(items.map(i => i.id)); }); }, - [client, setComplete], + [measureMount, setIds, client], ); const mountSortedView = useCallback( (n: number) => { - performance.mark('mount-start'); - client.setQueryData(['items', 'all'], FIXTURE_ITEMS.slice(0, n)); - setShowSortedView(true); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); + measureMount(() => { + client.setQueryData(['items', 'all'], FIXTURE_ITEMS.slice(0, n)); + setShowSortedView(true); }); }, - [client, setComplete], - ); - - const mountUnmountCycle = useCallback( - async (n: number, cycles: number) => { - for (let i = 0; i < cycles; i++) { - const p = new Promise(r => { - completeResolveRef.current = r; - }); - mount(n); - await p; - unmountAll(); - await new Promise(r => - requestAnimationFrame(() => requestAnimationFrame(() => r())), - ); - } - setComplete(); - }, - [mount, unmountAll, setComplete], + [measureMount, setShowSortedView, client], ); - const getRenderedCount = useCallback(() => ids.length, [ids]); - - const captureRefSnapshot = useCallback(() => { - captureSnapshot(); - }, []); - - const getRefStabilityReport = useCallback(() => getReport(), []); - - useEffect(() => { - window.__BENCH__ = { - mount, - updateEntity, - updateAuthor, - unmountAll, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - mountUnmountCycle, - bulkIngest, - mountSortedView, - }; - return () => { - delete window.__BENCH__; - }; - }, [ - mount, - updateEntity, - updateAuthor, - unmountAll, - mountUnmountCycle, - bulkIngest, - mountSortedView, - getRenderedCount, - captureRefSnapshot, - getRefStabilityReport, - ]); - - useEffect(() => { - document.body.setAttribute('data-app-ready', 'true'); - }, []); + registerAPI({ updateEntity, updateAuthor, bulkIngest, mountSortedView }); return (
@@ -283,17 +160,6 @@ function BenchmarkHarness() { ); } -function onProfilerRender( - _id: string, - phase: 'mount' | 'update' | 'nested-update', - actualDuration: number, -) { - performance.measure(`react-commit-${phase}`, { - start: performance.now() - actualDuration, - duration: actualDuration, - }); -} - const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( From 830199ecf96c514c638b1eaa281a6284be35b74b Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Thu, 12 Mar 2026 22:11:32 -0400 Subject: [PATCH 17/46] CRUD --- examples/benchmark-react/bench/runner.ts | 8 +- examples/benchmark-react/bench/scenarios.ts | 14 ++ .../benchmark-react/src/baseline/index.tsx | 96 +++++++--- .../benchmark-react/src/data-client/index.tsx | 66 +++++-- .../src/data-client/resources.ts | 116 +++++++++---- examples/benchmark-react/src/shared/data.ts | 9 + examples/benchmark-react/src/shared/server.ts | 134 ++++++++++++++ examples/benchmark-react/src/shared/types.ts | 8 +- examples/benchmark-react/src/swr/index.tsx | 164 +++++++++++------- .../src/tanstack-query/index.tsx | 128 ++++++++++---- 10 files changed, 557 insertions(+), 186 deletions(-) create mode 100644 examples/benchmark-react/src/shared/server.ts diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index aa01f884c7ab..034dd9d3bb0b 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -103,7 +103,9 @@ async function runScenario( scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || scenario.action === 'optimisticUpdate' || - scenario.action === 'invalidateAndResolve'; + scenario.action === 'invalidateAndResolve' || + scenario.action === 'createEntity' || + scenario.action === 'deleteEntity'; const isRefStability = isRefStabilityScenario(scenario); const isBulkIngest = scenario.action === 'bulkIngest'; @@ -334,7 +336,9 @@ async function main() { scenario.action === 'optimisticUpdate' || scenario.action === 'bulkIngest' || scenario.action === 'mountSortedView' || - scenario.action === 'invalidateAndResolve') + scenario.action === 'invalidateAndResolve' || + scenario.action === 'createEntity' || + scenario.action === 'deleteEntity') ) { const { median: rcMedian, range: rcRange } = computeStats( reactSamples, diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 0df11d0edfc0..c5649146243f 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -152,6 +152,20 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'hotPath', onlyLibs: ['data-client'], }, + { + nameSuffix: 'create-item', + action: 'createEntity', + args: [], + category: 'hotPath', + mountCount: 100, + }, + { + nameSuffix: 'delete-item', + action: 'deleteEntity', + args: ['item-0'], + category: 'hotPath', + mountCount: 100, + }, ]; export const LIBRARIES = [ diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 7c79a8f1ee26..733a5dad1c85 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -5,12 +5,24 @@ import { } from '@shared/benchHarness'; import { ItemRow } from '@shared/components'; import { + FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, + FIXTURE_ITEMS_BY_ID, generateFreshData, + sortByLabel, } from '@shared/data'; import { registerRefs } from '@shared/refStability'; -import type { Item, UpdateAuthorOptions } from '@shared/types'; +import { + fetchItemList, + createItem, + updateItem, + updateAuthor as serverUpdateAuthor, + deleteItem, + seedBulkItems, + seedItemList, +} from '@shared/server'; +import type { Author, Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useContext, useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; @@ -29,10 +41,7 @@ function ItemView({ id }: { id: string }) { function SortedListView() { const { items } = useContext(ItemsContext); - const sorted = useMemo( - () => [...items].sort((a, b) => a.label.localeCompare(b.label)), - [items], - ); + const sorted = useMemo(() => sortByLabel(items), [items]); return (
{sorted.map(item => ( @@ -61,11 +70,12 @@ function BenchmarkHarness() { const mount = useCallback( (n: number) => { - const sliced = FIXTURE_ITEMS.slice(0, n); - const slicedIds = sliced.map(i => i.id); + seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - setItems(sliced); - setIds(slicedIds); + fetchItemList().then(fetched => { + setItems(fetched); + setIds(fetched.map(i => i.id)); + }); }); }, [measureMount, setIds], @@ -73,14 +83,12 @@ function BenchmarkHarness() { const updateEntity = useCallback( (id: string) => { + const item = FIXTURE_ITEMS_BY_ID.get(id); + if (!item) return; measureUpdate(() => { - setItems(prev => - prev.map(item => - item.id === id ? - { ...item, label: `${item.label} (updated)` } - : item, - ), - ); + updateItem({ id, label: `${item.label} (updated)` }).then(parsed => { + setItems(prev => prev.map(i => (i.id === id ? parsed : i))); + }); }); }, [measureUpdate], @@ -90,18 +98,44 @@ function BenchmarkHarness() { (authorId: string, options?: UpdateAuthorOptions) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - const newAuthor = { ...author, name: `${author.name} (updated)` }; measureUpdateWithDelay(options, () => { - setItems(prev => - prev.map(item => - item.author.id === authorId ? { ...item, author: newAuthor } : item, - ), - ); + serverUpdateAuthor({ + id: authorId, + name: `${author.name} (updated)`, + }).then(parsed => { + setItems(prev => + prev.map(item => + item.author.id === authorId ? { ...item, author: parsed } : item, + ), + ); + }); }); }, [measureUpdateWithDelay], ); + const createEntity = useCallback(() => { + const author = FIXTURE_AUTHORS[0]; + measureUpdate(() => { + createItem({ label: 'New Item', author }).then(created => { + setItems(prev => [...prev, created]); + setIds(prev => [...prev, created.id]); + }); + }); + }, [measureUpdate, setIds]); + + const deleteEntity = useCallback( + (id: string) => { + measureUpdate(() => { + deleteItem({ id }).then(() => { + setItems(prev => prev.filter(i => i.id !== id)); + setIds(prev => prev.filter(i => i !== id)); + }); + }); + }, + [measureUpdate, setIds], + ); + const unmountAll = useCallback(() => { unmountBase(); setItems([]); @@ -110,10 +144,12 @@ function BenchmarkHarness() { const bulkIngest = useCallback( (n: number) => { const { items: freshItems } = generateFreshData(n); - const freshIds = freshItems.map(i => i.id); + seedBulkItems(freshItems); measureMount(() => { - setItems(freshItems); - setIds(freshIds); + fetchItemList().then(fetched => { + setItems(fetched); + setIds(fetched.map(i => i.id)); + }); }); }, [measureMount, setIds], @@ -121,10 +157,12 @@ function BenchmarkHarness() { const mountSortedView = useCallback( (n: number) => { - const sliced = FIXTURE_ITEMS.slice(0, n); + seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - setItems(sliced); - setShowSortedView(true); + fetchItemList().then(fetched => { + setItems(fetched); + setShowSortedView(true); + }); }); }, [measureMount, setShowSortedView], @@ -154,6 +192,8 @@ function BenchmarkHarness() { mountUnmountCycle, bulkIngest, mountSortedView, + createEntity, + deleteEntity, }); return ( diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index de625880e84c..a76dc3a787b3 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -8,23 +8,27 @@ import { mockInitialState } from '@data-client/react/mock'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { ItemRow } from '@shared/components'; import { + FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, generateFreshData, } from '@shared/data'; import { registerRefs } from '@shared/refStability'; +import { seedBulkItems } from '@shared/server'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { - AuthorEntity, + createItemEndpoint, + deleteItemEndpoint, getAuthor, getItem, getItemList, - ItemEntity, sortedItemsQuery, + updateAuthorEndpoint, + updateItemEndpoint, updateItemOptimistic, } from './resources'; @@ -85,6 +89,7 @@ function BenchmarkHarness() { measureMount, measureUpdate, measureUpdateWithDelay, + setIds, setShowSortedView, setSortedViewCount, unmountAll: unmountBase, @@ -97,11 +102,10 @@ function BenchmarkHarness() { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - controller.set( - ItemEntity, - { id }, - { ...item, label: `${item.label} (updated)` }, - ); + controller.fetch(updateItemEndpoint, { + id, + label: `${item.label} (updated)`, + }); }); }, [measureUpdate, controller], @@ -112,16 +116,40 @@ function BenchmarkHarness() { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; measureUpdateWithDelay(options, () => { - controller.set( - AuthorEntity, - { id: authorId }, - { ...author, name: `${author.name} (updated)` }, - ); + controller.fetch(updateAuthorEndpoint, { + id: authorId, + name: `${author.name} (updated)`, + }); }); }, [measureUpdateWithDelay, controller], ); + const createEntity = useCallback(() => { + const author = FIXTURE_AUTHORS[0]; + measureUpdate(() => { + controller + .fetch(createItemEndpoint, { + label: 'New Item', + author, + }) + .then((created: any) => { + setIds(prev => [...prev, created.id]); + }); + }); + }, [measureUpdate, controller, setIds]); + + const deleteEntity = useCallback( + (id: string) => { + measureUpdate(() => { + controller.fetch(deleteItemEndpoint, { id }).then(() => { + setIds(prev => prev.filter(i => i !== id)); + }); + }); + }, + [measureUpdate, controller, setIds], + ); + const unmountAll = useCallback(() => { unmountBase(); setShowListView(false); @@ -141,9 +169,11 @@ function BenchmarkHarness() { const bulkIngest = useCallback( (n: number) => { const { items } = generateFreshData(n); + seedBulkItems(items); measureMount(() => { - controller.setResponse(getItemList, items); - setShowListView(true); + controller.fetch(getItemList).then(() => { + setShowListView(true); + }); }); }, [measureMount, controller], @@ -165,11 +195,7 @@ function BenchmarkHarness() { if (!item) return; measureUpdate(() => { controller.invalidate(getItem, { id }); - controller.setResponse( - getItem, - { id }, - { ...item, label: `${item.label} (resolved)` }, - ); + controller.fetch(getItem, { id }); }); }, [measureUpdate, controller], @@ -183,6 +209,8 @@ function BenchmarkHarness() { bulkIngest, mountSortedView, invalidateAndResolve, + createEntity, + deleteEntity, }); return ( diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts index 7c878aa502f2..a8f448f57fbc 100644 --- a/examples/benchmark-react/src/data-client/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -1,4 +1,22 @@ -import { Entity, Endpoint, All, Query } from '@data-client/endpoint'; +import { + Entity, + Endpoint, + All, + Query, + Invalidate, +} from '@data-client/endpoint'; +import { sortByLabel } from '@shared/data'; +import { + fetchItem as serverFetchItem, + fetchAuthor as serverFetchAuthor, + fetchItemList as serverFetchItemList, + createItem as serverCreateItem, + updateItem as serverUpdateItem, + deleteItem as serverDeleteItem, + createAuthor as serverCreateAuthor, + updateAuthor as serverUpdateAuthor, + deleteAuthor as serverDeleteAuthor, +} from '@shared/server'; export class AuthorEntity extends Entity { id = ''; @@ -25,44 +43,74 @@ export class ItemEntity extends Entity { static schema = { author: AuthorEntity }; } -export const getAuthor = new Endpoint( - ({ id: _id }: { id: string }) => - Promise.reject(new Error('Not implemented - use fixtures')), - { - schema: AuthorEntity, - key: ({ id }: { id: string }) => `author:${id}`, - dataExpiryLength: Infinity, - }, -); +// ── READ ──────────────────────────────────────────────────────────────── -export const getItem = new Endpoint( - ({ id: _id }: { id: string }) => - Promise.reject(new Error('Not implemented - use fixtures')), - { - schema: ItemEntity, - key: ({ id }: { id: string }) => `item:${id}`, - dataExpiryLength: Infinity, - }, -); +export const getAuthor = new Endpoint(serverFetchAuthor, { + schema: AuthorEntity, + key: ({ id }: { id: string }) => `author:${id}`, + dataExpiryLength: Infinity, +}); -export const getItemList = new Endpoint( - () => Promise.reject(new Error('Not implemented - use fixtures')), - { - schema: [ItemEntity], - key: () => 'item:list', - dataExpiryLength: Infinity, - }, -); +export const getItem = new Endpoint(serverFetchItem, { + schema: ItemEntity, + key: ({ id }: { id: string }) => `item:${id}`, + dataExpiryLength: Infinity, +}); + +export const getItemList = new Endpoint(serverFetchItemList, { + schema: [ItemEntity], + key: () => 'item:list', + dataExpiryLength: Infinity, +}); + +// ── CREATE ────────────────────────────────────────────────────────────── + +export const createItemEndpoint = new Endpoint(serverCreateItem, { + schema: ItemEntity, + sideEffect: true, + key: () => 'item:create', +}); + +export const createAuthorEndpoint = new Endpoint(serverCreateAuthor, { + schema: AuthorEntity, + sideEffect: true, + key: () => 'author:create', +}); + +// ── UPDATE ────────────────────────────────────────────────────────────── + +export const updateItemEndpoint = new Endpoint(serverUpdateItem, { + schema: ItemEntity, + sideEffect: true, + key: ({ id }: { id: string }) => `item-update:${id}`, +}); + +export const updateAuthorEndpoint = new Endpoint(serverUpdateAuthor, { + schema: AuthorEntity, + sideEffect: true, + key: ({ id }: { id: string }) => `author-update:${id}`, +}); + +// ── DELETE ─────────────────────────────────────────────────────────────── + +export const deleteItemEndpoint = new Endpoint(serverDeleteItem, { + schema: new Invalidate(ItemEntity), + sideEffect: true, + key: ({ id }: { id: string }) => `item-delete:${id}`, +}); + +export const deleteAuthorEndpoint = new Endpoint(serverDeleteAuthor, { + schema: new Invalidate(AuthorEntity), + sideEffect: true, + key: ({ id }: { id: string }) => `author-delete:${id}`, +}); + +// ── DERIVED QUERIES ───────────────────────────────────────────────────── /** Derived sorted view via Query schema -- globally memoized by MemoCache */ export const sortedItemsQuery = new Query( new All(ItemEntity), - (entries: any[], { limit }: { limit?: number } = {}) => { - const sorted = [...entries].sort((a: any, b: any) => - a.label.localeCompare(b.label), - ); - return limit ? sorted.slice(0, limit) : sorted; - }, + (entries, { limit }: { limit?: number } = {}) => sortByLabel(entries, limit), ); // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -74,7 +122,7 @@ export const updateItemOptimistic = new Endpoint( { schema: ItemEntity, sideEffect: true, - key: ({ id }: { id: string; label: string }) => `item-update:${id}`, + key: ({ id }: { id: string; label: string }) => `item-optimistic:${id}`, getOptimisticResponse(snap: any, params: { id: string; label: string }) { const existing = snap.get(ItemEntity, { id: params.id }); if (!existing) throw snap.abort; diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index d06ae41e6a25..34131b8aa92a 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -1,5 +1,14 @@ import type { Author, Item } from './types'; +/** Sort items by label, optionally limiting to the first `limit` results. */ +export function sortByLabel( + items: T[], + limit?: number, +): T[] { + const sorted = [...items].sort((a, b) => a.label.localeCompare(b.label)); + return limit ? sorted.slice(0, limit) : sorted; +} + /** * Generate authors - shared across items to stress normalization. * Fewer authors than items means many items share the same author reference. diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts new file mode 100644 index 000000000000..306e2cca8a76 --- /dev/null +++ b/examples/benchmark-react/src/shared/server.ts @@ -0,0 +1,134 @@ +import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from './data'; +import type { Author, Item } from './types'; + +/** Fake server: holds JSON response strings keyed by resource type + id */ +export const jsonStore = new Map(); + +// Pre-seed with fixture data +for (const item of FIXTURE_ITEMS) { + jsonStore.set(`item:${item.id}`, JSON.stringify(item)); +} +for (const author of FIXTURE_AUTHORS) { + jsonStore.set(`author:${author.id}`, JSON.stringify(author)); +} +jsonStore.set('item:list', JSON.stringify(FIXTURE_ITEMS)); + +// ── READ ──────────────────────────────────────────────────────────────── + +export function fetchItem({ id }: { id: string }): Promise { + const json = jsonStore.get(`item:${id}`); + if (!json) return Promise.reject(new Error(`No data for item:${id}`)); + return Promise.resolve(JSON.parse(json)); +} + +export function fetchAuthor({ id }: { id: string }): Promise { + const json = jsonStore.get(`author:${id}`); + if (!json) return Promise.reject(new Error(`No data for author:${id}`)); + return Promise.resolve(JSON.parse(json)); +} + +export function fetchItemList(): Promise { + const json = jsonStore.get('item:list'); + if (!json) return Promise.reject(new Error('No data for item:list')); + return Promise.resolve(JSON.parse(json)); +} + +// ── CREATE ────────────────────────────────────────────────────────────── + +let createItemCounter = 0; + +export function createItem(body: { + label: string; + author: Author; +}): Promise { + const id = `created-item-${createItemCounter++}`; + const item: Item = { id, label: body.label, author: body.author }; + const json = JSON.stringify(item); + jsonStore.set(`item:${id}`, json); + return Promise.resolve(JSON.parse(json)); +} + +let createAuthorCounter = 0; + +export function createAuthor(body: { + login: string; + name: string; +}): Promise { + const id = `created-author-${createAuthorCounter++}`; + const author: Author = { id, login: body.login, name: body.name }; + const json = JSON.stringify(author); + jsonStore.set(`author:${id}`, json); + return Promise.resolve(JSON.parse(json)); +} + +// ── UPDATE ────────────────────────────────────────────────────────────── + +export function updateItem(params: { + id: string; + label?: string; + author?: Author; +}): Promise { + const existing = jsonStore.get(`item:${params.id}`); + if (!existing) + return Promise.reject(new Error(`No data for item:${params.id}`)); + const updated: Item = { ...JSON.parse(existing), ...params }; + const json = JSON.stringify(updated); + jsonStore.set(`item:${params.id}`, json); + return Promise.resolve(JSON.parse(json)); +} + +/** + * Updates the author and all items that embed it (simulates a DB join -- + * subsequent GET /items/:id would return the fresh author data). + */ +export function updateAuthor(params: { + id: string; + login?: string; + name?: string; +}): Promise { + const existing = jsonStore.get(`author:${params.id}`); + if (!existing) + return Promise.reject(new Error(`No data for author:${params.id}`)); + const updated: Author = { ...JSON.parse(existing), ...params }; + const json = JSON.stringify(updated); + jsonStore.set(`author:${params.id}`, json); + + // Propagate to all items embedding this author + for (const [key, itemJson] of jsonStore) { + if (!key.startsWith('item:') || key === 'item:list') continue; + const item: Item = JSON.parse(itemJson); + if (item.author?.id === params.id) { + jsonStore.set(key, JSON.stringify({ ...item, author: updated })); + } + } + + return Promise.resolve(JSON.parse(json)); +} + +// ── DELETE ─────────────────────────────────────────────────────────────── + +export function deleteItem({ id }: { id: string }): Promise<{ id: string }> { + jsonStore.delete(`item:${id}`); + return Promise.resolve({ id }); +} + +export function deleteAuthor({ id }: { id: string }): Promise<{ id: string }> { + jsonStore.delete(`author:${id}`); + return Promise.resolve({ id }); +} + +// ── BULK SEEDING ──────────────────────────────────────────────────────── + +/** Seed bulk items into the store (for bulkIngest scenario). */ +export function seedBulkItems(items: Item[]): void { + jsonStore.set('item:list', JSON.stringify(items)); + for (const item of items) { + jsonStore.set(`item:${item.id}`, JSON.stringify(item)); + jsonStore.set(`author:${item.author.id}`, JSON.stringify(item.author)); + } +} + +/** Seed a subset of fixture items for sorted view. */ +export function seedItemList(items: Item[]): void { + jsonStore.set('item:list', JSON.stringify(items)); +} diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index f5d3eb92f082..3a18e2672771 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -41,6 +41,10 @@ export interface BenchAPI { mountSortedView?(count: number): void; /** Invalidate a cached endpoint and immediately re-resolve. Measures Suspense round-trip. data-client only. */ invalidateAndResolve?(id: string): void; + /** Create a new item via mutation endpoint. */ + createEntity?(): void; + /** Delete an existing item via mutation endpoint. */ + deleteEntity?(id: string): void; } declare global { @@ -67,7 +71,9 @@ export type ScenarioAction = | { action: 'updateAuthor'; args: [string] } | { action: 'updateAuthor'; args: [string, UpdateAuthorOptions] } | { action: 'unmountAll'; args: [] } - | { action: 'bulkIngest'; args: [number] }; + | { action: 'bulkIngest'; args: [number] } + | { action: 'createEntity'; args: [] } + | { action: 'deleteEntity'; args: [string] }; export type ResultMetric = | 'duration' diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 8afccdcccab9..b1cfcccfff86 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -6,41 +6,52 @@ import { FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, generateFreshData, + sortByLabel, } from '@shared/data'; import { registerRefs } from '@shared/refStability'; +import { + fetchItem, + fetchAuthor, + fetchItemList, + createItem, + updateItem, + updateAuthor as serverUpdateAuthor, + deleteItem, + seedBulkItems, + seedItemList, +} from '@shared/server'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; -const fetcher = () => Promise.reject(new Error('Not implemented - use cache')); +/** SWR fetcher: dispatches to shared server functions based on cache key */ +const fetcher = (key: string): Promise => { + if (key.startsWith('item:')) return fetchItem({ id: key.slice(5) }); + if (key.startsWith('author:')) return fetchAuthor({ id: key.slice(7) }); + if (key === 'items:all') return fetchItemList(); + return Promise.reject(new Error(`Unknown key: ${key}`)); +}; -const cache = new Map< - string, - { data: unknown; isLoading: boolean; isValidating: boolean; error: undefined } ->(); -for (const author of FIXTURE_AUTHORS) { - cache.set(`author:${author.id}`, { - data: author, - isLoading: false, - isValidating: false, - error: undefined, - }); +type CacheEntry = { + data: unknown; + isLoading: boolean; + isValidating: boolean; + error: undefined; +}; + +function makeCacheEntry(data: unknown): CacheEntry { + return { data, isLoading: false, isValidating: false, error: undefined }; } + +const cache = new Map(); for (const item of FIXTURE_ITEMS) { - cache.set(`item:${item.id}`, { - data: item, - isLoading: false, - isValidating: false, - error: undefined, - }); + cache.set(`item:${item.id}`, makeCacheEntry(item)); } -cache.set('items:all', { - data: FIXTURE_ITEMS, - isLoading: false, - isValidating: false, - error: undefined, -}); +for (const author of FIXTURE_AUTHORS) { + cache.set(`author:${author.id}`, makeCacheEntry(author)); +} +cache.set('items:all', makeCacheEntry(FIXTURE_ITEMS)); function ItemView({ id }: { id: string }) { const { data: item } = useSWR(`item:${id}`, fetcher); @@ -51,11 +62,7 @@ function ItemView({ id }: { id: string }) { function SortedListView() { const { data: items } = useSWR('items:all', fetcher); - const sorted = useMemo( - () => - items ? [...items].sort((a, b) => a.label.localeCompare(b.label)) : [], - [items], - ); + const sorted = useMemo(() => (items ? sortByLabel(items) : []), [items]); return (
{sorted.map(item => ( @@ -84,9 +91,8 @@ function BenchmarkHarness() { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - void mutate(`item:${id}`, { - ...item, - label: `${item.label} (updated)`, + updateItem({ id, label: `${item.label} (updated)` }).then(data => { + void mutate(`item:${id}`, data, false); }); }); }, @@ -97,42 +103,65 @@ function BenchmarkHarness() { (authorId: string, options?: UpdateAuthorOptions) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - const newAuthor = { ...author, name: `${author.name} (updated)` }; measureUpdateWithDelay(options, () => { - void mutate(`author:${authorId}`, newAuthor); - for (const item of FIXTURE_ITEMS) { - if (item.author.id === authorId) { - void mutate(`item:${item.id}`, (prev: Item | undefined) => - prev ? { ...prev, author: newAuthor } : prev, - ); + serverUpdateAuthor({ + id: authorId, + name: `${author.name} (updated)`, + }).then(updatedAuthor => { + void mutate(`author:${authorId}`, updatedAuthor, false); + for (const item of FIXTURE_ITEMS) { + if (item.author.id === authorId) { + void mutate(`item:${item.id}`); + } } - } + }); }); }, [measureUpdateWithDelay, mutate], ); + const createEntity = useCallback(() => { + const author = FIXTURE_AUTHORS[0]; + measureUpdate(() => { + createItem({ label: 'New Item', author }).then(created => { + cache.set(`item:${created.id}`, makeCacheEntry(created)); + setIds(prev => [...prev, created.id]); + }); + }); + }, [measureUpdate, setIds]); + + const deleteEntity = useCallback( + (id: string) => { + measureUpdate(() => { + deleteItem({ id }).then(() => { + cache.delete(`item:${id}`); + setIds(prev => prev.filter(i => i !== id)); + }); + }); + }, + [measureUpdate, setIds], + ); + const bulkIngest = useCallback( (n: number) => { - const { items, authors } = generateFreshData(n); + const { items } = generateFreshData(n); + seedBulkItems(items); measureMount(() => { - for (const author of authors) { - cache.set(`author:${author.id}`, { - data: author, - isLoading: false, - isValidating: false, - error: undefined, - }); - } - for (const item of items) { - cache.set(`item:${item.id}`, { - data: item, - isLoading: false, - isValidating: false, - error: undefined, - }); - } - setIds(items.map(i => i.id)); + fetchItemList().then(parsed => { + const fetchedItems = parsed as Item[]; + const seenAuthors = new Set(); + for (const item of fetchedItems) { + cache.set(`item:${item.id}`, makeCacheEntry(item)); + if (!seenAuthors.has(item.author.id)) { + seenAuthors.add(item.author.id); + cache.set( + `author:${item.author.id}`, + makeCacheEntry(item.author), + ); + } + } + setIds(fetchedItems.map(i => i.id)); + }); }); }, [measureMount, setIds], @@ -140,20 +169,25 @@ function BenchmarkHarness() { const mountSortedView = useCallback( (n: number) => { + seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - cache.set('items:all', { - data: FIXTURE_ITEMS.slice(0, n), - isLoading: false, - isValidating: false, - error: undefined, + fetchItemList().then(parsed => { + cache.set('items:all', makeCacheEntry(parsed)); + setShowSortedView(true); }); - setShowSortedView(true); }); }, [measureMount, setShowSortedView], ); - registerAPI({ updateEntity, updateAuthor, bulkIngest, mountSortedView }); + registerAPI({ + updateEntity, + updateAuthor, + bulkIngest, + mountSortedView, + createEntity, + deleteEntity, + }); return (
diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 646a2da30b2a..f72fb13827dc 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -6,8 +6,20 @@ import { FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, generateFreshData, + sortByLabel, } from '@shared/data'; import { registerRefs } from '@shared/refStability'; +import { + fetchItem, + fetchAuthor, + fetchItemList, + createItem, + updateItem, + updateAuthor as serverUpdateAuthor, + deleteItem, + seedBulkItems, + seedItemList, +} from '@shared/server'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import { QueryClient, @@ -18,14 +30,22 @@ import { import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; -function seedCache(queryClient: QueryClient) { - for (const author of FIXTURE_AUTHORS) { - queryClient.setQueryData(['author', author.id], author); - } +function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { + const [type, id] = queryKey as string[]; + if (type === 'item' && id) return fetchItem({ id }); + if (type === 'author' && id) return fetchAuthor({ id }); + if (type === 'items') return fetchItemList(); + return Promise.reject(new Error(`Unknown queryKey: ${queryKey}`)); +} + +function seedCache(client: QueryClient) { for (const item of FIXTURE_ITEMS) { - queryClient.setQueryData(['item', item.id], item); + client.setQueryData(['item', item.id], item); } - queryClient.setQueryData(['items', 'all'], FIXTURE_ITEMS); + for (const author of FIXTURE_AUTHORS) { + client.setQueryData(['author', author.id], author); + } + client.setQueryData(['items', 'all'], FIXTURE_ITEMS); } const queryClient = new QueryClient({ @@ -41,10 +61,7 @@ seedCache(queryClient); function ItemView({ id }: { id: string }) { const { data: item } = useQuery({ queryKey: ['item', id], - queryFn: () => FIXTURE_ITEMS_BY_ID.get(id) as Item, - initialData: () => - (FIXTURE_ITEMS_BY_ID.get(id) ?? - queryClient.getQueryData(['item', id])) as Item, + queryFn, }); if (!item) return null; const itemAsItem = item as Item; @@ -55,13 +72,10 @@ function ItemView({ id }: { id: string }) { function SortedListView() { const { data: items } = useQuery({ queryKey: ['items', 'all'], - queryFn: () => FIXTURE_ITEMS, - initialData: () => - queryClient.getQueryData(['items', 'all']) ?? FIXTURE_ITEMS, + queryFn, }); const sorted = useMemo( - () => - items ? [...items].sort((a, b) => a.label.localeCompare(b.label)) : [], + () => (items ? sortByLabel(items as Item[]) : []), [items], ); return ( @@ -92,9 +106,8 @@ function BenchmarkHarness() { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - client.setQueryData(['item', id], { - ...item, - label: `${item.label} (updated)`, + updateItem({ id, label: `${item.label} (updated)` }).then(data => { + client.setQueryData(['item', id], data); }); }); }, @@ -105,32 +118,62 @@ function BenchmarkHarness() { (authorId: string, options?: UpdateAuthorOptions) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - const newAuthor = { ...author, name: `${author.name} (updated)` }; measureUpdateWithDelay(options, () => { - client.setQueryData(['author', authorId], newAuthor); - for (const item of FIXTURE_ITEMS) { - if (item.author.id === authorId) { - client.setQueryData(['item', item.id], (old: Item | undefined) => - old ? { ...old, author: newAuthor } : old, - ); + serverUpdateAuthor({ + id: authorId, + name: `${author.name} (updated)`, + }).then(updatedAuthor => { + client.setQueryData(['author', authorId], updatedAuthor); + for (const item of FIXTURE_ITEMS) { + if (item.author.id === authorId) { + client.refetchQueries({ queryKey: ['item', item.id] }); + } } - } + }); }); }, [measureUpdateWithDelay, client], ); + const createEntity = useCallback(() => { + const author = FIXTURE_AUTHORS[0]; + measureUpdate(() => { + createItem({ label: 'New Item', author }).then(created => { + client.setQueryData(['item', created.id], created); + setIds(prev => [...prev, created.id]); + }); + }); + }, [measureUpdate, client, setIds]); + + const deleteEntity = useCallback( + (id: string) => { + measureUpdate(() => { + deleteItem({ id }).then(() => { + client.removeQueries({ queryKey: ['item', id] }); + setIds(prev => prev.filter(i => i !== id)); + }); + }); + }, + [measureUpdate, client, setIds], + ); + const bulkIngest = useCallback( (n: number) => { - const { items, authors } = generateFreshData(n); + const { items } = generateFreshData(n); + seedBulkItems(items); measureMount(() => { - for (const author of authors) { - client.setQueryData(['author', author.id], author); - } - for (const item of items) { - client.setQueryData(['item', item.id], item); - } - setIds(items.map(i => i.id)); + fetchItemList().then(parsed => { + const fetchedItems = parsed as Item[]; + const seenAuthors = new Set(); + for (const item of fetchedItems) { + client.setQueryData(['item', item.id], item); + if (!seenAuthors.has(item.author.id)) { + seenAuthors.add(item.author.id); + client.setQueryData(['author', item.author.id], item.author); + } + } + setIds(fetchedItems.map(i => i.id)); + }); }); }, [measureMount, setIds, client], @@ -138,15 +181,26 @@ function BenchmarkHarness() { const mountSortedView = useCallback( (n: number) => { + seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - client.setQueryData(['items', 'all'], FIXTURE_ITEMS.slice(0, n)); - setShowSortedView(true); + client + .fetchQuery({ queryKey: ['items', 'all'], queryFn, staleTime: 0 }) + .then(() => { + setShowSortedView(true); + }); }); }, [measureMount, setShowSortedView, client], ); - registerAPI({ updateEntity, updateAuthor, bulkIngest, mountSortedView }); + registerAPI({ + updateEntity, + updateAuthor, + bulkIngest, + mountSortedView, + createEntity, + deleteEntity, + }); return (
From 60e8c10771414c5d6e14ab65ca53a8f49ddd52c4 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Thu, 12 Mar 2026 22:49:18 -0400 Subject: [PATCH 18/46] virtualize --- examples/benchmark-react/bench/scenarios.ts | 4 +- examples/benchmark-react/package.json | 1 + .../benchmark-react/src/baseline/index.tsx | 49 +++++++++--- .../benchmark-react/src/data-client/index.tsx | 74 +++++++++++++++---- .../benchmark-react/src/shared/components.tsx | 4 + examples/benchmark-react/src/shared/data.ts | 11 ++- examples/benchmark-react/src/swr/index.tsx | 49 +++++++++--- .../src/tanstack-query/index.tsx | 49 +++++++++--- yarn.lock | 11 +++ 9 files changed, 203 insertions(+), 49 deletions(-) diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index c5649146243f..f7671d844b03 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -139,11 +139,11 @@ const BASE_SCENARIOS: BaseScenario[] = [ preMountAction: 'mountSortedView', }, { - nameSuffix: 'update-shared-author-1000-mounted', + nameSuffix: 'update-shared-author-10000-mounted', action: 'updateAuthor', args: ['author-0'], category: 'hotPath', - mountCount: 1000, + mountCount: 10000, }, { nameSuffix: 'invalidate-and-resolve', diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index bc5383f2a708..976d6748d732 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query": "5.62.7", "react": "19.2.3", "react-dom": "19.2.3", + "react-window": "^2.2.7", "swr": "2.3.6" }, "devDependencies": { diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 733a5dad1c85..a236ebd74f84 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -3,7 +3,7 @@ import { useBenchState, waitForPaint, } from '@shared/benchHarness'; -import { ItemRow } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -25,6 +25,7 @@ import { import type { Author, Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useContext, useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import { List, type RowComponentProps } from 'react-window'; const ItemsContext = React.createContext<{ items: Item[]; @@ -39,14 +40,42 @@ function ItemView({ id }: { id: string }) { return ; } +function SortedRow({ + index, + style, + items, +}: RowComponentProps<{ items: Item[] }>) { + return ( +
+ +
+ ); +} + function SortedListView() { const { items } = useContext(ItemsContext); const sorted = useMemo(() => sortByLabel(items), [items]); return (
- {sorted.map(item => ( - - ))} + +
+ ); +} + +function ItemListRow({ + index, + style, + ids, +}: RowComponentProps<{ ids: string[] }>) { + return ( +
+
); } @@ -199,11 +228,13 @@ function BenchmarkHarness() { return (
-
- {ids.map(id => ( - - ))} -
+ {showSortedView && }
diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index a76dc3a787b3..4b77e952691a 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -6,7 +6,7 @@ import { } from '@data-client/react'; import { mockInitialState } from '@data-client/react/mock'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ItemRow } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -19,6 +19,7 @@ import { seedBulkItems } from '@shared/server'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import { List, type RowComponentProps } from 'react-window'; import { createItemEndpoint, @@ -53,16 +54,43 @@ function ItemView({ id }: { id: string }) { return ; } +function ListViewRow({ + index, + style, + items, +}: RowComponentProps<{ items: Item[] }>) { + return ( +
+ +
+ ); +} + /** Renders items from the list endpoint (models rendering a list fetch response). */ function ListView() { const items = useCache(getItemList); if (!items) return null; + const list = items as Item[]; return ( - <> - {(items as Item[]).map(item => ( - - ))} - + + ); +} + +function SortedRow({ + index, + style, + items, +}: RowComponentProps<{ items: Item[] }>) { + return ( +
+ +
); } @@ -72,9 +100,25 @@ function SortedListView({ count }: { count?: number }) { if (!items) return null; return (
- {items.map((item: any) => ( - - ))} + +
+ ); +} + +function ItemListRow({ + index, + style, + ids, +}: RowComponentProps<{ ids: string[] }>) { + return ( +
+
); } @@ -215,11 +259,13 @@ function BenchmarkHarness() { return (
-
- {ids.map(id => ( - - ))} -
+ {showListView && } {showSortedView && }
diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index b7d34f25b250..270e0ab635af 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -2,6 +2,10 @@ import React from 'react'; import type { Item } from './types'; +export const ITEM_HEIGHT = 30; +export const VISIBLE_COUNT = 20; +export const LIST_STYLE = { height: ITEM_HEIGHT * VISIBLE_COUNT } as const; + /** * Pure presentational component - no data-fetching logic. * Each library app wraps this with its own data-fetching hook. diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 34131b8aa92a..05147655aa0f 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -29,11 +29,10 @@ export function generateAuthors(count: number): Author[] { * Generate items with nested author entities (shared references). * Items cycle through authors so many items share the same author. */ -export function generateItems(count: number, authorCount = 10): Item[] { - const authors = generateAuthors(authorCount); +export function generateItems(count: number, authors: Author[]): Item[] { const items: Item[] = []; for (let i = 0; i < count; i++) { - const author = authors[i % authorCount]; + const author = authors[i % authors.length]; items.push({ id: `item-${i}`, label: `Item ${i}`, @@ -43,12 +42,12 @@ export function generateItems(count: number, authorCount = 10): Item[] { return items; } -/** Pre-generated fixture for benchmark - 1000 items, 20 shared authors */ -export const FIXTURE_ITEMS = generateItems(1000, 20); - /** Unique authors from fixture (for seeding and updateAuthor scenarios) */ export const FIXTURE_AUTHORS = generateAuthors(20); +/** Pre-generated fixture for benchmark - 10000 items, 20 shared authors */ +export const FIXTURE_ITEMS = generateItems(10000, FIXTURE_AUTHORS); + /** O(1) item lookup by id (avoids linear scans inside measurement regions) */ export const FIXTURE_ITEMS_BY_ID = new Map(FIXTURE_ITEMS.map(i => [i.id, i])); diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index b1cfcccfff86..a6c14136edee 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,5 +1,5 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ItemRow } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -23,6 +23,7 @@ import { import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; +import { List, type RowComponentProps } from 'react-window'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; /** SWR fetcher: dispatches to shared server functions based on cache key */ @@ -60,14 +61,42 @@ function ItemView({ id }: { id: string }) { return ; } +function SortedRow({ + index, + style, + items, +}: RowComponentProps<{ items: Item[] }>) { + return ( +
+ +
+ ); +} + function SortedListView() { const { data: items } = useSWR('items:all', fetcher); const sorted = useMemo(() => (items ? sortByLabel(items) : []), [items]); return (
- {sorted.map(item => ( - - ))} + +
+ ); +} + +function ItemListRow({ + index, + style, + ids, +}: RowComponentProps<{ ids: string[] }>) { + return ( +
+
); } @@ -191,11 +220,13 @@ function BenchmarkHarness() { return (
-
- {ids.map(id => ( - - ))} -
+ {showSortedView && }
); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index f72fb13827dc..ab72fa42a960 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,5 +1,5 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ItemRow } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -29,6 +29,7 @@ import { } from '@tanstack/react-query'; import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; +import { List, type RowComponentProps } from 'react-window'; function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { const [type, id] = queryKey as string[]; @@ -69,6 +70,18 @@ function ItemView({ id }: { id: string }) { return ; } +function SortedRow({ + index, + style, + items, +}: RowComponentProps<{ items: Item[] }>) { + return ( +
+ +
+ ); +} + function SortedListView() { const { data: items } = useQuery({ queryKey: ['items', 'all'], @@ -80,9 +93,25 @@ function SortedListView() { ); return (
- {sorted.map(item => ( - - ))} + +
+ ); +} + +function ItemListRow({ + index, + style, + ids, +}: RowComponentProps<{ ids: string[] }>) { + return ( +
+
); } @@ -204,11 +233,13 @@ function BenchmarkHarness() { return (
-
- {ids.map(id => ( - - ))} -
+ {showSortedView && }
); diff --git a/yarn.lock b/yarn.lock index 47a87ed36fa8..17b4c7474160 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14825,6 +14825,7 @@ __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.3.6" tsx: "npm:4.19.2" @@ -25242,6 +25243,16 @@ __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 58e4f855f99d5d71d7b05aef5cc11df7ba1941ac Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 13 Mar 2026 11:00:38 -0400 Subject: [PATCH 19/46] create adds to list; larger high end case --- examples/benchmark-react/bench/runner.ts | 39 ++++++++++++++++++- examples/benchmark-react/bench/scenarios.ts | 4 +- .../benchmark-react/src/baseline/index.tsx | 20 ++-------- .../benchmark-react/src/data-client/index.tsx | 32 ++------------- .../src/data-client/resources.ts | 5 ++- .../benchmark-react/src/shared/components.tsx | 14 +++++++ examples/benchmark-react/src/shared/server.ts | 5 +++ examples/benchmark-react/src/swr/index.tsx | 21 +++------- .../src/tanstack-query/index.tsx | 19 ++------- 9 files changed, 78 insertions(+), 81 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 034dd9d3bb0b..7877cb854002 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -253,7 +253,16 @@ async function main() { const browser = await chromium.launch({ headless: true }); const libraries = process.env.CI ? ['data-client'] : [...LIBRARIES]; + const scenarioCount = SCENARIOS_TO_RUN.length; for (let round = 0; round < TOTAL_RUNS; round++) { + const phase = round < WARMUP_RUNS ? 'warmup' : 'measure'; + const phaseRound = + round < WARMUP_RUNS ? round + 1 : round - WARMUP_RUNS + 1; + const phaseTotal = round < WARMUP_RUNS ? WARMUP_RUNS : MEASUREMENT_RUNS; + process.stderr.write( + `\n── Round ${round + 1}/${TOTAL_RUNS} (${phase} ${phaseRound}/${phaseTotal}) ──\n`, + ); + let scenarioDone = 0; for (const lib of shuffle(libraries)) { const context = await browser.newContext(); const page = await context.newPage(); @@ -265,9 +274,22 @@ async function main() { results[scenario.name].push(result.value); reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); traceResults[scenario.name].push(result.traceDuration ?? NaN); + scenarioDone++; + const unit = + scenario.resultMetric === 'heapDelta' ? 'bytes' + : ( + scenario.resultMetric === 'itemRefChanged' || + scenario.resultMetric === 'authorRefChanged' + ) ? + 'count' + : 'ms'; + process.stderr.write( + ` [${scenarioDone}/${scenarioCount}] ${scenario.name}: ${result.value.toFixed(2)} ${unit}${result.reactCommit != null ? ` (commit ${result.reactCommit.toFixed(2)} ms)` : ''}\n`, + ); } catch (err) { + scenarioDone++; console.error( - `Scenario ${scenario.name} failed:`, + ` [${scenarioDone}/${scenarioCount}] ${scenario.name} FAILED:`, err instanceof Error ? err.message : err, ); } @@ -285,6 +307,9 @@ async function main() { } 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(); @@ -292,9 +317,12 @@ async function main() { 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( - `Startup ${lib} failed:`, + ` ${lib} startup FAILED:`, err instanceof Error ? err.message : err, ); } @@ -397,6 +425,13 @@ async function main() { entry.name += BENCH_LABEL; } } + process.stderr.write(`\n── Results (${report.length} metrics) ──\n`); + for (const entry of report) { + process.stderr.write( + ` ${entry.name}: ${entry.value} ${entry.unit} ${entry.range}\n`, + ); + } + process.stderr.write('\n'); process.stdout.write(formatReport(report)); } diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index f7671d844b03..c77355e8523a 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -139,11 +139,11 @@ const BASE_SCENARIOS: BaseScenario[] = [ preMountAction: 'mountSortedView', }, { - nameSuffix: 'update-shared-author-10000-mounted', + nameSuffix: 'update-shared-author-2000-mounted', action: 'updateAuthor', args: ['author-0'], category: 'hotPath', - mountCount: 10000, + mountCount: 2000, }, { nameSuffix: 'invalidate-and-resolve', diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index a236ebd74f84..0c2b3e4e135f 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -3,7 +3,7 @@ import { useBenchState, waitForPaint, } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -40,18 +40,6 @@ function ItemView({ id }: { id: string }) { return ; } -function SortedRow({ - index, - style, - items, -}: RowComponentProps<{ items: Item[] }>) { - return ( -
- -
- ); -} - function SortedListView() { const { items } = useContext(ItemsContext); const sorted = useMemo(() => sortByLabel(items), [items]); @@ -61,7 +49,7 @@ function SortedListView() { style={LIST_STYLE} rowHeight={ITEM_HEIGHT} rowCount={sorted.length} - rowComponent={SortedRow} + rowComponent={ItemsRow} rowProps={{ items: sorted }} />
@@ -147,8 +135,8 @@ function BenchmarkHarness() { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { createItem({ label: 'New Item', author }).then(created => { - setItems(prev => [...prev, created]); - setIds(prev => [...prev, created.id]); + setItems(prev => [created, ...prev]); + setIds(prev => [created.id, ...prev]); }); }); }, [measureUpdate, setIds]); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 4b77e952691a..ee7bde4900be 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -6,7 +6,7 @@ import { } from '@data-client/react'; import { mockInitialState } from '@data-client/react/mock'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -54,18 +54,6 @@ function ItemView({ id }: { id: string }) { return ; } -function ListViewRow({ - index, - style, - items, -}: RowComponentProps<{ items: Item[] }>) { - return ( -
- -
- ); -} - /** Renders items from the list endpoint (models rendering a list fetch response). */ function ListView() { const items = useCache(getItemList); @@ -76,24 +64,12 @@ function ListView() { style={LIST_STYLE} rowHeight={ITEM_HEIGHT} rowCount={list.length} - rowComponent={ListViewRow} + rowComponent={ItemsRow} rowProps={{ items: list }} /> ); } -function SortedRow({ - index, - style, - items, -}: RowComponentProps<{ items: Item[] }>) { - return ( -
- -
- ); -} - /** Renders items sorted by label via Query schema (memoized by MemoCache). */ function SortedListView({ count }: { count?: number }) { const items = useQuery(sortedItemsQuery, { limit: count }); @@ -104,7 +80,7 @@ function SortedListView({ count }: { count?: number }) { style={LIST_STYLE} rowHeight={ITEM_HEIGHT} rowCount={items.length} - rowComponent={SortedRow} + rowComponent={ItemsRow} rowProps={{ items: items as Item[] }} />
@@ -178,7 +154,7 @@ function BenchmarkHarness() { author, }) .then((created: any) => { - setIds(prev => [...prev, created.id]); + setIds(prev => [created.id, ...prev]); }); }); }, [measureUpdate, controller, setIds]); diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts index a8f448f57fbc..5c7609360610 100644 --- a/examples/benchmark-react/src/data-client/resources.ts +++ b/examples/benchmark-react/src/data-client/resources.ts @@ -4,6 +4,7 @@ import { All, Query, Invalidate, + Collection, } from '@data-client/endpoint'; import { sortByLabel } from '@shared/data'; import { @@ -58,7 +59,7 @@ export const getItem = new Endpoint(serverFetchItem, { }); export const getItemList = new Endpoint(serverFetchItemList, { - schema: [ItemEntity], + schema: new Collection([ItemEntity]), key: () => 'item:list', dataExpiryLength: Infinity, }); @@ -66,7 +67,7 @@ export const getItemList = new Endpoint(serverFetchItemList, { // ── CREATE ────────────────────────────────────────────────────────────── export const createItemEndpoint = new Endpoint(serverCreateItem, { - schema: ItemEntity, + schema: getItemList.schema.unshift, sideEffect: true, key: () => 'item:create', }); diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 270e0ab635af..81467cc2b8bf 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { RowComponentProps } from 'react-window'; import type { Item } from './types'; @@ -18,3 +19,16 @@ export function ItemRow({ item }: { item: Item }) {
); } + +/** Generic react-window row that renders an ItemRow from an items array. */ +export function ItemsRow({ + index, + style, + items, +}: RowComponentProps<{ items: Item[] }>) { + return ( +
+ +
+ ); +} diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index 306e2cca8a76..8e3fda13406c 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -45,6 +45,11 @@ export function createItem(body: { const item: Item = { id, label: body.label, author: body.author }; const json = JSON.stringify(item); jsonStore.set(`item:${id}`, json); + // Prepend to item:list so refetching the list returns the new item first + const listJson = jsonStore.get('item:list'); + const list: Item[] = listJson ? JSON.parse(listJson) : []; + list.unshift(item); + jsonStore.set('item:list', JSON.stringify(list)); return Promise.resolve(JSON.parse(json)); } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index a6c14136edee..1dd0f56b99bc 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,5 +1,5 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -61,18 +61,6 @@ function ItemView({ id }: { id: string }) { return ; } -function SortedRow({ - index, - style, - items, -}: RowComponentProps<{ items: Item[] }>) { - return ( -
- -
- ); -} - function SortedListView() { const { data: items } = useSWR('items:all', fetcher); const sorted = useMemo(() => (items ? sortByLabel(items) : []), [items]); @@ -82,7 +70,7 @@ function SortedListView() { style={LIST_STYLE} rowHeight={ITEM_HEIGHT} rowCount={sorted.length} - rowComponent={SortedRow} + rowComponent={ItemsRow} rowProps={{ items: sorted }} />
@@ -154,10 +142,11 @@ function BenchmarkHarness() { measureUpdate(() => { createItem({ label: 'New Item', author }).then(created => { cache.set(`item:${created.id}`, makeCacheEntry(created)); - setIds(prev => [...prev, created.id]); + void mutate('items:all'); + setIds(prev => [created.id, ...prev]); }); }); - }, [measureUpdate, setIds]); + }, [measureUpdate, mutate, setIds]); const deleteEntity = useCallback( (id: string) => { diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index ab72fa42a960..5a94b10e10cd 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,5 +1,5 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -70,18 +70,6 @@ function ItemView({ id }: { id: string }) { return ; } -function SortedRow({ - index, - style, - items, -}: RowComponentProps<{ items: Item[] }>) { - return ( -
- -
- ); -} - function SortedListView() { const { data: items } = useQuery({ queryKey: ['items', 'all'], @@ -97,7 +85,7 @@ function SortedListView() { style={LIST_STYLE} rowHeight={ITEM_HEIGHT} rowCount={sorted.length} - rowComponent={SortedRow} + rowComponent={ItemsRow} rowProps={{ items: sorted }} />
@@ -169,7 +157,8 @@ function BenchmarkHarness() { measureUpdate(() => { createItem({ label: 'New Item', author }).then(created => { client.setQueryData(['item', created.id], created); - setIds(prev => [...prev, created.id]); + void client.invalidateQueries({ queryKey: ['items', 'all'] }); + setIds(prev => [created.id, ...prev]); }); }); }, [measureUpdate, client, setIds]); From 0253c11cb76675c83083724713a5e8a596adb5f6 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 13 Mar 2026 22:19:39 -0400 Subject: [PATCH 20/46] No seeding fix yarn lock --- .cursor/rules/benchmarking.mdc | 10 +- examples/benchmark-react/README.md | 64 ++- examples/benchmark-react/bench/runner.ts | 287 ++++++--- examples/benchmark-react/bench/scenarios.ts | 77 ++- examples/benchmark-react/bench/validate.ts | 544 ++++++++++++++++++ examples/benchmark-react/package.json | 7 +- .../benchmark-react/src/baseline/index.tsx | 177 ++---- .../benchmark-react/src/data-client/index.tsx | 161 ++---- .../src/data-client/resources.ts | 133 ----- .../src/shared/benchHarness.tsx | 32 +- examples/benchmark-react/src/shared/data.ts | 2 +- .../src/shared/refStability.ts | 41 +- .../benchmark-react/src/shared/resources.ts | 105 ++++ examples/benchmark-react/src/shared/server.ts | 53 +- examples/benchmark-react/src/shared/types.ts | 15 +- examples/benchmark-react/src/swr/index.tsx | 164 ++---- .../src/tanstack-query/index.tsx | 157 ++--- yarn.lock | 20 - 18 files changed, 1223 insertions(+), 826 deletions(-) create mode 100644 examples/benchmark-react/bench/validate.ts delete mode 100644 examples/benchmark-react/src/data-client/resources.ts create mode 100644 examples/benchmark-react/src/shared/resources.ts diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 12839e76aed3..1e7db5141804 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -16,7 +16,7 @@ When working on **`packages/react`** or comparing data-client to other React dat - **Where it lives**: `examples/benchmark-react/` - **How to run**: From repo root: `yarn build:benchmark-react`, then `yarn workspace example-benchmark-react preview &` and in another terminal `cd examples/benchmark-react && yarn bench` -- **What it measures**: Browser-based mount/update duration, ref-stability counts, sorted-view (Query memoization), optional memory (heap delta), startup metrics (FCP/TBT), and React Profiler commit times. Compares data-client, TanStack Query, SWR, and a plain React baseline. +- **What it measures**: Browser-based init/update duration, ref-stability counts, sorted-view (Query memoization), optional memory (heap delta), startup metrics (FCP/TBT), and React Profiler commit times. Compares data-client, TanStack Query, SWR, and a plain React baseline. - **CI**: `.github/workflows/benchmark-react.yml` runs on changes to `packages/react/src/**`, `packages/core/src/**`, `packages/endpoint/src/schemas/**`, `packages/normalizr/src/**`, or `examples/benchmark-react/**` and reports via `rhysd/github-action-benchmark` (customSmallerIsBetter). CI runs **data-client only** (hot-path scenarios) to track regressions; competitor libraries (TanStack Query, SWR, baseline) are for local comparison only. - **Report viewer**: Open `examples/benchmark-react/bench/report-viewer.html` in a browser and paste `react-bench-output.json` to view a comparison table and charts. Toggle "React commit" and "Trace" filters. Use "Load history" for time-series. @@ -26,8 +26,8 @@ See `@examples/benchmark-react/README.md` for methodology, adding a new library, Use this mapping when deciding which React benchmark scenarios are relevant to a change: -- **Mount scenarios** (`mount-100-items`, `mount-500-items`, `bulk-ingest-500`) - - Exercises: initial render with pre-populated cache +- **Init scenarios** (`init-100`, `init-500`) + - Exercises: full fetch + normalization + render pipeline (ListView auto-fetches from list endpoint) - Relevant for: `@data-client/react` hooks, `@data-client/core` store initialization - 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** | `mount-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | -| **Moderate** | `update-shared-author-*`, `bulk-ingest-*`, `sorted-view-update-*` | 5–10% | +| **Stable** | `init-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | +| **Moderate** | `update-shared-author-*`, `sorted-view-update-*` | 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/README.md b/examples/benchmark-react/README.md index 270dad87ff3d..6d5b840d1327 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -11,14 +11,14 @@ The repo has two benchmark suites: ## Methodology -- **What we measure:** Wall-clock time from triggering an action (e.g. `mount(100)` or `updateAuthor('author-0')`) until the harness sets `data-bench-complete` (after two `requestAnimationFrame` callbacks). Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. +- **What we measure:** Wall-clock time from triggering an action (e.g. `init(100)` or `updateAuthor('author-0')`) until the harness sets `data-bench-complete` (after two `requestAnimationFrame` callbacks). Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. - **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update), ref stability (fewer new object references), and derived-view memoization (`Query` schema avoids re-sorting when entities haven't changed). See [js-framework-benchmark "How the duration is measured"](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. - **Statistical:** Warmup runs are discarded; we report median and 95% CI. Libraries are interleaved per round to reduce environmental variance. -- **No CPU throttling:** Runs at native speed with more samples (3 warmup + 30 measurement locally, 15 in CI) for statistical significance rather than artificial slowdown. +- **No CPU throttling:** Runs at native speed with more samples for statistical significance rather than artificial slowdown. Small (cheap) scenarios use 3 warmup + 15 measurement runs locally (10 in CI); large (expensive) scenarios use 1 warmup + 4 measurement runs. ## Scenario categories -- **Hot path (in CI, data-client only)** — JS-only: mount, update propagation, ref-stability, sorted-view, bulk-ingest. No simulated network. CI runs only `data-client` scenarios to track our own regressions; competitor libraries are benchmarked locally for comparison. +- **Hot path (in CI, data-client only)** — JS-only: init (fetch + render), update propagation, ref-stability, sorted-view. No simulated network. CI runs only `data-client` scenarios to track our own regressions; competitor libraries are benchmarked locally for comparison. - **With network (local comparison)** — Same shared-author update but with simulated network delay (consistent ms per "request"). Used to compare overfetching: data-client needs one store update (1 × delay); non-normalized libs typically invalidate/refetch multiple queries (N × delay). **Not run in CI** — run locally with `yarn bench` (no `CI` env) to include these. - **Memory (local only)** — Heap delta after repeated mount/unmount cycles. - **Startup (local only)** — FCP and task duration via CDP `Performance.getMetrics`. @@ -27,20 +27,19 @@ The repo has two benchmark suites: **Hot path (CI)** -- **Mount** (`mount-100-items`, `mount-500-items`) — Time to mount 100 or 500 item rows (unit: ms). +- **Init** (`init-100`, `init-500`) — Time to show a ListView component that auto-fetches 100 or 500 items 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 item and propagate to the UI (unit: ms). - **Update shared author** (`update-shared-author-duration`) — 100 components, shared authors; update one author. Measures time to propagate (unit: ms). Normalized cache: one store update, all views of that author update. - **Update shared author (scaling)** (`update-shared-author-500-mounted`, `update-shared-author-1000-mounted`) — Same update with 500/1000 mounted components to test subscriber scaling. - **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. - **Sorted view mount** (`sorted-view-mount-500`) — Mount 500 items through a sorted/derived view. data-client uses `useQuery(sortedItemsQuery)` 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. -- **Bulk ingest** (`bulk-ingest-500`) — Generate fresh data at runtime, ingest into cache, and render. Exercises the full normalization pipeline. - **Optimistic update** (`optimistic-update`) — data-client only; applies an optimistic mutation via `getOptimisticResponse`. - **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)** -- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client uses 1 request; other libs use N requests (one per affected item query) to model overfetching. +- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client propagates via normalization (no extra request); other libs invalidate/refetch the list endpoint. **Memory (local only)** @@ -57,19 +56,18 @@ These are approximate values to help calibrate expectations. Exact numbers vary | Scenario | data-client | tanstack-query | swr | baseline | |---|---|---|---|---| -| `mount-100-items` | ~similar | ~similar | ~similar | ~similar | -| `update-shared-author-duration` (100 mounted) | Low (one store write propagates) | Higher (N cache writes) | Higher (N cache writes) | Higher (full array map) | -| `ref-stability-item-changed` (100 mounted) | ~1 changed | ~1 changed | ~1 changed | ~100 changed | -| `ref-stability-author-changed` (100 mounted) | ~5 changed | ~100 changed | ~100 changed | ~100 changed | +| `init-100` | ~similar | ~similar | ~similar | ~similar | +| `update-shared-author-duration` (100 mounted) | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | Higher (list refetch) | +| `ref-stability-item-changed` (100 mounted) | ~1 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | +| `ref-stability-author-changed` (100 mounted) | ~5 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | | `sorted-view-update-entity` | Fast (Query memoization skips re-sort) | Re-sorts on every item change | Re-sorts on every item change | Re-sorts on every item change | -| `bulk-ingest-500` | Normalization pipeline + render | Per-item cache seed + render | Per-item cache seed + render | Set state + render | ## Expected variance | Category | Scenarios | Typical run-to-run spread | |---|---|---| -| **Stable** | `mount-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2-5% | -| **Moderate** | `update-shared-author-*`, `bulk-ingest-*`, `sorted-view-update-*` | 5-10% | +| **Stable** | `init-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2-5% | +| **Moderate** | `update-shared-author-*`, `sorted-view-update-*` | 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. @@ -84,7 +82,7 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve ## Adding a new library 1. Add a new app under `src//index.tsx` (e.g. `src/urql/index.tsx`). -2. Implement the `BenchAPI` interface on `window.__BENCH__`: `mount`, `updateEntity`, `updateAuthor`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `bulkIngest`, `mountSortedView`. Use the shared presentational `ItemRow` from `@shared/components` and fixtures from `@shared/data`. +2. Implement the `BenchAPI` interface on `window.__BENCH__`: `init`, `updateEntity`, `updateAuthor`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `mountSortedView`. Use the shared presentational `ItemsRow` from `@shared/components` and fixtures from `@shared/data`. The harness (`useBenchState`) provides default `init`, `unmountAll`, `mountUnmountCycle`, `getRenderedCount`, and ref-stability methods; libraries only need to supply `updateEntity`, `updateAuthor`, and any overrides. 3. Add the library to `LIBRARIES` in `bench/scenarios.ts`. 4. Add a webpack entry in `webpack.config.cjs` for the new app and an `HtmlWebpackPlugin` entry so the app is served at `//`. 5. Add the dependency to `package.json` and run `yarn install`. @@ -133,6 +131,44 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve - `BENCH_PORT=` — port for `preview` server and bench runner (default `5173`) - `BENCH_BASE_URL=` — full base URL override (takes precedence over `BENCH_PORT`) +4. **Filtering scenarios** + + The runner supports CLI flags (with env var fallbacks) to select a subset of scenarios: + + | CLI flag | Env var | Description | + |---|---|---| + | `--lib ` | `BENCH_LIB` | Comma-separated library names (e.g. `data-client,swr`) | + | `--size ` | `BENCH_SIZE` | Run only `small` (cheap, full rigor) or `large` (expensive, reduced runs) scenarios | + | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`) or exact action name | + | `--scenario ` | `BENCH_SCENARIO` | Substring filter on scenario name | + + CLI flags take precedence over env vars. Examples: + + ```bash + yarn bench --lib data-client # only data-client + yarn bench --size small # only cheap scenarios (full warmup/measurement) + yarn bench --action mount # init, mountSortedView + yarn bench --action update --lib swr # update scenarios for swr only + yarn bench --scenario sorted-view # only sorted-view scenarios + ``` + + Convenience scripts: + + ```bash + yarn bench:small # --size small + yarn bench:large # --size large + yarn bench:dc # --lib data-client + ``` + +5. **Scenario sizes** + + Scenarios are classified as `small` or `large` based on their cost: + + - **Small** (3 warmup + 15 measurement): `init-100`, `update-single-entity`, `update-shared-author-duration`, `ref-stability-*`, `optimistic-update`, `invalidate-and-resolve`, `create-item`, `delete-item` + - **Large** (1 warmup + 4 measurement): `init-500`, `update-shared-author-500-mounted`, `update-shared-author-2000-mounted`, `memory-mount-unmount-cycle`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + + When running all scenarios (`yarn bench`), each group runs with its own warmup/measurement count. Use `--size` to run only one group. + ## Output The runner prints a JSON array in `customSmallerIsBetter` format (name, unit, value, range) to stdout. In CI this is written to `react-bench-output.json` and sent to the benchmark action. diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 7877cb854002..24833513be27 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -7,39 +7,110 @@ import { collectHeapUsed } from './memory.js'; import { formatReport, type BenchmarkResult } from './report.js'; import { SCENARIOS, - WARMUP_RUNS, - MEASUREMENT_RUNS, LIBRARIES, + RUN_CONFIG, + ACTION_GROUPS, } from './scenarios.js'; import { computeStats } from './stats.js'; import { parseTraceDuration } from './tracing.js'; +import type { Scenario, ScenarioSize } from '../src/shared/types.js'; -const BASE_URL = - process.env.BENCH_BASE_URL ?? - `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; -const BENCH_LABEL = - process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; -/** - * In CI we only run data-client hot-path scenarios to track our own regressions. - * Competitor libraries (tanstack-query, swr, baseline) are for local comparison only. - */ -const SCENARIOS_TO_RUN = - process.env.CI ? - SCENARIOS.filter( +// --------------------------------------------------------------------------- +// CLI + env var parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { + libs?: string[]; + size?: ScenarioSize; + actions?: string[]; + scenario?: string; +} { + const argv = process.argv.slice(2); + const get = (flag: string, envVar: string): string | undefined => { + const idx = argv.indexOf(flag); + if (idx !== -1 && idx + 1 < argv.length) return argv[idx + 1]; + return process.env[envVar] || undefined; + }; + + const libRaw = get('--lib', 'BENCH_LIB'); + const sizeRaw = get('--size', 'BENCH_SIZE'); + const actionRaw = get('--action', 'BENCH_ACTION'); + const scenarioRaw = get('--scenario', 'BENCH_SCENARIO'); + + const libs = libRaw ? libRaw.split(',').map(s => s.trim()) : undefined; + const size = sizeRaw === 'small' || sizeRaw === 'large' ? sizeRaw : undefined; + const actions = + actionRaw ? actionRaw.split(',').map(s => s.trim()) : undefined; + + return { libs, size, actions, scenario: scenarioRaw }; +} + +function filterScenarios(scenarios: Scenario[]): { + filtered: Scenario[]; + libraries: string[]; +} { + const { libs, size, actions, scenario: scenarioFilter } = parseArgs(); + + let filtered = scenarios; + + // In CI, restrict to data-client hot-path only (existing behavior) + if (process.env.CI) { + filtered = filtered.filter( s => s.name.startsWith('data-client:') && s.category !== 'withNetwork' && s.category !== 'memory' && s.category !== 'startup', - ) - : SCENARIOS; -const TOTAL_RUNS = WARMUP_RUNS + MEASUREMENT_RUNS; + ); + } + + if (libs) { + filtered = filtered.filter(s => libs.some(l => s.name.startsWith(`${l}:`))); + } + + if (size) { + filtered = filtered.filter(s => (s.size ?? 'small') === size); + } + + if (actions) { + const resolvedActions = new Set(); + for (const a of actions) { + if (a in ACTION_GROUPS) { + for (const act of ACTION_GROUPS[a]) resolvedActions.add(act); + } else { + resolvedActions.add(a); + } + } + filtered = filtered.filter(s => resolvedActions.has(s.action)); + } + + if (scenarioFilter) { + filtered = filtered.filter(s => s.name.includes(scenarioFilter)); + } + + const libraries = libs ?? (process.env.CI ? ['data-client'] : [...LIBRARIES]); + + return { filtered, libraries }; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const BASE_URL = + process.env.BENCH_BASE_URL ?? + `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; +const BENCH_LABEL = + process.env.BENCH_LABEL ? ` [${process.env.BENCH_LABEL}]` : ''; +const USE_TRACE = process.env.BENCH_TRACE === 'true'; + +// --------------------------------------------------------------------------- +// Scenario runner (unchanged logic) +// --------------------------------------------------------------------------- const REF_STABILITY_METRICS = ['itemRefChanged', 'authorRefChanged'] as const; -function isRefStabilityScenario( - scenario: (typeof SCENARIOS_TO_RUN)[0], -): scenario is (typeof SCENARIOS_TO_RUN)[0] & { +function isRefStabilityScenario(scenario: Scenario): scenario is Scenario & { resultMetric: (typeof REF_STABILITY_METRICS)[number]; } { return ( @@ -48,8 +119,6 @@ function isRefStabilityScenario( ); } -const USE_TRACE = process.env.BENCH_TRACE === 'true'; - interface ScenarioResult { value: number; reactCommit?: number; @@ -59,12 +128,15 @@ interface ScenarioResult { async function runScenario( page: Page, lib: string, - scenario: (typeof SCENARIOS_TO_RUN)[0], + scenario: Scenario, ): Promise { const appPath = `/${lib}/`; - await page.goto(`${BASE_URL}${appPath}`, { waitUntil: 'networkidle' }); + await page.goto(`${BASE_URL}${appPath}`, { + waitUntil: 'networkidle', + timeout: 120000, + }); await page.waitForSelector('[data-app-ready]', { - timeout: 10000, + timeout: 120000, state: 'attached', }); @@ -107,12 +179,12 @@ async function runScenario( scenario.action === 'createEntity' || scenario.action === 'deleteEntity'; const isRefStability = isRefStabilityScenario(scenario); - const isBulkIngest = scenario.action === 'bulkIngest'; + const isInit = scenario.action === 'init'; const mountCount = scenario.mountCount ?? (scenario.action === 'optimisticUpdate' ? 1 : 100); if (isUpdate || isRefStability) { - const preMountAction = scenario.preMountAction ?? 'mount'; + const preMountAction = scenario.preMountAction ?? 'init'; await harness.evaluate(el => el.removeAttribute('data-bench-complete')); await (bench as any).evaluate( (api: any, [action, n]: [string, number]) => api[action](n), @@ -182,10 +254,7 @@ async function runScenario( } const measures = await collectMeasures(page); - const isMountLike = - scenario.action === 'mount' || - isBulkIngest || - scenario.action === 'mountSortedView'; + const isMountLike = isInit || scenario.action === 'mountSortedView'; const duration = isMountLike ? getMeasureDuration(measures, 'mount-duration') @@ -202,6 +271,10 @@ async function runScenario( }; } +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + interface StartupMetrics { fcp: number; taskDuration: number; @@ -214,9 +287,12 @@ async function runStartupScenario( const cdp = await page.context().newCDPSession(page); await cdp.send('Performance.enable'); const appPath = `/${lib}/`; - await page.goto(`${BASE_URL}${appPath}`, { waitUntil: 'networkidle' }); + await page.goto(`${BASE_URL}${appPath}`, { + waitUntil: 'networkidle', + timeout: 120000, + }); await page.waitForSelector('[data-app-ready]', { - timeout: 10000, + timeout: 120000, state: 'attached', }); const { metrics } = await cdp.send('Performance.getMetrics'); @@ -231,6 +307,10 @@ async function runStartupScenario( return { fcp, taskDuration }; } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function shuffle(arr: T[]): T[] { const out = [...arr]; for (let i = out.length - 1; i > 0; i--) { @@ -240,7 +320,37 @@ function shuffle(arr: T[]): T[] { return out; } +function scenarioUnit(scenario: Scenario): string { + if ( + scenario.resultMetric === 'itemRefChanged' || + scenario.resultMetric === 'authorRefChanged' + ) + return 'count'; + if (scenario.resultMetric === 'heapDelta') return 'bytes'; + return 'ms'; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + async function main() { + const { filtered: SCENARIOS_TO_RUN, libraries } = filterScenarios(SCENARIOS); + + if (SCENARIOS_TO_RUN.length === 0) { + process.stderr.write('No scenarios matched the filters.\n'); + process.exit(1); + } + + // Group scenarios by size for differentiated run counts + const bySize: Record = { small: [], large: [] }; + for (const s of SCENARIOS_TO_RUN) { + bySize[s.size ?? 'small'].push(s); + } + const sizeGroups = ( + Object.entries(bySize) as [ScenarioSize, Scenario[]][] + ).filter(([, arr]) => arr.length > 0); + const results: Record = {}; const reactCommitResults: Record = {}; const traceResults: Record = {}; @@ -251,58 +361,54 @@ async function main() { } const browser = await chromium.launch({ headless: true }); - const libraries = process.env.CI ? ['data-client'] : [...LIBRARIES]; - - const scenarioCount = SCENARIOS_TO_RUN.length; - for (let round = 0; round < TOTAL_RUNS; round++) { - const phase = round < WARMUP_RUNS ? 'warmup' : 'measure'; - const phaseRound = - round < WARMUP_RUNS ? round + 1 : round - WARMUP_RUNS + 1; - const phaseTotal = round < WARMUP_RUNS ? WARMUP_RUNS : MEASUREMENT_RUNS; - process.stderr.write( - `\n── Round ${round + 1}/${TOTAL_RUNS} (${phase} ${phaseRound}/${phaseTotal}) ──\n`, - ); - let scenarioDone = 0; - for (const lib of shuffle(libraries)) { - const context = await browser.newContext(); - const page = await context.newPage(); - for (const scenario of SCENARIOS_TO_RUN) { - if (!scenario.name.startsWith(`${lib}:`)) continue; - try { - const result = await runScenario(page, lib, scenario); - results[scenario.name].push(result.value); - reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); - traceResults[scenario.name].push(result.traceDuration ?? NaN); - scenarioDone++; - const unit = - scenario.resultMetric === 'heapDelta' ? 'bytes' - : ( - scenario.resultMetric === 'itemRefChanged' || - scenario.resultMetric === 'authorRefChanged' - ) ? - 'count' - : 'ms'; - process.stderr.write( - ` [${scenarioDone}/${scenarioCount}] ${scenario.name}: ${result.value.toFixed(2)} ${unit}${result.reactCommit != null ? ` (commit ${result.reactCommit.toFixed(2)} ms)` : ''}\n`, - ); - } catch (err) { - scenarioDone++; - console.error( - ` [${scenarioDone}/${scenarioCount}] ${scenario.name} FAILED:`, - err instanceof Error ? err.message : err, - ); + // Run each size group with its own warmup/measurement counts + for (const [size, scenarios] of sizeGroups) { + const { warmup, measurement } = RUN_CONFIG[size]; + const totalRuns = warmup + measurement; + + for (let round = 0; round < totalRuns; round++) { + const phase = round < warmup ? 'warmup' : 'measure'; + const phaseRound = round < warmup ? round + 1 : round - warmup + 1; + const phaseTotal = round < warmup ? warmup : measurement; + process.stderr.write( + `\n── ${size} round ${round + 1}/${totalRuns} (${phase} ${phaseRound}/${phaseTotal}) ──\n`, + ); + let scenarioDone = 0; + for (const lib of shuffle([...libraries])) { + const context = await browser.newContext(); + const page = await context.newPage(); + + for (const scenario of scenarios) { + if (!scenario.name.startsWith(`${lib}:`)) continue; + try { + const result = await runScenario(page, lib, scenario); + 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}/${scenarios.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}/${scenarios.length}] ${scenario.name} FAILED:`, + err instanceof Error ? err.message : err, + ); + } } - } - await context.close(); + await context.close(); + } } } + // Startup scenarios (fast; only locally) const startupResults: Record = {}; const includeStartup = !process.env.CI; if (includeStartup) { - for (const lib of LIBRARIES) { + for (const lib of libraries) { startupResults[lib] = { fcp: [], tbt: [] }; } const STARTUP_RUNS = 5; @@ -310,7 +416,7 @@ async function main() { process.stderr.write( `\n── Startup round ${round + 1}/${STARTUP_RUNS} ──\n`, ); - for (const lib of shuffle([...LIBRARIES])) { + for (const lib of shuffle([...libraries])) { const context = await browser.newContext(); const page = await context.newPage(); try { @@ -333,20 +439,16 @@ async function main() { await browser.close(); + // --------------------------------------------------------------------------- + // Report + // --------------------------------------------------------------------------- const report: BenchmarkResult[] = []; for (const scenario of SCENARIOS_TO_RUN) { const samples = results[scenario.name]; - if (samples.length === 0) continue; - const { median, range } = computeStats(samples, WARMUP_RUNS); - let unit = 'ms'; - if ( - scenario.resultMetric === 'itemRefChanged' || - scenario.resultMetric === 'authorRefChanged' - ) { - unit = 'count'; - } else if (scenario.resultMetric === 'heapDelta') { - unit = 'bytes'; - } + const warmupRuns = RUN_CONFIG[scenario.size ?? 'small'].warmup; + if (samples.length <= warmupRuns) continue; + const { median, range } = computeStats(samples, warmupRuns); + const unit = scenarioUnit(scenario); report.push({ name: scenario.name, unit, @@ -354,15 +456,14 @@ async function main() { range, }); const reactSamples = reactCommitResults[scenario.name] - .slice(WARMUP_RUNS) + .slice(warmupRuns) .filter(x => !Number.isNaN(x)); if ( reactSamples.length > 0 && - (scenario.action === 'mount' || + (scenario.action === 'init' || scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || scenario.action === 'optimisticUpdate' || - scenario.action === 'bulkIngest' || scenario.action === 'mountSortedView' || scenario.action === 'invalidateAndResolve' || scenario.action === 'createEntity' || @@ -380,7 +481,7 @@ async function main() { }); } const traceSamples = traceResults[scenario.name] - .slice(WARMUP_RUNS) + .slice(warmupRuns) .filter(x => !Number.isNaN(x)); if (traceSamples.length > 0) { const { median: trMedian, range: trRange } = computeStats( @@ -397,7 +498,7 @@ async function main() { } if (includeStartup) { - for (const lib of LIBRARIES) { + for (const lib of libraries) { const s = startupResults[lib]; if (s && s.fcp.length > 0) { const fcpStats = computeStats(s.fcp, 0); diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index c77355e8523a..aed256b3e14b 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,12 +1,26 @@ -import type { Scenario } from '../src/shared/types.js'; +import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; export const SIMULATED_NETWORK_DELAY_MS = 50; -/** - * With 100 mounted items and 20 authors, each author is shared by 5 items. - * Non-normalized libs must refetch each affected item query individually. - */ -const ITEMS_PER_AUTHOR_100 = 5; +export const RUN_CONFIG: Record< + ScenarioSize, + { warmup: number; measurement: number } +> = { + small: { warmup: 3, measurement: process.env.CI ? 10 : 15 }, + large: { warmup: 1, measurement: process.env.CI ? 3 : 4 }, +}; + +export const ACTION_GROUPS: Record = { + mount: ['init', 'mountSortedView'], + update: ['updateEntity', 'updateAuthor'], + mutation: [ + 'createEntity', + 'deleteEntity', + 'optimisticUpdate', + 'invalidateAndResolve', + ], + memory: ['mountUnmountCycle'], +}; interface BaseScenario { nameSuffix: string; @@ -14,9 +28,10 @@ interface BaseScenario { 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 import('../src/shared/types.js').BenchAPI; + preMountAction?: keyof BenchAPI; /** Override args per library (e.g. different request counts for withNetwork). */ perLibArgs?: Partial>; /** Only run for these libraries. Omit to run for all. */ @@ -25,16 +40,17 @@ interface BaseScenario { const BASE_SCENARIOS: BaseScenario[] = [ { - nameSuffix: 'mount-100-items', - action: 'mount', + nameSuffix: 'init-100', + action: 'init', args: [100], category: 'hotPath', }, { - nameSuffix: 'mount-500-items', - action: 'mount', + nameSuffix: 'init-500', + action: 'init', args: [500], category: 'hotPath', + size: 'large', }, { nameSuffix: 'update-single-entity', @@ -72,30 +88,8 @@ const BASE_SCENARIOS: BaseScenario[] = [ simulatedRequestCount: 1, }, ], - perLibArgs: { - 'tanstack-query': [ - 'author-0', - { - simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, - simulatedRequestCount: ITEMS_PER_AUTHOR_100, - }, - ], - swr: [ - 'author-0', - { - simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, - simulatedRequestCount: ITEMS_PER_AUTHOR_100, - }, - ], - baseline: [ - 'author-0', - { - simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, - simulatedRequestCount: ITEMS_PER_AUTHOR_100, - }, - ], - }, category: 'withNetwork', + size: 'large', }, { nameSuffix: 'update-shared-author-500-mounted', @@ -103,6 +97,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['author-0'], category: 'hotPath', mountCount: 500, + size: 'large', }, { nameSuffix: 'memory-mount-unmount-cycle', @@ -110,6 +105,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: [500, 10], resultMetric: 'heapDelta', category: 'memory', + size: 'large', }, { nameSuffix: 'optimistic-update', @@ -118,17 +114,12 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'hotPath', onlyLibs: ['data-client'], }, - { - nameSuffix: 'bulk-ingest-500', - action: 'bulkIngest', - args: [500], - category: 'hotPath', - }, { nameSuffix: 'sorted-view-mount-500', action: 'mountSortedView', args: [500], category: 'hotPath', + size: 'large', }, { nameSuffix: 'sorted-view-update-entity', @@ -137,6 +128,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'hotPath', mountCount: 500, preMountAction: 'mountSortedView', + size: 'large', }, { nameSuffix: 'update-shared-author-2000-mounted', @@ -144,6 +136,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['author-0'], category: 'hotPath', mountCount: 2000, + size: 'large', }, { nameSuffix: 'invalidate-and-resolve', @@ -185,11 +178,9 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => args: base.perLibArgs?.[lib] ?? base.args, resultMetric: base.resultMetric, category: base.category, + size: base.size, mountCount: base.mountCount, preMountAction: base.preMountAction, }), ), ); - -export const WARMUP_RUNS = 3; -export const MEASUREMENT_RUNS = process.env.CI ? 10 : 15; diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts new file mode 100644 index 000000000000..395d6cf3b360 --- /dev/null +++ b/examples/benchmark-react/bench/validate.ts @@ -0,0 +1,544 @@ +/// +/** + * Validation harness for benchmark library implementations. + * + * Navigates to each library's benchmark app, exercises every BenchAPI action, + * and asserts that updates actually reach the DOM. Catches timing bugs where + * data-bench-complete fires before the UI has updated. + * + * Usage: + * npx tsx bench/validate.ts # all libraries + * npx tsx bench/validate.ts --lib data-client # one library + * npx tsx bench/validate.ts --lib swr,baseline # multiple + */ +import { chromium } from 'playwright'; +import type { Page } from 'playwright'; + +import { LIBRARIES } from './scenarios.js'; + +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_ITEM_COUNT = 20; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(): { libs: string[] } { + const argv = process.argv.slice(2); + const idx = argv.indexOf('--lib'); + if (idx !== -1 && idx + 1 < argv.length) { + return { libs: argv[idx + 1].split(',').map(s => s.trim()) }; + } + return { libs: [...LIBRARIES] }; +} + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +async function navigateToLib(page: Page, lib: string) { + await page.goto(`${BASE_URL}/${lib}/`, { + waitUntil: 'networkidle', + timeout: 30000, + }); + await page.waitForSelector('[data-app-ready]', { + timeout: 30000, + state: 'attached', + }); +} + +async function waitForComplete(page: Page, timeoutMs = 10000) { + await page.waitForSelector('[data-bench-complete]', { + timeout: timeoutMs, + state: 'attached', + }); +} + +async function clearComplete(page: Page) { + await page + .locator('[data-bench-harness]') + .evaluate(el => el.removeAttribute('data-bench-complete')); +} + +async function getItemLabels(page: Page): Promise> { + return page.evaluate(() => { + const out: Record = {}; + for (const el of document.querySelectorAll('[data-bench-item]')) { + const id = (el as HTMLElement).dataset.itemId ?? ''; + out[id] = el.querySelector('[data-label]')?.textContent?.trim() ?? ''; + } + return out; + }); +} + +async function getItemCount(page: Page): Promise { + return page.evaluate( + () => document.querySelectorAll('[data-bench-item]').length, + ); +} + +async function waitFor( + page: Page, + condition: () => Promise, + description: string, + timeoutMs = 5000, +) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await condition()) return; + await page.waitForTimeout(50); + } + throw new Error(`Timed out waiting for: ${description} (${timeoutMs}ms)`); +} + +/** Init items and wait until at least one appears in the DOM. */ +async function initAndWaitForItems( + page: Page, + count: number = TEST_ITEM_COUNT, +) { + await clearComplete(page); + await page.evaluate((n: number) => window.__BENCH__!.init(n), count); + await waitForComplete(page); + await waitFor( + page, + async () => (await getItemCount(page)) > 0, + `items rendered after init(${count})`, + ); +} + +// --------------------------------------------------------------------------- +// Assertion helpers +// --------------------------------------------------------------------------- + +function assert( + ok: boolean, + lib: string, + test: string, + message: string, +): asserts ok { + if (!ok) { + const err = new Error(`[${lib}] ${test}: ${message}`); + err.name = 'ValidationError'; + throw err; + } +} + +// --------------------------------------------------------------------------- +// Test registry +// --------------------------------------------------------------------------- + +interface TestResult { + name: string; + passed: boolean; + error?: string; + skipped?: boolean; +} + +type TestFn = (page: Page, lib: string) => Promise; + +const tests: { name: string; fn: TestFn; onlyLibs?: string[] }[] = []; +function test(name: string, fn: TestFn, opts?: { onlyLibs?: string[] }) { + tests.push({ name, fn, onlyLibs: opts?.onlyLibs }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests — each navigates to a fresh page, so they must do their own setup. +// ═══════════════════════════════════════════════════════════════════════════ + +// ── init ───────────────────────────────────────────────────────────────── + +test('init renders items with correct labels', async (page, lib) => { + await initAndWaitForItems(page); + + const labels = await getItemLabels(page); + const ids = Object.keys(labels); + assert(ids.length > 0, lib, 'init', `no items in DOM`); + + assert( + labels['item-0'] === 'Item 0', + lib, + 'init', + `item-0 label: expected "Item 0", got "${labels['item-0']}"`, + ); + + const renderedCount = await page.evaluate(() => + window.__BENCH__!.getRenderedCount(), + ); + assert( + renderedCount === TEST_ITEM_COUNT, + lib, + 'init getRenderedCount', + `expected ${TEST_ITEM_COUNT}, got ${renderedCount}`, + ); +}); + +// ── updateEntity ───────────────────────────────────────────────────────── + +test('updateEntity changes item label in DOM', async (page, lib) => { + await initAndWaitForItems(page); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateEntity('item-0')); + await waitForComplete(page); + + await waitFor( + page, + async () => + (await getItemLabels(page))['item-0']?.includes('(updated)') ?? false, + 'item-0 label contains "(updated)"', + ); + + const labels = await getItemLabels(page); + assert( + labels['item-0']?.includes('(updated)'), + lib, + 'updateEntity', + `item-0 should contain "(updated)", got "${labels['item-0']}"`, + ); + assert( + !labels['item-1']?.includes('(updated)'), + lib, + 'updateEntity unchanged', + `item-1 should be unchanged, got "${labels['item-1']}"`, + ); +}); + +// ── updateAuthor ───────────────────────────────────────────────────────── + +test('updateAuthor propagates to DOM', async (page, _lib) => { + await initAndWaitForItems(page); + + // The displayed column is author.login; updateAuthor changes author.name. + // Non-normalized libs refetch the whole list (which joins latest author). + // Verify at minimum that items are still present after the operation. + const labelsBefore = await getItemLabels(page); + const countBefore = Object.keys(labelsBefore).length; + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateAuthor('author-0')); + await waitForComplete(page); + + // After updateAuthor + any async refetch, items should still be rendered + await waitFor( + page, + async () => (await getItemCount(page)) >= countBefore, + 'items still rendered after updateAuthor', + 5000, + ); +}); + +// ── ref-stability: updateEntity ────────────────────────────────────────── + +test('ref-stability after updateEntity', async (page, lib) => { + await initAndWaitForItems(page); + + await page.evaluate(() => window.__BENCH__!.captureRefSnapshot()); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateEntity('item-0')); + await waitForComplete(page); + + // Wait for the label change to actually reach the DOM + await waitFor( + page, + async () => + (await getItemLabels(page))['item-0']?.includes('(updated)') ?? false, + 'item-0 label updated before ref check', + ); + + const r = await page.evaluate(() => + window.__BENCH__!.getRefStabilityReport(), + ); + const total = r.itemRefChanged + r.itemRefUnchanged; + assert( + total === TEST_ITEM_COUNT, + lib, + 'ref-stability total', + `expected ${TEST_ITEM_COUNT} items in report, got ${total} (changed=${r.itemRefChanged} unchanged=${r.itemRefUnchanged})`, + ); + assert( + r.itemRefChanged >= 1, + lib, + 'ref-stability changed', + `expected ≥1 itemRefChanged, got ${r.itemRefChanged}. ` + + `setCurrentItems may not have been called with updated data before measurement.`, + ); + process.stderr.write( + ` itemRefChanged=${r.itemRefChanged} authorRefChanged=${r.authorRefChanged}\n`, + ); +}); + +// ── ref-stability: updateAuthor ────────────────────────────────────────── + +test('ref-stability after updateAuthor', async (page, lib) => { + await initAndWaitForItems(page); + + await page.evaluate(() => window.__BENCH__!.captureRefSnapshot()); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateAuthor('author-0')); + await waitForComplete(page); + + // Wait for the author change to propagate (async refetch for SWR/tanstack) + await waitFor( + page, + async () => { + const r = await page.evaluate(() => + window.__BENCH__!.getRefStabilityReport(), + ); + return r.authorRefChanged > 0; + }, + 'authorRefChanged > 0', + 5000, + ); + + const r = await page.evaluate(() => + window.__BENCH__!.getRefStabilityReport(), + ); + const total = r.authorRefChanged + r.authorRefUnchanged; + assert( + total === TEST_ITEM_COUNT, + lib, + 'ref-stability-author total', + `expected ${TEST_ITEM_COUNT} items, got ${total}`, + ); + // 20 items ÷ 20 authors = 1 item per author + const expectedMin = Math.floor(TEST_ITEM_COUNT / 20); + assert( + r.authorRefChanged >= expectedMin, + lib, + 'ref-stability-author count', + `expected ≥${expectedMin} authorRefChanged, got ${r.authorRefChanged}`, + ); + process.stderr.write( + ` itemRefChanged=${r.itemRefChanged} authorRefChanged=${r.authorRefChanged}\n`, + ); +}); + +// ── createEntity ───────────────────────────────────────────────────────── + +test('createEntity adds an item', async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.createEntity === 'function', + )) + ) + return; + + await initAndWaitForItems(page, 10); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.createEntity!()); + await waitForComplete(page); + + await waitFor( + page, + async () => { + const labels = await getItemLabels(page); + return Object.values(labels).some(l => l === 'New Item'); + }, + '"New Item" appears in DOM', + 5000, + ); +}); + +// ── deleteEntity ───────────────────────────────────────────────────────── + +test('deleteEntity removes an item', async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.deleteEntity === 'function', + )) + ) + return; + + await initAndWaitForItems(page, 10); + + const labelsBefore = await getItemLabels(page); + assert( + 'item-0' in labelsBefore, + _lib, + 'deleteEntity setup', + 'item-0 missing', + ); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.deleteEntity!('item-0')); + await waitForComplete(page); + + await waitFor( + page, + async () => !('item-0' in (await getItemLabels(page))), + 'item-0 removed from DOM', + 5000, + ); +}); + +// ── optimisticUpdate ───────────────────────────────────────────────────── + +test( + 'optimisticUpdate changes label immediately', + async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.optimisticUpdate === 'function', + )) + ) + return; + + await initAndWaitForItems(page, 10); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.optimisticUpdate!()); + await waitForComplete(page); + + await waitFor( + page, + async () => + (await getItemLabels(page))['item-0']?.includes('(optimistic)') ?? + false, + 'item-0 label contains "(optimistic)"', + ); + }, + { onlyLibs: ['data-client'] }, +); + +// ── mountSortedView ────────────────────────────────────────────────────── + +test('mountSortedView renders sorted list', async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.mountSortedView === 'function', + )) + ) + return; + + // For data-client, sorted view queries All(ItemEntity) from the normalised + // store, so we must populate the store first via init. + await initAndWaitForItems(page); + await page.evaluate(() => window.__BENCH__!.unmountAll()); + await page.waitForTimeout(200); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.mountSortedView!(20)); + await waitForComplete(page); + + await waitFor( + page, + async () => + page.evaluate( + () => document.querySelector('[data-sorted-list]') !== null, + ), + '[data-sorted-list] rendered', + 5000, + ); +}); + +// ── invalidateAndResolve ───────────────────────────────────────────────── + +test( + 'invalidateAndResolve completes without error', + async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.invalidateAndResolve === 'function', + )) + ) + return; + + await initAndWaitForItems(page, 10); + + await clearComplete(page); + await page.evaluate(() => + window.__BENCH__!.invalidateAndResolve!('item-0'), + ); + await waitForComplete(page, 15000); + }, + { onlyLibs: ['data-client'] }, +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Runner +// ═══════════════════════════════════════════════════════════════════════════ + +async function runLibrary(lib: string): Promise { + const results: TestResult[] = []; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + page.on('console', msg => { + if (msg.type() === 'error') + process.stderr.write(` [console.error] ${msg.text()}\n`); + }); + page.on('pageerror', err => + process.stderr.write(` [page error] ${err.message}\n`), + ); + + for (const t of tests) { + if (t.onlyLibs && !t.onlyLibs.includes(lib)) { + results.push({ name: t.name, passed: true, skipped: true }); + continue; + } + + await navigateToLib(page, lib); + try { + await t.fn(page, lib); + results.push({ name: t.name, passed: true }); + } catch (err) { + results.push({ + name: t.name, + passed: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + await context.close(); + await browser.close(); + return results; +} + +async function main() { + const { libs } = parseArgs(); + let passed = 0; + let failed = 0; + let skipped = 0; + const failures: { lib: string; name: string; error: string }[] = []; + + for (const lib of libs) { + process.stderr.write(`\n━━ ${lib} ━━\n`); + for (const r of await runLibrary(lib)) { + if (r.skipped) { + skipped++; + process.stderr.write(` ⊘ ${r.name} (skipped)\n`); + } else if (r.passed) { + passed++; + process.stderr.write(` ✓ ${r.name}\n`); + } else { + failed++; + process.stderr.write(` ✗ ${r.name}\n ${r.error}\n`); + failures.push({ lib, name: r.name, error: r.error! }); + } + } + } + + process.stderr.write( + `\n━━ Summary: ${passed} passed, ${failed} failed, ${skipped} skipped ━━\n`, + ); + if (failures.length > 0) { + process.stderr.write('\nFailures:\n'); + for (const f of failures) + process.stderr.write(` [${f.lib}] ${f.name}: ${f.error}\n`); + process.exit(1); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index 976d6748d732..fd16c58746a0 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -9,8 +9,13 @@ "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: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:compiler": "yarn build:compiler && (yarn preview &) && sleep 5 && yarn bench:compiler", + "validate": "npx tsx bench/validate.ts", + "validate:run": "yarn build && (yarn preview &) && sleep 5 && yarn validate" }, "engines": { "node": ">=22" diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 0c2b3e4e135f..c08844718162 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -1,45 +1,31 @@ -import { - onProfilerRender, - useBenchState, - waitForPaint, -} from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; +import { onProfilerRender, useBenchState } from '@shared/benchHarness'; +import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, - generateFreshData, sortByLabel, } from '@shared/data'; -import { registerRefs } from '@shared/refStability'; -import { - fetchItemList, - createItem, - updateItem, - updateAuthor as serverUpdateAuthor, - deleteItem, - seedBulkItems, - seedItemList, -} from '@shared/server'; -import type { Author, Item, UpdateAuthorOptions } from '@shared/types'; -import React, { useCallback, useContext, useMemo, useState } from 'react'; +import { setCurrentItems } from '@shared/refStability'; +import { AuthorResource, ItemResource } from '@shared/resources'; +import { seedItemList } from '@shared/server'; +import type { Item, UpdateAuthorOptions } from '@shared/types'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { createRoot } from 'react-dom/client'; -import { List, type RowComponentProps } from 'react-window'; +import { List } from 'react-window'; const ItemsContext = React.createContext<{ items: Item[]; setItems: React.Dispatch>; }>(null as any); -function ItemView({ id }: { id: string }) { - const { items } = useContext(ItemsContext); - const item = items.find(i => i.id === id); - if (!item) return null; - registerRefs(id, item, item.author); - return ; -} - function SortedListView() { const { items } = useContext(ItemsContext); const sorted = useMemo(() => sortByLabel(items), [items]); @@ -56,59 +42,60 @@ function SortedListView() { ); } -function ItemListRow({ - index, - style, - ids, -}: RowComponentProps<{ ids: string[] }>) { +function ListView() { + const { items } = useContext(ItemsContext); + if (!items.length) return null; + setCurrentItems(items); return ( -
- -
+ ); } function BenchmarkHarness() { const [items, setItems] = useState([]); const { - ids, + listViewCount, showSortedView, containerRef, measureMount, measureUpdate, measureUpdateWithDelay, - setComplete, - completeResolveRef, - setIds, setShowSortedView, unmountAll: unmountBase, registerAPI, } = useBenchState(); - const mount = useCallback( - (n: number) => { - seedItemList(FIXTURE_ITEMS.slice(0, n)); - measureMount(() => { - fetchItemList().then(fetched => { - setItems(fetched); - setIds(fetched.map(i => i.id)); - }); - }); - }, - [measureMount, setIds], - ); + // Fetch items when listViewCount changes (populates context for ListView) + useEffect(() => { + if (listViewCount != null) { + ItemResource.getList({ count: listViewCount }).then(setItems); + } + }, [listViewCount]); + + const unmountAll = useCallback(() => { + unmountBase(); + setItems([]); + }, [unmountBase]); const updateEntity = useCallback( (id: string) => { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - updateItem({ id, label: `${item.label} (updated)` }).then(parsed => { - setItems(prev => prev.map(i => (i.id === id ? parsed : i))); - }); + ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( + () => { + ItemResource.getList({ count: listViewCount! }).then(setItems); + }, + ); }); }, - [measureUpdate], + [measureUpdate, listViewCount], ); const updateAuthor = useCallback( @@ -116,67 +103,42 @@ function BenchmarkHarness() { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; measureUpdateWithDelay(options, () => { - serverUpdateAuthor({ - id: authorId, - name: `${author.name} (updated)`, - }).then(parsed => { - setItems(prev => - prev.map(item => - item.author.id === authorId ? { ...item, author: parsed } : item, - ), - ); + AuthorResource.update( + { id: authorId }, + { name: `${author.name} (updated)` }, + ).then(() => { + ItemResource.getList({ count: listViewCount! }).then(setItems); }); }); }, - [measureUpdateWithDelay], + [measureUpdateWithDelay, listViewCount], ); const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { - createItem({ label: 'New Item', author }).then(created => { - setItems(prev => [created, ...prev]); - setIds(prev => [created.id, ...prev]); + ItemResource.create({ label: 'New Item', author }).then(() => { + ItemResource.getList({ count: listViewCount! }).then(setItems); }); }); - }, [measureUpdate, setIds]); + }, [measureUpdate, listViewCount]); const deleteEntity = useCallback( (id: string) => { measureUpdate(() => { - deleteItem({ id }).then(() => { - setItems(prev => prev.filter(i => i.id !== id)); - setIds(prev => prev.filter(i => i !== id)); + ItemResource.delete({ id }).then(() => { + ItemResource.getList({ count: listViewCount! }).then(setItems); }); }); }, - [measureUpdate, setIds], - ); - - const unmountAll = useCallback(() => { - unmountBase(); - setItems([]); - }, [unmountBase]); - - const bulkIngest = useCallback( - (n: number) => { - const { items: freshItems } = generateFreshData(n); - seedBulkItems(freshItems); - measureMount(() => { - fetchItemList().then(fetched => { - setItems(fetched); - setIds(fetched.map(i => i.id)); - }); - }); - }, - [measureMount, setIds], + [measureUpdate, listViewCount], ); const mountSortedView = useCallback( (n: number) => { seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - fetchItemList().then(fetched => { + ItemResource.getList().then(fetched => { setItems(fetched); setShowSortedView(true); }); @@ -185,29 +147,10 @@ function BenchmarkHarness() { [measureMount, setShowSortedView], ); - const mountUnmountCycle = useCallback( - async (n: number, cycles: number) => { - for (let i = 0; i < cycles; i++) { - const p = new Promise(r => { - completeResolveRef.current = r; - }); - mount(n); - await p; - unmountAll(); - await waitForPaint(); - } - setComplete(); - }, - [mount, unmountAll, setComplete, completeResolveRef], - ); - registerAPI({ - mount, updateEntity, updateAuthor, unmountAll, - mountUnmountCycle, - bulkIngest, mountSortedView, createEntity, deleteEntity, @@ -216,13 +159,7 @@ function BenchmarkHarness() { return (
- + {listViewCount != null && } {showSortedView && }
diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index ee7bde4900be..627958e99e64 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,64 +1,35 @@ import { + AsyncBoundary, DataProvider, - useCache, useController, useQuery, + useSuspense, } from '@data-client/react'; -import { mockInitialState } from '@data-client/react/mock'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, - generateFreshData, } from '@shared/data'; -import { registerRefs } from '@shared/refStability'; -import { seedBulkItems } from '@shared/server'; -import type { Item, UpdateAuthorOptions } from '@shared/types'; -import React, { useCallback, useState } from 'react'; -import { createRoot } from 'react-dom/client'; -import { List, type RowComponentProps } from 'react-window'; - +import { setCurrentItems } from '@shared/refStability'; import { - createItemEndpoint, - deleteItemEndpoint, - getAuthor, - getItem, - getItemList, + AuthorResource, + ItemResource, sortedItemsQuery, - updateAuthorEndpoint, - updateItemEndpoint, - updateItemOptimistic, -} from './resources'; - -const initialState = mockInitialState([ - { endpoint: getItemList, args: [], response: FIXTURE_ITEMS }, - ...FIXTURE_ITEMS.map(item => ({ - endpoint: getItem, - args: [{ id: item.id }] as [{ id: string }], - response: item, - })), - ...[...FIXTURE_AUTHORS_BY_ID.values()].map(author => ({ - endpoint: getAuthor, - args: [{ id: author.id }] as [{ id: string }], - response: author, - })), -]); - -function ItemView({ id }: { id: string }) { - const item = useCache(getItem, { id }); - if (!item) return null; - registerRefs(id, item as Item, item.author as Item['author']); - return ; -} +} from '@shared/resources'; +import type { Item, UpdateAuthorOptions } from '@shared/types'; +import React, { useCallback } from 'react'; +import { createRoot } from 'react-dom/client'; +import { List } from 'react-window'; /** Renders items from the list endpoint (models rendering a list fetch response). */ -function ListView() { - const items = useCache(getItemList); +function ListView({ count }: { count: number }) { + const items = useSuspense(ItemResource.getList, { count }); if (!items) return null; const list = items as Item[]; + setCurrentItems(list); return ( ) { - return ( -
- -
- ); -} - function BenchmarkHarness() { const controller = useController(); const { - ids, + listViewCount, showSortedView, sortedViewCount, containerRef, - measureMount, measureUpdate, measureUpdateWithDelay, - setIds, + measureMount, setShowSortedView, setSortedViewCount, - unmountAll: unmountBase, registerAPI, } = useBenchState(); - const [showListView, setShowListView] = useState(false); const updateEntity = useCallback( (id: string) => { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - controller.fetch(updateItemEndpoint, { - id, - label: `${item.label} (updated)`, - }); + controller.fetch( + ItemResource.update, + { id }, + { label: `${item.label} (updated)` }, + ); }); }, [measureUpdate, controller], @@ -136,10 +93,11 @@ function BenchmarkHarness() { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; measureUpdateWithDelay(options, () => { - controller.fetch(updateAuthorEndpoint, { - id: authorId, - name: `${author.name} (updated)`, - }); + controller.fetch( + AuthorResource.update, + { id: authorId }, + { name: `${author.name} (updated)` }, + ); }); }, [measureUpdateWithDelay, controller], @@ -148,57 +106,34 @@ function BenchmarkHarness() { const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { - controller - .fetch(createItemEndpoint, { - label: 'New Item', - author, - }) - .then((created: any) => { - setIds(prev => [created.id, ...prev]); - }); + controller.fetch(ItemResource.create, { + label: 'New Item', + author, + }); }); - }, [measureUpdate, controller, setIds]); + }, [measureUpdate, controller]); const deleteEntity = useCallback( (id: string) => { measureUpdate(() => { - controller.fetch(deleteItemEndpoint, { id }).then(() => { - setIds(prev => prev.filter(i => i !== id)); - }); + controller.fetch(ItemResource.delete, { id }); }); }, - [measureUpdate, controller, setIds], + [measureUpdate, controller], ); - const unmountAll = useCallback(() => { - unmountBase(); - setShowListView(false); - }, [unmountBase]); - const optimisticUpdate = useCallback(() => { const item = FIXTURE_ITEMS[0]; if (!item) return; measureUpdate(() => { - controller.fetch(updateItemOptimistic, { - id: item.id, - label: `${item.label} (optimistic)`, - }); + controller.fetch( + ItemResource.update, + { id: item.id }, + { label: `${item.label} (optimistic)` }, + ); }); }, [measureUpdate, controller]); - const bulkIngest = useCallback( - (n: number) => { - const { items } = generateFreshData(n); - seedBulkItems(items); - measureMount(() => { - controller.fetch(getItemList).then(() => { - setShowListView(true); - }); - }); - }, - [measureMount, controller], - ); - const mountSortedView = useCallback( (n: number) => { measureMount(() => { @@ -214,8 +149,7 @@ function BenchmarkHarness() { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - controller.invalidate(getItem, { id }); - controller.fetch(getItem, { id }); + controller.invalidate(ItemResource.get, { id }); }); }, [measureUpdate, controller], @@ -224,9 +158,7 @@ function BenchmarkHarness() { registerAPI({ updateEntity, updateAuthor, - unmountAll, optimisticUpdate, - bulkIngest, mountSortedView, invalidateAndResolve, createEntity, @@ -235,22 +167,17 @@ function BenchmarkHarness() { return (
- - {showListView && } - {showSortedView && } + + {listViewCount != null && } + {showSortedView && } +
); } const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( - + diff --git a/examples/benchmark-react/src/data-client/resources.ts b/examples/benchmark-react/src/data-client/resources.ts deleted file mode 100644 index 5c7609360610..000000000000 --- a/examples/benchmark-react/src/data-client/resources.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - Entity, - Endpoint, - All, - Query, - Invalidate, - Collection, -} from '@data-client/endpoint'; -import { sortByLabel } from '@shared/data'; -import { - fetchItem as serverFetchItem, - fetchAuthor as serverFetchAuthor, - fetchItemList as serverFetchItemList, - createItem as serverCreateItem, - updateItem as serverUpdateItem, - deleteItem as serverDeleteItem, - createAuthor as serverCreateAuthor, - updateAuthor as serverUpdateAuthor, - deleteAuthor as serverDeleteAuthor, -} from '@shared/server'; - -export class AuthorEntity extends Entity { - id = ''; - login = ''; - name = ''; - - pk() { - return this.id; - } - - static key = 'AuthorEntity'; -} - -export class ItemEntity extends Entity { - id = ''; - label = ''; - author = AuthorEntity.fromJS(); - - pk() { - return this.id; - } - - static key = 'ItemEntity'; - static schema = { author: AuthorEntity }; -} - -// ── READ ──────────────────────────────────────────────────────────────── - -export const getAuthor = new Endpoint(serverFetchAuthor, { - schema: AuthorEntity, - key: ({ id }: { id: string }) => `author:${id}`, - dataExpiryLength: Infinity, -}); - -export const getItem = new Endpoint(serverFetchItem, { - schema: ItemEntity, - key: ({ id }: { id: string }) => `item:${id}`, - dataExpiryLength: Infinity, -}); - -export const getItemList = new Endpoint(serverFetchItemList, { - schema: new Collection([ItemEntity]), - key: () => 'item:list', - dataExpiryLength: Infinity, -}); - -// ── CREATE ────────────────────────────────────────────────────────────── - -export const createItemEndpoint = new Endpoint(serverCreateItem, { - schema: getItemList.schema.unshift, - sideEffect: true, - key: () => 'item:create', -}); - -export const createAuthorEndpoint = new Endpoint(serverCreateAuthor, { - schema: AuthorEntity, - sideEffect: true, - key: () => 'author:create', -}); - -// ── UPDATE ────────────────────────────────────────────────────────────── - -export const updateItemEndpoint = new Endpoint(serverUpdateItem, { - schema: ItemEntity, - sideEffect: true, - key: ({ id }: { id: string }) => `item-update:${id}`, -}); - -export const updateAuthorEndpoint = new Endpoint(serverUpdateAuthor, { - schema: AuthorEntity, - sideEffect: true, - key: ({ id }: { id: string }) => `author-update:${id}`, -}); - -// ── DELETE ─────────────────────────────────────────────────────────────── - -export const deleteItemEndpoint = new Endpoint(serverDeleteItem, { - schema: new Invalidate(ItemEntity), - sideEffect: true, - key: ({ id }: { id: string }) => `item-delete:${id}`, -}); - -export const deleteAuthorEndpoint = new Endpoint(serverDeleteAuthor, { - schema: new Invalidate(AuthorEntity), - sideEffect: true, - key: ({ id }: { id: string }) => `author-delete:${id}`, -}); - -// ── DERIVED QUERIES ───────────────────────────────────────────────────── - -/** Derived sorted view via Query schema -- globally memoized by MemoCache */ -export const sortedItemsQuery = new Query( - new All(ItemEntity), - (entries, { limit }: { limit?: number } = {}) => sortByLabel(entries, limit), -); - -// eslint-disable-next-line @typescript-eslint/no-empty-function -const neverResolve = () => new Promise(() => {}); - -/** Optimistic mutation - fetch never resolves; getOptimisticResponse applies immediately */ -export const updateItemOptimistic = new Endpoint( - (_params: { id: string; label: string }) => neverResolve(), - { - schema: ItemEntity, - sideEffect: true, - key: ({ id }: { id: string; label: string }) => `item-optimistic:${id}`, - getOptimisticResponse(snap: any, params: { id: string; label: string }) { - const existing = snap.get(ItemEntity, { id: params.id }); - if (!existing) throw snap.abort; - return { ...existing, label: params.label }; - }, - }, -); diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 838e872f1452..45012c7e0413 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { FIXTURE_ITEMS } from './data'; import { captureSnapshot, getReport } from './refStability'; import type { BenchAPI, UpdateAuthorOptions } from './types'; @@ -33,7 +32,7 @@ type LibraryActions = Pick & /** * Shared benchmark harness state, measurement helpers, and API registration. * - * Standard BenchAPI actions (mount, unmountAll, mountUnmountCycle, + * Standard BenchAPI actions (init, unmountAll, mountUnmountCycle, * getRenderedCount, captureRefSnapshot, getRefStabilityReport) are provided * as defaults via `registerAPI`. Libraries only need to supply their * specific actions and any overrides. @@ -42,7 +41,7 @@ type LibraryActions = Pick & * updating dependency arrays or registration boilerplate. */ export function useBenchState() { - const [ids, setIds] = useState([]); + const [listViewCount, setListViewCount] = useState(); const [showSortedView, setShowSortedView] = useState(false); const [sortedViewCount, setSortedViewCount] = useState(); const containerRef = useRef(null); @@ -107,16 +106,17 @@ export function useBenchState() { [setComplete], ); - const mount = useCallback( + const init = useCallback( (n: number) => { - const slicedIds = FIXTURE_ITEMS.slice(0, n).map(i => i.id); - measureMount(() => setIds(slicedIds)); + measureMount(() => { + setListViewCount(n); + }); }, [measureMount], ); const unmountAll = useCallback(() => { - setIds([]); + setListViewCount(undefined); setShowSortedView(false); setSortedViewCount(undefined); }, []); @@ -127,17 +127,20 @@ export function useBenchState() { const p = new Promise(r => { completeResolveRef.current = r; }); - mount(n); + init(n); await p; unmountAll(); await waitForPaint(); } setComplete(); }, - [mount, unmountAll, setComplete], + [init, unmountAll, setComplete], ); - const getRenderedCount = useCallback(() => ids.length, [ids]); + const getRenderedCount = useCallback( + () => listViewCount ?? 0, + [listViewCount], + ); const captureRefSnapshot = useCallback(() => captureSnapshot(), []); const getRefStabilityReport = useCallback(() => getReport(), []); @@ -145,11 +148,11 @@ export function useBenchState() { * Register the BenchAPI on window.__BENCH__ with standard actions as defaults. * Call during render (after defining library-specific actions). * Libraries only pass their own actions + any overrides; standard actions - * (mount, unmountAll, etc.) are included automatically. + * (init, unmountAll, etc.) are included automatically. */ const registerAPI = (libraryActions: LibraryActions) => { apiRef.current = { - mount, + init, unmountAll, mountUnmountCycle, getRenderedCount, @@ -172,7 +175,7 @@ export function useBenchState() { }, []); return { - ids, + listViewCount, showSortedView, sortedViewCount, containerRef, @@ -183,11 +186,10 @@ export function useBenchState() { setComplete, completeResolveRef, - setIds, + setListViewCount, setShowSortedView, setSortedViewCount, - mount, unmountAll, registerAPI, }; diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 05147655aa0f..37b683b28fe9 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -46,7 +46,7 @@ export function generateItems(count: number, authors: Author[]): Item[] { export const FIXTURE_AUTHORS = generateAuthors(20); /** Pre-generated fixture for benchmark - 10000 items, 20 shared authors */ -export const FIXTURE_ITEMS = generateItems(10000, FIXTURE_AUTHORS); +export const FIXTURE_ITEMS = generateItems(2000, FIXTURE_AUTHORS); /** O(1) item lookup by id (avoids linear scans inside measurement regions) */ export const FIXTURE_ITEMS_BY_ID = new Map(FIXTURE_ITEMS.map(i => [i.id, i])); diff --git a/examples/benchmark-react/src/shared/refStability.ts b/examples/benchmark-react/src/shared/refStability.ts index 055bea63c2a0..8d6efe78a586 100644 --- a/examples/benchmark-react/src/shared/refStability.ts +++ b/examples/benchmark-react/src/shared/refStability.ts @@ -1,31 +1,30 @@ import type { Author, Item, RefStabilityReport } from './types'; -const currentRefs: Record = {}; -let snapshotRefs: Record | null = null; +let currentItems: Item[] = []; +let snapshotRefs: Map | null = null; /** - * Register current (item, author) refs for a row. Call from each row on render. + * Store the current items array. Called from ListView during render. + * Only stores the reference — negligible cost. */ -export function registerRefs(id: string, item: Item, author: Author): void { - currentRefs[id] = { item, author }; +export function setCurrentItems(items: Item[]): void { + currentItems = items; } /** - * Copy current refs into snapshot. Call after mount, before running an update. + * Build a snapshot from current items. Call after mount, before running an update. */ export function captureSnapshot(): void { - snapshotRefs = { ...currentRefs }; + snapshotRefs = new Map(); + for (const item of currentItems) { + snapshotRefs.set(item.id, { item, author: item.author }); + } } /** - * Compare current refs to snapshot and return counts. Call after update completes. + * Compare current items to snapshot and return counts. Call after update completes. */ export function getReport(): RefStabilityReport { - let itemRefUnchanged = 0; - let itemRefChanged = 0; - let authorRefUnchanged = 0; - let authorRefChanged = 0; - if (!snapshotRefs) { return { itemRefUnchanged: 0, @@ -35,17 +34,21 @@ export function getReport(): RefStabilityReport { }; } - for (const id of Object.keys(currentRefs)) { - const current = currentRefs[id]; - const snap = snapshotRefs[id]; - if (!current || !snap) continue; + let itemRefUnchanged = 0; + let itemRefChanged = 0; + let authorRefUnchanged = 0; + let authorRefChanged = 0; + + for (const item of currentItems) { + const snap = snapshotRefs.get(item.id); + if (!snap) continue; - if (current.item === snap.item) { + if (item === snap.item) { itemRefUnchanged++; } else { itemRefChanged++; } - if (current.author === snap.author) { + if (item.author === snap.author) { authorRefUnchanged++; } else { authorRefChanged++; diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts new file mode 100644 index 000000000000..80a65aec23dd --- /dev/null +++ b/examples/benchmark-react/src/shared/resources.ts @@ -0,0 +1,105 @@ +import { Entity, All, Query, Collection } from '@data-client/endpoint'; +import type { PolymorphicInterface } from '@data-client/endpoint'; +import { resource } from '@data-client/rest'; +import { sortByLabel } from '@shared/data'; +import { + fetchItem as serverFetchItem, + fetchAuthor as serverFetchAuthor, + fetchItemList as serverFetchItemList, + createItem as serverCreateItem, + updateItem as serverUpdateItem, + deleteItem as serverDeleteItem, + updateAuthor as serverUpdateAuthor, + deleteAuthor as serverDeleteAuthor, +} from '@shared/server'; +import { Author } from '@shared/types'; + +export class AuthorEntity extends Entity { + id = ''; + login = ''; + name = ''; + + pk() { + return this.id; + } + + static key = 'AuthorEntity'; +} + +export class ItemEntity extends Entity { + id = ''; + label = ''; + author = AuthorEntity.fromJS(); + + pk() { + return this.id; + } + + static key = 'ItemEntity'; + static schema = { author: AuthorEntity }; +} + +class ItemCollection< + S extends any[] | PolymorphicInterface = any, + Parent extends any[] = [urlParams: any, body?: any], +> extends Collection { + nonFilterArgumentKeys(key: string) { + return key === 'count'; + } +} + +export const ItemResource = resource({ + path: '/items/:id', + schema: ItemEntity, + optimistic: true, + Collection: ItemCollection, +}).extend(Base => ({ + get: Base.get.extend({ + fetch: serverFetchItem as any, + dataExpiryLength: Infinity, + }), + getList: Base.getList.extend({ + fetch: serverFetchItemList, + dataExpiryLength: Infinity, + }), + update: Base.update.extend({ + fetch: ((params: any, body: any) => + serverUpdateItem({ ...params, ...body })) as any, + }), + delete: Base.delete.extend({ + fetch: serverDeleteItem as any, + }), + create: Base.getList.unshift.extend({ + fetch: serverCreateItem as any, + body: {} as { + label: string; + author: Author; + }, + }), +})); + +export const AuthorResource = resource({ + path: '/authors/:id', + schema: AuthorEntity, + optimistic: true, +}).extend(Base => ({ + get: Base.get.extend({ + fetch: serverFetchAuthor as any, + dataExpiryLength: Infinity, + }), + update: Base.update.extend({ + fetch: ((params: any, body: any) => + serverUpdateAuthor({ ...params, ...body })) as any, + }), + delete: Base.delete.extend({ + fetch: serverDeleteAuthor as any, + }), +})); + +// ── DERIVED QUERIES ───────────────────────────────────────────────────── + +/** Derived sorted view via Query schema -- globally memoized by MemoCache */ +export const sortedItemsQuery = new Query( + new All(ItemEntity), + (entries, { limit }: { limit?: number } = {}) => sortByLabel(entries, limit), +); diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index 8e3fda13406c..11dfccb26334 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -18,7 +18,16 @@ jsonStore.set('item:list', JSON.stringify(FIXTURE_ITEMS)); export function fetchItem({ id }: { id: string }): Promise { const json = jsonStore.get(`item:${id}`); if (!json) return Promise.reject(new Error(`No data for item:${id}`)); - return Promise.resolve(JSON.parse(json)); + const item: Item = JSON.parse(json); + // Join latest author data (like a real DB join) so callers always + // see the current author without eager propagation in updateAuthor. + if (item.author?.id) { + const authorJson = jsonStore.get(`author:${item.author.id}`); + if (authorJson) { + item.author = JSON.parse(authorJson); + } + } + return Promise.resolve(item); } export function fetchAuthor({ id }: { id: string }): Promise { @@ -27,10 +36,24 @@ export function fetchAuthor({ id }: { id: string }): Promise { return Promise.resolve(JSON.parse(json)); } -export function fetchItemList(): Promise { +export function fetchItemList(params?: { count?: number }): Promise { const json = jsonStore.get('item:list'); if (!json) return Promise.reject(new Error('No data for item:list')); - return Promise.resolve(JSON.parse(json)); + const listItems: Item[] = JSON.parse(json); + const sliced = params?.count ? listItems.slice(0, params.count) : listItems; + // Join latest item + author data (like a real DB-backed API) + const items = sliced.map(listItem => { + const itemJson = jsonStore.get(`item:${listItem.id}`); + const item: Item = itemJson ? JSON.parse(itemJson) : listItem; + if (item.author?.id) { + const authorJson = jsonStore.get(`author:${item.author.id}`); + if (authorJson) { + item.author = JSON.parse(authorJson); + } + } + return item; + }); + return Promise.resolve(items); } // ── CREATE ────────────────────────────────────────────────────────────── @@ -83,8 +106,8 @@ export function updateItem(params: { } /** - * Updates the author and all items that embed it (simulates a DB join -- - * subsequent GET /items/:id would return the fresh author data). + * Updates the author record only. Item reads join the latest author via + * fetchItem (like a real DB), so no eager O(n) propagation is needed. */ export function updateAuthor(params: { id: string; @@ -98,15 +121,6 @@ export function updateAuthor(params: { const json = JSON.stringify(updated); jsonStore.set(`author:${params.id}`, json); - // Propagate to all items embedding this author - for (const [key, itemJson] of jsonStore) { - if (!key.startsWith('item:') || key === 'item:list') continue; - const item: Item = JSON.parse(itemJson); - if (item.author?.id === params.id) { - jsonStore.set(key, JSON.stringify({ ...item, author: updated })); - } - } - return Promise.resolve(JSON.parse(json)); } @@ -114,6 +128,11 @@ export function updateAuthor(params: { export function deleteItem({ id }: { id: string }): Promise<{ id: string }> { jsonStore.delete(`item:${id}`); + const listJson = jsonStore.get('item:list'); + if (listJson) { + const list: Item[] = JSON.parse(listJson); + jsonStore.set('item:list', JSON.stringify(list.filter(i => i.id !== id))); + } return Promise.resolve({ id }); } @@ -122,10 +141,10 @@ export function deleteAuthor({ id }: { id: string }): Promise<{ id: string }> { return Promise.resolve({ id }); } -// ── BULK SEEDING ──────────────────────────────────────────────────────── +// ── SEEDING ───────────────────────────────────────────────────────────── -/** Seed bulk items into the store (for bulkIngest scenario). */ -export function seedBulkItems(items: Item[]): void { +/** Seed items into the store. */ +export function seedItems(items: Item[]): void { jsonStore.set('item:list', JSON.stringify(items)); for (const item of items) { jsonStore.set(`item:${item.id}`, JSON.stringify(item)); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 3a18e2672771..617557739cca 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -24,19 +24,20 @@ export interface UpdateAuthorOptions { * Benchmark API interface exposed by each library app on window.__BENCH__ */ export interface BenchAPI { - mount(count: number): void; + /** Show a ListView that auto-fetches count items. Measures fetch + normalization + render pipeline. */ + init(count: number): void; updateEntity(id: string): void; updateAuthor(id: string, options?: UpdateAuthorOptions): void; unmountAll(): void; getRenderedCount(): number; captureRefSnapshot(): void; getRefStabilityReport(): RefStabilityReport; + /** Legacy ids-based mount; optional — prefer init. */ + mount?(count: number): void; /** For memory scenarios: mount n items, unmount, repeat cycles times; resolves when done. */ mountUnmountCycle?(count: number, cycles: number): Promise; /** Optimistic update via getOptimisticResponse; sets data-bench-complete when painted. data-client only. */ optimisticUpdate?(): void; - /** Ingest fresh data into an empty cache at runtime, then render. Measures normalization + rendering pipeline. */ - bulkIngest?(count: number): void; /** Mount a sorted/derived view of items. Exercises Query memoization (data-client) vs useMemo sort (others). */ mountSortedView?(count: number): void; /** Invalidate a cached endpoint and immediately re-resolve. Measures Suspense round-trip. data-client only. */ @@ -66,12 +67,11 @@ export interface Item { } export type ScenarioAction = - | { action: 'mount'; args: [number] } + | { action: 'init'; args: [number] } | { action: 'updateEntity'; args: [string] } | { action: 'updateAuthor'; args: [string] } | { action: 'updateAuthor'; args: [string, UpdateAuthorOptions] } | { action: 'unmountAll'; args: [] } - | { action: 'bulkIngest'; args: [number] } | { action: 'createEntity'; args: [] } | { action: 'deleteEntity'; args: [string] }; @@ -84,6 +84,9 @@ export type ResultMetric = /** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. memory = heap delta, not CI. startup = page load metrics, not CI. */ export type ScenarioCategory = 'hotPath' | 'withNetwork' | 'memory' | 'startup'; +/** small = cheap scenarios (full warmup + measurement). large = expensive scenarios (reduced runs). */ +export type ScenarioSize = 'small' | 'large'; + export interface Scenario { name: string; action: keyof BenchAPI; @@ -92,6 +95,8 @@ export interface Scenario { resultMetric?: ResultMetric; /** hotPath (default) = run in CI. withNetwork = comparison only. memory = heap delta. startup = page load metrics. */ category?: ScenarioCategory; + /** small (default) = full runs. large = reduced warmup/measurement for expensive scenarios. */ + size?: ScenarioSize; /** For update scenarios: number of items to mount before running the update (default 100). */ mountCount?: number; /** Use a different BenchAPI method to pre-mount (e.g. 'mountSortedView' instead of 'mount'). */ diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 1dd0f56b99bc..eaeb984c9541 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,66 +1,32 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, - generateFreshData, sortByLabel, } from '@shared/data'; -import { registerRefs } from '@shared/refStability'; -import { - fetchItem, - fetchAuthor, - fetchItemList, - createItem, - updateItem, - updateAuthor as serverUpdateAuthor, - deleteItem, - seedBulkItems, - seedItemList, -} from '@shared/server'; +import { setCurrentItems } from '@shared/refStability'; +import { AuthorResource, ItemResource } from '@shared/resources'; +import { seedItemList } from '@shared/server'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; -import { List, type RowComponentProps } from 'react-window'; +import { List } from 'react-window'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; -/** SWR fetcher: dispatches to shared server functions based on cache key */ +/** SWR fetcher: dispatches to shared resource fetch methods based on cache key */ const fetcher = (key: string): Promise => { - if (key.startsWith('item:')) return fetchItem({ id: key.slice(5) }); - if (key.startsWith('author:')) return fetchAuthor({ id: key.slice(7) }); - if (key === 'items:all') return fetchItemList(); + if (key.startsWith('item:')) return ItemResource.get({ id: key.slice(5) }); + if (key.startsWith('author:')) + return AuthorResource.get({ id: key.slice(7) }); + if (key === 'items:all') return ItemResource.getList(); + if (key.startsWith('items:')) + return ItemResource.getList({ count: Number(key.slice(6)) }); return Promise.reject(new Error(`Unknown key: ${key}`)); }; -type CacheEntry = { - data: unknown; - isLoading: boolean; - isValidating: boolean; - error: undefined; -}; - -function makeCacheEntry(data: unknown): CacheEntry { - return { data, isLoading: false, isValidating: false, error: undefined }; -} - -const cache = new Map(); -for (const item of FIXTURE_ITEMS) { - cache.set(`item:${item.id}`, makeCacheEntry(item)); -} -for (const author of FIXTURE_AUTHORS) { - cache.set(`author:${author.id}`, makeCacheEntry(author)); -} -cache.set('items:all', makeCacheEntry(FIXTURE_ITEMS)); - -function ItemView({ id }: { id: string }) { - const { data: item } = useSWR(`item:${id}`, fetcher); - if (!item) return null; - registerRefs(id, item, item.author); - return ; -} - function SortedListView() { const { data: items } = useSWR('items:all', fetcher); const sorted = useMemo(() => (items ? sortByLabel(items) : []), [items]); @@ -77,28 +43,30 @@ function SortedListView() { ); } -function ItemListRow({ - index, - style, - ids, -}: RowComponentProps<{ ids: string[] }>) { +function ListView({ count }: { count: number }) { + const { data: items } = useSWR(`items:${count}`, fetcher); + if (!items) return null; + setCurrentItems(items); return ( -
- -
+ ); } function BenchmarkHarness() { const { mutate } = useSWRConfig(); const { - ids, + listViewCount, showSortedView, containerRef, measureMount, measureUpdate, measureUpdateWithDelay, - setIds, setShowSortedView, registerAPI, } = useBenchState(); @@ -108,12 +76,14 @@ function BenchmarkHarness() { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - updateItem({ id, label: `${item.label} (updated)` }).then(data => { - void mutate(`item:${id}`, data, false); - }); + ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( + () => { + void mutate(`items:${listViewCount}`); + }, + ); }); }, - [measureUpdate, mutate], + [measureUpdate, mutate, listViewCount], ); const updateAuthor = useCallback( @@ -121,87 +91,53 @@ function BenchmarkHarness() { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; measureUpdateWithDelay(options, () => { - serverUpdateAuthor({ - id: authorId, - name: `${author.name} (updated)`, - }).then(updatedAuthor => { - void mutate(`author:${authorId}`, updatedAuthor, false); - for (const item of FIXTURE_ITEMS) { - if (item.author.id === authorId) { - void mutate(`item:${item.id}`); - } - } + AuthorResource.update( + { id: authorId }, + { name: `${author.name} (updated)` }, + ).then(() => { + void mutate(`items:${listViewCount}`); }); }); }, - [measureUpdateWithDelay, mutate], + [measureUpdateWithDelay, mutate, listViewCount], ); const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { - createItem({ label: 'New Item', author }).then(created => { - cache.set(`item:${created.id}`, makeCacheEntry(created)); - void mutate('items:all'); - setIds(prev => [created.id, ...prev]); + ItemResource.create({ label: 'New Item', author }).then(() => { + void mutate(`items:${listViewCount}`); }); }); - }, [measureUpdate, mutate, setIds]); + }, [measureUpdate, mutate, listViewCount]); const deleteEntity = useCallback( (id: string) => { measureUpdate(() => { - deleteItem({ id }).then(() => { - cache.delete(`item:${id}`); - setIds(prev => prev.filter(i => i !== id)); + ItemResource.delete({ id }).then(() => { + void mutate(`items:${listViewCount}`); }); }); }, - [measureUpdate, setIds], - ); - - const bulkIngest = useCallback( - (n: number) => { - const { items } = generateFreshData(n); - seedBulkItems(items); - measureMount(() => { - fetchItemList().then(parsed => { - const fetchedItems = parsed as Item[]; - const seenAuthors = new Set(); - for (const item of fetchedItems) { - cache.set(`item:${item.id}`, makeCacheEntry(item)); - if (!seenAuthors.has(item.author.id)) { - seenAuthors.add(item.author.id); - cache.set( - `author:${item.author.id}`, - makeCacheEntry(item.author), - ); - } - } - setIds(fetchedItems.map(i => i.id)); - }); - }); - }, - [measureMount, setIds], + [measureUpdate, mutate, listViewCount], ); const mountSortedView = useCallback( (n: number) => { seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - fetchItemList().then(parsed => { - cache.set('items:all', makeCacheEntry(parsed)); + ItemResource.getList().then(parsed => { + void mutate('items:all', parsed, false); setShowSortedView(true); }); }); }, - [measureMount, setShowSortedView], + [measureMount, setShowSortedView, mutate], ); registerAPI({ updateEntity, updateAuthor, - bulkIngest, mountSortedView, createEntity, deleteEntity, @@ -209,13 +145,7 @@ function BenchmarkHarness() { return (
- + {listViewCount != null && } {showSortedView && }
); @@ -225,11 +155,9 @@ const rootEl = document.getElementById('root') ?? document.body; createRoot(rootEl).render( cache as any, revalidateOnFocus: false, revalidateOnReconnect: false, revalidateIfStale: false, - revalidateOnMount: false, }} > diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 5a94b10e10cd..a994206dcbbc 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,25 +1,15 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemRow, ItemsRow, LIST_STYLE } from '@shared/components'; +import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, - generateFreshData, sortByLabel, } from '@shared/data'; -import { registerRefs } from '@shared/refStability'; -import { - fetchItem, - fetchAuthor, - fetchItemList, - createItem, - updateItem, - updateAuthor as serverUpdateAuthor, - deleteItem, - seedBulkItems, - seedItemList, -} from '@shared/server'; +import { setCurrentItems } from '@shared/refStability'; +import { AuthorResource, ItemResource } from '@shared/resources'; +import { seedItemList } from '@shared/server'; import type { Item, UpdateAuthorOptions } from '@shared/types'; import { QueryClient, @@ -29,26 +19,18 @@ import { } from '@tanstack/react-query'; import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; -import { List, type RowComponentProps } from 'react-window'; +import { List } from 'react-window'; function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { - const [type, id] = queryKey as string[]; - if (type === 'item' && id) return fetchItem({ id }); - if (type === 'author' && id) return fetchAuthor({ id }); - if (type === 'items') return fetchItemList(); + const [type, id] = queryKey as [string, string | number | undefined]; + if (type === 'item' && id) return ItemResource.get({ id: String(id) }); + if (type === 'author' && id) return AuthorResource.get({ id: String(id) }); + if (type === 'items' && typeof id === 'number') + return ItemResource.getList({ count: id }); + if (type === 'items') return ItemResource.getList(); return Promise.reject(new Error(`Unknown queryKey: ${queryKey}`)); } -function seedCache(client: QueryClient) { - for (const item of FIXTURE_ITEMS) { - client.setQueryData(['item', item.id], item); - } - for (const author of FIXTURE_AUTHORS) { - client.setQueryData(['author', author.id], author); - } - client.setQueryData(['items', 'all'], FIXTURE_ITEMS); -} - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -57,18 +39,6 @@ const queryClient = new QueryClient({ }, }, }); -seedCache(queryClient); - -function ItemView({ id }: { id: string }) { - const { data: item } = useQuery({ - queryKey: ['item', id], - queryFn, - }); - if (!item) return null; - const itemAsItem = item as Item; - registerRefs(id, itemAsItem, itemAsItem.author); - return ; -} function SortedListView() { const { data: items } = useQuery({ @@ -92,28 +62,34 @@ function SortedListView() { ); } -function ItemListRow({ - index, - style, - ids, -}: RowComponentProps<{ ids: string[] }>) { +function ListView({ count }: { count: number }) { + const { data: items } = useQuery({ + queryKey: ['items', count], + queryFn, + }); + if (!items) return null; + const list = items as Item[]; + setCurrentItems(list); return ( -
- -
+ ); } function BenchmarkHarness() { const client = useQueryClient(); const { - ids, + listViewCount, showSortedView, containerRef, measureMount, measureUpdate, measureUpdateWithDelay, - setIds, setShowSortedView, registerAPI, } = useBenchState(); @@ -123,12 +99,16 @@ function BenchmarkHarness() { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => { - updateItem({ id, label: `${item.label} (updated)` }).then(data => { - client.setQueryData(['item', id], data); - }); + ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( + () => { + void client.invalidateQueries({ + queryKey: ['items', listViewCount], + }); + }, + ); }); }, - [measureUpdate, client], + [measureUpdate, client, listViewCount], ); const updateAuthor = useCallback( @@ -136,65 +116,39 @@ function BenchmarkHarness() { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; measureUpdateWithDelay(options, () => { - serverUpdateAuthor({ - id: authorId, - name: `${author.name} (updated)`, - }).then(updatedAuthor => { - client.setQueryData(['author', authorId], updatedAuthor); - for (const item of FIXTURE_ITEMS) { - if (item.author.id === authorId) { - client.refetchQueries({ queryKey: ['item', item.id] }); - } - } + AuthorResource.update( + { id: authorId }, + { name: `${author.name} (updated)` }, + ).then(() => { + void client.invalidateQueries({ + queryKey: ['items', listViewCount], + }); }); }); }, - [measureUpdateWithDelay, client], + [measureUpdateWithDelay, client, listViewCount], ); const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { - createItem({ label: 'New Item', author }).then(created => { - client.setQueryData(['item', created.id], created); - void client.invalidateQueries({ queryKey: ['items', 'all'] }); - setIds(prev => [created.id, ...prev]); + ItemResource.create({ label: 'New Item', author }).then(() => { + void client.invalidateQueries({ queryKey: ['items', listViewCount] }); }); }); - }, [measureUpdate, client, setIds]); + }, [measureUpdate, client, listViewCount]); const deleteEntity = useCallback( (id: string) => { measureUpdate(() => { - deleteItem({ id }).then(() => { - client.removeQueries({ queryKey: ['item', id] }); - setIds(prev => prev.filter(i => i !== id)); - }); - }); - }, - [measureUpdate, client, setIds], - ); - - const bulkIngest = useCallback( - (n: number) => { - const { items } = generateFreshData(n); - seedBulkItems(items); - measureMount(() => { - fetchItemList().then(parsed => { - const fetchedItems = parsed as Item[]; - const seenAuthors = new Set(); - for (const item of fetchedItems) { - client.setQueryData(['item', item.id], item); - if (!seenAuthors.has(item.author.id)) { - seenAuthors.add(item.author.id); - client.setQueryData(['author', item.author.id], item.author); - } - } - setIds(fetchedItems.map(i => i.id)); + ItemResource.delete({ id }).then(() => { + void client.invalidateQueries({ + queryKey: ['items', listViewCount], + }); }); }); }, - [measureMount, setIds, client], + [measureUpdate, client, listViewCount], ); const mountSortedView = useCallback( @@ -214,7 +168,6 @@ function BenchmarkHarness() { registerAPI({ updateEntity, updateAuthor, - bulkIngest, mountSortedView, createEntity, deleteEntity, @@ -222,13 +175,7 @@ function BenchmarkHarness() { return (
- + {listViewCount != null && } {showSortedView && }
); diff --git a/yarn.lock b/yarn.lock index 17b4c7474160..8ce5b5774989 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28828,16 +28828,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.9.3": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 - languageName: node - linkType: hard - "typescript@npm:6.0.1-rc": version: 6.0.1-rc resolution: "typescript@npm:6.0.1-rc" @@ -28858,16 +28848,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.9.3#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A6.0.1-rc#optional!builtin": version: 6.0.1-rc resolution: "typescript@patch:typescript@npm%3A6.0.1-rc#optional!builtin::version=6.0.1-rc&hash=5786d5" From f5ea0877ce90b1d7231516dda4328731f810238f Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 15:38:32 -0400 Subject: [PATCH 21/46] fix test conditions to be more accurate --- examples/benchmark-react/bench/runner.ts | 15 ++++- examples/benchmark-react/bench/scenarios.ts | 18 ++---- .../benchmark-react/src/baseline/index.tsx | 17 +++--- .../benchmark-react/src/data-client/index.tsx | 9 ++- .../src/shared/benchHarness.tsx | 37 +++--------- examples/benchmark-react/src/shared/server.ts | 59 ++++++++++++++++--- examples/benchmark-react/src/shared/types.ts | 18 ++---- examples/benchmark-react/src/swr/index.tsx | 22 ++++--- .../src/tanstack-query/index.tsx | 19 +++--- 9 files changed, 115 insertions(+), 99 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 24833513be27..0397cfdf2179 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -218,15 +218,28 @@ async function runScenario( categories: 'devtools.timeline,blink', }); } + + if (scenario.networkDelayMs) { + await (bench as any).evaluate( + (api: any, ms: number) => api.setNetworkDelay(ms), + scenario.networkDelayMs, + ); + } + await (bench as any).evaluate((api: any, s: any) => { api[s.action](...s.args); }, scenario); + const completeTimeout = scenario.networkDelayMs ? 60000 : 10000; await page.waitForSelector('[data-bench-complete]', { - timeout: 10000, + timeout: completeTimeout, state: 'attached', }); + if (scenario.networkDelayMs) { + await (bench as any).evaluate((api: any) => api.setNetworkDelay(0)); + } + let traceDuration: number | undefined; if (cdpTracing) { try { diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index aed256b3e14b..b1f61057fd86 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,7 +1,5 @@ import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; -export const SIMULATED_NETWORK_DELAY_MS = 50; - export const RUN_CONFIG: Record< ScenarioSize, { warmup: number; measurement: number } @@ -32,10 +30,10 @@ interface BaseScenario { mountCount?: number; /** Use a different BenchAPI method to pre-mount items (e.g. 'mountSortedView' instead of 'mount'). */ preMountAction?: keyof BenchAPI; - /** Override args per library (e.g. different request counts for withNetwork). */ - perLibArgs?: Partial>; /** Only run for these libraries. Omit to run for all. */ onlyLibs?: string[]; + /** Simulated per-request network latency in ms (applied at the server layer). */ + networkDelayMs?: number; } const BASE_SCENARIOS: BaseScenario[] = [ @@ -81,15 +79,10 @@ const BASE_SCENARIOS: BaseScenario[] = [ { nameSuffix: 'update-shared-author-with-network', action: 'updateAuthor', - args: [ - 'author-0', - { - simulateNetworkDelayMs: SIMULATED_NETWORK_DELAY_MS, - simulatedRequestCount: 1, - }, - ], + args: ['author-0'], category: 'withNetwork', size: 'large', + networkDelayMs: 50, }, { nameSuffix: 'update-shared-author-500-mounted', @@ -175,12 +168,13 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => (base): Scenario => ({ name: `${lib}: ${base.nameSuffix}`, action: base.action, - args: base.perLibArgs?.[lib] ?? base.args, + args: base.args, resultMetric: base.resultMetric, category: base.category, size: base.size, mountCount: base.mountCount, preMountAction: base.preMountAction, + networkDelayMs: base.networkDelayMs, }), ), ); diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index c08844718162..2523a181c3ef 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -10,7 +10,7 @@ import { import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource } from '@shared/resources'; import { seedItemList } from '@shared/server'; -import type { Item, UpdateAuthorOptions } from '@shared/types'; +import type { Item } from '@shared/types'; import React, { useCallback, useContext, @@ -65,7 +65,6 @@ function BenchmarkHarness() { containerRef, measureMount, measureUpdate, - measureUpdateWithDelay, setShowSortedView, unmountAll: unmountBase, registerAPI, @@ -99,19 +98,19 @@ function BenchmarkHarness() { ); const updateAuthor = useCallback( - (authorId: string, options?: UpdateAuthorOptions) => { + (authorId: string) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - measureUpdateWithDelay(options, () => { + measureUpdate(() => AuthorResource.update( { id: authorId }, { name: `${author.name} (updated)` }, - ).then(() => { - ItemResource.getList({ count: listViewCount! }).then(setItems); - }); - }); + ).then(() => + ItemResource.getList({ count: listViewCount! }).then(setItems), + ), + ); }, - [measureUpdateWithDelay, listViewCount], + [measureUpdate, listViewCount], ); const createEntity = useCallback(() => { diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 627958e99e64..68ea54190218 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -19,7 +19,7 @@ import { ItemResource, sortedItemsQuery, } from '@shared/resources'; -import type { Item, UpdateAuthorOptions } from '@shared/types'; +import type { Item } from '@shared/types'; import React, { useCallback } from 'react'; import { createRoot } from 'react-dom/client'; import { List } from 'react-window'; @@ -66,7 +66,6 @@ function BenchmarkHarness() { sortedViewCount, containerRef, measureUpdate, - measureUpdateWithDelay, measureMount, setShowSortedView, setSortedViewCount, @@ -89,10 +88,10 @@ function BenchmarkHarness() { ); const updateAuthor = useCallback( - (authorId: string, options?: UpdateAuthorOptions) => { + (authorId: string) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - measureUpdateWithDelay(options, () => { + measureUpdate(() => { controller.fetch( AuthorResource.update, { id: authorId }, @@ -100,7 +99,7 @@ function BenchmarkHarness() { ); }); }, - [measureUpdateWithDelay, controller], + [measureUpdate, controller], ); const createEntity = useCallback(() => { diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 45012c7e0413..ef69e3c4afe4 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { captureSnapshot, getReport } from './refStability'; -import type { BenchAPI, UpdateAuthorOptions } from './types'; +import { setNetworkDelay } from './server'; +import type { BenchAPI } from './types'; export function afterPaint(fn: () => void): void { requestAnimationFrame(() => requestAnimationFrame(fn)); @@ -68,39 +69,19 @@ export function useBenchState() { ); const measureUpdate = useCallback( - (fn: () => void) => { - performance.mark('update-start'); - fn(); - afterPaint(() => { - performance.mark('update-end'); - performance.measure('update-duration', 'update-start', 'update-end'); - setComplete(); - }); - }, - [setComplete], - ); - - /** Like measureUpdate, but marks start before the delay and runs fn after it. */ - const measureUpdateWithDelay = useCallback( - (options: UpdateAuthorOptions | undefined, fn: () => void) => { + (fn: () => void | Promise) => { performance.mark('update-start'); - const delayMs = options?.simulateNetworkDelayMs ?? 0; - const requestCount = options?.simulatedRequestCount ?? 1; - const totalDelayMs = delayMs * requestCount; - - const doUpdate = () => { - fn(); + const result = fn(); + const finish = () => afterPaint(() => { performance.mark('update-end'); performance.measure('update-duration', 'update-start', 'update-end'); setComplete(); }); - }; - - if (totalDelayMs > 0) { - setTimeout(doUpdate, totalDelayMs); + if (result && typeof (result as any).then === 'function') { + (result as Promise).then(finish); } else { - doUpdate(); + finish(); } }, [setComplete], @@ -158,6 +139,7 @@ export function useBenchState() { getRenderedCount, captureRefSnapshot, getRefStabilityReport, + setNetworkDelay, ...libraryActions, } as BenchAPI; }; @@ -182,7 +164,6 @@ export function useBenchState() { measureMount, measureUpdate, - measureUpdateWithDelay, setComplete, completeResolveRef, diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index 11dfccb26334..733db5eb0839 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -1,6 +1,47 @@ import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from './data'; import type { Author, Item } from './types'; +// ── CONFIGURABLE NETWORK DELAY ────────────────────────────────────────── + +let networkDelayMs = 0; + +interface PendingDelay { + id: ReturnType; + resolve: (v: T) => void; + value: T; +} +const pendingDelays = new Set>(); + +function withDelay(value: T): Promise { + if (networkDelayMs <= 0) return Promise.resolve(value); + return new Promise(resolve => { + const entry: PendingDelay = { + id: setTimeout(() => { + pendingDelays.delete(entry); + resolve(value); + }, networkDelayMs), + resolve, + value, + }; + pendingDelays.add(entry); + }); +} + +/** + * Set simulated per-request network latency. Setting to 0 also flushes + * (immediately resolves) any pending delayed responses so no Promises leak. + */ +export function setNetworkDelay(ms: number) { + networkDelayMs = ms; + if (ms === 0) { + for (const entry of pendingDelays) { + clearTimeout(entry.id); + entry.resolve(entry.value); + } + pendingDelays.clear(); + } +} + /** Fake server: holds JSON response strings keyed by resource type + id */ export const jsonStore = new Map(); @@ -27,13 +68,13 @@ export function fetchItem({ id }: { id: string }): Promise { item.author = JSON.parse(authorJson); } } - return Promise.resolve(item); + return withDelay(item); } export function fetchAuthor({ id }: { id: string }): Promise { const json = jsonStore.get(`author:${id}`); if (!json) return Promise.reject(new Error(`No data for author:${id}`)); - return Promise.resolve(JSON.parse(json)); + return withDelay(JSON.parse(json) as Author); } export function fetchItemList(params?: { count?: number }): Promise { @@ -53,7 +94,7 @@ export function fetchItemList(params?: { count?: number }): Promise { } return item; }); - return Promise.resolve(items); + return withDelay(items); } // ── CREATE ────────────────────────────────────────────────────────────── @@ -73,7 +114,7 @@ export function createItem(body: { const list: Item[] = listJson ? JSON.parse(listJson) : []; list.unshift(item); jsonStore.set('item:list', JSON.stringify(list)); - return Promise.resolve(JSON.parse(json)); + return withDelay(JSON.parse(json) as Item); } let createAuthorCounter = 0; @@ -86,7 +127,7 @@ export function createAuthor(body: { const author: Author = { id, login: body.login, name: body.name }; const json = JSON.stringify(author); jsonStore.set(`author:${id}`, json); - return Promise.resolve(JSON.parse(json)); + return withDelay(JSON.parse(json) as Author); } // ── UPDATE ────────────────────────────────────────────────────────────── @@ -102,7 +143,7 @@ export function updateItem(params: { const updated: Item = { ...JSON.parse(existing), ...params }; const json = JSON.stringify(updated); jsonStore.set(`item:${params.id}`, json); - return Promise.resolve(JSON.parse(json)); + return withDelay(JSON.parse(json) as Item); } /** @@ -121,7 +162,7 @@ export function updateAuthor(params: { const json = JSON.stringify(updated); jsonStore.set(`author:${params.id}`, json); - return Promise.resolve(JSON.parse(json)); + return withDelay(JSON.parse(json) as Author); } // ── DELETE ─────────────────────────────────────────────────────────────── @@ -133,12 +174,12 @@ export function deleteItem({ id }: { id: string }): Promise<{ id: string }> { const list: Item[] = JSON.parse(listJson); jsonStore.set('item:list', JSON.stringify(list.filter(i => i.id !== id))); } - return Promise.resolve({ id }); + return withDelay({ id }); } export function deleteAuthor({ id }: { id: string }): Promise<{ id: string }> { jsonStore.delete(`author:${id}`); - return Promise.resolve({ id }); + return withDelay({ id }); } // ── SEEDING ───────────────────────────────────────────────────────────── diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 617557739cca..82a7527f4c67 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -9,17 +9,6 @@ export interface RefStabilityReport { authorRefChanged: number; } -/** - * Options for updateAuthor when simulating network (for comparison scenarios). - * Consistent delay so results are comparable across libraries. - */ -export interface UpdateAuthorOptions { - /** Simulated delay per "request" in ms (e.g. 50). */ - simulateNetworkDelayMs?: number; - /** Number of simulated round-trips (data-client = 1; non-normalized libs may need more). */ - simulatedRequestCount?: number; -} - /** * Benchmark API interface exposed by each library app on window.__BENCH__ */ @@ -27,7 +16,9 @@ export interface BenchAPI { /** Show a ListView that auto-fetches count items. Measures fetch + normalization + render pipeline. */ init(count: number): void; updateEntity(id: string): void; - updateAuthor(id: string, options?: UpdateAuthorOptions): void; + updateAuthor(id: string): void; + /** Set simulated per-request network latency (ms). 0 disables and flushes pending delays. */ + setNetworkDelay(ms: number): void; unmountAll(): void; getRenderedCount(): number; captureRefSnapshot(): void; @@ -70,7 +61,6 @@ export type ScenarioAction = | { action: 'init'; args: [number] } | { action: 'updateEntity'; args: [string] } | { action: 'updateAuthor'; args: [string] } - | { action: 'updateAuthor'; args: [string, UpdateAuthorOptions] } | { action: 'unmountAll'; args: [] } | { action: 'createEntity'; args: [] } | { action: 'deleteEntity'; args: [string] }; @@ -101,4 +91,6 @@ export interface Scenario { mountCount?: number; /** Use a different BenchAPI method to pre-mount (e.g. 'mountSortedView' instead of 'mount'). */ preMountAction?: keyof BenchAPI; + /** Simulated per-request network latency in ms (applied at the server layer). */ + networkDelayMs?: number; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index eaeb984c9541..2b8d6b7bb72e 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -10,7 +10,7 @@ import { import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource } from '@shared/resources'; import { seedItemList } from '@shared/server'; -import type { Item, UpdateAuthorOptions } from '@shared/types'; +import type { Item } from '@shared/types'; import React, { useCallback, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import { List } from 'react-window'; @@ -66,7 +66,6 @@ function BenchmarkHarness() { containerRef, measureMount, measureUpdate, - measureUpdateWithDelay, setShowSortedView, registerAPI, } = useBenchState(); @@ -87,19 +86,18 @@ function BenchmarkHarness() { ); const updateAuthor = useCallback( - (authorId: string, options?: UpdateAuthorOptions) => { + (authorId: string) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - measureUpdateWithDelay(options, () => { - AuthorResource.update( - { id: authorId }, - { name: `${author.name} (updated)` }, - ).then(() => { - void mutate(`items:${listViewCount}`); - }); - }); + measureUpdate( + () => + AuthorResource.update( + { id: authorId }, + { name: `${author.name} (updated)` }, + ).then(() => mutate(`items:${listViewCount}`)) as Promise, + ); }, - [measureUpdateWithDelay, mutate, listViewCount], + [measureUpdate, mutate, listViewCount], ); const createEntity = useCallback(() => { diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index a994206dcbbc..bc7d314452e5 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -10,7 +10,7 @@ import { import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource } from '@shared/resources'; import { seedItemList } from '@shared/server'; -import type { Item, UpdateAuthorOptions } from '@shared/types'; +import type { Item } from '@shared/types'; import { QueryClient, QueryClientProvider, @@ -89,7 +89,6 @@ function BenchmarkHarness() { containerRef, measureMount, measureUpdate, - measureUpdateWithDelay, setShowSortedView, registerAPI, } = useBenchState(); @@ -112,21 +111,21 @@ function BenchmarkHarness() { ); const updateAuthor = useCallback( - (authorId: string, options?: UpdateAuthorOptions) => { + (authorId: string) => { const author = FIXTURE_AUTHORS_BY_ID.get(authorId); if (!author) return; - measureUpdateWithDelay(options, () => { + measureUpdate(() => AuthorResource.update( { id: authorId }, { name: `${author.name} (updated)` }, - ).then(() => { - void client.invalidateQueries({ + ).then(() => + client.invalidateQueries({ queryKey: ['items', listViewCount], - }); - }); - }); + }), + ), + ); }, - [measureUpdateWithDelay, client, listViewCount], + [measureUpdate, client, listViewCount], ); const createEntity = useCallback(() => { From 6b15581850eb949e7ea13610caa6324413b12891 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 16:25:49 -0400 Subject: [PATCH 22/46] dynamic accuracy --- examples/benchmark-react/bench/runner.ts | 104 ++++++++++++++++--- examples/benchmark-react/bench/scenarios.ts | 32 ++++-- examples/benchmark-react/bench/stats.ts | 22 ++++ examples/benchmark-react/src/shared/types.ts | 2 + 4 files changed, 139 insertions(+), 21 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 0397cfdf2179..12d2a2a48f59 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -11,7 +11,7 @@ import { RUN_CONFIG, ACTION_GROUPS, } from './scenarios.js'; -import { computeStats } from './stats.js'; +import { computeStats, isConverged } from './stats.js'; import { parseTraceDuration } from './tracing.js'; import type { Scenario, ScenarioSize } from '../src/shared/types.js'; @@ -375,25 +375,72 @@ async function main() { const browser = await chromium.launch({ headless: true }); - // Run each size group with its own warmup/measurement counts - for (const [size, scenarios] of sizeGroups) { - const { warmup, measurement } = RUN_CONFIG[size]; - const totalRuns = warmup + measurement; + // Run deterministic scenarios once (no warmup needed) + const deterministicNames = new Set(); + const deterministicScenarios = SCENARIOS_TO_RUN.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); + 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(); + } + } - for (let round = 0; round < totalRuns; round++) { - const phase = round < warmup ? 'warmup' : 'measure'; - const phaseRound = round < warmup ? round + 1 : round - warmup + 1; - const phaseTotal = round < warmup ? warmup : measurement; + // Run each size group with adaptive per-scenario convergence + for (const [size, scenarios] of sizeGroups) { + const { warmup, minMeasurement, maxMeasurement, targetMarginPct } = + RUN_CONFIG[size]; + const nonDeterministic = scenarios.filter( + s => !deterministicNames.has(s.name), + ); + if (nonDeterministic.length === 0) continue; + + const maxRounds = warmup + maxMeasurement; + const converged = new Set(); + + for (let round = 0; round < maxRounds; round++) { + const isMeasure = round >= warmup; + const phase = isMeasure ? 'measure' : 'warmup'; + const phaseRound = isMeasure ? round - warmup + 1 : round + 1; + 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}/${totalRuns} (${phase} ${phaseRound}/${phaseTotal}) ──\n`, + `\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 scenarios) { - if (!scenario.name.startsWith(`${lib}:`)) continue; + for (const scenario of libScenarios) { try { const result = await runScenario(page, lib, scenario); results[scenario.name].push(result.value); @@ -401,12 +448,12 @@ async function main() { traceResults[scenario.name].push(result.traceDuration ?? NaN); scenarioDone++; process.stderr.write( - ` [${scenarioDone}/${scenarios.length}] ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}${result.reactCommit != null ? ` (commit ${result.reactCommit.toFixed(2)} ms)` : ''}\n`, + ` [${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}/${scenarios.length}] ${scenario.name} FAILED:`, + ` [${scenarioDone}/${active.length}] ${scenario.name} FAILED:`, err instanceof Error ? err.message : err, ); } @@ -414,6 +461,32 @@ async function main() { await context.close(); } + + // After each measurement round, check per-scenario convergence + if (isMeasure) { + for (const scenario of active) { + if ( + isConverged( + results[scenario.name], + warmup, + targetMarginPct, + minMeasurement, + ) + ) { + converged.add(scenario.name); + const nMeasured = results[scenario.name].length - warmup; + process.stderr.write( + ` [converged] ${scenario.name} after ${nMeasured} measurements\n`, + ); + } + } + if (converged.size === nonDeterministic.length) { + process.stderr.write( + `\n── All ${size} scenarios converged, stopping early ──\n`, + ); + break; + } + } } } @@ -458,7 +531,8 @@ async function main() { const report: BenchmarkResult[] = []; for (const scenario of SCENARIOS_TO_RUN) { const samples = results[scenario.name]; - const warmupRuns = RUN_CONFIG[scenario.size ?? 'small'].warmup; + const warmupRuns = + scenario.deterministic ? 0 : RUN_CONFIG[scenario.size ?? 'small'].warmup; if (samples.length <= warmupRuns) continue; const { median, range } = computeStats(samples, warmupRuns); const unit = scenarioUnit(scenario); diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index b1f61057fd86..f69db7ab1bd6 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,11 +1,26 @@ import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; -export const RUN_CONFIG: Record< - ScenarioSize, - { warmup: number; measurement: number } -> = { - small: { warmup: 3, measurement: process.env.CI ? 10 : 15 }, - large: { warmup: 1, measurement: process.env.CI ? 3 : 4 }, +export interface RunProfile { + warmup: number; + minMeasurement: number; + maxMeasurement: number; + /** Stop early when 95% CI margin is within this % of the median. */ + targetMarginPct: number; +} + +export const RUN_CONFIG: Record = { + small: { + warmup: 3, + minMeasurement: 3, + maxMeasurement: process.env.CI ? 10 : 20, + targetMarginPct: process.env.CI ? 15 : 10, + }, + large: { + warmup: 1, + minMeasurement: 2, + maxMeasurement: process.env.CI ? 4 : 8, + targetMarginPct: process.env.CI ? 20 : 15, + }, }; export const ACTION_GROUPS: Record = { @@ -34,6 +49,8 @@ interface BaseScenario { onlyLibs?: string[]; /** Simulated per-request network latency in ms (applied at the server layer). */ networkDelayMs?: number; + /** Result is deterministic (zero variance); run exactly once with no warmup. */ + deterministic?: boolean; } const BASE_SCENARIOS: BaseScenario[] = [ @@ -68,6 +85,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['item-0'], resultMetric: 'itemRefChanged', category: 'hotPath', + deterministic: true, }, { nameSuffix: 'ref-stability-author-changed', @@ -75,6 +93,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['author-0'], resultMetric: 'authorRefChanged', category: 'hotPath', + deterministic: true, }, { nameSuffix: 'update-shared-author-with-network', @@ -175,6 +194,7 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => mountCount: base.mountCount, preMountAction: base.preMountAction, networkDelayMs: base.networkDelayMs, + deterministic: base.deterministic, }), ), ); diff --git a/examples/benchmark-react/bench/stats.ts b/examples/benchmark-react/bench/stats.ts index 2ac242c41e9c..656eba75700a 100644 --- a/examples/benchmark-react/bench/stats.ts +++ b/examples/benchmark-react/bench/stats.ts @@ -1,3 +1,25 @@ +/** + * 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. + */ +export function isConverged( + samples: number[], + warmupCount: number, + 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; + if (mean === 0) return true; + const stdDev = Math.sqrt( + trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / trimmed.length, + ); + const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); + return (margin / Math.abs(mean)) * 100 <= targetMarginPct; +} + /** * Compute median, p95, and approximate 95% confidence interval from samples. * Discards warmup runs. diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 82a7527f4c67..5c3cef6f846e 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -93,4 +93,6 @@ export interface Scenario { preMountAction?: keyof BenchAPI; /** Simulated per-request network latency in ms (applied at the server layer). */ networkDelayMs?: number; + /** Result is deterministic (zero variance); run exactly once with no warmup. */ + deterministic?: boolean; } From 5f34537ab39465ddb91bfd66ea0301cb7bb9d1b2 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 16:53:54 -0400 Subject: [PATCH 23/46] fix bench measurements --- examples/benchmark-react/bench/validate.ts | 82 +++++++++++++++++++ .../benchmark-react/src/baseline/index.tsx | 30 ++++--- .../src/shared/benchHarness.tsx | 10 +++ examples/benchmark-react/src/swr/index.tsx | 30 ++++--- .../src/tanstack-query/index.tsx | 35 ++++---- 5 files changed, 137 insertions(+), 50 deletions(-) diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 395d6cf3b360..93d2bb89cb09 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -461,6 +461,88 @@ test( { onlyLibs: ['data-client'] }, ); +// ── TIMING VALIDATION ──────────────────────────────────────────────── +// Verify that when data-bench-complete fires (measurement ends), the DOM +// already reflects the update. A 100ms network delay makes timing bugs +// observable: if the measureUpdate callback doesn't return its promise +// chain, finish() fires via the sync-path double-rAF (~32ms) before the +// async fetch resolves. data-client passes because controller.fetch() +// dispatches optimistic updates to the store synchronously. + +test('updateEntity timing: DOM reflects change at measurement end', async (page, lib) => { + await initAndWaitForItems(page); + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateEntity('item-0')); + await waitForComplete(page); + + const labels = await getItemLabels(page); + assert( + labels['item-0']?.includes('(updated)') ?? false, + lib, + 'updateEntity timing', + `DOM not updated when data-bench-complete fired. ` + + `Ensure measureUpdate callback returns its promise chain.`, + ); + + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(0)); +}); + +test('createEntity timing: DOM reflects change at measurement end', async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.createEntity === 'function', + )) + ) + return; + + await initAndWaitForItems(page, 10); + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.createEntity!()); + await waitForComplete(page); + + const labels = await getItemLabels(page); + assert( + Object.values(labels).some(l => l === 'New Item'), + lib, + 'createEntity timing', + `"New Item" not in DOM when data-bench-complete fired. ` + + `Ensure measureUpdate callback returns its promise chain.`, + ); + + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(0)); +}); + +test('deleteEntity timing: DOM reflects change at measurement end', async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.deleteEntity === 'function', + )) + ) + return; + + await initAndWaitForItems(page, 10); + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.deleteEntity!('item-0')); + await waitForComplete(page); + + const labels = await getItemLabels(page); + assert( + !('item-0' in labels), + lib, + 'deleteEntity timing', + `item-0 still in DOM when data-bench-complete fired. ` + + `Ensure measureUpdate callback returns its promise chain.`, + ); + + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(0)); +}); + // ═══════════════════════════════════════════════════════════════════════════ // Runner // ═══════════════════════════════════════════════════════════════════════════ diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 2523a181c3ef..2107cf9ead62 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -86,13 +86,11 @@ function BenchmarkHarness() { (id: string) => { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; - measureUpdate(() => { + measureUpdate(() => ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => { - ItemResource.getList({ count: listViewCount! }).then(setItems); - }, - ); - }); + () => ItemResource.getList({ count: listViewCount! }).then(setItems), + ), + ); }, [measureUpdate, listViewCount], ); @@ -115,20 +113,20 @@ function BenchmarkHarness() { const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; - measureUpdate(() => { - ItemResource.create({ label: 'New Item', author }).then(() => { - ItemResource.getList({ count: listViewCount! }).then(setItems); - }); - }); + measureUpdate(() => + ItemResource.create({ label: 'New Item', author }).then(() => + ItemResource.getList({ count: listViewCount! }).then(setItems), + ), + ); }, [measureUpdate, listViewCount]); const deleteEntity = useCallback( (id: string) => { - measureUpdate(() => { - ItemResource.delete({ id }).then(() => { - ItemResource.getList({ count: listViewCount! }).then(setItems); - }); - }); + measureUpdate(() => + ItemResource.delete({ id }).then(() => + ItemResource.getList({ count: listViewCount! }).then(setItems), + ), + ); }, [measureUpdate, listViewCount], ); diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index ef69e3c4afe4..0ee94239df3f 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -68,6 +68,16 @@ export function useBenchState() { [setComplete], ); + /** + * Measure an update action. If the callback performs async work (fetch → + * setState), it MUST return the promise chain so finish() runs after the + * state update, not before. Sync dispatch (data-client's controller.fetch) + * legitimately returns void — the store update is synchronous. + * + * The timing-validation tests in validate.ts enforce this contract by + * injecting network delay and checking that the DOM is already updated + * when data-bench-complete fires. + */ const measureUpdate = useCallback( (fn: () => void | Promise) => { performance.mark('update-start'); diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 2b8d6b7bb72e..aea61a97f799 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -74,13 +74,11 @@ function BenchmarkHarness() { (id: string) => { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; - measureUpdate(() => { + measureUpdate(() => ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => { - void mutate(`items:${listViewCount}`); - }, - ); - }); + () => mutate(`items:${listViewCount}`), + ), + ); }, [measureUpdate, mutate, listViewCount], ); @@ -102,20 +100,20 @@ function BenchmarkHarness() { const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; - measureUpdate(() => { - ItemResource.create({ label: 'New Item', author }).then(() => { - void mutate(`items:${listViewCount}`); - }); - }); + measureUpdate(() => + ItemResource.create({ label: 'New Item', author }).then(() => + mutate(`items:${listViewCount}`), + ), + ); }, [measureUpdate, mutate, listViewCount]); const deleteEntity = useCallback( (id: string) => { - measureUpdate(() => { - ItemResource.delete({ id }).then(() => { - void mutate(`items:${listViewCount}`); - }); - }); + measureUpdate(() => + ItemResource.delete({ id }).then(() => + mutate(`items:${listViewCount}`), + ), + ); }, [measureUpdate, mutate, listViewCount], ); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index bc7d314452e5..ffd5ec8df360 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -97,15 +97,14 @@ function BenchmarkHarness() { (id: string) => { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; - measureUpdate(() => { + measureUpdate(() => ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => { - void client.invalidateQueries({ + () => + client.invalidateQueries({ queryKey: ['items', listViewCount], - }); - }, - ); - }); + }), + ), + ); }, [measureUpdate, client, listViewCount], ); @@ -130,22 +129,22 @@ function BenchmarkHarness() { const createEntity = useCallback(() => { const author = FIXTURE_AUTHORS[0]; - measureUpdate(() => { - ItemResource.create({ label: 'New Item', author }).then(() => { - void client.invalidateQueries({ queryKey: ['items', listViewCount] }); - }); - }); + measureUpdate(() => + ItemResource.create({ label: 'New Item', author }).then(() => + client.invalidateQueries({ queryKey: ['items', listViewCount] }), + ), + ); }, [measureUpdate, client, listViewCount]); const deleteEntity = useCallback( (id: string) => { - measureUpdate(() => { - ItemResource.delete({ id }).then(() => { - void client.invalidateQueries({ + measureUpdate(() => + ItemResource.delete({ id }).then(() => + client.invalidateQueries({ queryKey: ['items', listViewCount], - }); - }); - }); + }), + ), + ); }, [measureUpdate, client, listViewCount], ); From b0ab3fb94667bdd1b8636a98a1bfcb5850e7be05 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 17:04:59 -0400 Subject: [PATCH 24/46] remove unneeded bench --- examples/benchmark-react/README.md | 3 +- examples/benchmark-react/bench/runner.ts | 5 +--- examples/benchmark-react/bench/scenarios.ts | 14 +-------- examples/benchmark-react/bench/validate.ts | 29 ------------------- .../benchmark-react/src/data-client/index.tsx | 14 --------- examples/benchmark-react/src/shared/server.ts | 9 ------ examples/benchmark-react/src/shared/types.ts | 2 -- 7 files changed, 3 insertions(+), 73 deletions(-) diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 6d5b840d1327..7b80b680dba8 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -34,7 +34,6 @@ The repo has two benchmark suites: - **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. - **Sorted view mount** (`sorted-view-mount-500`) — Mount 500 items through a sorted/derived view. data-client uses `useQuery(sortedItemsQuery)` 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. -- **Optimistic update** (`optimistic-update`) — data-client only; applies an optimistic mutation via `getOptimisticResponse`. - **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)** @@ -164,7 +163,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): `init-100`, `update-single-entity`, `update-shared-author-duration`, `ref-stability-*`, `optimistic-update`, `invalidate-and-resolve`, `create-item`, `delete-item` + - **Small** (3 warmup + 15 measurement): `init-100`, `update-single-entity`, `update-shared-author-duration`, `ref-stability-*`, `invalidate-and-resolve`, `create-item`, `delete-item` - **Large** (1 warmup + 4 measurement): `init-500`, `update-shared-author-500-mounted`, `update-shared-author-2000-mounted`, `memory-mount-unmount-cycle`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` 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 12d2a2a48f59..805f69dfbb78 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -174,15 +174,13 @@ async function runScenario( const isUpdate = scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || - scenario.action === 'optimisticUpdate' || scenario.action === 'invalidateAndResolve' || scenario.action === 'createEntity' || scenario.action === 'deleteEntity'; const isRefStability = isRefStabilityScenario(scenario); const isInit = scenario.action === 'init'; - const mountCount = - scenario.mountCount ?? (scenario.action === 'optimisticUpdate' ? 1 : 100); + const mountCount = scenario.mountCount ?? 100; if (isUpdate || isRefStability) { const preMountAction = scenario.preMountAction ?? 'init'; await harness.evaluate(el => el.removeAttribute('data-bench-complete')); @@ -550,7 +548,6 @@ async function main() { (scenario.action === 'init' || scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || - scenario.action === 'optimisticUpdate' || scenario.action === 'mountSortedView' || scenario.action === 'invalidateAndResolve' || scenario.action === 'createEntity' || diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index f69db7ab1bd6..7375f21f310a 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -26,12 +26,7 @@ export const RUN_CONFIG: Record = { export const ACTION_GROUPS: Record = { mount: ['init', 'mountSortedView'], update: ['updateEntity', 'updateAuthor'], - mutation: [ - 'createEntity', - 'deleteEntity', - 'optimisticUpdate', - 'invalidateAndResolve', - ], + mutation: ['createEntity', 'deleteEntity', 'invalidateAndResolve'], memory: ['mountUnmountCycle'], }; @@ -119,13 +114,6 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'memory', size: 'large', }, - { - nameSuffix: 'optimistic-update', - action: 'optimisticUpdate', - args: [], - category: 'hotPath', - onlyLibs: ['data-client'], - }, { nameSuffix: 'sorted-view-mount-500', action: 'mountSortedView', diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 93d2bb89cb09..36f69b3e3137 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -378,35 +378,6 @@ test('deleteEntity removes an item', async (page, _lib) => { ); }); -// ── optimisticUpdate ───────────────────────────────────────────────────── - -test( - 'optimisticUpdate changes label immediately', - async (page, _lib) => { - if ( - !(await page.evaluate( - () => typeof window.__BENCH__?.optimisticUpdate === 'function', - )) - ) - return; - - await initAndWaitForItems(page, 10); - - await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.optimisticUpdate!()); - await waitForComplete(page); - - await waitFor( - page, - async () => - (await getItemLabels(page))['item-0']?.includes('(optimistic)') ?? - false, - 'item-0 label contains "(optimistic)"', - ); - }, - { onlyLibs: ['data-client'] }, -); - // ── mountSortedView ────────────────────────────────────────────────────── test('mountSortedView renders sorted list', async (page, _lib) => { diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 68ea54190218..06abf74084fc 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -10,7 +10,6 @@ import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, } from '@shared/data'; import { setCurrentItems } from '@shared/refStability'; @@ -121,18 +120,6 @@ function BenchmarkHarness() { [measureUpdate, controller], ); - const optimisticUpdate = useCallback(() => { - const item = FIXTURE_ITEMS[0]; - if (!item) return; - measureUpdate(() => { - controller.fetch( - ItemResource.update, - { id: item.id }, - { label: `${item.label} (optimistic)` }, - ); - }); - }, [measureUpdate, controller]); - const mountSortedView = useCallback( (n: number) => { measureMount(() => { @@ -157,7 +144,6 @@ function BenchmarkHarness() { registerAPI({ updateEntity, updateAuthor, - optimisticUpdate, mountSortedView, invalidateAndResolve, createEntity, diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index 733db5eb0839..ea2d97ee810e 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -184,15 +184,6 @@ export function deleteAuthor({ id }: { id: string }): Promise<{ id: string }> { // ── SEEDING ───────────────────────────────────────────────────────────── -/** Seed items into the store. */ -export function seedItems(items: Item[]): void { - jsonStore.set('item:list', JSON.stringify(items)); - for (const item of items) { - jsonStore.set(`item:${item.id}`, JSON.stringify(item)); - jsonStore.set(`author:${item.author.id}`, JSON.stringify(item.author)); - } -} - /** Seed a subset of fixture items for sorted view. */ export function seedItemList(items: Item[]): void { jsonStore.set('item:list', JSON.stringify(items)); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 5c3cef6f846e..697ff6650121 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -27,8 +27,6 @@ export interface BenchAPI { mount?(count: number): void; /** For memory scenarios: mount n items, unmount, repeat cycles times; resolves when done. */ mountUnmountCycle?(count: number, cycles: number): Promise; - /** Optimistic update via getOptimisticResponse; sets data-bench-complete when painted. data-client only. */ - optimisticUpdate?(): void; /** Mount a sorted/derived view of items. Exercises Query memoization (data-client) vs useMemo sort (others). */ mountSortedView?(count: number): void; /** Invalidate a cached endpoint and immediately re-resolve. Measures Suspense round-trip. data-client only. */ From ba040ef0993fd1cd626b5bd6fb879c788deeab31 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 19:56:26 -0400 Subject: [PATCH 25/46] fix measurement by eliminating paint timings from measurement --- examples/benchmark-react/bench/validate.ts | 2 +- .../benchmark-react/src/data-client/index.tsx | 55 +++++++++------ .../src/shared/benchHarness.tsx | 69 ++++++++++++------- .../benchmark-react/src/shared/components.tsx | 2 +- .../benchmark-react/src/shared/resources.ts | 8 ++- 5 files changed, 87 insertions(+), 49 deletions(-) diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 36f69b3e3137..65b508ed295f 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -212,7 +212,7 @@ test('updateEntity changes item label in DOM', async (page, lib) => { test('updateAuthor propagates to DOM', async (page, _lib) => { await initAndWaitForItems(page); - // The displayed column is author.login; updateAuthor changes author.name. + // The displayed column includes author.name; updateAuthor changes author.name. // Non-normalized libs refetch the whole list (which joins latest author). // Verify at minimum that items are still present after the operation. const labelsBefore = await getItemLabels(page); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 06abf74084fc..2fd70be8d99e 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,10 +1,4 @@ -import { - AsyncBoundary, - DataProvider, - useController, - useQuery, - useSuspense, -} from '@data-client/react'; +import { DataProvider, useController, useDLE } from '@data-client/react'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { @@ -16,8 +10,9 @@ import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource, - sortedItemsQuery, + sortedItemsEndpoint, } from '@shared/resources'; +import { jsonStore } from '@shared/server'; import type { Item } from '@shared/types'; import React, { useCallback } from 'react'; import { createRoot } from 'react-dom/client'; @@ -25,7 +20,7 @@ import { List } from 'react-window'; /** Renders items from the list endpoint (models rendering a list fetch response). */ function ListView({ count }: { count: number }) { - const items = useSuspense(ItemResource.getList, { count }); + const { data: items } = useDLE(ItemResource.getList, { count }); if (!items) return null; const list = items as Item[]; setCurrentItems(list); @@ -41,9 +36,9 @@ function ListView({ count }: { count: number }) { } /** Renders items sorted by label via Query schema (memoized by MemoCache). */ -function SortedListView({ count }: { count?: number }) { - const items = useQuery(sortedItemsQuery, { limit: count }); - if (!items) return null; +function SortedListView({ count }: { count: number }) { + const { data: items } = useDLE(sortedItemsEndpoint, { count }); + if (!items?.length) return null; return (
{ - const item = FIXTURE_ITEMS_BY_ID.get(id); - if (!item) return; - measureUpdate(() => { - controller.invalidate(ItemResource.get, { id }); - }); + // Tweak server data so the refetch returns different content, + // guaranteeing a visible DOM mutation for MutationObserver. + const raw = jsonStore.get(`item:${id}`); + if (raw) { + const item: Item = JSON.parse(raw); + item.label = `${item.label} (refetched)`; + jsonStore.set(`item:${id}`, JSON.stringify(item)); + } + measureUpdate( + () => { + controller.invalidate(ItemResource.getList, { + count: listViewCount!, + }); + }, + () => { + const el = containerRef.current!.querySelector( + `[data-item-id="${id}"] [data-label]`, + ); + return el?.textContent?.includes('(refetched)') ?? false; + }, + ); }, - [measureUpdate, controller], + [measureUpdate, controller, containerRef, listViewCount], ); registerAPI({ @@ -152,10 +163,10 @@ function BenchmarkHarness() { return (
- - {listViewCount != null && } - {showSortedView && } - + {listViewCount != null && } + {showSortedView && sortedViewCount != null && ( + + )}
); } diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 0ee94239df3f..fdd563e7c4ed 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -55,44 +55,67 @@ export function useBenchState() { containerRef.current?.setAttribute('data-bench-complete', 'true'); }, []); + /** + * 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. + */ const measureMount = useCallback( (fn: () => void) => { + const container = containerRef.current!; + 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); + setComplete(); + } + }); + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + const timer = setTimeout(() => { + observer.disconnect(); + }, 30000); performance.mark('mount-start'); fn(); - afterPaint(() => { - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }); }, [setComplete], ); /** - * Measure an update action. If the callback performs async work (fetch → - * setState), it MUST return the promise chain so finish() runs after the - * state update, not before. Sync dispatch (data-client's controller.fetch) - * legitimately returns void — the store update is synchronous. + * Measure an update action via MutationObserver. Ends on the first DOM + * mutation in the container — React commits atomically so the first + * mutation batch IS the final state for updates. * - * The timing-validation tests in validate.ts enforce this contract by - * injecting network delay and checking that the DOM is already updated - * when data-bench-complete fires. + * For multi-phase scenarios like invalidateAndResolve (items disappear + * then reappear), pass an `isReady` predicate to wait for the final state. */ const measureUpdate = useCallback( - (fn: () => void | Promise) => { - performance.mark('update-start'); - const result = fn(); - const finish = () => - afterPaint(() => { + (fn: () => void, isReady?: () => boolean) => { + const container = containerRef.current!; + const observer = new MutationObserver(() => { + if (!isReady || isReady()) { performance.mark('update-end'); performance.measure('update-duration', 'update-start', 'update-end'); + observer.disconnect(); + clearTimeout(timer); setComplete(); - }); - if (result && typeof (result as any).then === 'function') { - (result as Promise).then(finish); - } else { - finish(); - } + } + }); + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + const timer = setTimeout(() => { + observer.disconnect(); + }, 30000); + performance.mark('update-start'); + fn(); }, [setComplete], ); diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 81467cc2b8bf..d08ed006f472 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -15,7 +15,7 @@ export function ItemRow({ item }: { item: Item }) { return (
{item.label} - {item.author.login} + {item.author.name}
); } diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index 80a65aec23dd..c3cc70cb21c8 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -100,6 +100,10 @@ export const AuthorResource = resource({ /** Derived sorted view via Query schema -- globally memoized by MemoCache */ export const sortedItemsQuery = new Query( - new All(ItemEntity), - (entries, { limit }: { limit?: number } = {}) => sortByLabel(entries, limit), + ItemResource.getList.schema, + (entries, { count }: { count?: number } = {}) => sortByLabel(entries, count), ); + +export const sortedItemsEndpoint = ItemResource.getList.extend({ + schema: sortedItemsQuery.schema, +}); From c9d5d60f2dbf29bad36d60956bd49e1a381a9059 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 20:48:56 -0400 Subject: [PATCH 26/46] increase scale, remove redundant --- examples/benchmark-react/bench/runner.ts | 4 ++-- examples/benchmark-react/bench/scenarios.ts | 17 ++++++----------- examples/benchmark-react/bench/validate.ts | 16 ++++++++-------- examples/benchmark-react/src/baseline/index.tsx | 4 ++-- .../benchmark-react/src/data-client/index.tsx | 4 ++-- .../benchmark-react/src/shared/components.tsx | 2 +- examples/benchmark-react/src/shared/data.ts | 2 +- examples/benchmark-react/src/shared/types.ts | 6 +++--- examples/benchmark-react/src/swr/index.tsx | 4 ++-- .../src/tanstack-query/index.tsx | 4 ++-- examples/benchmark-react/webpack.config.cjs | 8 ++++---- 11 files changed, 33 insertions(+), 38 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 805f69dfbb78..e7989d33e42a 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -175,7 +175,7 @@ async function runScenario( scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || scenario.action === 'invalidateAndResolve' || - scenario.action === 'createEntity' || + scenario.action === 'unshiftItem' || scenario.action === 'deleteEntity'; const isRefStability = isRefStabilityScenario(scenario); const isInit = scenario.action === 'init'; @@ -550,7 +550,7 @@ async function main() { scenario.action === 'updateAuthor' || scenario.action === 'mountSortedView' || scenario.action === 'invalidateAndResolve' || - scenario.action === 'createEntity' || + scenario.action === 'unshiftItem' || scenario.action === 'deleteEntity') ) { const { median: rcMedian, range: rcRange } = computeStats( diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 7375f21f310a..a0630f9eaf2c 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -26,7 +26,7 @@ export const RUN_CONFIG: Record = { export const ACTION_GROUPS: Record = { mount: ['init', 'mountSortedView'], update: ['updateEntity', 'updateAuthor'], - mutation: ['createEntity', 'deleteEntity', 'invalidateAndResolve'], + mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve'], memory: ['mountUnmountCycle'], }; @@ -68,12 +68,6 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['item-0'], category: 'hotPath', }, - { - nameSuffix: 'update-shared-author-duration', - action: 'updateAuthor', - args: ['author-0'], - category: 'hotPath', - }, { nameSuffix: 'ref-stability-item-changed', action: 'updateEntity', @@ -95,6 +89,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ action: 'updateAuthor', args: ['author-0'], category: 'withNetwork', + mountCount: 500, size: 'large', networkDelayMs: 50, }, @@ -131,11 +126,11 @@ const BASE_SCENARIOS: BaseScenario[] = [ size: 'large', }, { - nameSuffix: 'update-shared-author-2000-mounted', + nameSuffix: 'update-shared-author-10000-mounted', action: 'updateAuthor', args: ['author-0'], category: 'hotPath', - mountCount: 2000, + mountCount: 10000, size: 'large', }, { @@ -146,8 +141,8 @@ const BASE_SCENARIOS: BaseScenario[] = [ onlyLibs: ['data-client'], }, { - nameSuffix: 'create-item', - action: 'createEntity', + nameSuffix: 'unshift-item', + action: 'unshiftItem', args: [], category: 'hotPath', mountCount: 100, diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 65b508ed295f..2d19ce02542e 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -319,12 +319,12 @@ test('ref-stability after updateAuthor', async (page, lib) => { ); }); -// ── createEntity ───────────────────────────────────────────────────────── +// ── unshiftItem ────────────────────────────────────────────────────────── -test('createEntity adds an item', async (page, _lib) => { +test('unshiftItem adds an item', async (page, _lib) => { if ( !(await page.evaluate( - () => typeof window.__BENCH__?.createEntity === 'function', + () => typeof window.__BENCH__?.unshiftItem === 'function', )) ) return; @@ -332,7 +332,7 @@ test('createEntity adds an item', async (page, _lib) => { await initAndWaitForItems(page, 10); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.createEntity!()); + await page.evaluate(() => window.__BENCH__!.unshiftItem!()); await waitForComplete(page); await waitFor( @@ -460,10 +460,10 @@ test('updateEntity timing: DOM reflects change at measurement end', async (page, await page.evaluate(() => window.__BENCH__!.setNetworkDelay(0)); }); -test('createEntity timing: DOM reflects change at measurement end', async (page, lib) => { +test('unshiftItem timing: DOM reflects change at measurement end', async (page, lib) => { if ( !(await page.evaluate( - () => typeof window.__BENCH__?.createEntity === 'function', + () => typeof window.__BENCH__?.unshiftItem === 'function', )) ) return; @@ -472,14 +472,14 @@ test('createEntity timing: DOM reflects change at measurement end', async (page, await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.createEntity!()); + await page.evaluate(() => window.__BENCH__!.unshiftItem!()); await waitForComplete(page); const labels = await getItemLabels(page); assert( Object.values(labels).some(l => l === 'New Item'), lib, - 'createEntity timing', + 'unshiftItem timing', `"New Item" not in DOM when data-bench-complete fired. ` + `Ensure measureUpdate callback returns its promise chain.`, ); diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 2107cf9ead62..cb304b5760fa 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -111,7 +111,7 @@ function BenchmarkHarness() { [measureUpdate, listViewCount], ); - const createEntity = useCallback(() => { + const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => ItemResource.create({ label: 'New Item', author }).then(() => @@ -149,7 +149,7 @@ function BenchmarkHarness() { updateAuthor, unmountAll, mountSortedView, - createEntity, + unshiftItem, deleteEntity, }); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 2fd70be8d99e..3b5c11815387 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -96,7 +96,7 @@ function BenchmarkHarness() { [measureUpdate, controller], ); - const createEntity = useCallback(() => { + const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { controller.fetch(ItemResource.create, { @@ -157,7 +157,7 @@ function BenchmarkHarness() { updateAuthor, mountSortedView, invalidateAndResolve, - createEntity, + unshiftItem, deleteEntity, }); diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index d08ed006f472..82c4f45ffcb8 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -4,7 +4,7 @@ import type { RowComponentProps } from 'react-window'; import type { Item } from './types'; export const ITEM_HEIGHT = 30; -export const VISIBLE_COUNT = 20; +export const VISIBLE_COUNT = 40; export const LIST_STYLE = { height: ITEM_HEIGHT * VISIBLE_COUNT } as const; /** diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 37b683b28fe9..05147655aa0f 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -46,7 +46,7 @@ export function generateItems(count: number, authors: Author[]): Item[] { export const FIXTURE_AUTHORS = generateAuthors(20); /** Pre-generated fixture for benchmark - 10000 items, 20 shared authors */ -export const FIXTURE_ITEMS = generateItems(2000, FIXTURE_AUTHORS); +export const FIXTURE_ITEMS = generateItems(10000, FIXTURE_AUTHORS); /** O(1) item lookup by id (avoids linear scans inside measurement regions) */ export const FIXTURE_ITEMS_BY_ID = new Map(FIXTURE_ITEMS.map(i => [i.id, i])); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 697ff6650121..d3c854fbfcf5 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -31,8 +31,8 @@ export interface BenchAPI { mountSortedView?(count: number): void; /** Invalidate a cached endpoint and immediately re-resolve. Measures Suspense round-trip. data-client only. */ invalidateAndResolve?(id: string): void; - /** Create a new item via mutation endpoint. */ - createEntity?(): void; + /** Prepend a new item via mutation endpoint. */ + unshiftItem?(): void; /** Delete an existing item via mutation endpoint. */ deleteEntity?(id: string): void; } @@ -60,7 +60,7 @@ export type ScenarioAction = | { action: 'updateEntity'; args: [string] } | { action: 'updateAuthor'; args: [string] } | { action: 'unmountAll'; args: [] } - | { action: 'createEntity'; args: [] } + | { action: 'unshiftItem'; args: [] } | { action: 'deleteEntity'; args: [string] }; export type ResultMetric = diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index aea61a97f799..6df4414a8d2a 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -98,7 +98,7 @@ function BenchmarkHarness() { [measureUpdate, mutate, listViewCount], ); - const createEntity = useCallback(() => { + const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => ItemResource.create({ label: 'New Item', author }).then(() => @@ -135,7 +135,7 @@ function BenchmarkHarness() { updateEntity, updateAuthor, mountSortedView, - createEntity, + unshiftItem, deleteEntity, }); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index ffd5ec8df360..c64036e5da48 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -127,7 +127,7 @@ function BenchmarkHarness() { [measureUpdate, client, listViewCount], ); - const createEntity = useCallback(() => { + const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => ItemResource.create({ label: 'New Item', author }).then(() => @@ -167,7 +167,7 @@ function BenchmarkHarness() { updateEntity, updateAuthor, mountSortedView, - createEntity, + unshiftItem, deleteEntity, }); diff --git a/examples/benchmark-react/webpack.config.cjs b/examples/benchmark-react/webpack.config.cjs index 674cd9de8d84..1c258cbd97e6 100644 --- a/examples/benchmark-react/webpack.config.cjs +++ b/examples/benchmark-react/webpack.config.cjs @@ -1,6 +1,6 @@ -const path = require('path'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); const { makeConfig } = require('@anansi/webpack-config'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const path = require('path'); const LIBRARIES = ['data-client', 'tanstack-query', 'swr', 'baseline']; @@ -26,7 +26,7 @@ module.exports = (env, argv) => { config.resolve.alias = { ...config.resolve.alias, '@shared': path.resolve(__dirname, 'src/shared'), - 'swr': require.resolve('swr'), + swr: require.resolve('swr'), }; config.entry = entries; @@ -34,7 +34,7 @@ module.exports = (env, argv) => { config.output.chunkFilename = '[name].chunk.js'; config.plugins = config.plugins.filter( - (p) => p.constructor.name !== 'HtmlWebpackPlugin', + p => p.constructor.name !== 'HtmlWebpackPlugin', ); for (const lib of LIBRARIES) { config.plugins.push( From 02810189f474cb244c6870b97196292e67dcb881 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 20:55:57 -0400 Subject: [PATCH 27/46] bench name updates --- .cursor/rules/benchmarking.mdc | 10 +++++----- examples/benchmark-react/README.md | 17 ++++++++--------- examples/benchmark-react/bench/scenarios.ts | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 1e7db5141804..61e4e3379a93 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -26,15 +26,15 @@ See `@examples/benchmark-react/README.md` for methodology, adding a new library, Use this mapping when deciding which React benchmark scenarios are relevant to a change: -- **Init scenarios** (`init-100`, `init-500`) +- **Get list scenarios** (`getlist-100`, `getlist-500`) - Exercises: full fetch + normalization + render pipeline (ListView auto-fetches from list endpoint) - Relevant for: `@data-client/react` hooks, `@data-client/core` store initialization - All libraries -- **Update propagation** (`update-single-entity`, `update-shared-author-duration`, `update-shared-author-500-mounted`, `update-shared-author-1000-mounted`) - - Exercises: store update → React rerender → DOM paint +- **Update propagation** (`update-single-entity`, `update-shared-author-500-mounted`, `update-shared-author-10000-mounted`) + - 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 author) + - All libraries (normalization advantage shows with shared author at scale) - **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) - Exercises: referential equality preservation through normalization @@ -58,7 +58,7 @@ Use this mapping when deciding which React benchmark scenarios are relevant to a | Category | Scenarios | Typical run-to-run spread | |---|---|---| -| **Stable** | `init-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | +| **Stable** | `getlist-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | | **Moderate** | `update-shared-author-*`, `sorted-view-update-*` | 5–10% | | **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10–25% | diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 7b80b680dba8..7ebfb9e4034d 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -11,7 +11,7 @@ The repo has two benchmark suites: ## Methodology -- **What we measure:** Wall-clock time from triggering an action (e.g. `init(100)` or `updateAuthor('author-0')`) until the harness sets `data-bench-complete` (after two `requestAnimationFrame` callbacks). Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. +- **What we measure:** Wall-clock time from triggering an action (e.g. `init(100)` or `updateAuthor('author-0')`) until a MutationObserver detects the expected DOM change in the benchmark container. Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. - **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update), ref stability (fewer new object references), and derived-view memoization (`Query` schema avoids re-sorting when entities haven't changed). See [js-framework-benchmark "How the duration is measured"](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. - **Statistical:** Warmup runs are discarded; we report median and 95% CI. Libraries are interleaved per round to reduce environmental variance. - **No CPU throttling:** Runs at native speed with more samples for statistical significance rather than artificial slowdown. Small (cheap) scenarios use 3 warmup + 15 measurement runs locally (10 in CI); large (expensive) scenarios use 1 warmup + 4 measurement runs. @@ -27,10 +27,9 @@ The repo has two benchmark suites: **Hot path (CI)** -- **Init** (`init-100`, `init-500`) — Time to show a ListView component that auto-fetches 100 or 500 items from the list endpoint, then renders (unit: ms). Exercises the full fetch + normalization + render pipeline. +- **Get list** (`getlist-100`, `getlist-500`) — Time to show a ListView component that auto-fetches 100 or 500 items 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 item and propagate to the UI (unit: ms). -- **Update shared author** (`update-shared-author-duration`) — 100 components, shared authors; update one author. Measures time to propagate (unit: ms). Normalized cache: one store update, all views of that author update. -- **Update shared author (scaling)** (`update-shared-author-500-mounted`, `update-shared-author-1000-mounted`) — Same update with 500/1000 mounted components to test subscriber scaling. +- **Update shared author (scaling)** (`update-shared-author-500-mounted`, `update-shared-author-10000-mounted`) — Update one shared author with 500 or 10,000 mounted items to test subscriber scaling. Normalized cache: one store update, all views of that author update. - **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. - **Sorted view mount** (`sorted-view-mount-500`) — Mount 500 items through a sorted/derived view. data-client uses `useQuery(sortedItemsQuery)` 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. @@ -55,8 +54,8 @@ These are approximate values to help calibrate expectations. Exact numbers vary | Scenario | data-client | tanstack-query | swr | baseline | |---|---|---|---|---| -| `init-100` | ~similar | ~similar | ~similar | ~similar | -| `update-shared-author-duration` (100 mounted) | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | Higher (list refetch) | +| `getlist-100` | ~similar | ~similar | ~similar | ~similar | +| `update-shared-author-500-mounted` | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | Higher (list refetch) | | `ref-stability-item-changed` (100 mounted) | ~1 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | | `ref-stability-author-changed` (100 mounted) | ~5 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | | `sorted-view-update-entity` | Fast (Query memoization skips re-sort) | Re-sorts on every item change | Re-sorts on every item change | Re-sorts on every item change | @@ -65,7 +64,7 @@ These are approximate values to help calibrate expectations. Exact numbers vary | Category | Scenarios | Typical run-to-run spread | |---|---|---| -| **Stable** | `init-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2-5% | +| **Stable** | `getlist-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2-5% | | **Moderate** | `update-shared-author-*`, `sorted-view-update-*` | 5-10% | | **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 10-25% | @@ -163,8 +162,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): `init-100`, `update-single-entity`, `update-shared-author-duration`, `ref-stability-*`, `invalidate-and-resolve`, `create-item`, `delete-item` - - **Large** (1 warmup + 4 measurement): `init-500`, `update-shared-author-500-mounted`, `update-shared-author-2000-mounted`, `memory-mount-unmount-cycle`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + - **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-author-500-mounted`, `update-shared-author-10000-mounted`, `memory-mount-unmount-cycle`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` 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/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index a0630f9eaf2c..d717c1ad5d8c 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -50,13 +50,13 @@ interface BaseScenario { const BASE_SCENARIOS: BaseScenario[] = [ { - nameSuffix: 'init-100', + nameSuffix: 'getlist-100', action: 'init', args: [100], category: 'hotPath', }, { - nameSuffix: 'init-500', + nameSuffix: 'getlist-500', action: 'init', args: [500], category: 'hotPath', From 0de0cafe4653c8b82b0592dfb7dbf420f12ccffe Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 21:19:45 -0400 Subject: [PATCH 28/46] more realistic data --- examples/benchmark-react/src/shared/data.ts | 41 +++++++++++++++++++ .../benchmark-react/src/shared/resources.ts | 11 +++++ examples/benchmark-react/src/shared/server.ts | 24 ++++++++++- examples/benchmark-react/src/shared/types.ts | 11 +++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 05147655aa0f..3643efb9a439 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -13,6 +13,20 @@ export function sortByLabel( * Generate authors - shared across items to stress normalization. * Fewer authors than items means many items share the same author reference. */ +const STATUSES: Item['status'][] = ['open', 'closed', 'in_progress']; +const TAG_POOL = [ + 'bug', + 'feature', + 'docs', + 'perf', + 'security', + 'ux', + 'refactor', + 'test', + 'infra', + 'deps', +]; + export function generateAuthors(count: number): Author[] { const authors: Author[] = []; for (let i = 0; i < count; i++) { @@ -20,6 +34,11 @@ export function generateAuthors(count: number): Author[] { id: `author-${i}`, login: `user${i}`, name: `User ${i}`, + avatarUrl: `https://avatars.example.com/u/${i}?s=64`, + email: `user${i}@example.com`, + bio: `Software engineer #${i}. Likes open source and coffee.`, + followers: (i * 137 + 42) % 10000, + createdAt: new Date(2020, 0, 1 + (i % 365)).toISOString(), }); } return authors; @@ -33,9 +52,19 @@ export function generateItems(count: number, authors: Author[]): Item[] { const items: Item[] = []; for (let i = 0; i < count; i++) { const author = authors[i % authors.length]; + const created = new Date(2023, i % 12, 1 + (i % 28)).toISOString(); items.push({ id: `item-${i}`, label: `Item ${i}`, + description: `Description for item ${i}: a moderately long text field that exercises serialization and storage overhead in the benchmark.`, + status: STATUSES[i % STATUSES.length], + priority: (i % 5) + 1, + tags: [ + TAG_POOL[i % TAG_POOL.length], + TAG_POOL[(i * 3 + 1) % TAG_POOL.length], + ], + createdAt: created, + updatedAt: new Date(2024, i % 12, 1 + (i % 28)).toISOString(), author: { ...author }, }); } @@ -70,14 +99,26 @@ export function generateFreshData( id: `fresh-author-${i}`, login: `freshuser${i}`, name: `Fresh User ${i}`, + avatarUrl: `https://avatars.example.com/u/fresh-${i}?s=64`, + email: `freshuser${i}@example.com`, + bio: `Fresh contributor #${i}.`, + followers: (i * 89 + 17) % 5000, + createdAt: new Date(2021, 6, 1 + (i % 28)).toISOString(), }); } const items: Item[] = []; for (let i = 0; i < itemCount; i++) { const author = authors[i % authorCount]; + const created = new Date(2024, i % 12, 1 + (i % 28)).toISOString(); items.push({ id: `fresh-item-${i}`, label: `Fresh Item ${i}`, + description: `Fresh item ${i} description with enough text to be realistic.`, + status: STATUSES[i % STATUSES.length], + priority: (i % 5) + 1, + tags: [TAG_POOL[i % TAG_POOL.length]], + createdAt: created, + updatedAt: created, author: { ...author }, }); } diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index c3cc70cb21c8..eae71988d8d7 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -18,6 +18,11 @@ export class AuthorEntity extends Entity { id = ''; login = ''; name = ''; + avatarUrl = ''; + email = ''; + bio = ''; + followers = 0; + createdAt = ''; pk() { return this.id; @@ -29,6 +34,12 @@ export class AuthorEntity extends Entity { export class ItemEntity extends Entity { id = ''; label = ''; + description = ''; + status: 'open' | 'closed' | 'in_progress' = 'open'; + priority = 0; + tags: string[] = []; + createdAt = ''; + updatedAt = ''; author = AuthorEntity.fromJS(); pk() { diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index ea2d97ee810e..e78f0b887a28 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -106,7 +106,18 @@ export function createItem(body: { author: Author; }): Promise { const id = `created-item-${createItemCounter++}`; - const item: Item = { id, label: body.label, author: body.author }; + const now = new Date().toISOString(); + const item: Item = { + id, + label: body.label, + description: '', + status: 'open', + priority: 3, + tags: [], + createdAt: now, + updatedAt: now, + author: body.author, + }; const json = JSON.stringify(item); jsonStore.set(`item:${id}`, json); // Prepend to item:list so refetching the list returns the new item first @@ -124,7 +135,16 @@ export function createAuthor(body: { name: string; }): Promise { const id = `created-author-${createAuthorCounter++}`; - const author: Author = { id, login: body.login, name: body.name }; + const author: Author = { + id, + login: body.login, + name: body.name, + avatarUrl: `https://avatars.example.com/u/${id}?s=64`, + email: `${body.login}@example.com`, + bio: '', + followers: 0, + createdAt: new Date().toISOString(), + }; const json = JSON.stringify(author); jsonStore.set(`author:${id}`, json); return withDelay(JSON.parse(json) as Author); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index d3c854fbfcf5..b23fbdd53079 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -47,11 +47,22 @@ export interface Author { id: string; login: string; name: string; + avatarUrl: string; + email: string; + bio: string; + followers: number; + createdAt: string; } export interface Item { id: string; label: string; + description: string; + status: 'open' | 'closed' | 'in_progress'; + priority: number; + tags: string[]; + createdAt: string; + updatedAt: string; author: Author; } From aed6a64a290ed90c86a8e99fe3bfa147fcd201ef Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 21:26:35 -0400 Subject: [PATCH 29/46] MutationObserver timeout silently fails without signaling completion --- examples/benchmark-react/src/shared/benchHarness.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index fdd563e7c4ed..9354753e2bbe 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -79,6 +79,7 @@ export function useBenchState() { }); const timer = setTimeout(() => { observer.disconnect(); + setComplete(); }, 30000); performance.mark('mount-start'); fn(); @@ -113,6 +114,7 @@ export function useBenchState() { }); const timer = setTimeout(() => { observer.disconnect(); + setComplete(); }, 30000); performance.mark('update-start'); fn(); From bb70f1eec449c5a98904f92af7b23645b3cb69ff Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 21:38:20 -0400 Subject: [PATCH 30/46] Make sorted mount consistent --- examples/benchmark-react/src/baseline/index.tsx | 11 ++++++----- examples/benchmark-react/src/data-client/index.tsx | 4 +++- examples/benchmark-react/src/shared/resources.ts | 2 +- examples/benchmark-react/src/swr/index.tsx | 8 +++----- examples/benchmark-react/src/tanstack-query/index.tsx | 9 +++------ 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index cb304b5760fa..342120fa4b72 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -27,8 +27,12 @@ const ItemsContext = React.createContext<{ }>(null as any); function SortedListView() { - const { items } = useContext(ItemsContext); + const { items, setItems } = useContext(ItemsContext); + useEffect(() => { + ItemResource.getList().then(setItems); + }, [setItems]); const sorted = useMemo(() => sortByLabel(items), [items]); + if (!sorted.length) return null; return (
{ seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - ItemResource.getList().then(fetched => { - setItems(fetched); - setShowSortedView(true); - }); + setShowSortedView(true); }); }, [measureMount, setShowSortedView], diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 3b5c11815387..f14ef7ff020a 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -4,6 +4,7 @@ import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, + FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, } from '@shared/data'; import { setCurrentItems } from '@shared/refStability'; @@ -12,7 +13,7 @@ import { ItemResource, sortedItemsEndpoint, } from '@shared/resources'; -import { jsonStore } from '@shared/server'; +import { jsonStore, seedItemList } from '@shared/server'; import type { Item } from '@shared/types'; import React, { useCallback } from 'react'; import { createRoot } from 'react-dom/client'; @@ -117,6 +118,7 @@ function BenchmarkHarness() { const mountSortedView = useCallback( (n: number) => { + seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { setSortedViewCount(n); setShowSortedView(true); diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index eae71988d8d7..bbd3252bbf95 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -116,5 +116,5 @@ export const sortedItemsQuery = new Query( ); export const sortedItemsEndpoint = ItemResource.getList.extend({ - schema: sortedItemsQuery.schema, + schema: sortedItemsQuery, }); diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 6df4414a8d2a..06a4c17be386 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -30,6 +30,7 @@ const fetcher = (key: string): Promise => { function SortedListView() { const { data: items } = useSWR('items:all', fetcher); const sorted = useMemo(() => (items ? sortByLabel(items) : []), [items]); + if (!sorted.length) return null; return (
{ seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - ItemResource.getList().then(parsed => { - void mutate('items:all', parsed, false); - setShowSortedView(true); - }); + setShowSortedView(true); }); }, - [measureMount, setShowSortedView, mutate], + [measureMount, setShowSortedView], ); registerAPI({ diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index c64036e5da48..1cd6c9450264 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -49,6 +49,7 @@ function SortedListView() { () => (items ? sortByLabel(items as Item[]) : []), [items], ); + if (!sorted.length) return null; return (
{ seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { - client - .fetchQuery({ queryKey: ['items', 'all'], queryFn, staleTime: 0 }) - .then(() => { - setShowSortedView(true); - }); + setShowSortedView(true); }); }, - [measureMount, setShowSortedView, client], + [measureMount, setShowSortedView], ); registerAPI({ From 3cd8079054b055f2ebd5b28d3242413cf17d337f Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 21:39:11 -0400 Subject: [PATCH 31/46] fix sorted-view-update-entity for some frameworks --- examples/benchmark-react/src/swr/index.tsx | 19 +++++++++++-------- .../src/tanstack-query/index.tsx | 16 ++++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 06a4c17be386..b8e019a2faf8 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -77,11 +77,12 @@ function BenchmarkHarness() { if (!item) return; measureUpdate(() => ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => mutate(`items:${listViewCount}`), + () => + mutate(key => typeof key === 'string' && key.startsWith('items:')), ), ); }, - [measureUpdate, mutate, listViewCount], + [measureUpdate, mutate], ); const updateAuthor = useCallback( @@ -93,30 +94,32 @@ function BenchmarkHarness() { AuthorResource.update( { id: authorId }, { name: `${author.name} (updated)` }, - ).then(() => mutate(`items:${listViewCount}`)) as Promise, + ).then(() => + mutate(key => typeof key === 'string' && key.startsWith('items:')), + ) as Promise, ); }, - [measureUpdate, mutate, listViewCount], + [measureUpdate, mutate], ); const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => ItemResource.create({ label: 'New Item', author }).then(() => - mutate(`items:${listViewCount}`), + mutate(key => typeof key === 'string' && key.startsWith('items:')), ), ); - }, [measureUpdate, mutate, listViewCount]); + }, [measureUpdate, mutate]); const deleteEntity = useCallback( (id: string) => { measureUpdate(() => ItemResource.delete({ id }).then(() => - mutate(`items:${listViewCount}`), + mutate(key => typeof key === 'string' && key.startsWith('items:')), ), ); }, - [measureUpdate, mutate, listViewCount], + [measureUpdate, mutate], ); const mountSortedView = useCallback( diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 1cd6c9450264..fb2e0d5056ef 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -102,12 +102,12 @@ function BenchmarkHarness() { ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( () => client.invalidateQueries({ - queryKey: ['items', listViewCount], + queryKey: ['items'], }), ), ); }, - [measureUpdate, client, listViewCount], + [measureUpdate, client], ); const updateAuthor = useCallback( @@ -120,34 +120,34 @@ function BenchmarkHarness() { { name: `${author.name} (updated)` }, ).then(() => client.invalidateQueries({ - queryKey: ['items', listViewCount], + queryKey: ['items'], }), ), ); }, - [measureUpdate, client, listViewCount], + [measureUpdate, client], ); const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => ItemResource.create({ label: 'New Item', author }).then(() => - client.invalidateQueries({ queryKey: ['items', listViewCount] }), + client.invalidateQueries({ queryKey: ['items'] }), ), ); - }, [measureUpdate, client, listViewCount]); + }, [measureUpdate, client]); const deleteEntity = useCallback( (id: string) => { measureUpdate(() => ItemResource.delete({ id }).then(() => client.invalidateQueries({ - queryKey: ['items', listViewCount], + queryKey: ['items'], }), ), ); }, - [measureUpdate, client, listViewCount], + [measureUpdate, client], ); const mountSortedView = useCallback( From d02338059776d42eaf54609842a073be9075eaa8 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 22:31:29 -0400 Subject: [PATCH 32/46] change reporting --- examples/benchmark-react/README.md | 6 ++- examples/benchmark-react/bench/runner.ts | 63 +++++++++++++++++++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md index 7ebfb9e4034d..f2b70cfbfa8a 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -137,7 +137,7 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve |---|---|---| | `--lib ` | `BENCH_LIB` | Comma-separated library names (e.g. `data-client,swr`) | | `--size ` | `BENCH_SIZE` | Run only `small` (cheap, full rigor) or `large` (expensive, reduced runs) scenarios | - | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`) or exact action name | + | `--action ` | `BENCH_ACTION` | Filter by action group (`mount`, `update`, `mutation`, `memory`) or exact action name. Memory is **not run by default**; use `--action memory` to include. | | `--scenario ` | `BENCH_SCENARIO` | Substring filter on scenario name | CLI flags take precedence over env vars. Examples: @@ -146,6 +146,7 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve yarn bench --lib data-client # only data-client yarn bench --size small # only cheap scenarios (full warmup/measurement) yarn bench --action mount # init, mountSortedView + yarn bench --action memory # memory-mount-unmount-cycle (heap delta; opt-in category) yarn bench --action update --lib swr # update scenarios for swr only yarn bench --scenario sorted-view # only sorted-view scenarios ``` @@ -163,7 +164,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-author-500-mounted`, `update-shared-author-10000-mounted`, `memory-mount-unmount-cycle`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + - **Large** (1 warmup + 4 measurement): `getlist-500`, `update-shared-author-500-mounted`, `update-shared-author-10000-mounted`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + - **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 e7989d33e42a..b37e6e25ceb8 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -62,6 +62,12 @@ function filterScenarios(scenarios: Scenario[]): { s.category !== 'memory' && s.category !== 'startup', ); + } else if ( + !actions || + !actions.some(a => a === 'memory' || a === 'mountUnmountCycle') + ) { + // Locally: exclude memory by default; use --action memory to include + filtered = filtered.filter(s => s.category !== 'memory'); } if (libs) { @@ -353,9 +359,13 @@ async function main() { process.exit(1); } - // Group scenarios by size for differentiated run counts + // 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 SCENARIOS_TO_RUN) { + for (const s of mainScenarios) { bySize[s.size ?? 'small'].push(s); } const sizeGroups = ( @@ -373,9 +383,9 @@ async function main() { const browser = await chromium.launch({ headless: true }); - // Run deterministic scenarios once (no warmup needed) + // Run deterministic scenarios once (no warmup needed) — main scenarios only const deterministicNames = new Set(); - const deterministicScenarios = SCENARIOS_TO_RUN.filter(s => s.deterministic); + const deterministicScenarios = mainScenarios.filter(s => s.deterministic); if (deterministicScenarios.length > 0) { process.stderr.write( `\n── Deterministic scenarios (${deterministicScenarios.length}) ──\n`, @@ -414,7 +424,7 @@ async function main() { 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; @@ -488,9 +498,50 @@ async function main() { } } + // Memory category: run in its own phase (opt-in via --action memory) + const MEMORY_WARMUP = 1; + const MEMORY_MEASUREMENTS = 3; + if (memoryScenarios.length > 0) { + 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'; + process.stderr.write( + `\n── Memory round ${round + 1}/${MEMORY_WARMUP + MEMORY_MEASUREMENTS} (${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); + 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 = !process.env.CI; + const includeStartup = false; // Bench not set up for startup metrics (FCP/task duration) if (includeStartup) { for (const lib of libraries) { startupResults[lib] = { fcp: [], tbt: [] }; From 41fcaa971c47024e111e59b475d399d0f961946a Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 23:10:57 -0400 Subject: [PATCH 33/46] move scenario --- examples/benchmark-react/bench/runner.ts | 6 +- examples/benchmark-react/bench/scenarios.ts | 10 +- examples/benchmark-react/bench/validate.ts | 70 ++++++++++++ .../benchmark-react/src/baseline/index.tsx | 102 +++++++++++++++++- .../benchmark-react/src/data-client/index.tsx | 56 +++++++++- .../src/shared/benchHarness.tsx | 19 ++++ .../benchmark-react/src/shared/components.tsx | 1 + .../benchmark-react/src/shared/resources.ts | 4 + examples/benchmark-react/src/shared/server.ts | 15 ++- examples/benchmark-react/src/shared/types.ts | 7 +- examples/benchmark-react/src/swr/index.tsx | 66 +++++++++++- .../src/tanstack-query/index.tsx | 62 ++++++++++- 12 files changed, 403 insertions(+), 15 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index b37e6e25ceb8..0060c6adeeeb 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -182,7 +182,8 @@ async function runScenario( scenario.action === 'updateAuthor' || scenario.action === 'invalidateAndResolve' || scenario.action === 'unshiftItem' || - scenario.action === 'deleteEntity'; + scenario.action === 'deleteEntity' || + scenario.action === 'moveItem'; const isRefStability = isRefStabilityScenario(scenario); const isInit = scenario.action === 'init'; @@ -602,7 +603,8 @@ async function main() { scenario.action === 'mountSortedView' || scenario.action === 'invalidateAndResolve' || scenario.action === 'unshiftItem' || - scenario.action === 'deleteEntity') + scenario.action === 'deleteEntity' || + scenario.action === 'moveItem') ) { const { median: rcMedian, range: rcRange } = computeStats( reactSamples, diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index d717c1ad5d8c..37e17e70f7f0 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -26,7 +26,7 @@ export const RUN_CONFIG: Record = { export const ACTION_GROUPS: Record = { mount: ['init', 'mountSortedView'], update: ['updateEntity', 'updateAuthor'], - mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve'], + mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], memory: ['mountUnmountCycle'], }; @@ -154,6 +154,14 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'hotPath', mountCount: 100, }, + { + nameSuffix: 'move-item', + action: 'moveItem', + args: ['item-0'], + category: 'hotPath', + mountCount: 100, + preMountAction: 'initDualList', + }, ]; export const LIBRARIES = [ diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 2d19ce02542e..bec79ef99586 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -432,6 +432,76 @@ test( { onlyLibs: ['data-client'] }, ); +// ── moveItem ───────────────────────────────────────────────────────── + +test('moveItem moves item between status lists', async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.moveItem === 'function', + )) + ) + return; + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.initDualList!(20)); + await waitForComplete(page); + + await waitFor( + page, + async () => + page.evaluate( + () => + document.querySelector( + '[data-status-list="open"] [data-bench-item]', + ) !== null && + document.querySelector( + '[data-status-list="closed"] [data-bench-item]', + ) !== null, + ), + 'both status lists rendered', + 5000, + ); + + // item-0 has status 'open' in fixture data + const inOpen = await page.evaluate( + () => + document.querySelector( + '[data-status-list="open"] [data-item-id="item-0"]', + ) !== null, + ); + assert(inOpen, lib, 'moveItem setup', 'item-0 not in open list'); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.moveItem!('item-0')); + await waitForComplete(page); + + await waitFor( + page, + async () => + page.evaluate( + () => + document.querySelector( + '[data-status-list="closed"] [data-item-id="item-0"]', + ) !== null, + ), + 'item-0 in closed list after move', + 5000, + ); + + const inOpenAfter = await page.evaluate( + () => + document.querySelector( + '[data-status-list="open"] [data-item-id="item-0"]', + ) !== null, + ); + assert( + !inOpenAfter, + lib, + 'moveItem removed from source', + 'item-0 still in open list after move', + ); +}); + // ── TIMING VALIDATION ──────────────────────────────────────────────── // Verify that when data-bench-complete fires (measurement ends), the DOM // already reflects the update. A 100ms network delay makes timing bugs diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 342120fa4b72..34904f5e84af 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -1,5 +1,10 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; +import { + DUAL_LIST_STYLE, + ITEM_HEIGHT, + ItemsRow, + LIST_STYLE, +} from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -61,11 +66,52 @@ function ListView() { ); } +const DualListContext = React.createContext<{ + openItems: Item[]; + closedItems: Item[]; + setOpenItems: React.Dispatch>; + setClosedItems: React.Dispatch>; +}>(null as any); + +function DualListView() { + const { openItems, closedItems } = useContext(DualListContext); + return ( +
+ {openItems.length > 0 && ( +
+ +
+ )} + {closedItems.length > 0 && ( +
+ +
+ )} +
+ ); +} + function BenchmarkHarness() { const [items, setItems] = useState([]); + const [openItems, setOpenItems] = useState([]); + const [closedItems, setClosedItems] = useState([]); const { listViewCount, showSortedView, + showDualList, + dualListCount, containerRef, measureMount, measureUpdate, @@ -81,9 +127,22 @@ function BenchmarkHarness() { } }, [listViewCount]); + useEffect(() => { + if (showDualList && dualListCount != null) { + ItemResource.getList({ status: 'open', count: dualListCount }).then( + setOpenItems, + ); + ItemResource.getList({ status: 'closed', count: dualListCount }).then( + setClosedItems, + ); + } + }, [showDualList, dualListCount]); + const unmountAll = useCallback(() => { unmountBase(); setItems([]); + setOpenItems([]); + setClosedItems([]); }, [unmountBase]); const updateEntity = useCallback( @@ -135,6 +194,33 @@ function BenchmarkHarness() { [measureUpdate, listViewCount], ); + const moveItem = useCallback( + (id: string) => { + measureUpdate( + () => + ItemResource.update({ id }, { status: 'closed' }).then(() => + Promise.all([ + ItemResource.getList({ + status: 'open', + count: dualListCount!, + }).then(setOpenItems), + ItemResource.getList({ + status: 'closed', + count: dualListCount!, + }).then(setClosedItems), + ]), + ), + () => { + const source = containerRef.current?.querySelector( + '[data-status-list="open"]', + ); + return source?.querySelector(`[data-item-id="${id}"]`) == null; + }, + ); + }, + [measureUpdate, dualListCount, containerRef], + ); + const mountSortedView = useCallback( (n: number) => { seedItemList(FIXTURE_ITEMS.slice(0, n)); @@ -152,14 +238,20 @@ function BenchmarkHarness() { mountSortedView, unshiftItem, deleteEntity, + moveItem, }); return ( -
- {listViewCount != null && } - {showSortedView && } -
+ +
+ {listViewCount != null && } + {showSortedView && } + {showDualList && dualListCount != null && } +
+
); } diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index f14ef7ff020a..db74983a480d 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,6 +1,11 @@ import { DataProvider, useController, useDLE } from '@data-client/react'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; +import { + DUAL_LIST_STYLE, + ITEM_HEIGHT, + ItemsRow, + LIST_STYLE, +} from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -53,12 +58,40 @@ function SortedListView({ count }: { count: number }) { ); } +function StatusListView({ status, count }: { status: string; count: number }) { + const { data: items } = useDLE(ItemResource.getList, { status, count }); + if (!items) return null; + const list = items as Item[]; + return ( +
+ +
+ ); +} + +function DualListView({ count }: { count: number }) { + return ( +
+ + +
+ ); +} + function BenchmarkHarness() { const controller = useController(); const { listViewCount, showSortedView, sortedViewCount, + showDualList, + dualListCount, containerRef, measureUpdate, measureMount, @@ -116,6 +149,23 @@ function BenchmarkHarness() { [measureUpdate, controller], ); + const moveItem = useCallback( + (id: string) => { + measureUpdate( + () => { + controller.fetch(ItemResource.move, { id }, { status: 'closed' }); + }, + () => { + const source = containerRef.current?.querySelector( + '[data-status-list="open"]', + ); + return source?.querySelector(`[data-item-id="${id}"]`) == null; + }, + ); + }, + [measureUpdate, controller, containerRef], + ); + const mountSortedView = useCallback( (n: number) => { seedItemList(FIXTURE_ITEMS.slice(0, n)); @@ -161,6 +211,7 @@ function BenchmarkHarness() { invalidateAndResolve, unshiftItem, deleteEntity, + moveItem, }); return ( @@ -169,6 +220,9 @@ function BenchmarkHarness() { {showSortedView && sortedViewCount != null && ( )} + {showDualList && dualListCount != null && ( + + )}
); } diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 9354753e2bbe..423f061295a1 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -45,6 +45,8 @@ export function useBenchState() { const [listViewCount, setListViewCount] = useState(); const [showSortedView, setShowSortedView] = useState(false); const [sortedViewCount, setSortedViewCount] = useState(); + const [showDualList, setShowDualList] = useState(false); + const [dualListCount, setDualListCount] = useState(); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const apiRef = useRef(null as any); @@ -135,8 +137,20 @@ export function useBenchState() { setListViewCount(undefined); setShowSortedView(false); setSortedViewCount(undefined); + setShowDualList(false); + setDualListCount(undefined); }, []); + const initDualList = useCallback( + (n: number) => { + measureMount(() => { + setDualListCount(n); + setShowDualList(true); + }); + }, + [measureMount], + ); + const mountUnmountCycle = useCallback( async (n: number, cycles: number) => { for (let i = 0; i < cycles; i++) { @@ -169,6 +183,7 @@ export function useBenchState() { const registerAPI = (libraryActions: LibraryActions) => { apiRef.current = { init, + initDualList, unmountAll, mountUnmountCycle, getRenderedCount, @@ -195,6 +210,8 @@ export function useBenchState() { listViewCount, showSortedView, sortedViewCount, + showDualList, + dualListCount, containerRef, measureMount, @@ -205,6 +222,8 @@ export function useBenchState() { setListViewCount, setShowSortedView, setSortedViewCount, + setShowDualList, + setDualListCount, unmountAll, registerAPI, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 82c4f45ffcb8..9a9a18362e3d 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -6,6 +6,7 @@ import type { Item } from './types'; export const ITEM_HEIGHT = 30; export const VISIBLE_COUNT = 40; export const LIST_STYLE = { height: ITEM_HEIGHT * VISIBLE_COUNT } as const; +export const DUAL_LIST_STYLE = { display: 'flex', gap: 8 } as const; /** * Pure presentational component - no data-fetching logic. diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index bbd3252bbf95..b3ee5173a3ba 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -87,6 +87,10 @@ export const ItemResource = resource({ author: Author; }, }), + move: Base.getList.move.extend({ + fetch: ((params: any, body: any) => + serverUpdateItem({ ...params, ...body })) as any, + }), })); export const AuthorResource = resource({ diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index e78f0b887a28..a887ea25259b 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -77,13 +77,15 @@ export function fetchAuthor({ id }: { id: string }): Promise { return withDelay(JSON.parse(json) as Author); } -export function fetchItemList(params?: { count?: number }): Promise { +export function fetchItemList(params?: { + count?: number; + status?: string; +}): Promise { const json = jsonStore.get('item:list'); if (!json) return Promise.reject(new Error('No data for item:list')); const listItems: Item[] = JSON.parse(json); - const sliced = params?.count ? listItems.slice(0, params.count) : listItems; // Join latest item + author data (like a real DB-backed API) - const items = sliced.map(listItem => { + let items = listItems.map(listItem => { const itemJson = jsonStore.get(`item:${listItem.id}`); const item: Item = itemJson ? JSON.parse(itemJson) : listItem; if (item.author?.id) { @@ -94,6 +96,12 @@ export function fetchItemList(params?: { count?: number }): Promise { } return item; }); + if (params?.status) { + items = items.filter(i => i.status === params.status); + } + if (params?.count) { + items = items.slice(0, params.count); + } return withDelay(items); } @@ -155,6 +163,7 @@ export function createAuthor(body: { export function updateItem(params: { id: string; label?: string; + status?: Item['status']; author?: Author; }): Promise { const existing = jsonStore.get(`item:${params.id}`); diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index b23fbdd53079..d1b5b93aba11 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -35,6 +35,10 @@ export interface BenchAPI { unshiftItem?(): void; /** Delete an existing item via mutation endpoint. */ deleteEntity?(id: string): void; + /** Mount two side-by-side lists filtered by status ('open' and 'closed'). */ + initDualList?(count: number): void; + /** Move an item from one status-filtered list to another. Exercises Collection.move (data-client) vs invalidate+refetch (others). */ + moveItem?(id: string): void; } declare global { @@ -72,7 +76,8 @@ export type ScenarioAction = | { action: 'updateAuthor'; args: [string] } | { action: 'unmountAll'; args: [] } | { action: 'unshiftItem'; args: [] } - | { action: 'deleteEntity'; args: [string] }; + | { action: 'deleteEntity'; args: [string] } + | { action: 'moveItem'; args: [string] }; export type ResultMetric = | 'duration' diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index b8e019a2faf8..b8c5a9601244 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,5 +1,10 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; +import { + DUAL_LIST_STYLE, + ITEM_HEIGHT, + ItemsRow, + LIST_STYLE, +} from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -22,6 +27,13 @@ const fetcher = (key: string): Promise => { if (key.startsWith('author:')) return AuthorResource.get({ id: key.slice(7) }); if (key === 'items:all') return ItemResource.getList(); + if (key.startsWith('items:status:')) { + const [status, count] = key.slice(13).split(':'); + return ItemResource.getList({ + status, + ...(count ? { count: Number(count) } : {}), + }); + } if (key.startsWith('items:')) return ItemResource.getList({ count: Number(key.slice(6)) }); return Promise.reject(new Error(`Unknown key: ${key}`)); @@ -59,11 +71,41 @@ function ListView({ count }: { count: number }) { ); } +function StatusListView({ status, count }: { status: string; count: number }) { + const { data: items } = useSWR( + `items:status:${status}:${count}`, + fetcher, + ); + if (!items) return null; + return ( +
+ +
+ ); +} + +function DualListView({ count }: { count: number }) { + return ( +
+ + +
+ ); +} + function BenchmarkHarness() { const { mutate } = useSWRConfig(); const { listViewCount, showSortedView, + showDualList, + dualListCount, containerRef, measureMount, measureUpdate, @@ -122,6 +164,24 @@ function BenchmarkHarness() { [measureUpdate, mutate], ); + const moveItem = useCallback( + (id: string) => { + measureUpdate( + () => + ItemResource.update({ id }, { status: 'closed' }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('items:')), + ), + () => { + const source = containerRef.current?.querySelector( + '[data-status-list="open"]', + ); + return source?.querySelector(`[data-item-id="${id}"]`) == null; + }, + ); + }, + [measureUpdate, mutate, containerRef], + ); + const mountSortedView = useCallback( (n: number) => { seedItemList(FIXTURE_ITEMS.slice(0, n)); @@ -138,12 +198,16 @@ function BenchmarkHarness() { mountSortedView, unshiftItem, deleteEntity, + moveItem, }); return (
{listViewCount != null && } {showSortedView && } + {showDualList && dualListCount != null && ( + + )}
); } diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index fb2e0d5056ef..6334dd8b3a9f 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,5 +1,10 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; -import { ITEM_HEIGHT, ItemsRow, LIST_STYLE } from '@shared/components'; +import { + DUAL_LIST_STYLE, + ITEM_HEIGHT, + ItemsRow, + LIST_STYLE, +} from '@shared/components'; import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, @@ -25,6 +30,8 @@ function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { const [type, id] = queryKey as [string, string | number | undefined]; if (type === 'item' && id) return ItemResource.get({ id: String(id) }); if (type === 'author' && id) return AuthorResource.get({ id: String(id) }); + if (type === 'items' && id && typeof id === 'object') + return ItemResource.getList(id as { status?: string; count?: number }); if (type === 'items' && typeof id === 'number') return ItemResource.getList({ count: id }); if (type === 'items') return ItemResource.getList(); @@ -82,11 +89,42 @@ function ListView({ count }: { count: number }) { ); } +function StatusListView({ status, count }: { status: string; count: number }) { + const { data: items } = useQuery({ + queryKey: ['items', { status, count }], + queryFn, + }); + if (!items) return null; + const list = items as Item[]; + return ( +
+ +
+ ); +} + +function DualListView({ count }: { count: number }) { + return ( +
+ + +
+ ); +} + function BenchmarkHarness() { const client = useQueryClient(); const { listViewCount, showSortedView, + showDualList, + dualListCount, containerRef, measureMount, measureUpdate, @@ -150,6 +188,24 @@ function BenchmarkHarness() { [measureUpdate, client], ); + const moveItem = useCallback( + (id: string) => { + measureUpdate( + () => + ItemResource.update({ id }, { status: 'closed' }).then(() => + client.invalidateQueries({ queryKey: ['items'] }), + ), + () => { + const source = containerRef.current?.querySelector( + '[data-status-list="open"]', + ); + return source?.querySelector(`[data-item-id="${id}"]`) == null; + }, + ); + }, + [measureUpdate, client, containerRef], + ); + const mountSortedView = useCallback( (n: number) => { seedItemList(FIXTURE_ITEMS.slice(0, n)); @@ -166,12 +222,16 @@ function BenchmarkHarness() { mountSortedView, unshiftItem, deleteEntity, + moveItem, }); return (
{listViewCount != null && } {showSortedView && } + {showDualList && dualListCount != null && ( + + )}
); } From 802d8a7277e61cda36b2f1355fef6c79eb46c30a Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 14 Mar 2026 23:17:48 -0400 Subject: [PATCH 34/46] Init scenarios capture wrong react-commit-update measurement --- examples/benchmark-react/bench/measure.ts | 2 +- examples/benchmark-react/bench/runner.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/benchmark-react/bench/measure.ts b/examples/benchmark-react/bench/measure.ts index e7b3c5f259df..f753c1f5c41d 100644 --- a/examples/benchmark-react/bench/measure.ts +++ b/examples/benchmark-react/bench/measure.ts @@ -27,6 +27,6 @@ export function getMeasureDuration( measures: PerformanceMeasure[], name: string, ): number { - const m = measures.find(x => x.name === name); + const m = measures.findLast(x => x.name === name); return m?.duration ?? 0; } diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 0060c6adeeeb..ce01099ce78f 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -231,6 +231,11 @@ async function runScenario( ); } + await page.evaluate(() => { + performance.clearMarks(); + performance.clearMeasures(); + }); + await (bench as any).evaluate((api: any, s: any) => { api[s.action](...s.args); }, scenario); From ac6bcc13a6f4b7e75feaefa0bc97c2a1bbd68019 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 15 Mar 2026 12:38:26 -0400 Subject: [PATCH 35/46] Upgrade benchmark baseline from dual list to triple list Made-with: Cursor --- examples/benchmark-react/bench/runner.ts | 6 +- examples/benchmark-react/bench/scenarios.ts | 4 +- examples/benchmark-react/bench/validate.ts | 2 +- .../benchmark-react/src/baseline/index.tsx | 67 ++++++++++++++----- .../benchmark-react/src/data-client/index.tsx | 54 ++++++++------- .../src/shared/benchHarness.tsx | 24 +++---- .../benchmark-react/src/shared/components.tsx | 2 +- examples/benchmark-react/src/shared/types.ts | 4 +- examples/benchmark-react/src/swr/index.tsx | 15 +++-- .../src/tanstack-query/index.tsx | 15 +++-- 10 files changed, 119 insertions(+), 74 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index ce01099ce78f..eedd6d9c3ef4 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -277,7 +277,10 @@ async function runScenario( } const measures = await collectMeasures(page); - const isMountLike = isInit || scenario.action === 'mountSortedView'; + const isMountLike = + isInit || + scenario.action === 'mountSortedView' || + scenario.action === 'initTripleList'; const duration = isMountLike ? getMeasureDuration(measures, 'mount-duration') @@ -603,6 +606,7 @@ async function main() { if ( reactSamples.length > 0 && (scenario.action === 'init' || + scenario.action === 'initTripleList' || scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || scenario.action === 'mountSortedView' || diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index 37e17e70f7f0..e3aecf4302ff 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -24,7 +24,7 @@ export const RUN_CONFIG: Record = { }; export const ACTION_GROUPS: Record = { - mount: ['init', 'mountSortedView'], + mount: ['init', 'initTripleList', 'mountSortedView'], update: ['updateEntity', 'updateAuthor'], mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], memory: ['mountUnmountCycle'], @@ -160,7 +160,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ args: ['item-0'], category: 'hotPath', mountCount: 100, - preMountAction: 'initDualList', + preMountAction: 'initTripleList', }, ]; diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index bec79ef99586..05a72999d221 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -443,7 +443,7 @@ test('moveItem moves item between status lists', async (page, lib) => { return; await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.initDualList!(20)); + await page.evaluate(() => window.__BENCH__!.initTripleList!(20)); await waitForComplete(page); await waitFor( diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 34904f5e84af..a189d7c29c17 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -1,6 +1,6 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { - DUAL_LIST_STYLE, + TRIPLE_LIST_STYLE, ITEM_HEIGHT, ItemsRow, LIST_STYLE, @@ -66,17 +66,20 @@ function ListView() { ); } -const DualListContext = React.createContext<{ +const TripleListContext = React.createContext<{ openItems: Item[]; closedItems: Item[]; + inProgressItems: Item[]; setOpenItems: React.Dispatch>; setClosedItems: React.Dispatch>; + setInProgressItems: React.Dispatch>; }>(null as any); -function DualListView() { - const { openItems, closedItems } = useContext(DualListContext); +function TripleListView() { + const { openItems, closedItems, inProgressItems } = + useContext(TripleListContext); return ( -
+
{openItems.length > 0 && (
)} + {inProgressItems.length > 0 && ( +
+ +
+ )}
); } @@ -107,11 +121,12 @@ function BenchmarkHarness() { const [items, setItems] = useState([]); const [openItems, setOpenItems] = useState([]); const [closedItems, setClosedItems] = useState([]); + const [inProgressItems, setInProgressItems] = useState([]); const { listViewCount, showSortedView, - showDualList, - dualListCount, + showTripleList, + tripleListCount, containerRef, measureMount, measureUpdate, @@ -128,21 +143,26 @@ function BenchmarkHarness() { }, [listViewCount]); useEffect(() => { - if (showDualList && dualListCount != null) { - ItemResource.getList({ status: 'open', count: dualListCount }).then( + if (showTripleList && tripleListCount != null) { + ItemResource.getList({ status: 'open', count: tripleListCount }).then( setOpenItems, ); - ItemResource.getList({ status: 'closed', count: dualListCount }).then( + ItemResource.getList({ status: 'closed', count: tripleListCount }).then( setClosedItems, ); + ItemResource.getList({ + status: 'in_progress', + count: tripleListCount, + }).then(setInProgressItems); } - }, [showDualList, dualListCount]); + }, [showTripleList, tripleListCount]); const unmountAll = useCallback(() => { unmountBase(); setItems([]); setOpenItems([]); setClosedItems([]); + setInProgressItems([]); }, [unmountBase]); const updateEntity = useCallback( @@ -202,12 +222,16 @@ function BenchmarkHarness() { Promise.all([ ItemResource.getList({ status: 'open', - count: dualListCount!, + count: tripleListCount!, }).then(setOpenItems), ItemResource.getList({ status: 'closed', - count: dualListCount!, + count: tripleListCount!, }).then(setClosedItems), + ItemResource.getList({ + status: 'in_progress', + count: tripleListCount!, + }).then(setInProgressItems), ]), ), () => { @@ -218,7 +242,7 @@ function BenchmarkHarness() { }, ); }, - [measureUpdate, dualListCount, containerRef], + [measureUpdate, tripleListCount, containerRef], ); const mountSortedView = useCallback( @@ -243,15 +267,22 @@ function BenchmarkHarness() { return ( -
{listViewCount != null && } {showSortedView && } - {showDualList && dualListCount != null && } + {showTripleList && tripleListCount != null && }
-
+
); } diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index db74983a480d..64ab6b6a0a21 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,7 +1,7 @@ import { DataProvider, useController, useDLE } from '@data-client/react'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { - DUAL_LIST_STYLE, + TRIPLE_LIST_STYLE, ITEM_HEIGHT, ItemsRow, LIST_STYLE, @@ -18,7 +18,7 @@ import { ItemResource, sortedItemsEndpoint, } from '@shared/resources'; -import { jsonStore, seedItemList } from '@shared/server'; +import { getItem, patchItem, seedItemList } from '@shared/server'; import type { Item } from '@shared/types'; import React, { useCallback } from 'react'; import { createRoot } from 'react-dom/client'; @@ -75,11 +75,12 @@ function StatusListView({ status, count }: { status: string; count: number }) { ); } -function DualListView({ count }: { count: number }) { +function TripleListView({ count }: { count: number }) { return ( -
+
+
); } @@ -90,8 +91,8 @@ function BenchmarkHarness() { listViewCount, showSortedView, sortedViewCount, - showDualList, - dualListCount, + showTripleList, + tripleListCount, containerRef, measureUpdate, measureMount, @@ -133,10 +134,14 @@ function BenchmarkHarness() { const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => { - controller.fetch(ItemResource.create, { - label: 'New Item', - author, - }); + (controller.fetch as any)( + ItemResource.create, + { status: 'open' }, + { + label: 'New Item', + author, + }, + ); }); }, [measureUpdate, controller]); @@ -179,19 +184,22 @@ function BenchmarkHarness() { const invalidateAndResolve = useCallback( (id: string) => { - // Tweak server data so the refetch returns different content, - // guaranteeing a visible DOM mutation for MutationObserver. - const raw = jsonStore.get(`item:${id}`); - if (raw) { - const item: Item = JSON.parse(raw); - item.label = `${item.label} (refetched)`; - jsonStore.set(`item:${id}`, JSON.stringify(item)); + const item = getItem(id); + if (item) { + patchItem(id, { label: `${item.label} (refetched)` }); } measureUpdate( () => { - controller.invalidate(ItemResource.getList, { - count: listViewCount!, - }); + if (tripleListCount != null) { + controller.invalidate(ItemResource.getList, { + status: 'open', + count: tripleListCount, + }); + } else { + controller.invalidate(ItemResource.getList, { + count: listViewCount!, + }); + } }, () => { const el = containerRef.current!.querySelector( @@ -201,7 +209,7 @@ function BenchmarkHarness() { }, ); }, - [measureUpdate, controller, containerRef, listViewCount], + [measureUpdate, controller, containerRef, tripleListCount, listViewCount], ); registerAPI({ @@ -220,8 +228,8 @@ function BenchmarkHarness() { {showSortedView && sortedViewCount != null && ( )} - {showDualList && dualListCount != null && ( - + {showTripleList && tripleListCount != null && ( + )}
); diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 423f061295a1..2ea236b77a9a 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -45,8 +45,8 @@ export function useBenchState() { const [listViewCount, setListViewCount] = useState(); const [showSortedView, setShowSortedView] = useState(false); const [sortedViewCount, setSortedViewCount] = useState(); - const [showDualList, setShowDualList] = useState(false); - const [dualListCount, setDualListCount] = useState(); + const [showTripleList, setShowTripleList] = useState(false); + const [tripleListCount, setTripleListCount] = useState(); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const apiRef = useRef(null as any); @@ -137,15 +137,15 @@ export function useBenchState() { setListViewCount(undefined); setShowSortedView(false); setSortedViewCount(undefined); - setShowDualList(false); - setDualListCount(undefined); + setShowTripleList(false); + setTripleListCount(undefined); }, []); - const initDualList = useCallback( + const initTripleList = useCallback( (n: number) => { measureMount(() => { - setDualListCount(n); - setShowDualList(true); + setTripleListCount(n); + setShowTripleList(true); }); }, [measureMount], @@ -183,7 +183,7 @@ export function useBenchState() { const registerAPI = (libraryActions: LibraryActions) => { apiRef.current = { init, - initDualList, + initTripleList, unmountAll, mountUnmountCycle, getRenderedCount, @@ -210,8 +210,8 @@ export function useBenchState() { listViewCount, showSortedView, sortedViewCount, - showDualList, - dualListCount, + showTripleList, + tripleListCount, containerRef, measureMount, @@ -222,8 +222,8 @@ export function useBenchState() { setListViewCount, setShowSortedView, setSortedViewCount, - setShowDualList, - setDualListCount, + setShowTripleList, + setTripleListCount, unmountAll, registerAPI, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 9a9a18362e3d..ff74e70224b5 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -6,7 +6,7 @@ import type { Item } from './types'; export const ITEM_HEIGHT = 30; export const VISIBLE_COUNT = 40; export const LIST_STYLE = { height: ITEM_HEIGHT * VISIBLE_COUNT } as const; -export const DUAL_LIST_STYLE = { display: 'flex', gap: 8 } as const; +export const TRIPLE_LIST_STYLE = { display: 'flex', gap: 8 } as const; /** * Pure presentational component - no data-fetching logic. diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index d1b5b93aba11..7cc7591bab0b 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -35,8 +35,8 @@ export interface BenchAPI { unshiftItem?(): void; /** Delete an existing item via mutation endpoint. */ deleteEntity?(id: string): void; - /** Mount two side-by-side lists filtered by status ('open' and 'closed'). */ - initDualList?(count: number): void; + /** Mount three side-by-side lists filtered by status ('open', 'closed', 'in_progress'). */ + initTripleList?(count: number): void; /** Move an item from one status-filtered list to another. Exercises Collection.move (data-client) vs invalidate+refetch (others). */ moveItem?(id: string): void; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index b8c5a9601244..7c9e2db41108 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,6 +1,6 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { - DUAL_LIST_STYLE, + TRIPLE_LIST_STYLE, ITEM_HEIGHT, ItemsRow, LIST_STYLE, @@ -90,11 +90,12 @@ function StatusListView({ status, count }: { status: string; count: number }) { ); } -function DualListView({ count }: { count: number }) { +function TripleListView({ count }: { count: number }) { return ( -
+
+
); } @@ -104,8 +105,8 @@ function BenchmarkHarness() { const { listViewCount, showSortedView, - showDualList, - dualListCount, + showTripleList, + tripleListCount, containerRef, measureMount, measureUpdate, @@ -205,8 +206,8 @@ function BenchmarkHarness() {
{listViewCount != null && } {showSortedView && } - {showDualList && dualListCount != null && ( - + {showTripleList && tripleListCount != null && ( + )}
); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 6334dd8b3a9f..eeef444d052e 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,6 +1,6 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { - DUAL_LIST_STYLE, + TRIPLE_LIST_STYLE, ITEM_HEIGHT, ItemsRow, LIST_STYLE, @@ -109,11 +109,12 @@ function StatusListView({ status, count }: { status: string; count: number }) { ); } -function DualListView({ count }: { count: number }) { +function TripleListView({ count }: { count: number }) { return ( -
+
+
); } @@ -123,8 +124,8 @@ function BenchmarkHarness() { const { listViewCount, showSortedView, - showDualList, - dualListCount, + showTripleList, + tripleListCount, containerRef, measureMount, measureUpdate, @@ -229,8 +230,8 @@ function BenchmarkHarness() {
{listViewCount != null && } {showSortedView && } - {showDualList && dualListCount != null && ( - + {showTripleList && tripleListCount != null && ( + )}
); From 4443a8dc63e1ef69247830ea9838ad6278c48060 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 15 Mar 2026 13:48:46 -0400 Subject: [PATCH 36/46] bugbot --- examples/benchmark-react/bench/runner.ts | 13 +++++++++---- .../benchmark-react/src/shared/benchHarness.tsx | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index eedd6d9c3ef4..09f13251fc0d 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -236,9 +236,12 @@ async function runScenario( performance.clearMeasures(); }); - await (bench as any).evaluate((api: any, s: any) => { - api[s.action](...s.args); - }, scenario); + await (bench as any).evaluate( + (api: any, { action, args }: { action: string; args: unknown[] }) => { + api[action](...args); + }, + { action: scenario.action, args: scenario.args }, + ); const completeTimeout = scenario.networkDelayMs ? 60000 : 10000; await page.waitForSelector('[data-bench-complete]', { @@ -590,7 +593,9 @@ async function main() { for (const scenario of SCENARIOS_TO_RUN) { const samples = results[scenario.name]; const warmupRuns = - scenario.deterministic ? 0 : RUN_CONFIG[scenario.size ?? 'small'].warmup; + 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 unit = scenarioUnit(scenario); diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 2ea236b77a9a..bca0b55541b2 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -63,7 +63,7 @@ export function useBenchState() { * skipping intermediate states like Suspense fallbacks or empty first renders. */ const measureMount = useCallback( - (fn: () => void) => { + (fn: () => unknown) => { const container = containerRef.current!; const observer = new MutationObserver(() => { if (container.querySelector('[data-bench-item], [data-sorted-list]')) { @@ -98,7 +98,7 @@ export function useBenchState() { * then reappear), pass an `isReady` predicate to wait for the final state. */ const measureUpdate = useCallback( - (fn: () => void, isReady?: () => boolean) => { + (fn: () => unknown, isReady?: () => boolean) => { const container = containerRef.current!; const observer = new MutationObserver(() => { if (!isReady || isReady()) { From 6ecbcf6c85716f3ef988d4661eb5dc717ecdd748 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 16 Mar 2026 22:19:38 -0400 Subject: [PATCH 37/46] server sim --- examples/benchmark-react/bench/runner.ts | 2 + .../benchmark-react/src/baseline/index.tsx | 58 ++-- .../benchmark-react/src/data-client/index.tsx | 19 +- .../src/shared/benchHarness.tsx | 3 +- .../benchmark-react/src/shared/components.tsx | 17 +- .../benchmark-react/src/shared/resources.ts | 13 +- examples/benchmark-react/src/shared/server.ts | 244 +++++---------- .../src/shared/server.worker.ts | 289 ++++++++++++++++++ examples/benchmark-react/src/shared/types.ts | 2 + examples/benchmark-react/src/swr/index.tsx | 13 +- .../src/tanstack-query/index.tsx | 13 +- examples/benchmark-react/webpack.config.cjs | 7 + packages/endpoint/src/schemaTypes.ts | 8 + packages/endpoint/src/schemas/Collection.ts | 8 + 14 files changed, 496 insertions(+), 200 deletions(-) create mode 100644 examples/benchmark-react/src/shared/server.worker.ts diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 09f13251fc0d..02e1cf885607 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -249,6 +249,8 @@ async function runScenario( state: 'attached', }); + await (bench as any).evaluate((api: any) => api.flushPendingMutations()); + if (scenario.networkDelayMs) { await (bench as any).evaluate((api: any) => api.setNetworkDelay(0)); } diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index a189d7c29c17..3153ce8e0529 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -82,6 +82,7 @@ function TripleListView() {
{openItems.length > 0 && (
+ {openItems.length} 0 && (
+ {closedItems.length} 0 && (
+ {inProgressItems.length} { + if (tripleListCount != null) { + return Promise.all([ + ItemResource.getList({ status: 'open', count: tripleListCount }).then( + setOpenItems, + ), + ItemResource.getList({ + status: 'closed', + count: tripleListCount, + }).then(setClosedItems), + ItemResource.getList({ + status: 'in_progress', + count: tripleListCount, + }).then(setInProgressItems), + ]); + } + return ItemResource.getList({ count: listViewCount! }).then(setItems); + }, [listViewCount, tripleListCount]); + const updateEntity = useCallback( (id: string) => { const item = FIXTURE_ITEMS_BY_ID.get(id); if (!item) return; measureUpdate(() => ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => ItemResource.getList({ count: listViewCount! }).then(setItems), + refetchActiveList, ), ); }, - [measureUpdate, listViewCount], + [measureUpdate, refetchActiveList], ); const updateAuthor = useCallback( @@ -186,32 +208,26 @@ function BenchmarkHarness() { AuthorResource.update( { id: authorId }, { name: `${author.name} (updated)` }, - ).then(() => - ItemResource.getList({ count: listViewCount! }).then(setItems), - ), + ).then(refetchActiveList), ); }, - [measureUpdate, listViewCount], + [measureUpdate, refetchActiveList], ); const unshiftItem = useCallback(() => { const author = FIXTURE_AUTHORS[0]; measureUpdate(() => - ItemResource.create({ label: 'New Item', author }).then(() => - ItemResource.getList({ count: listViewCount! }).then(setItems), + ItemResource.create({ label: 'New Item', author }).then( + refetchActiveList, ), ); - }, [measureUpdate, listViewCount]); + }, [measureUpdate, refetchActiveList]); const deleteEntity = useCallback( (id: string) => { - measureUpdate(() => - ItemResource.delete({ id }).then(() => - ItemResource.getList({ count: listViewCount! }).then(setItems), - ), - ); + measureUpdate(() => ItemResource.delete({ id }).then(refetchActiveList)); }, - [measureUpdate, listViewCount], + [measureUpdate, refetchActiveList], ); const moveItem = useCallback( @@ -238,7 +254,13 @@ function BenchmarkHarness() { const source = containerRef.current?.querySelector( '[data-status-list="open"]', ); - return source?.querySelector(`[data-item-id="${id}"]`) == null; + const dest = containerRef.current?.querySelector( + '[data-status-list="closed"]', + ); + return ( + source?.querySelector(`[data-item-id="${id}"]`) == null && + dest?.querySelector(`[data-item-id="${id}"]`) != null + ); }, ); }, @@ -246,8 +268,8 @@ function BenchmarkHarness() { ); const mountSortedView = useCallback( - (n: number) => { - seedItemList(FIXTURE_ITEMS.slice(0, n)); + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { setShowSortedView(true); }); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 64ab6b6a0a21..0280e384889a 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -64,6 +64,7 @@ function StatusListView({ status, count }: { status: string; count: number }) { const list = items as Item[]; return (
+ {list.length} { - seedItemList(FIXTURE_ITEMS.slice(0, n)); + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { setSortedViewCount(n); setShowSortedView(true); @@ -183,10 +190,10 @@ function BenchmarkHarness() { ); const invalidateAndResolve = useCallback( - (id: string) => { - const item = getItem(id); + async (id: string) => { + const item = await getItem(id); if (item) { - patchItem(id, { label: `${item.label} (refetched)` }); + await patchItem(id, { label: `${item.label} (refetched)` }); } measureUpdate( () => { diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index bca0b55541b2..6e70ef0872f5 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { captureSnapshot, getReport } from './refStability'; -import { setNetworkDelay } from './server'; +import { flushPendingMutations, setNetworkDelay } from './server'; import type { BenchAPI } from './types'; export function afterPaint(fn: () => void): void { @@ -190,6 +190,7 @@ export function useBenchState() { captureRefSnapshot, getRefStabilityReport, setNetworkDelay, + flushPendingMutations, ...libraryActions, } as BenchAPI; }; diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index ff74e70224b5..24dd85931c3e 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -8,18 +8,27 @@ export const VISIBLE_COUNT = 40; export const LIST_STYLE = { height: ITEM_HEIGHT * VISIBLE_COUNT } as const; export const TRIPLE_LIST_STYLE = { display: 'flex', gap: 8 } as const; +const PRIORITY_LABELS = ['', 'low', 'med', 'high', 'crit', 'max'] as const; + /** - * Pure presentational component - no data-fetching logic. - * Each library app wraps this with its own data-fetching hook. + * Memoized row component. With referential equality (data-client, tanstack-query), + * unchanged items skip this entirely. Without it (SWR, baseline), every row + * re-renders on any list change — the extra render weight makes that visible. */ -export function ItemRow({ item }: { item: Item }) { +export const ItemRow = React.memo(function ItemRow({ item }: { item: Item }) { + const tagStr = item.tags.join(', '); + const prioLabel = PRIORITY_LABELS[item.priority] ?? item.priority; return (
{item.label} {item.author.name} + {prioLabel} + {item.status} + {tagStr} + {item.description}
); -} +}); /** Generic react-window row that renders an ItemRow from an items array. */ export function ItemsRow({ diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index b3ee5173a3ba..a45def419043 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -54,6 +54,14 @@ class ItemCollection< S extends any[] | PolymorphicInterface = any, Parent extends any[] = [urlParams: any, body?: any], > extends Collection { + constructor(schema: S, options?: any) { + super(schema, options); + (this as any).move = this.moveWith((existing: any, incoming: any) => [ + ...incoming, + ...existing, + ]); + } + nonFilterArgumentKeys(key: string) { return key === 'count'; } @@ -70,7 +78,7 @@ export const ItemResource = resource({ dataExpiryLength: Infinity, }), getList: Base.getList.extend({ - fetch: serverFetchItemList, + fetch: serverFetchItemList as any, dataExpiryLength: Infinity, }), update: Base.update.extend({ @@ -81,7 +89,8 @@ export const ItemResource = resource({ fetch: serverDeleteItem as any, }), create: Base.getList.unshift.extend({ - fetch: serverCreateItem as any, + fetch: ((...args: any[]) => + serverCreateItem(args.length > 1 ? args[1] : args[0])) as any, body: {} as { label: string; author: Author; diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index a887ea25259b..ca839776972d 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -1,164 +1,89 @@ -import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from './data'; import type { Author, Item } from './types'; -// ── CONFIGURABLE NETWORK DELAY ────────────────────────────────────────── - -let networkDelayMs = 0; - -interface PendingDelay { - id: ReturnType; - resolve: (v: T) => void; - value: T; -} -const pendingDelays = new Set>(); - -function withDelay(value: T): Promise { - if (networkDelayMs <= 0) return Promise.resolve(value); - return new Promise(resolve => { - const entry: PendingDelay = { - id: setTimeout(() => { - pendingDelays.delete(entry); - resolve(value); - }, networkDelayMs), - resolve, - value, - }; - pendingDelays.add(entry); - }); -} +// ── WORKER SETUP ───────────────────────────────────────────────────────── + +const worker = new Worker(new URL('./server.worker.ts', import.meta.url)); + +let nextId = 0; +const pending = new Map< + number, + { resolve: (v: any) => void; reject: (e: Error) => void } +>(); -/** - * Set simulated per-request network latency. Setting to 0 also flushes - * (immediately resolves) any pending delayed responses so no Promises leak. - */ -export function setNetworkDelay(ms: number) { - networkDelayMs = ms; - if (ms === 0) { - for (const entry of pendingDelays) { - clearTimeout(entry.id); - entry.resolve(entry.value); - } - pendingDelays.clear(); +worker.onmessage = (e: MessageEvent) => { + const { id, result, error } = e.data as { + id: number; + result?: string; + error?: string; + }; + const entry = pending.get(id); + if (!entry) return; + pending.delete(id); + if (error) { + entry.reject(new Error(error)); + } else { + entry.resolve(result != null ? JSON.parse(result) : undefined); } +}; + +function sendRequest(method: string, params?: any): Promise { + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ id, method, params }); + }); } -/** Fake server: holds JSON response strings keyed by resource type + id */ -export const jsonStore = new Map(); +// ── PENDING MUTATION TRACKING ──────────────────────────────────────────── -// Pre-seed with fixture data -for (const item of FIXTURE_ITEMS) { - jsonStore.set(`item:${item.id}`, JSON.stringify(item)); +const pendingMutations = new Set>(); + +function sendMutation(method: string, params?: any): Promise { + const p = sendRequest(method, params); + pendingMutations.add(p); + p.finally(() => pendingMutations.delete(p)); + return p; } -for (const author of FIXTURE_AUTHORS) { - jsonStore.set(`author:${author.id}`, JSON.stringify(author)); + +export function flushPendingMutations(): Promise { + if (pendingMutations.size === 0) return Promise.resolve(); + return Promise.allSettled([...pendingMutations]).then(() => {}); } -jsonStore.set('item:list', JSON.stringify(FIXTURE_ITEMS)); -// ── READ ──────────────────────────────────────────────────────────────── +// ── READ ───────────────────────────────────────────────────────────────── -export function fetchItem({ id }: { id: string }): Promise { - const json = jsonStore.get(`item:${id}`); - if (!json) return Promise.reject(new Error(`No data for item:${id}`)); - const item: Item = JSON.parse(json); - // Join latest author data (like a real DB join) so callers always - // see the current author without eager propagation in updateAuthor. - if (item.author?.id) { - const authorJson = jsonStore.get(`author:${item.author.id}`); - if (authorJson) { - item.author = JSON.parse(authorJson); - } - } - return withDelay(item); +export function fetchItem(params: { id: string }): Promise { + return sendRequest('fetchItem', params); } -export function fetchAuthor({ id }: { id: string }): Promise { - const json = jsonStore.get(`author:${id}`); - if (!json) return Promise.reject(new Error(`No data for author:${id}`)); - return withDelay(JSON.parse(json) as Author); +export function fetchAuthor(params: { id: string }): Promise { + return sendRequest('fetchAuthor', params); } export function fetchItemList(params?: { count?: number; status?: string; }): Promise { - const json = jsonStore.get('item:list'); - if (!json) return Promise.reject(new Error('No data for item:list')); - const listItems: Item[] = JSON.parse(json); - // Join latest item + author data (like a real DB-backed API) - let items = listItems.map(listItem => { - const itemJson = jsonStore.get(`item:${listItem.id}`); - const item: Item = itemJson ? JSON.parse(itemJson) : listItem; - if (item.author?.id) { - const authorJson = jsonStore.get(`author:${item.author.id}`); - if (authorJson) { - item.author = JSON.parse(authorJson); - } - } - return item; - }); - if (params?.status) { - items = items.filter(i => i.status === params.status); - } - if (params?.count) { - items = items.slice(0, params.count); - } - return withDelay(items); + return sendRequest('fetchItemList', params); } -// ── CREATE ────────────────────────────────────────────────────────────── - -let createItemCounter = 0; +// ── CREATE ─────────────────────────────────────────────────────────────── export function createItem(body: { label: string; author: Author; }): Promise { - const id = `created-item-${createItemCounter++}`; - const now = new Date().toISOString(); - const item: Item = { - id, - label: body.label, - description: '', - status: 'open', - priority: 3, - tags: [], - createdAt: now, - updatedAt: now, - author: body.author, - }; - const json = JSON.stringify(item); - jsonStore.set(`item:${id}`, json); - // Prepend to item:list so refetching the list returns the new item first - const listJson = jsonStore.get('item:list'); - const list: Item[] = listJson ? JSON.parse(listJson) : []; - list.unshift(item); - jsonStore.set('item:list', JSON.stringify(list)); - return withDelay(JSON.parse(json) as Item); + return sendMutation('createItem', body); } -let createAuthorCounter = 0; - export function createAuthor(body: { login: string; name: string; }): Promise { - const id = `created-author-${createAuthorCounter++}`; - const author: Author = { - id, - login: body.login, - name: body.name, - avatarUrl: `https://avatars.example.com/u/${id}?s=64`, - email: `${body.login}@example.com`, - bio: '', - followers: 0, - createdAt: new Date().toISOString(), - }; - const json = JSON.stringify(author); - jsonStore.set(`author:${id}`, json); - return withDelay(JSON.parse(json) as Author); + return sendMutation('createAuthor', body); } -// ── UPDATE ────────────────────────────────────────────────────────────── +// ── UPDATE ─────────────────────────────────────────────────────────────── export function updateItem(params: { id: string; @@ -166,54 +91,47 @@ export function updateItem(params: { status?: Item['status']; author?: Author; }): Promise { - const existing = jsonStore.get(`item:${params.id}`); - if (!existing) - return Promise.reject(new Error(`No data for item:${params.id}`)); - const updated: Item = { ...JSON.parse(existing), ...params }; - const json = JSON.stringify(updated); - jsonStore.set(`item:${params.id}`, json); - return withDelay(JSON.parse(json) as Item); -} - -/** - * Updates the author record only. Item reads join the latest author via - * fetchItem (like a real DB), so no eager O(n) propagation is needed. - */ + return sendMutation('updateItem', params); +} + export function updateAuthor(params: { id: string; login?: string; name?: string; }): Promise { - const existing = jsonStore.get(`author:${params.id}`); - if (!existing) - return Promise.reject(new Error(`No data for author:${params.id}`)); - const updated: Author = { ...JSON.parse(existing), ...params }; - const json = JSON.stringify(updated); - jsonStore.set(`author:${params.id}`, json); + return sendMutation('updateAuthor', params); +} + +// ── DELETE ──────────────────────────────────────────────────────────────── - return withDelay(JSON.parse(json) as Author); +export function deleteItem(params: { id: string }): Promise<{ id: string }> { + return sendMutation('deleteItem', params); } -// ── DELETE ─────────────────────────────────────────────────────────────── +export function deleteAuthor(params: { id: string }): Promise<{ id: string }> { + return sendMutation('deleteAuthor', params); +} -export function deleteItem({ id }: { id: string }): Promise<{ id: string }> { - jsonStore.delete(`item:${id}`); - const listJson = jsonStore.get('item:list'); - if (listJson) { - const list: Item[] = JSON.parse(listJson); - jsonStore.set('item:list', JSON.stringify(list.filter(i => i.id !== id))); - } - return withDelay({ id }); +// ── DIRECT STORE ACCESS (pre-measurement setup) ───────────────────────── + +export function getItem(id: string): Promise { + return sendRequest('getItem', { id }); } -export function deleteAuthor({ id }: { id: string }): Promise<{ id: string }> { - jsonStore.delete(`author:${id}`); - return withDelay({ id }); +export function patchItem(id: string, patch: Partial): Promise { + return sendRequest('patchItem', { id, patch }); } -// ── SEEDING ───────────────────────────────────────────────────────────── +export function seedItemList(items: Item[]): Promise { + return sendRequest('seedItemList', { items }); +} + +// ── CONTROL ────────────────────────────────────────────────────────────── -/** Seed a subset of fixture items for sorted view. */ -export function seedItemList(items: Item[]): void { - jsonStore.set('item:list', JSON.stringify(items)); +export function setNetworkDelay(ms: number): void { + worker.postMessage({ + id: nextId++, + method: 'setNetworkDelay', + params: { ms }, + }); } diff --git a/examples/benchmark-react/src/shared/server.worker.ts b/examples/benchmark-react/src/shared/server.worker.ts new file mode 100644 index 000000000000..ac4950242a4a --- /dev/null +++ b/examples/benchmark-react/src/shared/server.worker.ts @@ -0,0 +1,289 @@ +/// + +import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from './data'; +import type { Author, Item } from './types'; + +declare const self: DedicatedWorkerGlobalScope; + +// ── NETWORK DELAY ──────────────────────────────────────────────────────── + +let networkDelayMs = 0; + +function respond(id: number, value: unknown) { + const json = JSON.stringify(value); + if (networkDelayMs <= 0) { + self.postMessage({ id, result: json }); + } else { + setTimeout(() => self.postMessage({ id, result: json }), networkDelayMs); + } +} + +function respondError(id: number, message: string) { + self.postMessage({ id, error: message }); +} + +// ── IN-MEMORY STORES ───────────────────────────────────────────────────── + +const itemStore = new Map(); +const authorStore = new Map(); +let masterList: Item[] = []; +const statusIndex = new Map(); +const idToPosition = new Map(); + +function rebuildStatusIndex() { + statusIndex.clear(); + for (const item of masterList) { + let list = statusIndex.get(item.status); + if (!list) { + list = []; + statusIndex.set(item.status, list); + } + list.push(item); + } +} + +function rebuildPositionIndex() { + idToPosition.clear(); + for (let i = 0; i < masterList.length; i++) { + idToPosition.set(masterList[i].id, i); + } +} + +// Pre-seed with fixture data +for (const author of FIXTURE_AUTHORS) { + authorStore.set(author.id, { ...author }); +} +for (const item of FIXTURE_ITEMS) { + const seeded: Item = { ...item, author: { ...item.author } }; + itemStore.set(item.id, seeded); + masterList.push(seeded); +} +rebuildStatusIndex(); +rebuildPositionIndex(); + +// ── READ ───────────────────────────────────────────────────────────────── + +function fetchItem({ id }: { id: string }): Item { + const item = itemStore.get(id); + if (!item) throw new Error(`No data for item:${id}`); + if (item.author?.id) { + const latest = authorStore.get(item.author.id); + if (latest && latest !== item.author) return { ...item, author: latest }; + } + return item; +} + +function fetchAuthor({ id }: { id: string }): Author { + const author = authorStore.get(id); + if (!author) throw new Error(`No data for author:${id}`); + return author; +} + +function fetchItemList(params?: { count?: number; status?: string }): Item[] { + let items: Item[] = + params?.status ? (statusIndex.get(params.status) ?? []) : masterList; + if (params?.count) { + items = items.slice(0, params.count); + } + items = items.map(item => { + if (item.author?.id) { + const latest = authorStore.get(item.author.id); + if (latest && latest !== item.author) return { ...item, author: latest }; + } + return item; + }); + return items; +} + +// ── CREATE ─────────────────────────────────────────────────────────────── + +let createItemCounter = 0; + +function createItem(body: { label: string; author: Author }): Item { + const id = `created-item-${createItemCounter++}`; + const now = new Date().toISOString(); + const item: Item = { + id, + label: body.label, + description: '', + status: 'open', + priority: 3, + tags: [], + createdAt: now, + updatedAt: now, + author: body.author, + }; + itemStore.set(id, item); + masterList.unshift(item); + rebuildPositionIndex(); + const statusList = statusIndex.get(item.status); + if (statusList) { + statusList.unshift(item); + } else { + statusIndex.set(item.status, [item]); + } + return item; +} + +let createAuthorCounter = 0; + +function createAuthor(body: { login: string; name: string }): Author { + const id = `created-author-${createAuthorCounter++}`; + const author: Author = { + id, + login: body.login, + name: body.name, + avatarUrl: `https://avatars.example.com/u/${id}?s=64`, + email: `${body.login}@example.com`, + bio: '', + followers: 0, + createdAt: new Date().toISOString(), + }; + authorStore.set(id, author); + return author; +} + +// ── UPDATE ─────────────────────────────────────────────────────────────── + +function updateItem(params: { + id: string; + label?: string; + status?: Item['status']; + author?: Author; +}): Item { + const existing = itemStore.get(params.id); + if (!existing) throw new Error(`No data for item:${params.id}`); + const updated: Item = { ...existing, ...params }; + itemStore.set(params.id, updated); + const idx = idToPosition.get(params.id) ?? -1; + if (idx >= 0) masterList[idx] = updated; + if (existing.status !== updated.status) { + rebuildStatusIndex(); + } else { + const sList = statusIndex.get(updated.status); + if (sList) { + const si = sList.indexOf(existing); + if (si >= 0) sList[si] = updated; + } + } + return updated; +} + +function updateAuthor(params: { + id: string; + login?: string; + name?: string; +}): Author { + const existing = authorStore.get(params.id); + if (!existing) throw new Error(`No data for author:${params.id}`); + const updated: Author = { ...existing, ...params }; + authorStore.set(params.id, updated); + return updated; +} + +// ── DELETE ──────────────────────────────────────────────────────────────── + +function deleteItem({ id }: { id: string }): { id: string } { + itemStore.delete(id); + const idx = idToPosition.get(id) ?? -1; + if (idx >= 0) { + masterList.splice(idx, 1); + rebuildPositionIndex(); + } + rebuildStatusIndex(); + return { id }; +} + +function deleteAuthor({ id }: { id: string }): { id: string } { + authorStore.delete(id); + return { id }; +} + +// ── DIRECT STORE ACCESS ────────────────────────────────────────────────── + +function getItem(id: string): Item | undefined { + return itemStore.get(id); +} + +function patchItem(id: string, patch: Partial): void { + const existing = itemStore.get(id); + if (!existing) return; + const updated: Item = { ...existing, ...patch }; + itemStore.set(id, updated); + const idx = idToPosition.get(id) ?? -1; + if (idx >= 0) masterList[idx] = updated; + if (existing.status !== updated.status) { + rebuildStatusIndex(); + } else { + const sList = statusIndex.get(updated.status); + if (sList) { + const si = sList.indexOf(existing); + if (si >= 0) sList[si] = updated; + } + } +} + +function seedItemList(items: Item[]): void { + masterList = items; + itemStore.clear(); + authorStore.clear(); + for (const item of items) { + itemStore.set(item.id, item); + if (item.author) authorStore.set(item.author.id, item.author); + } + rebuildStatusIndex(); + rebuildPositionIndex(); +} + +// ── MESSAGE HANDLER ────────────────────────────────────────────────────── + +const MUTATION_METHODS = new Set([ + 'createItem', + 'createAuthor', + 'updateItem', + 'updateAuthor', + 'deleteItem', + 'deleteAuthor', + 'patchItem', + 'seedItemList', +]); + +const methods: Record unknown> = { + fetchItem, + fetchAuthor, + fetchItemList, + createItem, + createAuthor, + updateItem, + updateAuthor, + deleteItem, + deleteAuthor, + getItem: ({ id }: { id: string }) => getItem(id), + patchItem: ({ id, patch }: { id: string; patch: Partial }) => + patchItem(id, patch), + seedItemList: ({ items }: { items: Item[] }) => seedItemList(items), + setNetworkDelay: ({ ms }: { ms: number }) => { + networkDelayMs = ms; + }, +}; + +self.onmessage = (e: MessageEvent) => { + const { id, method, params } = e.data as { + id: number; + method: string; + params: any; + }; + + const fn = methods[method]; + if (!fn) { + respondError(id, `Unknown method: ${method}`); + return; + } + + try { + const result = fn(params); + respond(id, result === undefined ? null : result); + } catch (err) { + respondError(id, err instanceof Error ? err.message : String(err)); + } +}; diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 7cc7591bab0b..875bf8a4dbd2 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -19,6 +19,8 @@ export interface BenchAPI { updateAuthor(id: string): void; /** Set simulated per-request network latency (ms). 0 disables and flushes pending delays. */ setNetworkDelay(ms: number): void; + /** Wait for all deferred server mutations to settle before next iteration. */ + flushPendingMutations(): Promise; unmountAll(): void; getRenderedCount(): number; captureRefSnapshot(): void; diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 7c9e2db41108..b880d1f403ea 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -79,6 +79,7 @@ function StatusListView({ status, count }: { status: string; count: number }) { if (!items) return null; return (
+ {items.length} { - seedItemList(FIXTURE_ITEMS.slice(0, n)); + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { setShowSortedView(true); }); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index eeef444d052e..1dc75b7965f7 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -98,6 +98,7 @@ function StatusListView({ status, count }: { status: string; count: number }) { const list = items as Item[]; return (
+ {list.length} { - seedItemList(FIXTURE_ITEMS.slice(0, n)); + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); measureMount(() => { setShowSortedView(true); }); diff --git a/examples/benchmark-react/webpack.config.cjs b/examples/benchmark-react/webpack.config.cjs index 1c258cbd97e6..91e5f676a9d5 100644 --- a/examples/benchmark-react/webpack.config.cjs +++ b/examples/benchmark-react/webpack.config.cjs @@ -29,6 +29,13 @@ module.exports = (env, argv) => { swr: require.resolve('swr'), }; + // Remove worker-loader rule — we use webpack 5's native Worker support + if (config.module?.rules?.[0]?.oneOf) { + config.module.rules[0].oneOf = config.module.rules[0].oneOf.filter( + r => !r.test || !String(r.test).includes('worker'), + ); + } + config.entry = entries; config.output.filename = '[name].js'; config.output.chunkFilename = '[name].chunk.js'; diff --git a/packages/endpoint/src/schemaTypes.ts b/packages/endpoint/src/schemaTypes.ts index a5e79e9beece..c3c694fe286a 100644 --- a/packages/endpoint/src/schemaTypes.ts +++ b/packages/endpoint/src/schemaTypes.ts @@ -59,6 +59,14 @@ export interface CollectionInterface< ) => (collectionKey: Record) => boolean, ): Collection; + /** Constructs a custom move schema for this collection + * + * @see https://dataclient.io/rest/api/Collection#moveWith + */ + moveWith

( + merge: (existing: any, incoming: any) => any, + ): Collection; + readonly cacheWith: object; readonly schema: S; diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 848456456c67..11eca080ce87 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -88,6 +88,14 @@ export default class CollectionSchema< return CreateAdder(this, merge, createCollectionFilter); } + moveWith

( + merge: (existing: any, incoming: any) => any, + ): CollectionSchema { + const rMerge = + this.schema instanceof ArraySchema ? removeMerge : valuesRemoveMerge; + return CreateMover(this, merge, rMerge); + } + // this adds to any list *in store* that has same members as the urlParams // so fetch(create, { userId: 'bob', completed: true }, data) // would possibly add to {}, {userId: 'bob'}, {completed: true}, {userId: 'bob', completed: true } - but only those already in the store From 9262b4d1dfdae3833eaeab118f00f1446895770f Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 17 Mar 2026 21:29:12 -0400 Subject: [PATCH 38/46] Make author component more expensive --- .../benchmark-react/src/shared/components.tsx | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 24dd85931c3e..20fd86a23969 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { RowComponentProps } from 'react-window'; -import type { Item } from './types'; +import type { Author, Item } from './types'; export const ITEM_HEIGHT = 30; export const VISIBLE_COUNT = 40; @@ -10,25 +10,71 @@ export const TRIPLE_LIST_STYLE = { display: 'flex', gap: 8 } as const; const PRIORITY_LABELS = ['', 'low', 'med', 'high', 'crit', 'max'] as const; +function djb2(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return hash >>> 0; +} + +/** + * Expensive memoized author component. Simulates a realistic rich author + * card: avatar color derivation, bio truncation, follower formatting, date + * parsing. Libraries that preserve author referential equality skip this + * entirely on unrelated updates; those that don't pay per row. + */ +function AuthorView({ author }: { author: Author }) { + const hash = djb2(author.id + author.login + author.email + author.bio); + const hue = hash % 360; + const initials = author.name + .split(' ') + .map(w => w[0]) + .join('') + .toUpperCase(); + const bioWords = author.bio.split(/\s+/); + const truncatedBio = + bioWords.length > 12 ? bioWords.slice(0, 12).join(' ') + '…' : author.bio; + const followerStr = + author.followers >= 1000 ? + `${(author.followers / 1000).toFixed(1)}k` + : String(author.followers); + const joinYear = new Date(author.createdAt).getFullYear(); + + return ( + + {initials} + {author.name} + {truncatedBio} + {followerStr} + {joinYear} + + ); +} + /** - * Memoized row component. With referential equality (data-client, tanstack-query), - * unchanged items skip this entirely. Without it (SWR, baseline), every row - * re-renders on any list change — the extra render weight makes that visible. + * Row component — React Compiler auto-memoizes props/values. Libraries that + * preserve referential equality benefit from the compiler's caching; those + * that don't still re-execute the expensive AuthorView on every render. */ -export const ItemRow = React.memo(function ItemRow({ item }: { item: Item }) { +export function ItemRow({ item }: { item: Item }) { const tagStr = item.tags.join(', '); const prioLabel = PRIORITY_LABELS[item.priority] ?? item.priority; return (

{item.label} - {item.author.name} + {prioLabel} {item.status} {tagStr} {item.description}
); -}); +} /** Generic react-window row that renders an ItemRow from an items array. */ export function ItemsRow({ From cc51e698b23b8d4b5f972de8988c8892533e6d41 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 17 Mar 2026 22:52:50 -0400 Subject: [PATCH 39/46] network sim flag; new scenario --- examples/benchmark-react/bench/runner.ts | 60 ++++++++++----- examples/benchmark-react/bench/scenarios.ts | 34 +++++---- examples/benchmark-react/bench/validate.ts | 50 ++++++++++++ .../benchmark-react/src/baseline/index.tsx | 76 ++++++++++++++----- .../benchmark-react/src/data-client/index.tsx | 74 ++++++++++++++++-- .../src/shared/benchHarness.tsx | 39 +++++++++- .../benchmark-react/src/shared/components.tsx | 13 ++++ examples/benchmark-react/src/shared/server.ts | 8 ++ .../src/shared/server.worker.ts | 14 +++- examples/benchmark-react/src/shared/types.ts | 14 ++-- examples/benchmark-react/src/swr/index.tsx | 57 ++++++++++++-- .../src/tanstack-query/index.tsx | 60 +++++++++++++-- 12 files changed, 411 insertions(+), 88 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 02e1cf885607..195166a53c79 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -10,6 +10,7 @@ import { LIBRARIES, RUN_CONFIG, ACTION_GROUPS, + NETWORK_SIM_DELAYS, } from './scenarios.js'; import { computeStats, isConverged } from './stats.js'; import { parseTraceDuration } from './tracing.js'; @@ -24,6 +25,7 @@ function parseArgs(): { size?: ScenarioSize; actions?: string[]; scenario?: string; + networkSim: boolean; } { const argv = process.argv.slice(2); const get = (flag: string, envVar: string): string | undefined => { @@ -36,20 +38,30 @@ function parseArgs(): { const sizeRaw = get('--size', 'BENCH_SIZE'); const actionRaw = get('--action', 'BENCH_ACTION'); const scenarioRaw = get('--scenario', 'BENCH_SCENARIO'); + const networkSimRaw = get('--network-sim', 'BENCH_NETWORK_SIM'); const libs = libRaw ? libRaw.split(',').map(s => s.trim()) : undefined; const size = sizeRaw === 'small' || sizeRaw === 'large' ? sizeRaw : undefined; const actions = actionRaw ? actionRaw.split(',').map(s => s.trim()) : undefined; + const networkSim = + networkSimRaw != null ? networkSimRaw !== 'false' : !process.env.CI; - return { libs, size, actions, scenario: scenarioRaw }; + return { libs, size, actions, scenario: scenarioRaw, networkSim }; } function filterScenarios(scenarios: Scenario[]): { filtered: Scenario[]; libraries: string[]; + networkSim: boolean; } { - const { libs, size, actions, scenario: scenarioFilter } = parseArgs(); + const { + libs, + size, + actions, + scenario: scenarioFilter, + networkSim, + } = parseArgs(); let filtered = scenarios; @@ -58,7 +70,6 @@ function filterScenarios(scenarios: Scenario[]): { filtered = filtered.filter( s => s.name.startsWith('data-client:') && - s.category !== 'withNetwork' && s.category !== 'memory' && s.category !== 'startup', ); @@ -96,7 +107,7 @@ function filterScenarios(scenarios: Scenario[]): { const libraries = libs ?? (process.env.CI ? ['data-client'] : [...LIBRARIES]); - return { filtered, libraries }; + return { filtered, libraries, networkSim }; } // --------------------------------------------------------------------------- @@ -135,6 +146,7 @@ async function runScenario( page: Page, lib: string, scenario: Scenario, + networkSim: boolean, ): Promise { const appPath = `/${lib}/`; await page.goto(`${BASE_URL}${appPath}`, { @@ -153,6 +165,13 @@ async function runScenario( if (await bench.evaluate(b => b == null)) throw new Error('window.__BENCH__ not found'); + if (networkSim) { + await (bench as any).evaluate( + (api: any, delays: Record) => api.setMethodDelays(delays), + NETWORK_SIM_DELAYS, + ); + } + const isMemory = scenario.action === 'mountUnmountCycle' && scenario.resultMetric === 'heapDelta'; @@ -224,13 +243,6 @@ async function runScenario( }); } - if (scenario.networkDelayMs) { - await (bench as any).evaluate( - (api: any, ms: number) => api.setNetworkDelay(ms), - scenario.networkDelayMs, - ); - } - await page.evaluate(() => { performance.clearMarks(); performance.clearMeasures(); @@ -243,7 +255,7 @@ async function runScenario( { action: scenario.action, args: scenario.args }, ); - const completeTimeout = scenario.networkDelayMs ? 60000 : 10000; + const completeTimeout = networkSim ? 30000 : 10000; await page.waitForSelector('[data-bench-complete]', { timeout: completeTimeout, state: 'attached', @@ -251,10 +263,6 @@ async function runScenario( await (bench as any).evaluate((api: any) => api.flushPendingMutations()); - if (scenario.networkDelayMs) { - await (bench as any).evaluate((api: any) => api.setNetworkDelay(0)); - } - let traceDuration: number | undefined; if (cdpTracing) { try { @@ -285,7 +293,8 @@ async function runScenario( const isMountLike = isInit || scenario.action === 'mountSortedView' || - scenario.action === 'initTripleList'; + scenario.action === 'initTripleList' || + scenario.action === 'listDetailSwitch'; const duration = isMountLike ? getMeasureDuration(measures, 'mount-duration') @@ -366,7 +375,15 @@ function scenarioUnit(scenario: Scenario): string { // --------------------------------------------------------------------------- async function main() { - const { filtered: SCENARIOS_TO_RUN, libraries } = filterScenarios(SCENARIOS); + const { + filtered: SCENARIOS_TO_RUN, + libraries, + networkSim, + } = filterScenarios(SCENARIOS); + + if (networkSim) { + process.stderr.write('Network simulation: ON\n'); + } if (SCENARIOS_TO_RUN.length === 0) { process.stderr.write('No scenarios matched the filters.\n'); @@ -413,7 +430,7 @@ async function main() { const page = await context.newPage(); for (const scenario of libScenarios) { try { - const result = await runScenario(page, lib, scenario); + 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); @@ -464,7 +481,7 @@ async function main() { for (const scenario of libScenarios) { try { - const result = await runScenario(page, lib, scenario); + 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); @@ -534,7 +551,7 @@ async function main() { const page = await context.newPage(); for (const scenario of libScenarios) { try { - const result = await runScenario(page, lib, scenario); + 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); @@ -617,6 +634,7 @@ async function main() { scenario.action === 'updateEntity' || scenario.action === 'updateAuthor' || scenario.action === 'mountSortedView' || + scenario.action === 'listDetailSwitch' || scenario.action === 'invalidateAndResolve' || scenario.action === 'unshiftItem' || scenario.action === 'deleteEntity' || diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index e3aecf4302ff..c48fc1e50194 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -1,5 +1,18 @@ import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; +/** Per-method network latency used when --network-sim is enabled (default: on). */ +export const NETWORK_SIM_DELAYS: Record = { + fetchItemList: 80, + fetchItem: 50, + fetchAuthor: 50, + createItem: 50, + createAuthor: 50, + updateItem: 50, + updateAuthor: 50, + deleteItem: 50, + deleteAuthor: 50, +}; + export interface RunProfile { warmup: number; minMeasurement: number; @@ -24,7 +37,7 @@ export const RUN_CONFIG: Record = { }; export const ACTION_GROUPS: Record = { - mount: ['init', 'initTripleList', 'mountSortedView'], + mount: ['init', 'initTripleList', 'mountSortedView', 'listDetailSwitch'], update: ['updateEntity', 'updateAuthor'], mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], memory: ['mountUnmountCycle'], @@ -42,8 +55,6 @@ interface BaseScenario { preMountAction?: keyof BenchAPI; /** Only run for these libraries. Omit to run for all. */ onlyLibs?: string[]; - /** Simulated per-request network latency in ms (applied at the server layer). */ - networkDelayMs?: number; /** Result is deterministic (zero variance); run exactly once with no warmup. */ deterministic?: boolean; } @@ -84,15 +95,6 @@ const BASE_SCENARIOS: BaseScenario[] = [ category: 'hotPath', deterministic: true, }, - { - nameSuffix: 'update-shared-author-with-network', - action: 'updateAuthor', - args: ['author-0'], - category: 'withNetwork', - mountCount: 500, - size: 'large', - networkDelayMs: 50, - }, { nameSuffix: 'update-shared-author-500-mounted', action: 'updateAuthor', @@ -125,6 +127,13 @@ const BASE_SCENARIOS: BaseScenario[] = [ preMountAction: 'mountSortedView', size: 'large', }, + { + nameSuffix: 'list-detail-switch', + action: 'listDetailSwitch', + args: [500], + category: 'hotPath', + size: 'large', + }, { nameSuffix: 'update-shared-author-10000-mounted', action: 'updateAuthor', @@ -184,7 +193,6 @@ export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => size: base.size, mountCount: base.mountCount, preMountAction: base.preMountAction, - networkDelayMs: base.networkDelayMs, deterministic: base.deterministic, }), ), diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index 05a72999d221..bd4f5fc1157c 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -502,6 +502,56 @@ test('moveItem moves item between status lists', async (page, lib) => { ); }); +// ── listDetailSwitch ───────────────────────────────────────────────── + +test('listDetailSwitch completes with correct DOM transitions', async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.listDetailSwitch === 'function', + )) + ) + return; + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.listDetailSwitch!(20)); + await waitForComplete(page, 30000); + + // After completion we should be back on the sorted list (last transition) + const hasSortedList = await page.evaluate( + () => document.querySelector('[data-sorted-list]') !== null, + ); + assert( + hasSortedList, + lib, + 'listDetailSwitch sorted-list', + 'sorted list not in DOM after listDetailSwitch completed', + ); + + // Detail view should be gone + const hasDetail = await page.evaluate( + () => document.querySelector('[data-detail-view]') !== null, + ); + assert( + !hasDetail, + lib, + 'listDetailSwitch detail-gone', + 'detail view still in DOM after listDetailSwitch completed', + ); + + // A mount-duration measure should have been recorded + const hasMeasure = await page.evaluate(() => + performance + .getEntriesByType('measure') + .some(m => m.name === 'mount-duration'), + ); + assert( + hasMeasure, + lib, + 'listDetailSwitch measure', + 'no mount-duration performance measure recorded', + ); +}); + // ── TIMING VALIDATION ──────────────────────────────────────────────── // Verify that when data-bench-complete fires (measurement ends), the DOM // already reflects the update. A 100ms network delay makes timing bugs diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 3153ce8e0529..d9de8f1626c0 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -2,8 +2,10 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, + ItemRow, ItemsRow, LIST_STYLE, + PlainItemList, } from '@shared/components'; import { FIXTURE_AUTHORS, @@ -83,43 +85,38 @@ function TripleListView() { {openItems.length > 0 && (
{openItems.length} - +
)} {closedItems.length > 0 && (
{closedItems.length} - +
)} {inProgressItems.length > 0 && (
{inProgressItems.length} - +
)}
); } +function DetailView({ id }: { id: string }) { + const [item, setItem] = useState(null); + useEffect(() => { + ItemResource.get({ id }).then(setItem); + }, [id]); + if (!item) return null; + return ( +
+ +
+ ); +} + function BenchmarkHarness() { const [items, setItems] = useState([]); const [openItems, setOpenItems] = useState([]); @@ -130,10 +127,14 @@ function BenchmarkHarness() { showSortedView, showTripleList, tripleListCount, + detailItemId, containerRef, measureMount, measureUpdate, + waitForElement, + setComplete, setShowSortedView, + setDetailItemId, unmountAll: unmountBase, registerAPI, } = useBenchState(); @@ -277,11 +278,43 @@ function BenchmarkHarness() { [measureMount, setShowSortedView], ); + const listDetailSwitch = useCallback( + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + // Warmup cycle (unmeasured) — exercises the detail mount path + setShowSortedView(false); + setDetailItemId('item-0'); + await waitForElement('[data-detail-view]'); + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + performance.mark('mount-start'); + for (let i = 1; i <= 10; i++) { + setShowSortedView(false); + setDetailItemId(`item-${i}`); + await waitForElement('[data-detail-view]'); + + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + } + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }, + [setShowSortedView, setDetailItemId, waitForElement, setComplete], + ); + registerAPI({ updateEntity, updateAuthor, unmountAll, mountSortedView, + listDetailSwitch, unshiftItem, deleteEntity, moveItem, @@ -303,6 +336,7 @@ function BenchmarkHarness() { {listViewCount != null && } {showSortedView && } {showTripleList && tripleListCount != null && } + {detailItemId != null && }
diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 0280e384889a..3190ca54e846 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,10 +1,17 @@ -import { DataProvider, useController, useDLE } from '@data-client/react'; +import { + DataProvider, + useController, + useDLE, + useSuspense, +} from '@data-client/react'; import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, + ItemRow, ItemsRow, LIST_STYLE, + PlainItemList, } from '@shared/components'; import { FIXTURE_AUTHORS, @@ -65,13 +72,7 @@ function StatusListView({ status, count }: { status: string; count: number }) { return (
{list.length} - +
); } @@ -86,6 +87,15 @@ function TripleListView({ count }: { count: number }) { ); } +function DetailView({ id }: { id: string }) { + const item = useSuspense(ItemResource.get, { id }); + return ( +
+ +
+ ); +} + function BenchmarkHarness() { const controller = useController(); const { @@ -94,11 +104,15 @@ function BenchmarkHarness() { sortedViewCount, showTripleList, tripleListCount, + detailItemId, containerRef, measureUpdate, measureMount, + waitForElement, + setComplete, setShowSortedView, setSortedViewCount, + setDetailItemId, registerAPI, } = useBenchState(); @@ -189,6 +203,44 @@ function BenchmarkHarness() { [measureMount, setSortedViewCount, setShowSortedView], ); + const listDetailSwitch = useCallback( + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); + setSortedViewCount(n); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + // Warmup cycle (unmeasured) — exercises the detail mount path + setShowSortedView(false); + setDetailItemId('item-0'); + await waitForElement('[data-detail-view]'); + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + performance.mark('mount-start'); + for (let i = 1; i <= 10; i++) { + setShowSortedView(false); + setDetailItemId(`item-${i}`); + await waitForElement('[data-detail-view]'); + + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + } + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }, + [ + setSortedViewCount, + setShowSortedView, + setDetailItemId, + waitForElement, + setComplete, + ], + ); + const invalidateAndResolve = useCallback( async (id: string) => { const item = await getItem(id); @@ -223,6 +275,7 @@ function BenchmarkHarness() { updateEntity, updateAuthor, mountSortedView, + listDetailSwitch, invalidateAndResolve, unshiftItem, deleteEntity, @@ -238,6 +291,11 @@ function BenchmarkHarness() { {showTripleList && tripleListCount != null && ( )} + {detailItemId != null && ( + Loading...
}> + + + )}
); } diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 6e70ef0872f5..623e6ca7a2c5 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -1,7 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { captureSnapshot, getReport } from './refStability'; -import { flushPendingMutations, setNetworkDelay } from './server'; +import { + flushPendingMutations, + setMethodDelays, + setNetworkDelay, +} from './server'; import type { BenchAPI } from './types'; export function afterPaint(fn: () => void): void { @@ -47,6 +51,7 @@ export function useBenchState() { const [sortedViewCount, setSortedViewCount] = useState(); const [showTripleList, setShowTripleList] = useState(false); const [tripleListCount, setTripleListCount] = useState(); + const [detailItemId, setDetailItemId] = useState(null); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const apiRef = useRef(null as any); @@ -124,6 +129,33 @@ export function useBenchState() { [setComplete], ); + /** + * Wait for an element matching `selector` to appear in the container. + * Resolves immediately if already present; otherwise observes mutations. + */ + const waitForElement = useCallback((selector: string) => { + const container = containerRef.current!; + if (container.querySelector(selector)) return Promise.resolve(); + return new Promise(resolve => { + const observer = new MutationObserver(() => { + if (container.querySelector(selector)) { + observer.disconnect(); + clearTimeout(timer); + resolve(); + } + }); + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + const timer = setTimeout(() => { + observer.disconnect(); + resolve(); + }, 30000); + }); + }, []); + const init = useCallback( (n: number) => { measureMount(() => { @@ -139,6 +171,7 @@ export function useBenchState() { setSortedViewCount(undefined); setShowTripleList(false); setTripleListCount(undefined); + setDetailItemId(null); }, []); const initTripleList = useCallback( @@ -190,6 +223,7 @@ export function useBenchState() { captureRefSnapshot, getRefStabilityReport, setNetworkDelay, + setMethodDelays, flushPendingMutations, ...libraryActions, } as BenchAPI; @@ -213,10 +247,12 @@ export function useBenchState() { sortedViewCount, showTripleList, tripleListCount, + detailItemId, containerRef, measureMount, measureUpdate, + waitForElement, setComplete, completeResolveRef, @@ -225,6 +261,7 @@ export function useBenchState() { setSortedViewCount, setShowTripleList, setTripleListCount, + setDetailItemId, unmountAll, registerAPI, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 20fd86a23969..2654e74c8fac 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -88,3 +88,16 @@ export function ItemsRow({
); } + +/** Plain (non-virtualized) list keyed by item pk. Renders up to VISIBLE_COUNT items. */ +export function PlainItemList({ items }: { items: Item[] }) { + const visible = + items.length > VISIBLE_COUNT ? items.slice(0, VISIBLE_COUNT) : items; + return ( +
+ {visible.map(item => ( + + ))} +
+ ); +} diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index ca839776972d..dd36a32a9e82 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -135,3 +135,11 @@ export function setNetworkDelay(ms: number): void { params: { ms }, }); } + +export function setMethodDelays(delays: Record): void { + worker.postMessage({ + id: nextId++, + method: 'setMethodDelays', + params: { delays }, + }); +} diff --git a/examples/benchmark-react/src/shared/server.worker.ts b/examples/benchmark-react/src/shared/server.worker.ts index ac4950242a4a..83b866e2ac79 100644 --- a/examples/benchmark-react/src/shared/server.worker.ts +++ b/examples/benchmark-react/src/shared/server.worker.ts @@ -8,13 +8,15 @@ declare const self: DedicatedWorkerGlobalScope; // ── NETWORK DELAY ──────────────────────────────────────────────────────── let networkDelayMs = 0; +let methodDelays: Record = {}; -function respond(id: number, value: unknown) { +function respond(id: number, method: string, value: unknown) { const json = JSON.stringify(value); - if (networkDelayMs <= 0) { + const delay = methodDelays[method] ?? networkDelayMs; + if (delay <= 0) { self.postMessage({ id, result: json }); } else { - setTimeout(() => self.postMessage({ id, result: json }), networkDelayMs); + setTimeout(() => self.postMessage({ id, result: json }), delay); } } @@ -264,6 +266,10 @@ const methods: Record unknown> = { seedItemList: ({ items }: { items: Item[] }) => seedItemList(items), setNetworkDelay: ({ ms }: { ms: number }) => { networkDelayMs = ms; + methodDelays = {}; + }, + setMethodDelays: ({ delays }: { delays: Record }) => { + methodDelays = delays; }, }; @@ -282,7 +288,7 @@ self.onmessage = (e: MessageEvent) => { try { const result = fn(params); - respond(id, result === undefined ? null : result); + respond(id, method, result === undefined ? null : result); } catch (err) { respondError(id, err instanceof Error ? err.message : String(err)); } diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 875bf8a4dbd2..188996ea2bdf 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -17,8 +17,10 @@ export interface BenchAPI { init(count: number): void; updateEntity(id: string): void; updateAuthor(id: string): void; - /** Set simulated per-request network latency (ms). 0 disables and flushes pending delays. */ + /** Set simulated per-request network latency (ms). 0 disables and clears per-method delays. */ setNetworkDelay(ms: number): void; + /** Set per-method network latency overrides (e.g. { fetchItemList: 80, fetchItem: 50 }). */ + setMethodDelays(delays: Record): void; /** Wait for all deferred server mutations to settle before next iteration. */ flushPendingMutations(): Promise; unmountAll(): void; @@ -41,6 +43,8 @@ export interface BenchAPI { initTripleList?(count: number): void; /** Move an item from one status-filtered list to another. Exercises Collection.move (data-client) vs invalidate+refetch (others). */ moveItem?(id: string): void; + /** Switch between sorted list view and individual item detail views 10 times (20 renders). Exercises normalized cache lookup (data-client) vs per-navigation fetch (others). */ + listDetailSwitch?(count: number): void; } declare global { @@ -87,8 +91,8 @@ export type ResultMetric = | 'authorRefChanged' | 'heapDelta'; -/** hotPath = JS only, included in CI. withNetwork = simulated network/overfetching, comparison only. memory = heap delta, not CI. startup = page load metrics, not CI. */ -export type ScenarioCategory = 'hotPath' | 'withNetwork' | 'memory' | 'startup'; +/** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. */ +export type ScenarioCategory = 'hotPath' | 'memory' | 'startup'; /** small = cheap scenarios (full warmup + measurement). large = expensive scenarios (reduced runs). */ export type ScenarioSize = 'small' | 'large'; @@ -99,7 +103,7 @@ export interface Scenario { args: unknown[]; /** Which value to report; default 'duration'. Ref-stability use itemRefChanged/authorRefChanged; memory use heapDelta. */ resultMetric?: ResultMetric; - /** hotPath (default) = run in CI. withNetwork = comparison only. memory = heap delta. startup = page load metrics. */ + /** hotPath (default) = run in CI. memory = heap delta. startup = page load metrics. */ category?: ScenarioCategory; /** small (default) = full runs. large = reduced warmup/measurement for expensive scenarios. */ size?: ScenarioSize; @@ -107,8 +111,6 @@ export interface Scenario { mountCount?: number; /** Use a different BenchAPI method to pre-mount (e.g. 'mountSortedView' instead of 'mount'). */ preMountAction?: keyof BenchAPI; - /** Simulated per-request network latency in ms (applied at the server layer). */ - networkDelayMs?: number; /** Result is deterministic (zero variance); run exactly once with no warmup. */ deterministic?: boolean; } diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index b880d1f403ea..c86dad93c264 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -2,8 +2,10 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, + ItemRow, ItemsRow, LIST_STYLE, + PlainItemList, } from '@shared/components'; import { FIXTURE_AUTHORS, @@ -56,6 +58,16 @@ function SortedListView() { ); } +function DetailView({ id }: { id: string }) { + const { data: item } = useSWR(`item:${id}`, fetcher); + if (!item) return null; + return ( +
+ +
+ ); +} + function ListView({ count }: { count: number }) { const { data: items } = useSWR(`items:${count}`, fetcher); if (!items) return null; @@ -80,13 +92,7 @@ function StatusListView({ status, count }: { status: string; count: number }) { return (
{items.length} - +
); } @@ -108,10 +114,14 @@ function BenchmarkHarness() { showSortedView, showTripleList, tripleListCount, + detailItemId, containerRef, measureMount, measureUpdate, + waitForElement, + setComplete, setShowSortedView, + setDetailItemId, registerAPI, } = useBenchState(); @@ -200,10 +210,42 @@ function BenchmarkHarness() { [measureMount, setShowSortedView], ); + const listDetailSwitch = useCallback( + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + // Warmup cycle (unmeasured) — exercises the detail mount path + setShowSortedView(false); + setDetailItemId('item-0'); + await waitForElement('[data-detail-view]'); + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + performance.mark('mount-start'); + for (let i = 1; i <= 10; i++) { + setShowSortedView(false); + setDetailItemId(`item-${i}`); + await waitForElement('[data-detail-view]'); + + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + } + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }, + [setShowSortedView, setDetailItemId, waitForElement, setComplete], + ); + registerAPI({ updateEntity, updateAuthor, mountSortedView, + listDetailSwitch, unshiftItem, deleteEntity, moveItem, @@ -216,6 +258,7 @@ function BenchmarkHarness() { {showTripleList && tripleListCount != null && ( )} + {detailItemId != null && }
); } diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 1dc75b7965f7..156f69155fdf 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -2,8 +2,10 @@ import { onProfilerRender, useBenchState } from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, + ItemRow, ItemsRow, LIST_STYLE, + PlainItemList, } from '@shared/components'; import { FIXTURE_AUTHORS, @@ -70,6 +72,19 @@ function SortedListView() { ); } +function DetailView({ id }: { id: string }) { + const { data: item } = useQuery({ + queryKey: ['item', id], + queryFn, + }); + if (!item) return null; + return ( +
+ +
+ ); +} + function ListView({ count }: { count: number }) { const { data: items } = useQuery({ queryKey: ['items', count], @@ -99,13 +114,7 @@ function StatusListView({ status, count }: { status: string; count: number }) { return (
{list.length} - +
); } @@ -127,10 +136,14 @@ function BenchmarkHarness() { showSortedView, showTripleList, tripleListCount, + detailItemId, containerRef, measureMount, measureUpdate, + waitForElement, + setComplete, setShowSortedView, + setDetailItemId, registerAPI, } = useBenchState(); @@ -224,10 +237,42 @@ function BenchmarkHarness() { [measureMount, setShowSortedView], ); + const listDetailSwitch = useCallback( + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + // Warmup cycle (unmeasured) — exercises the detail mount path + setShowSortedView(false); + setDetailItemId('item-0'); + await waitForElement('[data-detail-view]'); + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + performance.mark('mount-start'); + for (let i = 1; i <= 10; i++) { + setShowSortedView(false); + setDetailItemId(`item-${i}`); + await waitForElement('[data-detail-view]'); + + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + } + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }, + [setShowSortedView, setDetailItemId, waitForElement, setComplete], + ); + registerAPI({ updateEntity, updateAuthor, mountSortedView, + listDetailSwitch, unshiftItem, deleteEntity, moveItem, @@ -240,6 +285,7 @@ function BenchmarkHarness() { {showTripleList && tripleListCount != null && ( )} + {detailItemId != null && }
); } From 198954772700208a896b7988e0007b5c0912b6de Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 17 Mar 2026 22:58:47 -0400 Subject: [PATCH 40/46] bugbot --- examples/benchmark-react/bench/runner.ts | 37 +++++++++++++++++-- .../src/shared/benchHarness.tsx | 6 +++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index 195166a53c79..b5d6e7270a96 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -191,6 +191,14 @@ async function runScenario( timeout: 60000, state: 'attached', }); + const timedOut = await harness.evaluate(el => + el.hasAttribute('data-bench-timeout'), + ); + if (timedOut) { + throw new Error( + `Harness timeout during mountUnmountCycle: a cycle did not complete within 30 s`, + ); + } const heapAfter = await collectHeapUsed(cdp); await bench.dispose(); return { value: heapAfter - heapBefore }; @@ -209,7 +217,10 @@ async function runScenario( const mountCount = scenario.mountCount ?? 100; if (isUpdate || isRefStability) { const preMountAction = scenario.preMountAction ?? 'init'; - await harness.evaluate(el => el.removeAttribute('data-bench-complete')); + await harness.evaluate(el => { + el.removeAttribute('data-bench-complete'); + el.removeAttribute('data-bench-timeout'); + }); await (bench as any).evaluate( (api: any, [action, n]: [string, number]) => api[action](n), [preMountAction, mountCount], @@ -218,6 +229,14 @@ async function runScenario( timeout: 10000, state: 'attached', }); + const preMountTimedOut = await harness.evaluate(el => + el.hasAttribute('data-bench-timeout'), + ); + if (preMountTimedOut) { + throw new Error( + `Harness timeout during pre-mount (${preMountAction}): did not complete within 30 s`, + ); + } await page.evaluate(() => { performance.clearMarks(); performance.clearMeasures(); @@ -228,7 +247,10 @@ async function runScenario( await (bench as any).evaluate((api: any) => api.captureRefSnapshot()); } - await harness.evaluate(el => el.removeAttribute('data-bench-complete')); + await harness.evaluate(el => { + el.removeAttribute('data-bench-complete'); + el.removeAttribute('data-bench-timeout'); + }); const cdpTracing = USE_TRACE && !isRefStability ? await page.context().newCDPSession(page) @@ -255,12 +277,21 @@ async function runScenario( { action: scenario.action, args: scenario.args }, ); - const completeTimeout = networkSim ? 30000 : 10000; + const completeTimeout = networkSim ? 60000 : 10000; await page.waitForSelector('[data-bench-complete]', { timeout: completeTimeout, state: 'attached', }); + const timedOut = await harness.evaluate(el => + el.hasAttribute('data-bench-timeout'), + ); + if (timedOut) { + throw new Error( + `Harness timeout: MutationObserver did not detect expected DOM update within 30 s`, + ); + } + await (bench as any).evaluate((api: any) => api.flushPendingMutations()); let traceDuration: number | undefined; diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index 623e6ca7a2c5..e65b19ccb24c 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -86,6 +86,9 @@ export function useBenchState() { }); 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'); @@ -121,6 +124,9 @@ export function useBenchState() { }); const timer = setTimeout(() => { observer.disconnect(); + performance.mark('update-end'); + performance.measure('update-duration', 'update-start', 'update-end'); + container.setAttribute('data-bench-timeout', 'true'); setComplete(); }, 30000); performance.mark('update-start'); From b7d453209008749cd3e9fd86dc08a8d7999e4e49 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Tue, 17 Mar 2026 23:58:38 -0400 Subject: [PATCH 41/46] DRY --- examples/benchmark-react/bench/runner.ts | 4 + .../benchmark-react/src/baseline/index.tsx | 77 ++---------- .../benchmark-react/src/data-client/index.tsx | 117 ++++++------------ .../src/shared/benchHarness.tsx | 114 ++++++++++++++--- examples/benchmark-react/src/shared/types.ts | 2 + examples/benchmark-react/src/swr/index.tsx | 99 ++++----------- .../src/tanstack-query/index.tsx | 85 ++----------- 7 files changed, 179 insertions(+), 319 deletions(-) diff --git a/examples/benchmark-react/bench/runner.ts b/examples/benchmark-react/bench/runner.ts index b5d6e7270a96..f9a5c77c1c1c 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -199,6 +199,10 @@ async function runScenario( `Harness timeout during mountUnmountCycle: a cycle did not complete within 30 s`, ); } + await (bench as any).evaluate((api: any) => { + if (api.triggerGC) api.triggerGC(); + }); + await page.waitForTimeout(100); const heapAfter = await collectHeapUsed(cdp); await bench.dispose(); return { value: heapAfter - heapBefore }; diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index d9de8f1626c0..272382031d18 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -1,4 +1,8 @@ -import { onProfilerRender, useBenchState } from '@shared/benchHarness'; +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, @@ -10,13 +14,11 @@ import { import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, sortByLabel, } from '@shared/data'; import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource } from '@shared/resources'; -import { seedItemList } from '@shared/server'; import type { Item } from '@shared/types'; import React, { useCallback, @@ -25,7 +27,6 @@ import React, { useMemo, useState, } from 'react'; -import { createRoot } from 'react-dom/client'; import { List } from 'react-window'; const ItemsContext = React.createContext<{ @@ -129,12 +130,7 @@ function BenchmarkHarness() { tripleListCount, detailItemId, containerRef, - measureMount, measureUpdate, - waitForElement, - setComplete, - setShowSortedView, - setDetailItemId, unmountAll: unmountBase, registerAPI, } = useBenchState(); @@ -251,70 +247,16 @@ function BenchmarkHarness() { }).then(setInProgressItems), ]), ), - () => { - const source = containerRef.current?.querySelector( - '[data-status-list="open"]', - ); - const dest = containerRef.current?.querySelector( - '[data-status-list="closed"]', - ); - return ( - source?.querySelector(`[data-item-id="${id}"]`) == null && - dest?.querySelector(`[data-item-id="${id}"]`) != null - ); - }, + () => moveItemIsReady(containerRef, id), ); }, [measureUpdate, tripleListCount, containerRef], ); - const mountSortedView = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - measureMount(() => { - setShowSortedView(true); - }); - }, - [measureMount, setShowSortedView], - ); - - const listDetailSwitch = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - // Warmup cycle (unmeasured) — exercises the detail mount path - setShowSortedView(false); - setDetailItemId('item-0'); - await waitForElement('[data-detail-view]'); - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - performance.mark('mount-start'); - for (let i = 1; i <= 10; i++) { - setShowSortedView(false); - setDetailItemId(`item-${i}`); - await waitForElement('[data-detail-view]'); - - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - } - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }, - [setShowSortedView, setDetailItemId, waitForElement, setComplete], - ); - registerAPI({ updateEntity, updateAuthor, unmountAll, - mountSortedView, - listDetailSwitch, unshiftItem, deleteEntity, moveItem, @@ -343,9 +285,4 @@ function BenchmarkHarness() { ); } -const rootEl = document.getElementById('root') ?? document.body; -createRoot(rootEl).render( - - - , -); +renderBenchApp(BenchmarkHarness); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 3190ca54e846..13d6c0b539c3 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -1,10 +1,16 @@ import { DataProvider, + GCPolicy, useController, useDLE, useSuspense, } from '@data-client/react'; -import { onProfilerRender, useBenchState } from '@shared/benchHarness'; +import type { Controller } from '@data-client/react'; +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, @@ -16,7 +22,6 @@ import { import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, } from '@shared/data'; import { setCurrentItems } from '@shared/refStability'; @@ -25,12 +30,32 @@ import { ItemResource, sortedItemsEndpoint, } from '@shared/resources'; -import { getItem, patchItem, seedItemList } from '@shared/server'; +import { getItem, patchItem } from '@shared/server'; import type { Item } from '@shared/types'; import React, { useCallback } from 'react'; -import { createRoot } from 'react-dom/client'; 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. */ +class BenchGCPolicy extends GCPolicy { + constructor() { + super({ expiresAt: () => 0 }); + } + + init(controller: Controller) { + this.controller = controller; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + cleanup() {} + + public sweep() { + this.runSweep(); + } +} + +const benchGC = new BenchGCPolicy(); + /** Renders items from the list endpoint (models rendering a list fetch response). */ function ListView({ count }: { count: number }) { const { data: items } = useDLE(ItemResource.getList, { count }); @@ -107,12 +132,6 @@ function BenchmarkHarness() { detailItemId, containerRef, measureUpdate, - measureMount, - waitForElement, - setComplete, - setShowSortedView, - setSortedViewCount, - setDetailItemId, registerAPI, } = useBenchState(); @@ -175,72 +194,12 @@ function BenchmarkHarness() { () => { controller.fetch(ItemResource.move, { id }, { status: 'closed' }); }, - () => { - const source = containerRef.current?.querySelector( - '[data-status-list="open"]', - ); - const dest = containerRef.current?.querySelector( - '[data-status-list="closed"]', - ); - return ( - source?.querySelector(`[data-item-id="${id}"]`) == null && - dest?.querySelector(`[data-item-id="${id}"]`) != null - ); - }, + () => moveItemIsReady(containerRef, id), ); }, [measureUpdate, controller, containerRef], ); - const mountSortedView = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - measureMount(() => { - setSortedViewCount(n); - setShowSortedView(true); - }); - }, - [measureMount, setSortedViewCount, setShowSortedView], - ); - - const listDetailSwitch = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - setSortedViewCount(n); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - // Warmup cycle (unmeasured) — exercises the detail mount path - setShowSortedView(false); - setDetailItemId('item-0'); - await waitForElement('[data-detail-view]'); - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - performance.mark('mount-start'); - for (let i = 1; i <= 10; i++) { - setShowSortedView(false); - setDetailItemId(`item-${i}`); - await waitForElement('[data-detail-view]'); - - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - } - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }, - [ - setSortedViewCount, - setShowSortedView, - setDetailItemId, - waitForElement, - setComplete, - ], - ); - const invalidateAndResolve = useCallback( async (id: string) => { const item = await getItem(id); @@ -274,12 +233,11 @@ function BenchmarkHarness() { registerAPI({ updateEntity, updateAuthor, - mountSortedView, - listDetailSwitch, invalidateAndResolve, unshiftItem, deleteEntity, moveItem, + triggerGC: () => benchGC.sweep(), }); return ( @@ -300,11 +258,8 @@ function BenchmarkHarness() { ); } -const rootEl = document.getElementById('root') ?? document.body; -createRoot(rootEl).render( - - - - - , -); +function BenchProvider({ children }: { children: React.ReactNode }) { + return {children}; +} + +renderBenchApp(BenchmarkHarness, BenchProvider); diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index e65b19ccb24c..c7454973549c 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -1,8 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { FIXTURE_ITEMS } from './data'; import { captureSnapshot, getReport } from './refStability'; import { flushPendingMutations, + seedItemList, setMethodDelays, setNetworkDelay, } from './server'; @@ -27,6 +30,29 @@ export function onProfilerRender( }); } +const OBSERVE_MUTATIONS: MutationObserverInit = { + childList: true, + subtree: true, + characterData: true, +}; + +/** Check whether an item has moved from the "open" to the "closed" status list. */ +export function moveItemIsReady( + containerRef: React.RefObject, + id: string, +): boolean { + const source = containerRef.current?.querySelector( + '[data-status-list="open"]', + ); + const dest = containerRef.current?.querySelector( + '[data-status-list="closed"]', + ); + return ( + source?.querySelector(`[data-item-id="${id}"]`) == null && + dest?.querySelector(`[data-item-id="${id}"]`) != null + ); +} + /** * Actions that each library must provide (not supplied by the shared harness). * All other BenchAPI methods can be optionally overridden or added. @@ -79,11 +105,7 @@ export function useBenchState() { setComplete(); } }); - observer.observe(container, { - childList: true, - subtree: true, - characterData: true, - }); + observer.observe(container, OBSERVE_MUTATIONS); const timer = setTimeout(() => { observer.disconnect(); performance.mark('mount-end'); @@ -117,11 +139,7 @@ export function useBenchState() { setComplete(); } }); - observer.observe(container, { - childList: true, - subtree: true, - characterData: true, - }); + observer.observe(container, OBSERVE_MUTATIONS); const timer = setTimeout(() => { observer.disconnect(); performance.mark('update-end'); @@ -150,11 +168,7 @@ export function useBenchState() { resolve(); } }); - observer.observe(container, { - childList: true, - subtree: true, - characterData: true, - }); + observer.observe(container, OBSERVE_MUTATIONS); const timer = setTimeout(() => { observer.disconnect(); resolve(); @@ -198,12 +212,61 @@ export function useBenchState() { }); init(n); await p; - unmountAll(); + apiRef.current?.unmountAll?.(); await waitForPaint(); } setComplete(); }, - [init, unmountAll, setComplete], + [init, setComplete], + ); + + const mountSortedView = useCallback( + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); + measureMount(() => { + setSortedViewCount(n); + setShowSortedView(true); + }); + }, + [measureMount, setSortedViewCount, setShowSortedView], + ); + + const listDetailSwitch = useCallback( + async (n: number) => { + await seedItemList(FIXTURE_ITEMS.slice(0, n)); + setSortedViewCount(n); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + // Warmup cycle (unmeasured) — exercises the detail mount path + setShowSortedView(false); + setDetailItemId('item-0'); + await waitForElement('[data-detail-view]'); + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + performance.mark('mount-start'); + for (let i = 1; i <= 10; i++) { + setShowSortedView(false); + setDetailItemId(`item-${i}`); + await waitForElement('[data-detail-view]'); + + setDetailItemId(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + } + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }, + [ + setSortedViewCount, + setShowSortedView, + setDetailItemId, + waitForElement, + setComplete, + ], ); const getRenderedCount = useCallback( @@ -225,6 +288,8 @@ export function useBenchState() { initTripleList, unmountAll, mountUnmountCycle, + mountSortedView, + listDetailSwitch, getRenderedCount, captureRefSnapshot, getRefStabilityReport, @@ -273,3 +338,16 @@ export function useBenchState() { registerAPI, }; } + +export function renderBenchApp( + Harness: React.ComponentType, + Wrapper?: React.ComponentType<{ children: React.ReactNode }>, +) { + const rootEl = document.getElementById('root') ?? document.body; + const inner = ( + + + + ); + createRoot(rootEl).render(Wrapper ? {inner} : inner); +} diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 188996ea2bdf..52e0e2699ff7 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -45,6 +45,8 @@ export interface BenchAPI { moveItem?(id: string): void; /** Switch between sorted list view and individual item detail views 10 times (20 renders). Exercises normalized cache lookup (data-client) vs per-navigation fetch (others). */ listDetailSwitch?(count: number): void; + /** Trigger store garbage collection (data-client only). Used by memory scenarios to flush unreferenced data before heap measurement. */ + triggerGC?(): void; } declare global { diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index c86dad93c264..258a5d286b4c 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -1,4 +1,8 @@ -import { onProfilerRender, useBenchState } from '@shared/benchHarness'; +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, @@ -10,16 +14,13 @@ import { import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, sortByLabel, } from '@shared/data'; import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource } from '@shared/resources'; -import { seedItemList } from '@shared/server'; import type { Item } from '@shared/types'; import React, { useCallback, useMemo } from 'react'; -import { createRoot } from 'react-dom/client'; import { List } from 'react-window'; import useSWR, { SWRConfig, useSWRConfig } from 'swr'; @@ -116,12 +117,7 @@ function BenchmarkHarness() { tripleListCount, detailItemId, containerRef, - measureMount, measureUpdate, - waitForElement, - setComplete, - setShowSortedView, - setDetailItemId, registerAPI, } = useBenchState(); @@ -183,69 +179,15 @@ function BenchmarkHarness() { ItemResource.update({ id }, { status: 'closed' }).then(() => mutate(key => typeof key === 'string' && key.startsWith('items:')), ), - () => { - const source = containerRef.current?.querySelector( - '[data-status-list="open"]', - ); - const dest = containerRef.current?.querySelector( - '[data-status-list="closed"]', - ); - return ( - source?.querySelector(`[data-item-id="${id}"]`) == null && - dest?.querySelector(`[data-item-id="${id}"]`) != null - ); - }, + () => moveItemIsReady(containerRef, id), ); }, [measureUpdate, mutate, containerRef], ); - const mountSortedView = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - measureMount(() => { - setShowSortedView(true); - }); - }, - [measureMount, setShowSortedView], - ); - - const listDetailSwitch = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - // Warmup cycle (unmeasured) — exercises the detail mount path - setShowSortedView(false); - setDetailItemId('item-0'); - await waitForElement('[data-detail-view]'); - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - performance.mark('mount-start'); - for (let i = 1; i <= 10; i++) { - setShowSortedView(false); - setDetailItemId(`item-${i}`); - await waitForElement('[data-detail-view]'); - - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - } - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }, - [setShowSortedView, setDetailItemId, waitForElement, setComplete], - ); - registerAPI({ updateEntity, updateAuthor, - mountSortedView, - listDetailSwitch, unshiftItem, deleteEntity, moveItem, @@ -263,17 +205,18 @@ function BenchmarkHarness() { ); } -const rootEl = document.getElementById('root') ?? document.body; -createRoot(rootEl).render( - - - - - , -); +function BenchProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +renderBenchApp(BenchmarkHarness, BenchProvider); diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 156f69155fdf..780c330194f5 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -1,4 +1,8 @@ -import { onProfilerRender, useBenchState } from '@shared/benchHarness'; +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; import { TRIPLE_LIST_STYLE, ITEM_HEIGHT, @@ -10,13 +14,11 @@ import { import { FIXTURE_AUTHORS, FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS, FIXTURE_ITEMS_BY_ID, sortByLabel, } from '@shared/data'; import { setCurrentItems } from '@shared/refStability'; import { AuthorResource, ItemResource } from '@shared/resources'; -import { seedItemList } from '@shared/server'; import type { Item } from '@shared/types'; import { QueryClient, @@ -25,7 +27,6 @@ import { useQueryClient, } from '@tanstack/react-query'; import React, { useCallback, useMemo } from 'react'; -import { createRoot } from 'react-dom/client'; import { List } from 'react-window'; function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { @@ -138,12 +139,7 @@ function BenchmarkHarness() { tripleListCount, detailItemId, containerRef, - measureMount, measureUpdate, - waitForElement, - setComplete, - setShowSortedView, - setDetailItemId, registerAPI, } = useBenchState(); @@ -210,69 +206,15 @@ function BenchmarkHarness() { ItemResource.update({ id }, { status: 'closed' }).then(() => client.invalidateQueries({ queryKey: ['items'] }), ), - () => { - const source = containerRef.current?.querySelector( - '[data-status-list="open"]', - ); - const dest = containerRef.current?.querySelector( - '[data-status-list="closed"]', - ); - return ( - source?.querySelector(`[data-item-id="${id}"]`) == null && - dest?.querySelector(`[data-item-id="${id}"]`) != null - ); - }, + () => moveItemIsReady(containerRef, id), ); }, [measureUpdate, client, containerRef], ); - const mountSortedView = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - measureMount(() => { - setShowSortedView(true); - }); - }, - [measureMount, setShowSortedView], - ); - - const listDetailSwitch = useCallback( - async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - // Warmup cycle (unmeasured) — exercises the detail mount path - setShowSortedView(false); - setDetailItemId('item-0'); - await waitForElement('[data-detail-view]'); - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - - performance.mark('mount-start'); - for (let i = 1; i <= 10; i++) { - setShowSortedView(false); - setDetailItemId(`item-${i}`); - await waitForElement('[data-detail-view]'); - - setDetailItemId(null); - setShowSortedView(true); - await waitForElement('[data-sorted-list]'); - } - performance.mark('mount-end'); - performance.measure('mount-duration', 'mount-start', 'mount-end'); - setComplete(); - }, - [setShowSortedView, setDetailItemId, waitForElement, setComplete], - ); - registerAPI({ updateEntity, updateAuthor, - mountSortedView, - listDetailSwitch, unshiftItem, deleteEntity, moveItem, @@ -290,11 +232,10 @@ function BenchmarkHarness() { ); } -const rootEl = document.getElementById('root') ?? document.body; -createRoot(rootEl).render( - - - - - , -); +function BenchProvider({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +renderBenchApp(BenchmarkHarness, BenchProvider); From 7cc2f23cbe338dbc9cfa72959f32ab790689ff43 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 18 Mar 2026 00:11:26 -0400 Subject: [PATCH 42/46] Potential fix for code scanning alert no. 83: DOM text reinterpreted as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- examples/benchmark-react/bench/report-viewer.html | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/benchmark-react/bench/report-viewer.html b/examples/benchmark-react/bench/report-viewer.html index 94535a1e89a3..66631d13daef 100644 --- a/examples/benchmark-react/bench/report-viewer.html +++ b/examples/benchmark-react/bench/report-viewer.html @@ -89,6 +89,16 @@

Time-series (load multiple runs)

function isTraceSuffix(name) { return name.indexOf('(trace)') !== -1; } function isBaseSuffix(name) { return !isReactCommitSuffix(name) && !isTraceSuffix(name); } + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); + } + function filterData(data) { var showBase = filterBase.checked; var showRC = filterReactCommit.checked; @@ -132,11 +142,11 @@

Time-series (load multiple runs)

var scenarioNames = Object.keys(suffixToLibRows).sort(); var libList = Array.from(libs).sort(); - var thead = ['Scenario'].concat(libList).map(function (c) { return '' + c + ''; }).join(''); + var thead = ['Scenario'].concat(libList).map(function (c) { return '' + escapeHtml(c) + ''; }).join(''); var tbody = ''; scenarioNames.forEach(function (scenarioName) { var byLib = suffixToLibRows[scenarioName] || {}; - var cells = ['' + scenarioName + '']; + var cells = ['' + escapeHtml(scenarioName) + '']; var minV = Infinity, maxV = -Infinity; libList.forEach(function (lib) { var r = byLib[lib]; From 6768766214f238b33566ddc4f4cc32af1ce17984 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 18 Mar 2026 00:13:09 -0400 Subject: [PATCH 43/46] delete dead code --- examples/benchmark-react/src/shared/server.worker.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/examples/benchmark-react/src/shared/server.worker.ts b/examples/benchmark-react/src/shared/server.worker.ts index 83b866e2ac79..9f26f6aeb42a 100644 --- a/examples/benchmark-react/src/shared/server.worker.ts +++ b/examples/benchmark-react/src/shared/server.worker.ts @@ -239,17 +239,6 @@ function seedItemList(items: Item[]): void { // ── MESSAGE HANDLER ────────────────────────────────────────────────────── -const MUTATION_METHODS = new Set([ - 'createItem', - 'createAuthor', - 'updateItem', - 'updateAuthor', - 'deleteItem', - 'deleteAuthor', - 'patchItem', - 'seedItemList', -]); - const methods: Record unknown> = { fetchItem, fetchAuthor, From e246f1f59a16791d4a8e33c23f4c611ecccdfc2c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 18 Mar 2026 08:54:49 -0400 Subject: [PATCH 44/46] movewith docs --- .changeset/collection-movewith.md | 21 +++ docs/rest/api/Collection.md | 38 ++++++ examples/benchmark-react/package.json | 4 +- .../benchmark-react/src/shared/resources.ts | 7 +- packages/endpoint/src/index.ts | 10 +- packages/endpoint/src/schema.d.ts | 6 + packages/endpoint/src/schema.js | 2 +- packages/endpoint/src/schemas/Collection.ts | 8 +- .../src/schemas/__tests__/Collection.test.ts | 123 +++++++++++++++++- .../2026-01-19-v0.16-release-announcement.md | 17 +++ 10 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 .changeset/collection-movewith.md diff --git a/.changeset/collection-movewith.md b/.changeset/collection-movewith.md new file mode 100644 index 000000000000..ef13497c3f79 --- /dev/null +++ b/.changeset/collection-movewith.md @@ -0,0 +1,21 @@ +--- +'@data-client/endpoint': minor +'@data-client/rest': minor +--- + +Add `Collection.moveWith()` for custom move schemas + +Analogous to [`addWith()`](https://dataclient.io/rest/api/Collection#addWith), `moveWith()` constructs a custom move schema that controls how entities are added to their destination collection. The remove behavior is automatically derived from the collection type (Array or Values). + +New exports: `unshift` merge function for convenience. + +```ts +import { Collection, unshift } from '@data-client/rest'; + +class MyCollection extends Collection { + constructor(schema, options) { + super(schema, options); + this.move = this.moveWith(unshift); + } +} +``` diff --git a/docs/rest/api/Collection.md b/docs/rest/api/Collection.md index e7fa7c0f83d6..38c32819336f 100644 --- a/docs/rest/api/Collection.md +++ b/docs/rest/api/Collection.md @@ -601,6 +601,44 @@ e.g., `'10' == 10` boolean; ``` +### moveWith(merge): MoveSchema {#moveWith} + +Constructs a custom move schema for this collection. This is analogous to [addWith](#addWith) +but for [move](#move) operations. The `merge` function controls how entities are added to +their destination collection, while the remove behavior is automatically derived from +the collection type (Array or Values). + +This is useful when you need to control the insertion position of moved items +(e.g., prepending instead of appending). + +#### merge(collection, moved) + +Controls how the moved entity is added to its destination collection. + +The exported [`unshift`](#unshift-merge) merge function places items at the start: + +```ts +import { Collection, unshift } from '@data-client/rest'; + +class MyCollection extends Collection { + constructor(schema, options) { + super(schema, options); + // Prepend moved items instead of appending + // highlight-next-line + this.move = this.moveWith(unshift); + } +} +``` + +### unshift (merge function) {#unshift-merge} + +A merge function that places incoming items at the _start_ of the collection. +Use with [moveWith](#moveWith) or [addWith](#addWith) to control insertion order. + +```ts +import { unshift } from '@data-client/rest'; +``` + ## Lifecycle Methods ### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldReorder} diff --git a/examples/benchmark-react/package.json b/examples/benchmark-react/package.json index fd16c58746a0..64a4eab378aa 100644 --- a/examples/benchmark-react/package.json +++ b/examples/benchmark-react/package.json @@ -4,8 +4,8 @@ "private": true, "description": "React rendering benchmark comparing @data-client/react against other data libraries", "scripts": { - "build": "webpack --mode=production", - "build:compiler": "REACT_COMPILER=true webpack --mode=production", + "build": "BROWSERSLIST_ENV=2026 webpack --mode=production", + "build:compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=true 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", diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index a45def419043..55de2ef8fcb2 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -1,4 +1,4 @@ -import { Entity, All, Query, Collection } from '@data-client/endpoint'; +import { Entity, All, Query, Collection, unshift } from '@data-client/endpoint'; import type { PolymorphicInterface } from '@data-client/endpoint'; import { resource } from '@data-client/rest'; import { sortByLabel } from '@shared/data'; @@ -56,10 +56,7 @@ class ItemCollection< > extends Collection { constructor(schema: S, options?: any) { super(schema, options); - (this as any).move = this.moveWith((existing: any, incoming: any) => [ - ...incoming, - ...existing, - ]); + (this as any).move = this.moveWith(unshift); } nonFilterArgumentKeys(key: string) { diff --git a/packages/endpoint/src/index.ts b/packages/endpoint/src/index.ts index 3aec1ef63ec1..c25f73ebb747 100644 --- a/packages/endpoint/src/index.ts +++ b/packages/endpoint/src/index.ts @@ -11,7 +11,15 @@ export type { } from './endpoint.js'; export * as schema from './schema.js'; // Direct exports of schema members (except Object and Array) -export { Union, Invalidate, Collection, Query, Values, All } from './schema.js'; +export { + Union, + Invalidate, + Collection, + Query, + Values, + All, + unshift, +} from './schema.js'; // Without this we get 'cannot be named without a reference to' for resource()....why is this? // Clue 1) It only happens with types mentioned in return types of other types export type { Array, DefaultArgs, Object } from './schema.js'; diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index 03be5905b45f..13f16fbe7d5b 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -364,6 +364,12 @@ export class Values implements SchemaClass { ): undefined; } +/** Collection merge that places incoming items at the start. + * + * @see https://dataclient.io/rest/api/Collection#moveWith + */ +export declare const unshift: (existing: any, incoming: any) => any; + export declare let CollectionRoot: CollectionConstructor; /** diff --git a/packages/endpoint/src/schema.js b/packages/endpoint/src/schema.js index 29ee5b729c52..8c9eceb1c1f6 100644 --- a/packages/endpoint/src/schema.js +++ b/packages/endpoint/src/schema.js @@ -5,7 +5,7 @@ export { default as Array } from './schemas/Array.js'; export { default as All } from './schemas/All.js'; export { default as Object } from './schemas/Object.js'; export { default as Invalidate } from './schemas/Invalidate.js'; -export { default as Collection } from './schemas/Collection.js'; +export { default as Collection, unshift } from './schemas/Collection.js'; export { default as EntityMixin, default as Entity, diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 11eca080ce87..40ea34d094d3 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -13,7 +13,11 @@ import type { DefaultArgs } from '../schemaTypes.js'; const pushMerge = (existing: any, incoming: any) => { return [...existing, ...incoming]; }; -const unshiftMerge = (existing: any, incoming: any) => { +/** Collection merge that places incoming items at the start. + * + * @see https://dataclient.io/rest/api/Collection#moveWith + */ +export const unshift = (existing: any, incoming: any) => { return [...incoming, ...existing]; }; const valuesMerge = (existing: any, incoming: any) => { @@ -155,7 +159,7 @@ export default class CollectionSchema< if (this.schema instanceof ArraySchema) { this.createIfValid = createArray; this.push = CreateAdder(this, pushMerge); - this.unshift = CreateAdder(this, unshiftMerge); + this.unshift = CreateAdder(this, unshift); this.remove = CreateAdder(this, removeMerge); this.move = CreateMover(this, pushMerge, removeMerge); } else if (schema instanceof Values) { diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index d86c4a4391b8..937a34d66598 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -6,7 +6,7 @@ import { Record } from 'immutable'; import { SimpleMemoCache } from './denormalize'; import { PolymorphicInterface } from '../..'; -import { schema, Collection, Union } from '../..'; +import { schema, Collection, Union, unshift } from '../..'; import PolymorphicSchema from '../Polymorphic'; let dateSpy: jest.Spied; @@ -1146,6 +1146,127 @@ describe(`${schema.Collection.name} normalization`, () => { }); }); + describe('moveWith(unshift) should prepend to destination collection', () => { + class UnshiftCollection< + S extends any[] | schema.Array | schema.Values, + > extends Collection { + constructor(schema: S, options?: any) { + super(schema, options); + (this as any).move = this.moveWith(unshift); + } + } + + const initializingSchema = new UnshiftCollection([Todo]); + + it('moves entity and prepends to destination', () => { + let state = { + ...initialState, + ...normalize( + initializingSchema, + [{ id: '10', userId: 1, title: 'movable todo' }], + [{ userId: '1' }], + initialState, + ), + }; + state = { + ...state, + ...normalize( + initializingSchema, + [{ id: '20', userId: 2, title: 'existing todo' }], + [{ userId: '2' }], + state, + ), + }; + + const moveState = { + ...state, + ...normalize( + initializingSchema.move, + { id: '10', userId: 2, title: 'movable todo' }, + [{ id: '10' }, { userId: '2' }], + state, + ), + }; + + const userOneList = denormalize( + initializingSchema, + JSON.stringify({ userId: '1' }), + moveState.entities, + ) as any; + expect(userOneList).toHaveLength(0); + + const userTwoList = denormalize( + initializingSchema, + JSON.stringify({ userId: '2' }), + moveState.entities, + ) as any; + expect(userTwoList).toHaveLength(2); + // moved item should be first (unshift prepends) + expect(userTwoList[0].id).toBe('10'); + expect(userTwoList[1].id).toBe('20'); + }); + + it('Values collection uses moveWith with custom merge', () => { + const valuesMerge = (existing: any, incoming: any) => ({ + ...incoming, + ...existing, + }); + class ValuesUnshiftCollection< + S extends any[] | schema.Array | schema.Values, + > extends Collection { + constructor(s: S, options?: any) { + super(s, options); + (this as any).move = this.moveWith(valuesMerge); + } + } + + const valuesSchema = new ValuesUnshiftCollection(new schema.Values(Todo)); + + let state = { + ...initialState, + ...normalize( + valuesSchema, + { '10': { id: '10', userId: 1, title: 'movable' } }, + [{ userId: '1' }], + initialState, + ), + }; + state = { + ...state, + ...normalize( + valuesSchema, + { '20': { id: '20', userId: 2, title: 'existing' } }, + [{ userId: '2' }], + state, + ), + }; + + const moveState = { + ...state, + ...normalize( + valuesSchema.move, + { id: '10', userId: 2, title: 'movable' }, + [{ id: '10' }, { userId: '2' }], + state, + ), + }; + + const userOneValues = denormalize( + valuesSchema, + JSON.stringify({ userId: '1' }), + moveState.entities, + ) as any; + expect(Object.keys(userOneValues)).toHaveLength(0); + + const userTwoValues = denormalize( + valuesSchema, + JSON.stringify({ userId: '2' }), + moveState.entities, + ) as any; + expect(Object.keys(userTwoValues)).toHaveLength(2); + }); + }); + describe('move should remove from old Values collection and add to new', () => { const valuesSchema = new Collection(new schema.Values(Todo)); diff --git a/website/blog/2026-01-19-v0.16-release-announcement.md b/website/blog/2026-01-19-v0.16-release-announcement.md index 1b04d7a10ae1..c6fa982117f6 100644 --- a/website/blog/2026-01-19-v0.16-release-announcement.md +++ b/website/blog/2026-01-19-v0.16-release-announcement.md @@ -15,6 +15,7 @@ import { parallelFetchFixtures } from '@site/src/fixtures/post-comments'; - [Parallel data loading with useFetch()](/blog/2026/01/19/v0.16-release-announcement#parallel-data-loading) - Fetch multiple endpoints concurrently with `use(useFetch())`, avoiding sequential waterfalls - [Direct schema imports](/blog/2026/01/19/v0.16-release-announcement#direct-schema-imports) - Import schema classes directly without the `schema` namespace - [Collection.move](/blog/2026/01/19/v0.16-release-announcement#collection-move) - Move entities between [Collections](/rest/api/Collection) with a single operation +- [Collection.moveWith()](/blog/2026/01/19/v0.16-release-announcement#collection-move) - Customize move behavior (e.g., prepend instead of append) **Performance:** @@ -128,6 +129,22 @@ await ctrl.fetch( Works for both path parameters and search parameters, and supports Array and Values collections. +### Collection.moveWith() + +[Collection.moveWith()](/rest/api/Collection#moveWith) constructs a custom move schema, analogous to [addWith()](/rest/api/Collection#addWith). The `merge` function controls how entities are added to their destination collection (e.g., prepending instead of appending), while the remove behavior is automatically derived. The [`unshift`](/rest/api/Collection#unshift-merge) merge function is provided for convenience. + +```ts +import { Collection, unshift } from '@data-client/rest'; + +class MyCollection extends Collection { + constructor(schema, options) { + super(schema, options); + // highlight-next-line + this.move = this.moveWith(unshift); + } +} +``` + ## Parallel data loading [useFetch()](/docs/api/useFetch) now returns a [UsablePromise](https://react.dev/reference/react/use) thenable, From e4f0affa8b3515c5ff45f8a5594fafa0fa081a8b Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 18 Mar 2026 19:57:28 -0400 Subject: [PATCH 45/46] review --- examples/benchmark-react/bench/tracing.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/examples/benchmark-react/bench/tracing.ts b/examples/benchmark-react/bench/tracing.ts index d23a783cb6c0..db8596eb26e0 100644 --- a/examples/benchmark-react/bench/tracing.ts +++ b/examples/benchmark-react/bench/tracing.ts @@ -18,11 +18,24 @@ export function parseTraceDuration(traceBuffer: Buffer): number { e.cat?.includes('devtools.timeline'), ); - const firstTs = Math.min(...events.map(e => e.ts).filter(x => x != null)); - const lastPaint = - paintEvents.length ? - Math.max(...paintEvents.map(e => e.ts + (e.dur ?? 0))) - : Math.max(...events.map(e => e.ts + (e.dur ?? 0))); + let firstTs = Infinity; + let lastAll = -Infinity; + for (const e of events) { + if (e.ts != null) { + if (e.ts < firstTs) firstTs = e.ts; + const end = e.ts + (e.dur ?? 0); + if (end > lastAll) lastAll = end; + } + } + let lastPaint = -Infinity; + if (paintEvents.length) { + for (const e of paintEvents) { + const end = e.ts + (e.dur ?? 0); + if (end > lastPaint) lastPaint = end; + } + } else { + lastPaint = lastAll; + } return (lastPaint - firstTs) / 1000; } catch { From feca46987423e2959e8c6db754f5837c9dea1e9c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 18 Mar 2026 22:08:14 -0400 Subject: [PATCH 46/46] switch to github data --- .cursor/rules/benchmarking.mdc | 8 +- examples/benchmark-react/README.md | 32 +- examples/benchmark-react/bench/runner.ts | 18 +- examples/benchmark-react/bench/scenarios.ts | 59 ++-- examples/benchmark-react/bench/stats.ts | 9 +- examples/benchmark-react/bench/validate.ts | 233 +++++++-------- .../benchmark-react/src/baseline/index.tsx | 269 ++++++++--------- .../benchmark-react/src/data-client/index.tsx | 173 ++++++----- .../src/shared/benchHarness.tsx | 72 ++--- .../benchmark-react/src/shared/components.tsx | 84 +++--- examples/benchmark-react/src/shared/data.ts | 276 +++++++++++++----- .../src/shared/refStability.ts | 60 ++-- .../benchmark-react/src/shared/resources.ts | 138 +++++---- examples/benchmark-react/src/shared/server.ts | 86 +++--- .../src/shared/server.worker.ts | 275 +++++++++-------- examples/benchmark-react/src/shared/types.ts | 103 ++++--- examples/benchmark-react/src/swr/index.tsx | 165 +++++------ .../src/tanstack-query/index.tsx | 178 +++++------ package.json | 2 +- 19 files changed, 1195 insertions(+), 1045 deletions(-) diff --git a/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 61e4e3379a93..6ecfa7b2a3a8 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -31,12 +31,12 @@ 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-author-500-mounted`, `update-shared-author-10000-mounted`) +- **Update propagation** (`update-single-entity`, `update-shared-user-500-mounted`, `update-shared-user-10000-mounted`) - 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 author at scale) + - All libraries (normalization advantage shows with shared user at scale) -- **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) +- **Ref-stability** (`ref-stability-issue-changed`, `ref-stability-user-changed`) - Exercises: referential equality preservation through normalization - Relevant for: `@data-client/normalizr` denormalize memoization, Entity identity - All libraries (data-client should show fewest changed refs) @@ -59,7 +59,7 @@ 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-author-*`, `sorted-view-update-*` | 5–10% | +| **Moderate** | `update-shared-user-*`, `sorted-view-update-*` | 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/README.md b/examples/benchmark-react/README.md index f2b70cfbfa8a..b8ce66acd76d 100644 --- a/examples/benchmark-react/README.md +++ b/examples/benchmark-react/README.md @@ -11,7 +11,7 @@ The repo has two benchmark suites: ## Methodology -- **What we measure:** Wall-clock time from triggering an action (e.g. `init(100)` or `updateAuthor('author-0')`) until a MutationObserver detects the expected DOM change in the benchmark container. Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. +- **What we measure:** Wall-clock time from triggering an action (e.g. `init(100)` or `updateUser('user0')`) until a MutationObserver detects the expected DOM change in the benchmark container. Optionally we also record React Profiler commit duration and, with `BENCH_TRACE=true`, Chrome trace duration. - **Why:** Normalized caching should show wins on shared-entity updates (one store write, many components update), ref stability (fewer new object references), and derived-view memoization (`Query` schema avoids re-sorting when entities haven't changed). See [js-framework-benchmark "How the duration is measured"](https://github.com/krausest/js-framework-benchmark/wiki/How-the-duration-is-measured) for a similar timeline-based approach. - **Statistical:** Warmup runs are discarded; we report median and 95% CI. Libraries are interleaved per round to reduce environmental variance. - **No CPU throttling:** Runs at native speed with more samples for statistical significance rather than artificial slowdown. Small (cheap) scenarios use 3 warmup + 15 measurement runs locally (10 in CI); large (expensive) scenarios use 1 warmup + 4 measurement runs. @@ -27,21 +27,21 @@ 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 items 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 item and propagate to the UI (unit: ms). -- **Update shared author (scaling)** (`update-shared-author-500-mounted`, `update-shared-author-10000-mounted`) — Update one shared author with 500 or 10,000 mounted items to test subscriber scaling. Normalized cache: one store update, all views of that author update. -- **Ref-stability** (`ref-stability-item-changed`, `ref-stability-author-changed`) — Count of components that received a **new** object reference after an update (unit: count; smaller is better). Normalization keeps referential equality for unchanged entities. -- **Sorted view mount** (`sorted-view-mount-500`) — Mount 500 items through a sorted/derived view. data-client uses `useQuery(sortedItemsQuery)` with `Query` schema memoization; competitors use `useMemo` + sort. +- **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. +- **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)** -- **Update shared author with network** (`update-shared-author-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client propagates via normalization (no extra request); other libs invalidate/refetch the list endpoint. +- **Update shared user with network** (`update-shared-user-with-network`) — Same as above with a simulated delay (e.g. 50 ms) per "request." data-client propagates via normalization (no extra request); other libs invalidate/refetch the list endpoint. **Memory (local only)** -- **Memory mount/unmount cycle** (`memory-mount-unmount-cycle`) — Mount 500 items, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. +- **Memory mount/unmount cycle** (`memory-mount-unmount-cycle`) — Mount 500 issues, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. **Startup (local only)** @@ -55,17 +55,17 @@ These are approximate values to help calibrate expectations. Exact numbers vary | Scenario | data-client | tanstack-query | swr | baseline | |---|---|---|---|---| | `getlist-100` | ~similar | ~similar | ~similar | ~similar | -| `update-shared-author-500-mounted` | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | Higher (list refetch) | -| `ref-stability-item-changed` (100 mounted) | ~1 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | -| `ref-stability-author-changed` (100 mounted) | ~5 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | -| `sorted-view-update-entity` | Fast (Query memoization skips re-sort) | Re-sorts on every item change | Re-sorts on every item change | Re-sorts on every item change | +| `update-shared-user-500-mounted` | Low (one store write propagates) | Higher (list refetch) | Higher (list refetch) | Higher (list refetch) | +| `ref-stability-issue-changed` (100 mounted) | ~1 changed | ~100 changed (list refetch) | ~100 changed (list refetch) | ~100 changed (list refetch) | +| `ref-stability-user-changed` (100 mounted) | ~5 changed | ~100 changed (list refetch) | ~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 | 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-author-*`, `sorted-view-update-*` | 5-10% | +| **Moderate** | `update-shared-user-*`, `sorted-view-update-*` | 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. @@ -73,14 +73,14 @@ Regressions >5% on stable scenarios or >15% on volatile scenarios are worth inve ## Interpreting results - **Lower is better** for duration (ms), ref-stability counts, and heap delta (bytes). -- **Ref-stability:** data-client's normalized cache keeps referential equality for unchanged entities, so `itemRefChanged` and `authorRefChanged` should stay low. Non-normalized libs typically show higher counts because they create new object references for every cache write. +- **Ref-stability:** data-client's normalized cache keeps referential equality for unchanged entities, so `issueRefChanged` and `userRefChanged` should stay low. Non-normalized libs typically show higher counts because they create new object references for every cache write. - **React commit:** Reported as `(react commit)` suffix entries. These measure React Profiler `actualDuration` and isolate React reconciliation cost from layout/paint. - **Report viewer:** Toggle the "Base metrics", "React commit", and "Trace" checkboxes to filter the comparison table. Use "Load history" to compare multiple runs over time. ## Adding a new library 1. Add a new app under `src//index.tsx` (e.g. `src/urql/index.tsx`). -2. Implement the `BenchAPI` interface on `window.__BENCH__`: `init`, `updateEntity`, `updateAuthor`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `mountSortedView`. Use the shared presentational `ItemsRow` from `@shared/components` and fixtures from `@shared/data`. The harness (`useBenchState`) provides default `init`, `unmountAll`, `mountUnmountCycle`, `getRenderedCount`, and ref-stability methods; libraries only need to supply `updateEntity`, `updateAuthor`, and any overrides. +2. Implement the `BenchAPI` interface on `window.__BENCH__`: `init`, `updateEntity`, `updateUser`, `unmountAll`, `getRenderedCount`, `captureRefSnapshot`, `getRefStabilityReport`, and optionally `mountUnmountCycle`, `mountSortedView`. Use the shared presentational `IssuesRow` from `@shared/components` and fixtures from `@shared/data`. The harness (`useBenchState`) provides default `init`, `unmountAll`, `mountUnmountCycle`, `getRenderedCount`, and ref-stability methods; libraries only need to supply `updateEntity`, `updateUser`, and any overrides. 3. Add the library to `LIBRARIES` in `bench/scenarios.ts`. 4. Add a webpack entry in `webpack.config.cjs` for the new app and an `HtmlWebpackPlugin` entry so the app is served at `//`. 5. Add the dependency to `package.json` and run `yarn install`. @@ -164,7 +164,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-single-entity`, `ref-stability-*`, `invalidate-and-resolve`, `unshift-item`, `delete-item` - - **Large** (1 warmup + 4 measurement): `getlist-500`, `update-shared-author-500-mounted`, `update-shared-author-10000-mounted`, `update-shared-author-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + - **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` - **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 f9a5c77c1c1c..27d5e678ad61 100644 --- a/examples/benchmark-react/bench/runner.ts +++ b/examples/benchmark-react/bench/runner.ts @@ -125,14 +125,14 @@ const USE_TRACE = process.env.BENCH_TRACE === 'true'; // Scenario runner (unchanged logic) // --------------------------------------------------------------------------- -const REF_STABILITY_METRICS = ['itemRefChanged', 'authorRefChanged'] as const; +const REF_STABILITY_METRICS = ['issueRefChanged', 'userRefChanged'] as const; function isRefStabilityScenario(scenario: Scenario): scenario is Scenario & { resultMetric: (typeof REF_STABILITY_METRICS)[number]; } { return ( - scenario.resultMetric === 'itemRefChanged' || - scenario.resultMetric === 'authorRefChanged' + scenario.resultMetric === 'issueRefChanged' || + scenario.resultMetric === 'userRefChanged' ); } @@ -210,7 +210,7 @@ async function runScenario( const isUpdate = scenario.action === 'updateEntity' || - scenario.action === 'updateAuthor' || + scenario.action === 'updateUser' || scenario.action === 'invalidateAndResolve' || scenario.action === 'unshiftItem' || scenario.action === 'deleteEntity' || @@ -328,7 +328,7 @@ async function runScenario( const isMountLike = isInit || scenario.action === 'mountSortedView' || - scenario.action === 'initTripleList' || + scenario.action === 'initDoubleList' || scenario.action === 'listDetailSwitch'; const duration = isMountLike ? @@ -397,8 +397,8 @@ function shuffle(arr: T[]): T[] { function scenarioUnit(scenario: Scenario): string { if ( - scenario.resultMetric === 'itemRefChanged' || - scenario.resultMetric === 'authorRefChanged' + scenario.resultMetric === 'issueRefChanged' || + scenario.resultMetric === 'userRefChanged' ) return 'count'; if (scenario.resultMetric === 'heapDelta') return 'bytes'; @@ -665,9 +665,9 @@ async function main() { if ( reactSamples.length > 0 && (scenario.action === 'init' || - scenario.action === 'initTripleList' || + scenario.action === 'initDoubleList' || scenario.action === 'updateEntity' || - scenario.action === 'updateAuthor' || + scenario.action === 'updateUser' || scenario.action === 'mountSortedView' || scenario.action === 'listDetailSwitch' || scenario.action === 'invalidateAndResolve' || diff --git a/examples/benchmark-react/bench/scenarios.ts b/examples/benchmark-react/bench/scenarios.ts index c48fc1e50194..fc7d0bda1ca4 100644 --- a/examples/benchmark-react/bench/scenarios.ts +++ b/examples/benchmark-react/bench/scenarios.ts @@ -2,15 +2,14 @@ import type { BenchAPI, Scenario, ScenarioSize } from '../src/shared/types.js'; /** Per-method network latency used when --network-sim is enabled (default: on). */ export const NETWORK_SIM_DELAYS: Record = { - fetchItemList: 80, - fetchItem: 50, - fetchAuthor: 50, - createItem: 50, - createAuthor: 50, - updateItem: 50, - updateAuthor: 50, - deleteItem: 50, - deleteAuthor: 50, + fetchIssueList: 80, + fetchIssue: 50, + fetchUser: 50, + createIssue: 50, + updateIssue: 50, + updateUser: 50, + deleteIssue: 50, + deleteUser: 50, }; export interface RunProfile { @@ -37,8 +36,8 @@ export const RUN_CONFIG: Record = { }; export const ACTION_GROUPS: Record = { - mount: ['init', 'initTripleList', 'mountSortedView', 'listDetailSwitch'], - update: ['updateEntity', 'updateAuthor'], + mount: ['init', 'initDoubleList', 'mountSortedView', 'listDetailSwitch'], + update: ['updateEntity', 'updateUser'], mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], memory: ['mountUnmountCycle'], }; @@ -76,29 +75,29 @@ const BASE_SCENARIOS: BaseScenario[] = [ { nameSuffix: 'update-single-entity', action: 'updateEntity', - args: ['item-0'], + args: [1], category: 'hotPath', }, { - nameSuffix: 'ref-stability-item-changed', + nameSuffix: 'ref-stability-issue-changed', action: 'updateEntity', - args: ['item-0'], - resultMetric: 'itemRefChanged', + args: [1], + resultMetric: 'issueRefChanged', category: 'hotPath', deterministic: true, }, { - nameSuffix: 'ref-stability-author-changed', - action: 'updateAuthor', - args: ['author-0'], - resultMetric: 'authorRefChanged', + nameSuffix: 'ref-stability-user-changed', + action: 'updateUser', + args: ['user0'], + resultMetric: 'userRefChanged', category: 'hotPath', deterministic: true, }, { - nameSuffix: 'update-shared-author-500-mounted', - action: 'updateAuthor', - args: ['author-0'], + nameSuffix: 'update-shared-user-500-mounted', + action: 'updateUser', + args: ['user0'], category: 'hotPath', mountCount: 500, size: 'large', @@ -121,7 +120,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ { nameSuffix: 'sorted-view-update-entity', action: 'updateEntity', - args: ['item-0'], + args: [1], category: 'hotPath', mountCount: 500, preMountAction: 'mountSortedView', @@ -135,9 +134,9 @@ const BASE_SCENARIOS: BaseScenario[] = [ size: 'large', }, { - nameSuffix: 'update-shared-author-10000-mounted', - action: 'updateAuthor', - args: ['author-0'], + nameSuffix: 'update-shared-user-10000-mounted', + action: 'updateUser', + args: ['user0'], category: 'hotPath', mountCount: 10000, size: 'large', @@ -145,7 +144,7 @@ const BASE_SCENARIOS: BaseScenario[] = [ { nameSuffix: 'invalidate-and-resolve', action: 'invalidateAndResolve', - args: ['item-0'], + args: [1], category: 'hotPath', onlyLibs: ['data-client'], }, @@ -159,17 +158,17 @@ const BASE_SCENARIOS: BaseScenario[] = [ { nameSuffix: 'delete-item', action: 'deleteEntity', - args: ['item-0'], + args: [1], category: 'hotPath', mountCount: 100, }, { nameSuffix: 'move-item', action: 'moveItem', - args: ['item-0'], + args: [1], category: 'hotPath', mountCount: 100, - preMountAction: 'initTripleList', + preMountAction: 'initDoubleList', }, ]; diff --git a/examples/benchmark-react/bench/stats.ts b/examples/benchmark-react/bench/stats.ts index 656eba75700a..d9b339abb122 100644 --- a/examples/benchmark-react/bench/stats.ts +++ b/examples/benchmark-react/bench/stats.ts @@ -14,7 +14,7 @@ export function isConverged( const mean = trimmed.reduce((sum, x) => sum + x, 0) / trimmed.length; if (mean === 0) return true; const stdDev = Math.sqrt( - trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / trimmed.length, + trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (trimmed.length - 1), ); const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); return (margin / Math.abs(mean)) * 100 <= targetMarginPct; @@ -29,8 +29,9 @@ export function computeStats( warmupCount: number, ): { median: number; p95: number; range: string } { const trimmed = samples.slice(warmupCount); - if (trimmed.length === 0) { - return { median: 0, p95: 0, range: '± 0' }; + if (trimmed.length <= 1) { + const v = trimmed[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; @@ -38,7 +39,7 @@ export function computeStats( 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, + trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (trimmed.length - 1), ); const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); return { diff --git a/examples/benchmark-react/bench/validate.ts b/examples/benchmark-react/bench/validate.ts index bd4f5fc1157c..15a69b8cb504 100644 --- a/examples/benchmark-react/bench/validate.ts +++ b/examples/benchmark-react/bench/validate.ts @@ -21,7 +21,7 @@ const BASE_URL = `http://localhost:${process.env.BENCH_PORT ?? '5173'}`; // react-window virtualises; keep test counts within the visible window -const TEST_ITEM_COUNT = 20; +const TEST_ISSUE_COUNT = 20; // --------------------------------------------------------------------------- // CLI @@ -64,18 +64,18 @@ async function clearComplete(page: Page) { .evaluate(el => el.removeAttribute('data-bench-complete')); } -async function getItemLabels(page: Page): Promise> { +async function getIssueTitles(page: Page): Promise> { return page.evaluate(() => { - const out: Record = {}; + const out: Record = {}; for (const el of document.querySelectorAll('[data-bench-item]')) { - const id = (el as HTMLElement).dataset.itemId ?? ''; - out[id] = el.querySelector('[data-label]')?.textContent?.trim() ?? ''; + const num = Number((el as HTMLElement).dataset.issueNumber ?? '0'); + out[num] = el.querySelector('[data-title]')?.textContent?.trim() ?? ''; } return out; }); } -async function getItemCount(page: Page): Promise { +async function getIssueCount(page: Page): Promise { return page.evaluate( () => document.querySelectorAll('[data-bench-item]').length, ); @@ -95,18 +95,18 @@ async function waitFor( throw new Error(`Timed out waiting for: ${description} (${timeoutMs}ms)`); } -/** Init items and wait until at least one appears in the DOM. */ -async function initAndWaitForItems( +/** Init issues and wait until at least one appears in the DOM. */ +async function initAndWaitForIssues( page: Page, - count: number = TEST_ITEM_COUNT, + count: number = TEST_ISSUE_COUNT, ) { await clearComplete(page); await page.evaluate((n: number) => window.__BENCH__!.init(n), count); await waitForComplete(page); await waitFor( page, - async () => (await getItemCount(page)) > 0, - `items rendered after init(${count})`, + async () => (await getIssueCount(page)) > 0, + `issues rendered after init(${count})`, ); } @@ -151,82 +151,78 @@ function test(name: string, fn: TestFn, opts?: { onlyLibs?: string[] }) { // ── init ───────────────────────────────────────────────────────────────── -test('init renders items with correct labels', async (page, lib) => { - await initAndWaitForItems(page); +test('init renders issues with correct titles', async (page, lib) => { + await initAndWaitForIssues(page); - const labels = await getItemLabels(page); - const ids = Object.keys(labels); - assert(ids.length > 0, lib, 'init', `no items in DOM`); + const titles = await getIssueTitles(page); + const nums = Object.keys(titles).map(Number); + assert(nums.length > 0, lib, 'init', `no issues in DOM`); + // Issue #1 is the first generated issue assert( - labels['item-0'] === 'Item 0', + titles[1] != null && titles[1].length > 0, lib, 'init', - `item-0 label: expected "Item 0", got "${labels['item-0']}"`, + `issue #1 title missing or empty, got "${titles[1]}"`, ); const renderedCount = await page.evaluate(() => window.__BENCH__!.getRenderedCount(), ); assert( - renderedCount === TEST_ITEM_COUNT, + renderedCount === TEST_ISSUE_COUNT, lib, 'init getRenderedCount', - `expected ${TEST_ITEM_COUNT}, got ${renderedCount}`, + `expected ${TEST_ISSUE_COUNT}, got ${renderedCount}`, ); }); // ── updateEntity ───────────────────────────────────────────────────────── -test('updateEntity changes item label in DOM', async (page, lib) => { - await initAndWaitForItems(page); +test('updateEntity changes issue title in DOM', async (page, lib) => { + await initAndWaitForIssues(page); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.updateEntity('item-0')); + await page.evaluate(() => window.__BENCH__!.updateEntity(1)); await waitForComplete(page); await waitFor( page, - async () => - (await getItemLabels(page))['item-0']?.includes('(updated)') ?? false, - 'item-0 label contains "(updated)"', + async () => (await getIssueTitles(page))[1]?.includes('(updated)') ?? false, + 'issue #1 title contains "(updated)"', ); - const labels = await getItemLabels(page); + const titles = await getIssueTitles(page); assert( - labels['item-0']?.includes('(updated)'), + titles[1]?.includes('(updated)'), lib, 'updateEntity', - `item-0 should contain "(updated)", got "${labels['item-0']}"`, + `issue #1 should contain "(updated)", got "${titles[1]}"`, ); assert( - !labels['item-1']?.includes('(updated)'), + !titles[2]?.includes('(updated)'), lib, 'updateEntity unchanged', - `item-1 should be unchanged, got "${labels['item-1']}"`, + `issue #2 should be unchanged, got "${titles[2]}"`, ); }); -// ── updateAuthor ───────────────────────────────────────────────────────── +// ── updateUser ─────────────────────────────────────────────────────────── -test('updateAuthor propagates to DOM', async (page, _lib) => { - await initAndWaitForItems(page); +test('updateUser propagates to DOM', async (page, _lib) => { + await initAndWaitForIssues(page); - // The displayed column includes author.name; updateAuthor changes author.name. - // Non-normalized libs refetch the whole list (which joins latest author). - // Verify at minimum that items are still present after the operation. - const labelsBefore = await getItemLabels(page); - const countBefore = Object.keys(labelsBefore).length; + const titlesBefore = await getIssueTitles(page); + const countBefore = Object.keys(titlesBefore).length; await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.updateAuthor('author-0')); + await page.evaluate(() => window.__BENCH__!.updateUser('user0')); await waitForComplete(page); - // After updateAuthor + any async refetch, items should still be rendered await waitFor( page, - async () => (await getItemCount(page)) >= countBefore, - 'items still rendered after updateAuthor', + async () => (await getIssueCount(page)) >= countBefore, + 'issues still rendered after updateUser', 5000, ); }); @@ -234,94 +230,91 @@ test('updateAuthor propagates to DOM', async (page, _lib) => { // ── ref-stability: updateEntity ────────────────────────────────────────── test('ref-stability after updateEntity', async (page, lib) => { - await initAndWaitForItems(page); + await initAndWaitForIssues(page); await page.evaluate(() => window.__BENCH__!.captureRefSnapshot()); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.updateEntity('item-0')); + await page.evaluate(() => window.__BENCH__!.updateEntity(1)); await waitForComplete(page); - // Wait for the label change to actually reach the DOM await waitFor( page, - async () => - (await getItemLabels(page))['item-0']?.includes('(updated)') ?? false, - 'item-0 label updated before ref check', + async () => (await getIssueTitles(page))[1]?.includes('(updated)') ?? false, + 'issue #1 title updated before ref check', ); const r = await page.evaluate(() => window.__BENCH__!.getRefStabilityReport(), ); - const total = r.itemRefChanged + r.itemRefUnchanged; + const total = r.issueRefChanged + r.issueRefUnchanged; assert( - total === TEST_ITEM_COUNT, + total === TEST_ISSUE_COUNT, lib, 'ref-stability total', - `expected ${TEST_ITEM_COUNT} items in report, got ${total} (changed=${r.itemRefChanged} unchanged=${r.itemRefUnchanged})`, + `expected ${TEST_ISSUE_COUNT} issues in report, got ${total} (changed=${r.issueRefChanged} unchanged=${r.issueRefUnchanged})`, ); assert( - r.itemRefChanged >= 1, + r.issueRefChanged >= 1, lib, 'ref-stability changed', - `expected ≥1 itemRefChanged, got ${r.itemRefChanged}. ` + - `setCurrentItems may not have been called with updated data before measurement.`, + `expected ≥1 issueRefChanged, got ${r.issueRefChanged}. ` + + `setCurrentIssues may not have been called with updated data before measurement.`, ); process.stderr.write( - ` itemRefChanged=${r.itemRefChanged} authorRefChanged=${r.authorRefChanged}\n`, + ` issueRefChanged=${r.issueRefChanged} userRefChanged=${r.userRefChanged}\n`, ); }); -// ── ref-stability: updateAuthor ────────────────────────────────────────── +// ── ref-stability: updateUser ──────────────────────────────────────────── -test('ref-stability after updateAuthor', async (page, lib) => { - await initAndWaitForItems(page); +test('ref-stability after updateUser', async (page, lib) => { + await initAndWaitForIssues(page); await page.evaluate(() => window.__BENCH__!.captureRefSnapshot()); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.updateAuthor('author-0')); + await page.evaluate(() => window.__BENCH__!.updateUser('user0')); await waitForComplete(page); - // Wait for the author change to propagate (async refetch for SWR/tanstack) await waitFor( page, async () => { const r = await page.evaluate(() => window.__BENCH__!.getRefStabilityReport(), ); - return r.authorRefChanged > 0; + return r.userRefChanged > 0; }, - 'authorRefChanged > 0', + 'userRefChanged > 0', 5000, ); const r = await page.evaluate(() => window.__BENCH__!.getRefStabilityReport(), ); - const total = r.authorRefChanged + r.authorRefUnchanged; + const total = r.userRefChanged + r.userRefUnchanged; assert( - total === TEST_ITEM_COUNT, + total === TEST_ISSUE_COUNT, lib, - 'ref-stability-author total', - `expected ${TEST_ITEM_COUNT} items, got ${total}`, + 'ref-stability-user total', + `expected ${TEST_ISSUE_COUNT} issues, got ${total}`, ); - // 20 items ÷ 20 authors = 1 item per author - const expectedMin = Math.floor(TEST_ITEM_COUNT / 20); + // 20 issues ÷ 20 users = 1 issue per user + const expectedMin = Math.floor(TEST_ISSUE_COUNT / 20); assert( - r.authorRefChanged >= expectedMin, + r.userRefChanged >= expectedMin, lib, - 'ref-stability-author count', - `expected ≥${expectedMin} authorRefChanged, got ${r.authorRefChanged}`, + 'ref-stability-user count', + `expected ≥${expectedMin} userRefChanged, got ${r.userRefChanged}`, ); process.stderr.write( - ` itemRefChanged=${r.itemRefChanged} authorRefChanged=${r.authorRefChanged}\n`, + ` issueRefChanged=${r.issueRefChanged} userRefChanged=${r.userRefChanged}\n`, ); }); // ── unshiftItem ────────────────────────────────────────────────────────── -test('unshiftItem adds an item', async (page, _lib) => { +test('unshiftItem adds an issue', async (page, _lib) => { if ( !(await page.evaluate( () => typeof window.__BENCH__?.unshiftItem === 'function', @@ -329,7 +322,7 @@ test('unshiftItem adds an item', async (page, _lib) => { ) return; - await initAndWaitForItems(page, 10); + await initAndWaitForIssues(page, 10); await clearComplete(page); await page.evaluate(() => window.__BENCH__!.unshiftItem!()); @@ -338,17 +331,17 @@ test('unshiftItem adds an item', async (page, _lib) => { await waitFor( page, async () => { - const labels = await getItemLabels(page); - return Object.values(labels).some(l => l === 'New Item'); + const titles = await getIssueTitles(page); + return Object.values(titles).some(t => t === 'New Issue'); }, - '"New Item" appears in DOM', + '"New Issue" appears in DOM', 5000, ); }); // ── deleteEntity ───────────────────────────────────────────────────────── -test('deleteEntity removes an item', async (page, _lib) => { +test('deleteEntity removes an issue', async (page, _lib) => { if ( !(await page.evaluate( () => typeof window.__BENCH__?.deleteEntity === 'function', @@ -356,24 +349,19 @@ test('deleteEntity removes an item', async (page, _lib) => { ) return; - await initAndWaitForItems(page, 10); + await initAndWaitForIssues(page, 10); - const labelsBefore = await getItemLabels(page); - assert( - 'item-0' in labelsBefore, - _lib, - 'deleteEntity setup', - 'item-0 missing', - ); + const titlesBefore = await getIssueTitles(page); + assert(1 in titlesBefore, _lib, 'deleteEntity setup', 'issue #1 missing'); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.deleteEntity!('item-0')); + await page.evaluate(() => window.__BENCH__!.deleteEntity!(1)); await waitForComplete(page); await waitFor( page, - async () => !('item-0' in (await getItemLabels(page))), - 'item-0 removed from DOM', + async () => !(1 in (await getIssueTitles(page))), + 'issue #1 removed from DOM', 5000, ); }); @@ -388,9 +376,9 @@ test('mountSortedView renders sorted list', async (page, _lib) => { ) return; - // For data-client, sorted view queries All(ItemEntity) from the normalised + // For data-client, sorted view queries All(IssueEntity) from the normalised // store, so we must populate the store first via init. - await initAndWaitForItems(page); + await initAndWaitForIssues(page); await page.evaluate(() => window.__BENCH__!.unmountAll()); await page.waitForTimeout(200); @@ -421,12 +409,10 @@ test( ) return; - await initAndWaitForItems(page, 10); + await initAndWaitForIssues(page, 10); await clearComplete(page); - await page.evaluate(() => - window.__BENCH__!.invalidateAndResolve!('item-0'), - ); + await page.evaluate(() => window.__BENCH__!.invalidateAndResolve!(1)); await waitForComplete(page, 15000); }, { onlyLibs: ['data-client'] }, @@ -434,7 +420,7 @@ test( // ── moveItem ───────────────────────────────────────────────────────── -test('moveItem moves item between status lists', async (page, lib) => { +test('moveItem moves issue between state lists', async (page, lib) => { if ( !(await page.evaluate( () => typeof window.__BENCH__?.moveItem === 'function', @@ -443,7 +429,7 @@ test('moveItem moves item between status lists', async (page, lib) => { return; await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.initTripleList!(20)); + await page.evaluate(() => window.__BENCH__!.initDoubleList!(20)); await waitForComplete(page); await waitFor( @@ -452,27 +438,27 @@ test('moveItem moves item between status lists', async (page, lib) => { page.evaluate( () => document.querySelector( - '[data-status-list="open"] [data-bench-item]', + '[data-state-list="open"] [data-bench-item]', ) !== null && document.querySelector( - '[data-status-list="closed"] [data-bench-item]', + '[data-state-list="closed"] [data-bench-item]', ) !== null, ), - 'both status lists rendered', + 'both state lists rendered', 5000, ); - // item-0 has status 'open' in fixture data + // Issue #1 has state 'open' in fixture data (number 1 => index 0, i%3!==0 => open) const inOpen = await page.evaluate( () => document.querySelector( - '[data-status-list="open"] [data-item-id="item-0"]', + '[data-state-list="open"] [data-issue-number="1"]', ) !== null, ); - assert(inOpen, lib, 'moveItem setup', 'item-0 not in open list'); + assert(inOpen, lib, 'moveItem setup', 'issue #1 not in open list'); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.moveItem!('item-0')); + await page.evaluate(() => window.__BENCH__!.moveItem!(1)); await waitForComplete(page); await waitFor( @@ -481,24 +467,24 @@ test('moveItem moves item between status lists', async (page, lib) => { page.evaluate( () => document.querySelector( - '[data-status-list="closed"] [data-item-id="item-0"]', + '[data-state-list="closed"] [data-issue-number="1"]', ) !== null, ), - 'item-0 in closed list after move', + 'issue #1 in closed list after move', 5000, ); const inOpenAfter = await page.evaluate( () => document.querySelector( - '[data-status-list="open"] [data-item-id="item-0"]', + '[data-state-list="open"] [data-issue-number="1"]', ) !== null, ); assert( !inOpenAfter, lib, 'moveItem removed from source', - 'item-0 still in open list after move', + 'issue #1 still in open list after move', ); }); @@ -516,7 +502,6 @@ test('listDetailSwitch completes with correct DOM transitions', async (page, lib await page.evaluate(() => window.__BENCH__!.listDetailSwitch!(20)); await waitForComplete(page, 30000); - // After completion we should be back on the sorted list (last transition) const hasSortedList = await page.evaluate( () => document.querySelector('[data-sorted-list]') !== null, ); @@ -527,7 +512,6 @@ test('listDetailSwitch completes with correct DOM transitions', async (page, lib 'sorted list not in DOM after listDetailSwitch completed', ); - // Detail view should be gone const hasDetail = await page.evaluate( () => document.querySelector('[data-detail-view]') !== null, ); @@ -538,7 +522,6 @@ test('listDetailSwitch completes with correct DOM transitions', async (page, lib 'detail view still in DOM after listDetailSwitch completed', ); - // A mount-duration measure should have been recorded const hasMeasure = await page.evaluate(() => performance .getEntriesByType('measure') @@ -561,16 +544,16 @@ test('listDetailSwitch completes with correct DOM transitions', async (page, lib // dispatches optimistic updates to the store synchronously. test('updateEntity timing: DOM reflects change at measurement end', async (page, lib) => { - await initAndWaitForItems(page); + await initAndWaitForIssues(page); await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.updateEntity('item-0')); + await page.evaluate(() => window.__BENCH__!.updateEntity(1)); await waitForComplete(page); - const labels = await getItemLabels(page); + const titles = await getIssueTitles(page); assert( - labels['item-0']?.includes('(updated)') ?? false, + titles[1]?.includes('(updated)') ?? false, lib, 'updateEntity timing', `DOM not updated when data-bench-complete fired. ` + @@ -588,19 +571,19 @@ test('unshiftItem timing: DOM reflects change at measurement end', async (page, ) return; - await initAndWaitForItems(page, 10); + await initAndWaitForIssues(page, 10); await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); await clearComplete(page); await page.evaluate(() => window.__BENCH__!.unshiftItem!()); await waitForComplete(page); - const labels = await getItemLabels(page); + const titles = await getIssueTitles(page); assert( - Object.values(labels).some(l => l === 'New Item'), + Object.values(titles).some(t => t === 'New Issue'), lib, 'unshiftItem timing', - `"New Item" not in DOM when data-bench-complete fired. ` + + `"New Issue" not in DOM when data-bench-complete fired. ` + `Ensure measureUpdate callback returns its promise chain.`, ); @@ -615,19 +598,19 @@ test('deleteEntity timing: DOM reflects change at measurement end', async (page, ) return; - await initAndWaitForItems(page, 10); + await initAndWaitForIssues(page, 10); await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); await clearComplete(page); - await page.evaluate(() => window.__BENCH__!.deleteEntity!('item-0')); + await page.evaluate(() => window.__BENCH__!.deleteEntity!(1)); await waitForComplete(page); - const labels = await getItemLabels(page); + const titles = await getIssueTitles(page); assert( - !('item-0' in labels), + !(1 in titles), lib, 'deleteEntity timing', - `item-0 still in DOM when data-bench-complete fired. ` + + `issue #1 still in DOM when data-bench-complete fired. ` + `Ensure measureUpdate callback returns its promise chain.`, ); diff --git a/examples/benchmark-react/src/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx index 272382031d18..1a33fac37974 100644 --- a/examples/benchmark-react/src/baseline/index.tsx +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -4,22 +4,22 @@ import { useBenchState, } from '@shared/benchHarness'; import { - TRIPLE_LIST_STYLE, - ITEM_HEIGHT, - ItemRow, - ItemsRow, + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, LIST_STYLE, - PlainItemList, + PlainIssueList, } from '@shared/components'; import { - FIXTURE_AUTHORS, - FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS_BY_ID, - sortByLabel, + FIXTURE_USERS, + FIXTURE_USERS_BY_LOGIN, + FIXTURE_ISSUES_BY_NUMBER, + sortByTitle, } from '@shared/data'; -import { setCurrentItems } from '@shared/refStability'; -import { AuthorResource, ItemResource } from '@shared/resources'; -import type { Item } from '@shared/types'; +import { setCurrentIssues } from '@shared/refStability'; +import { UserResource, IssueResource } from '@shared/resources'; +import type { Issue } from '@shared/types'; import React, { useCallback, useContext, @@ -29,233 +29,212 @@ import React, { } from 'react'; import { List } from 'react-window'; -const ItemsContext = React.createContext<{ - items: Item[]; - setItems: React.Dispatch>; +const IssuesContext = React.createContext<{ + issues: Issue[]; + setIssues: React.Dispatch>; }>(null as any); function SortedListView() { - const { items, setItems } = useContext(ItemsContext); + const { issues, setIssues } = useContext(IssuesContext); useEffect(() => { - ItemResource.getList().then(setItems); - }, [setItems]); - const sorted = useMemo(() => sortByLabel(items), [items]); + IssueResource.getList().then(setIssues); + }, [setIssues]); + const sorted = useMemo(() => sortByTitle(issues), [issues]); if (!sorted.length) return null; return (
); } function ListView() { - const { items } = useContext(ItemsContext); - if (!items.length) return null; - setCurrentItems(items); + const { issues } = useContext(IssuesContext); + if (!issues.length) return null; + setCurrentIssues(issues); return ( ); } -const TripleListContext = React.createContext<{ - openItems: Item[]; - closedItems: Item[]; - inProgressItems: Item[]; - setOpenItems: React.Dispatch>; - setClosedItems: React.Dispatch>; - setInProgressItems: React.Dispatch>; +const DoubleListContext = React.createContext<{ + openIssues: Issue[]; + closedIssues: Issue[]; + setOpenIssues: React.Dispatch>; + setClosedIssues: React.Dispatch>; }>(null as any); -function TripleListView() { - const { openItems, closedItems, inProgressItems } = - useContext(TripleListContext); +function DoubleListView() { + const { openIssues, closedIssues } = useContext(DoubleListContext); return ( -
- {openItems.length > 0 && ( -
- {openItems.length} - +
+ {openIssues.length > 0 && ( +
+ {openIssues.length} +
)} - {closedItems.length > 0 && ( -
- {closedItems.length} - -
- )} - {inProgressItems.length > 0 && ( -
- {inProgressItems.length} - + {closedIssues.length > 0 && ( +
+ {closedIssues.length} +
)}
); } -function DetailView({ id }: { id: string }) { - const [item, setItem] = useState(null); +function DetailView({ number }: { number: number }) { + const [issue, setIssue] = useState(null); useEffect(() => { - ItemResource.get({ id }).then(setItem); - }, [id]); - if (!item) return null; + IssueResource.get({ number }).then(setIssue); + }, [number]); + if (!issue) return null; return ( -
- +
+
); } function BenchmarkHarness() { - const [items, setItems] = useState([]); - const [openItems, setOpenItems] = useState([]); - const [closedItems, setClosedItems] = useState([]); - const [inProgressItems, setInProgressItems] = useState([]); + const [issues, setIssues] = useState([]); + const [openIssues, setOpenIssues] = useState([]); + const [closedIssues, setClosedIssues] = useState([]); const { listViewCount, showSortedView, - showTripleList, - tripleListCount, - detailItemId, + showDoubleList, + doubleListCount, + detailIssueNumber, containerRef, measureUpdate, unmountAll: unmountBase, registerAPI, } = useBenchState(); - // Fetch items when listViewCount changes (populates context for ListView) useEffect(() => { if (listViewCount != null) { - ItemResource.getList({ count: listViewCount }).then(setItems); + IssueResource.getList({ count: listViewCount }).then(setIssues); } }, [listViewCount]); useEffect(() => { - if (showTripleList && tripleListCount != null) { - ItemResource.getList({ status: 'open', count: tripleListCount }).then( - setOpenItems, + if (showDoubleList && doubleListCount != null) { + IssueResource.getList({ state: 'open', count: doubleListCount }).then( + setOpenIssues, ); - ItemResource.getList({ status: 'closed', count: tripleListCount }).then( - setClosedItems, + IssueResource.getList({ state: 'closed', count: doubleListCount }).then( + setClosedIssues, ); - ItemResource.getList({ - status: 'in_progress', - count: tripleListCount, - }).then(setInProgressItems); } - }, [showTripleList, tripleListCount]); + }, [showDoubleList, doubleListCount]); const unmountAll = useCallback(() => { unmountBase(); - setItems([]); - setOpenItems([]); - setClosedItems([]); - setInProgressItems([]); + setIssues([]); + setOpenIssues([]); + setClosedIssues([]); }, [unmountBase]); const refetchActiveList = useCallback(() => { - if (tripleListCount != null) { + if (doubleListCount != null) { return Promise.all([ - ItemResource.getList({ status: 'open', count: tripleListCount }).then( - setOpenItems, - ), - ItemResource.getList({ - status: 'closed', - count: tripleListCount, - }).then(setClosedItems), - ItemResource.getList({ - status: 'in_progress', - count: tripleListCount, - }).then(setInProgressItems), + IssueResource.getList({ + state: 'open', + count: doubleListCount, + }).then(setOpenIssues), + IssueResource.getList({ + state: 'closed', + count: doubleListCount, + }).then(setClosedIssues), ]); } - return ItemResource.getList({ count: listViewCount! }).then(setItems); - }, [listViewCount, tripleListCount]); + return IssueResource.getList({ count: listViewCount! }).then(setIssues); + }, [listViewCount, doubleListCount]); const updateEntity = useCallback( - (id: string) => { - const item = FIXTURE_ITEMS_BY_ID.get(id); - if (!item) return; + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; measureUpdate(() => - ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - refetchActiveList, - ), + IssueResource.update( + { number }, + { title: `${issue.title} (updated)` }, + ).then(refetchActiveList), ); }, [measureUpdate, refetchActiveList], ); - const updateAuthor = useCallback( - (authorId: string) => { - const author = FIXTURE_AUTHORS_BY_ID.get(authorId); - if (!author) return; + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; measureUpdate(() => - AuthorResource.update( - { id: authorId }, - { name: `${author.name} (updated)` }, - ).then(refetchActiveList), + UserResource.update({ login }, { name: `${user.name} (updated)` }).then( + refetchActiveList, + ), ); }, [measureUpdate, refetchActiveList], ); const unshiftItem = useCallback(() => { - const author = FIXTURE_AUTHORS[0]; + const user = FIXTURE_USERS[0]; measureUpdate(() => - ItemResource.create({ label: 'New Item', author }).then( + IssueResource.create({ title: 'New Issue', user }).then( refetchActiveList, ), ); }, [measureUpdate, refetchActiveList]); const deleteEntity = useCallback( - (id: string) => { - measureUpdate(() => ItemResource.delete({ id }).then(refetchActiveList)); + (number: number) => { + measureUpdate(() => + IssueResource.delete({ number }).then(refetchActiveList), + ); }, [measureUpdate, refetchActiveList], ); const moveItem = useCallback( - (id: string) => { + (number: number) => { measureUpdate( () => - ItemResource.update({ id }, { status: 'closed' }).then(() => + IssueResource.update({ number }, { state: 'closed' }).then(() => Promise.all([ - ItemResource.getList({ - status: 'open', - count: tripleListCount!, - }).then(setOpenItems), - ItemResource.getList({ - status: 'closed', - count: tripleListCount!, - }).then(setClosedItems), - ItemResource.getList({ - status: 'in_progress', - count: tripleListCount!, - }).then(setInProgressItems), + IssueResource.getList({ + state: 'open', + count: doubleListCount!, + }).then(setOpenIssues), + IssueResource.getList({ + state: 'closed', + count: doubleListCount!, + }).then(setClosedIssues), ]), ), - () => moveItemIsReady(containerRef, id), + () => moveItemIsReady(containerRef, number), ); }, - [measureUpdate, tripleListCount, containerRef], + [measureUpdate, doubleListCount, containerRef], ); registerAPI({ updateEntity, - updateAuthor, + updateUser, unmountAll, unshiftItem, deleteEntity, @@ -263,25 +242,25 @@ function BenchmarkHarness() { }); return ( - - +
{listViewCount != null && } {showSortedView && } - {showTripleList && tripleListCount != null && } - {detailItemId != null && } + {showDoubleList && doubleListCount != null && } + {detailIssueNumber != null && ( + + )}
-
-
+ + ); } diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx index 13d6c0b539c3..a33c0f261983 100644 --- a/examples/benchmark-react/src/data-client/index.tsx +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -12,26 +12,26 @@ import { useBenchState, } from '@shared/benchHarness'; import { - TRIPLE_LIST_STYLE, - ITEM_HEIGHT, - ItemRow, - ItemsRow, + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, LIST_STYLE, - PlainItemList, + PlainIssueList, } from '@shared/components'; import { - FIXTURE_AUTHORS, - FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS_BY_ID, + FIXTURE_USERS, + FIXTURE_ISSUES_BY_NUMBER, + FIXTURE_USERS_BY_LOGIN, } from '@shared/data'; -import { setCurrentItems } from '@shared/refStability'; +import { setCurrentIssues } from '@shared/refStability'; import { - AuthorResource, - ItemResource, - sortedItemsEndpoint, + UserResource, + IssueResource, + sortedIssuesEndpoint, } from '@shared/resources'; -import { getItem, patchItem } from '@shared/server'; -import type { Item } from '@shared/types'; +import { getIssue, patchIssue } from '@shared/server'; +import type { Issue } from '@shared/types'; import React, { useCallback } from 'react'; import { List } from 'react-window'; @@ -56,67 +56,66 @@ class BenchGCPolicy extends GCPolicy { const benchGC = new BenchGCPolicy(); -/** Renders items from the list endpoint (models rendering a list fetch response). */ +/** Renders issues from the list endpoint (models rendering a list fetch response). */ function ListView({ count }: { count: number }) { - const { data: items } = useDLE(ItemResource.getList, { count }); - if (!items) return null; - const list = items as Item[]; - setCurrentItems(list); + const { data: issues } = useDLE(IssueResource.getList, { count }); + if (!issues) return null; + const list = issues as Issue[]; + setCurrentIssues(list); return ( ); } -/** Renders items sorted by label via Query schema (memoized by MemoCache). */ +/** Renders issues sorted by title via Query schema (memoized by MemoCache). */ function SortedListView({ count }: { count: number }) { - const { data: items } = useDLE(sortedItemsEndpoint, { count }); - if (!items?.length) return null; + const { data: issues } = useDLE(sortedIssuesEndpoint, { count }); + if (!issues?.length) return null; return (
); } -function StatusListView({ status, count }: { status: string; count: number }) { - const { data: items } = useDLE(ItemResource.getList, { status, count }); - if (!items) return null; - const list = items as Item[]; +function StateListView({ state, count }: { state: string; count: number }) { + const { data: issues } = useDLE(IssueResource.getList, { state, count }); + if (!issues) return null; + const list = issues as Issue[]; return ( -
- {list.length} - +
+ {list.length} +
); } -function TripleListView({ count }: { count: number }) { +function DoubleListView({ count }: { count: number }) { return ( -
- - - +
+ +
); } -function DetailView({ id }: { id: string }) { - const item = useSuspense(ItemResource.get, { id }); +function DetailView({ number }: { number: number }) { + const issue = useSuspense(IssueResource.get, { number }); return ( -
- +
+
); } @@ -127,38 +126,38 @@ function BenchmarkHarness() { listViewCount, showSortedView, sortedViewCount, - showTripleList, - tripleListCount, - detailItemId, + showDoubleList, + doubleListCount, + detailIssueNumber, containerRef, measureUpdate, registerAPI, } = useBenchState(); const updateEntity = useCallback( - (id: string) => { - const item = FIXTURE_ITEMS_BY_ID.get(id); - if (!item) return; + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; measureUpdate(() => { controller.fetch( - ItemResource.update, - { id }, - { label: `${item.label} (updated)` }, + IssueResource.update, + { number }, + { title: `${issue.title} (updated)` }, ); }); }, [measureUpdate, controller], ); - const updateAuthor = useCallback( - (authorId: string) => { - const author = FIXTURE_AUTHORS_BY_ID.get(authorId); - if (!author) return; + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; measureUpdate(() => { controller.fetch( - AuthorResource.update, - { id: authorId }, - { name: `${author.name} (updated)` }, + UserResource.update, + { login }, + { name: `${user.name} (updated)` }, ); }); }, @@ -166,73 +165,73 @@ function BenchmarkHarness() { ); const unshiftItem = useCallback(() => { - const author = FIXTURE_AUTHORS[0]; + const user = FIXTURE_USERS[0]; measureUpdate(() => { (controller.fetch as any)( - ItemResource.create, - { status: 'open' }, + IssueResource.create, + { state: 'open' }, { - label: 'New Item', - author, + title: 'New Issue', + user, }, ); }); }, [measureUpdate, controller]); const deleteEntity = useCallback( - (id: string) => { + (number: number) => { measureUpdate(() => { - controller.fetch(ItemResource.delete, { id }); + controller.fetch(IssueResource.delete, { number }); }); }, [measureUpdate, controller], ); const moveItem = useCallback( - (id: string) => { + (number: number) => { measureUpdate( () => { - controller.fetch(ItemResource.move, { id }, { status: 'closed' }); + controller.fetch(IssueResource.move, { number }, { state: 'closed' }); }, - () => moveItemIsReady(containerRef, id), + () => moveItemIsReady(containerRef, number), ); }, [measureUpdate, controller, containerRef], ); const invalidateAndResolve = useCallback( - async (id: string) => { - const item = await getItem(id); - if (item) { - await patchItem(id, { label: `${item.label} (refetched)` }); + async (number: number) => { + const issue = await getIssue(number); + if (issue) { + await patchIssue(number, { title: `${issue.title} (refetched)` }); } measureUpdate( () => { - if (tripleListCount != null) { - controller.invalidate(ItemResource.getList, { - status: 'open', - count: tripleListCount, + if (doubleListCount != null) { + controller.invalidate(IssueResource.getList, { + state: 'open', + count: doubleListCount, }); } else { - controller.invalidate(ItemResource.getList, { + controller.invalidate(IssueResource.getList, { count: listViewCount!, }); } }, () => { const el = containerRef.current!.querySelector( - `[data-item-id="${id}"] [data-label]`, + `[data-issue-number="${number}"] [data-title]`, ); return el?.textContent?.includes('(refetched)') ?? false; }, ); }, - [measureUpdate, controller, containerRef, tripleListCount, listViewCount], + [measureUpdate, controller, containerRef, doubleListCount, listViewCount], ); registerAPI({ updateEntity, - updateAuthor, + updateUser, invalidateAndResolve, unshiftItem, deleteEntity, @@ -246,12 +245,12 @@ function BenchmarkHarness() { {showSortedView && sortedViewCount != null && ( )} - {showTripleList && tripleListCount != null && ( - + {showDoubleList && doubleListCount != null && ( + )} - {detailItemId != null && ( + {detailIssueNumber != null && ( Loading...
}> - + )}
diff --git a/examples/benchmark-react/src/shared/benchHarness.tsx b/examples/benchmark-react/src/shared/benchHarness.tsx index c7454973549c..d1ea05ecccbe 100644 --- a/examples/benchmark-react/src/shared/benchHarness.tsx +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { FIXTURE_ITEMS } from './data'; +import { FIXTURE_ISSUES } from './data'; import { captureSnapshot, getReport } from './refStability'; import { flushPendingMutations, - seedItemList, + seedIssueList, setMethodDelays, setNetworkDelay, } from './server'; @@ -36,20 +36,20 @@ const OBSERVE_MUTATIONS: MutationObserverInit = { characterData: true, }; -/** Check whether an item has moved from the "open" to the "closed" status list. */ +/** Check whether an issue has moved from the "open" to the "closed" state list. */ export function moveItemIsReady( containerRef: React.RefObject, - id: string, + number: number, ): boolean { const source = containerRef.current?.querySelector( - '[data-status-list="open"]', + '[data-state-list="open"]', ); const dest = containerRef.current?.querySelector( - '[data-status-list="closed"]', + '[data-state-list="closed"]', ); return ( - source?.querySelector(`[data-item-id="${id}"]`) == null && - dest?.querySelector(`[data-item-id="${id}"]`) != null + source?.querySelector(`[data-issue-number="${number}"]`) == null && + dest?.querySelector(`[data-issue-number="${number}"]`) != null ); } @@ -57,8 +57,8 @@ export function moveItemIsReady( * Actions that each library must provide (not supplied by the shared harness). * All other BenchAPI methods can be optionally overridden or added. */ -type LibraryActions = Pick & - Partial>; +type LibraryActions = Pick & + Partial>; /** * Shared benchmark harness state, measurement helpers, and API registration. @@ -75,9 +75,11 @@ export function useBenchState() { const [listViewCount, setListViewCount] = useState(); const [showSortedView, setShowSortedView] = useState(false); const [sortedViewCount, setSortedViewCount] = useState(); - const [showTripleList, setShowTripleList] = useState(false); - const [tripleListCount, setTripleListCount] = useState(); - const [detailItemId, setDetailItemId] = useState(null); + const [showDoubleList, setShowDoubleList] = useState(false); + const [doubleListCount, setDoubleListCount] = useState(); + const [detailIssueNumber, setDetailIssueNumber] = useState( + null, + ); const containerRef = useRef(null); const completeResolveRef = useRef<(() => void) | null>(null); const apiRef = useRef(null as any); @@ -124,7 +126,7 @@ export function useBenchState() { * mutation in the container — React commits atomically so the first * mutation batch IS the final state for updates. * - * For multi-phase scenarios like invalidateAndResolve (items disappear + * For multi-phase scenarios like invalidateAndResolve (issues disappear * then reappear), pass an `isReady` predicate to wait for the final state. */ const measureUpdate = useCallback( @@ -189,16 +191,16 @@ export function useBenchState() { setListViewCount(undefined); setShowSortedView(false); setSortedViewCount(undefined); - setShowTripleList(false); - setTripleListCount(undefined); - setDetailItemId(null); + setShowDoubleList(false); + setDoubleListCount(undefined); + setDetailIssueNumber(null); }, []); - const initTripleList = useCallback( + const initDoubleList = useCallback( (n: number) => { measureMount(() => { - setTripleListCount(n); - setShowTripleList(true); + setDoubleListCount(n); + setShowDoubleList(true); }); }, [measureMount], @@ -222,7 +224,7 @@ export function useBenchState() { const mountSortedView = useCallback( async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); + await seedIssueList(FIXTURE_ISSUES.slice(0, n)); measureMount(() => { setSortedViewCount(n); setShowSortedView(true); @@ -233,26 +235,26 @@ export function useBenchState() { const listDetailSwitch = useCallback( async (n: number) => { - await seedItemList(FIXTURE_ITEMS.slice(0, n)); + await seedIssueList(FIXTURE_ISSUES.slice(0, n)); setSortedViewCount(n); setShowSortedView(true); await waitForElement('[data-sorted-list]'); // Warmup cycle (unmeasured) — exercises the detail mount path setShowSortedView(false); - setDetailItemId('item-0'); + setDetailIssueNumber(1); await waitForElement('[data-detail-view]'); - setDetailItemId(null); + setDetailIssueNumber(null); setShowSortedView(true); await waitForElement('[data-sorted-list]'); performance.mark('mount-start'); - for (let i = 1; i <= 10; i++) { + for (let i = 2; i <= 11; i++) { setShowSortedView(false); - setDetailItemId(`item-${i}`); + setDetailIssueNumber(i); await waitForElement('[data-detail-view]'); - setDetailItemId(null); + setDetailIssueNumber(null); setShowSortedView(true); await waitForElement('[data-sorted-list]'); } @@ -263,7 +265,7 @@ export function useBenchState() { [ setSortedViewCount, setShowSortedView, - setDetailItemId, + setDetailIssueNumber, waitForElement, setComplete, ], @@ -285,7 +287,7 @@ export function useBenchState() { const registerAPI = (libraryActions: LibraryActions) => { apiRef.current = { init, - initTripleList, + initDoubleList, unmountAll, mountUnmountCycle, mountSortedView, @@ -316,9 +318,9 @@ export function useBenchState() { listViewCount, showSortedView, sortedViewCount, - showTripleList, - tripleListCount, - detailItemId, + showDoubleList, + doubleListCount, + detailIssueNumber, containerRef, measureMount, @@ -330,9 +332,9 @@ export function useBenchState() { setListViewCount, setShowSortedView, setSortedViewCount, - setShowTripleList, - setTripleListCount, - setDetailItemId, + setShowDoubleList, + setDoubleListCount, + setDetailIssueNumber, unmountAll, registerAPI, diff --git a/examples/benchmark-react/src/shared/components.tsx b/examples/benchmark-react/src/shared/components.tsx index 2654e74c8fac..aa5853a61e7c 100644 --- a/examples/benchmark-react/src/shared/components.tsx +++ b/examples/benchmark-react/src/shared/components.tsx @@ -1,14 +1,12 @@ import React from 'react'; import type { RowComponentProps } from 'react-window'; -import type { Author, Item } from './types'; +import type { Issue, User } from './types'; -export const ITEM_HEIGHT = 30; +export const ISSUE_HEIGHT = 30; export const VISIBLE_COUNT = 40; -export const LIST_STYLE = { height: ITEM_HEIGHT * VISIBLE_COUNT } as const; -export const TRIPLE_LIST_STYLE = { display: 'flex', gap: 8 } as const; - -const PRIORITY_LABELS = ['', 'low', 'med', 'high', 'crit', 'max'] as const; +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 { let hash = 5381; @@ -19,36 +17,36 @@ function djb2(str: string): number { } /** - * Expensive memoized author component. Simulates a realistic rich author + * Expensive memoized user component. Simulates a realistic rich user * card: avatar color derivation, bio truncation, follower formatting, date - * parsing. Libraries that preserve author referential equality skip this + * parsing. Libraries that preserve user referential equality skip this * entirely on unrelated updates; those that don't pay per row. */ -function AuthorView({ author }: { author: Author }) { - const hash = djb2(author.id + author.login + author.email + author.bio); +function UserView({ user }: { user: User }) { + const hash = djb2(user.login + user.email + user.bio); const hue = hash % 360; - const initials = author.name + const initials = user.name .split(' ') .map(w => w[0]) .join('') .toUpperCase(); - const bioWords = author.bio.split(/\s+/); + const bioWords = user.bio.split(/\s+/); const truncatedBio = - bioWords.length > 12 ? bioWords.slice(0, 12).join(' ') + '…' : author.bio; + bioWords.length > 12 ? bioWords.slice(0, 12).join(' ') + '…' : user.bio; const followerStr = - author.followers >= 1000 ? - `${(author.followers / 1000).toFixed(1)}k` - : String(author.followers); - const joinYear = new Date(author.createdAt).getFullYear(); + user.followers >= 1000 ? + `${(user.followers / 1000).toFixed(1)}k` + : String(user.followers); + const joinYear = new Date(user.createdAt).getFullYear(); return ( {initials} - {author.name} + {user.name} {truncatedBio} {followerStr} {joinYear} @@ -56,47 +54,51 @@ function AuthorView({ author }: { author: Author }) { ); } +const STATE_ICONS: Record = { + open: '🟢', + closed: '🟣', +}; + /** * Row component — React Compiler auto-memoizes props/values. Libraries that * preserve referential equality benefit from the compiler's caching; those - * that don't still re-execute the expensive AuthorView on every render. + * that don't still re-execute the expensive UserView on every render. */ -export function ItemRow({ item }: { item: Item }) { - const tagStr = item.tags.join(', '); - const prioLabel = PRIORITY_LABELS[item.priority] ?? item.priority; +export function IssueRow({ issue }: { issue: Issue }) { + const labelStr = issue.labels.map(l => l.name).join(', '); return ( -
- {item.label} - - {prioLabel} - {item.status} - {tagStr} - {item.description} +
+ {issue.title} + + {STATE_ICONS[issue.state] ?? issue.state} + {issue.comments} + {labelStr} + {issue.body}
); } -/** Generic react-window row that renders an ItemRow from an items array. */ -export function ItemsRow({ +/** Generic react-window row that renders an IssueRow from an issues array. */ +export function IssuesRow({ index, style, - items, -}: RowComponentProps<{ items: Item[] }>) { + issues, +}: RowComponentProps<{ issues: Issue[] }>) { return (
- +
); } -/** Plain (non-virtualized) list keyed by item pk. Renders up to VISIBLE_COUNT items. */ -export function PlainItemList({ items }: { items: Item[] }) { +/** Plain (non-virtualized) list keyed by issue number. Renders up to VISIBLE_COUNT issues. */ +export function PlainIssueList({ issues }: { issues: Issue[] }) { const visible = - items.length > VISIBLE_COUNT ? items.slice(0, VISIBLE_COUNT) : items; + issues.length > VISIBLE_COUNT ? issues.slice(0, VISIBLE_COUNT) : issues; return (
- {visible.map(item => ( - + {visible.map(issue => ( + ))}
); diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts index 3643efb9a439..67f1f858c59b 100644 --- a/examples/benchmark-react/src/shared/data.ts +++ b/examples/benchmark-react/src/shared/data.ts @@ -1,126 +1,252 @@ -import type { Author, Item } from './types'; +import type { Issue, Label, User } from './types'; -/** Sort items by label, optionally limiting to the first `limit` results. */ -export function sortByLabel( +/** Sort issues by title, optionally limiting to the first `limit` results. */ +export function sortByTitle( items: T[], limit?: number, ): T[] { - const sorted = [...items].sort((a, b) => a.label.localeCompare(b.label)); + const sorted = [...items].sort((a, b) => a.title.localeCompare(b.title)); return limit ? sorted.slice(0, limit) : sorted; } -/** - * Generate authors - shared across items to stress normalization. - * Fewer authors than items means many items share the same author reference. - */ -const STATUSES: Item['status'][] = ['open', 'closed', 'in_progress']; -const TAG_POOL = [ - 'bug', - 'feature', - 'docs', - 'perf', - 'security', - 'ux', - 'refactor', - 'test', - 'infra', - 'deps', +const LABEL_DEFS: { name: string; color: string; description: string }[] = [ + { name: 'bug', color: 'd73a4a', description: "Something isn't working" }, + { + name: 'enhancement', + color: 'a2eeef', + description: 'New feature or request', + }, + { + name: 'documentation', + color: '0075ca', + description: 'Improvements or additions to documentation', + }, + { + name: 'good first issue', + color: '7057ff', + description: 'Good for newcomers', + }, + { + name: 'help wanted', + color: '008672', + description: 'Extra attention needed', + }, + { + name: 'performance', + color: 'fbca04', + description: 'Performance-related issue', + }, + { + name: 'breaking change', + color: 'e11d48', + description: 'Introduces a breaking change', + }, + { + name: 'dependencies', + color: '0366d6', + description: 'Pull requests that update a dependency', + }, + { name: 'refactor', color: 'c5def5', description: 'Code refactoring' }, + { + name: 'testing', + color: 'bfd4f2', + description: 'Related to testing infrastructure', + }, +]; + +export const FIXTURE_LABELS: Label[] = LABEL_DEFS.map((def, i) => ({ + id: i + 1, + nodeId: `MDU6TGFiZWw${i + 1}`, + name: def.name, + color: def.color, + description: def.description, + default: i < 3, +})); + +const ISSUE_TITLE_PREFIXES = [ + 'Add', + 'Deprecate', + 'Fix', + 'Handle', + 'Improve', + 'Migrate', + 'Refactor', + 'Remove', + 'Support', + 'Update', +]; + +const ISSUE_TITLE_SUBJECTS = [ + 'type inference for nested schemas', + 'race condition in concurrent fetches', + 'memory leak in subscription manager', + 'cache invalidation after mutation', + 'optimistic update rollback on error', + 'SSR hydration mismatch', + 'pagination with cursor-based endpoints', + 'retry logic for network failures', + 'stale-while-revalidate behavior', + 'normalized entity merging', + 'query parameter serialization', + 'WebSocket reconnection handling', + 'bundle size reduction for tree-shaking', + 'TypeScript strict mode compatibility', + 'React 19 concurrent features', + 'error boundary integration', + 'middleware execution order', + 'batch request coalescing', + 'schema validation at runtime', + 'offline-first data synchronization', ]; -export function generateAuthors(count: number): Author[] { - const authors: Author[] = []; +/** + * Generate users - shared across issues to stress normalization. + * Fewer users than issues means many issues share the same user reference. + */ +export function generateUsers(count: number): User[] { + const users: User[] = []; for (let i = 0; i < count; i++) { - authors.push({ - id: `author-${i}`, + users.push({ + id: 1000 + i, login: `user${i}`, + nodeId: `MDQ6VXNlcjEwMDA${i}`, + avatarUrl: `https://avatars.githubusercontent.com/u/${1000 + i}?v=4`, + gravatarId: '', + type: 'User', + siteAdmin: false, + htmlUrl: `https://github.com/user${i}`, name: `User ${i}`, - avatarUrl: `https://avatars.example.com/u/${i}?s=64`, + company: i % 3 === 0 ? 'Acme Corp' : '', + blog: i % 4 === 0 ? `https://user${i}.dev` : '', + location: ['San Francisco', 'London', 'Tokyo', 'Berlin', 'Sydney'][i % 5], email: `user${i}@example.com`, bio: `Software engineer #${i}. Likes open source and coffee.`, + publicRepos: (i * 7 + 3) % 200, + publicGists: (i * 3 + 1) % 50, followers: (i * 137 + 42) % 10000, - createdAt: new Date(2020, 0, 1 + (i % 365)).toISOString(), + following: (i * 11 + 5) % 500, + createdAt: new Date(2015, 0, 1 + (i % 365)).toISOString(), + updatedAt: new Date(2024, i % 12, 1 + (i % 28)).toISOString(), }); } - return authors; + return users; } /** - * Generate items with nested author entities (shared references). - * Items cycle through authors so many items share the same author. + * Generate issues with nested user entities (shared references) and labels. + * Issues cycle through users so many issues share the same user. */ -export function generateItems(count: number, authors: Author[]): Item[] { - const items: Item[] = []; +export function generateIssues( + count: number, + users: User[], + labels: Label[] = FIXTURE_LABELS, +): Issue[] { + const issues: Issue[] = []; + const titlePrefixes = ISSUE_TITLE_PREFIXES; + const titleSubjects = ISSUE_TITLE_SUBJECTS; for (let i = 0; i < count; i++) { - const author = authors[i % authors.length]; + const user = users[i % users.length]; const created = new Date(2023, i % 12, 1 + (i % 28)).toISOString(); - items.push({ - id: `item-${i}`, - label: `Item ${i}`, - description: `Description for item ${i}: a moderately long text field that exercises serialization and storage overhead in the benchmark.`, - status: STATUSES[i % STATUSES.length], - priority: (i % 5) + 1, - tags: [ - TAG_POOL[i % TAG_POOL.length], - TAG_POOL[(i * 3 + 1) % TAG_POOL.length], - ], + const state: Issue['state'] = i % 3 === 2 ? 'closed' : 'open'; + const labelCount = i % 4; + const issueLabels: Label[] = []; + for (let li = 0; li < labelCount; li++) { + issueLabels.push(labels[(i + li) % labels.length]); + } + const num = i + 1; + issues.push({ + id: 100000 + num, + number: num, + title: `${titlePrefixes[i % titlePrefixes.length]} ${titleSubjects[i % titleSubjects.length]}`, + body: `Description for issue #${num}: a moderately long text field that exercises serialization and storage overhead in the benchmark.`, + state, + locked: i % 20 === 0, + comments: i % 15, + labels: issueLabels, + user: { ...user }, + htmlUrl: `https://github.com/owner/repo/issues/${num}`, + repositoryUrl: 'https://api.github.com/repos/owner/repo', + authorAssociation: i % 5 === 0 ? 'MEMBER' : 'NONE', createdAt: created, updatedAt: new Date(2024, i % 12, 1 + (i % 28)).toISOString(), - author: { ...author }, + closedAt: + state === 'closed' ? new Date(2024, i % 12, 5).toISOString() : null, }); } - return items; + return issues; } -/** Unique authors from fixture (for seeding and updateAuthor scenarios) */ -export const FIXTURE_AUTHORS = generateAuthors(20); +/** Unique users from fixture (for seeding and updateUser scenarios) */ +export const FIXTURE_USERS = generateUsers(20); -/** Pre-generated fixture for benchmark - 10000 items, 20 shared authors */ -export const FIXTURE_ITEMS = generateItems(10000, FIXTURE_AUTHORS); +/** Pre-generated fixture for benchmark - 10000 issues, 20 shared users */ +export const FIXTURE_ISSUES = generateIssues(10000, FIXTURE_USERS); -/** O(1) item lookup by id (avoids linear scans inside measurement regions) */ -export const FIXTURE_ITEMS_BY_ID = new Map(FIXTURE_ITEMS.map(i => [i.id, i])); +/** O(1) issue lookup by number */ +export const FIXTURE_ISSUES_BY_NUMBER = new Map( + FIXTURE_ISSUES.map(i => [i.number, i]), +); -/** O(1) author lookup by id */ -export const FIXTURE_AUTHORS_BY_ID = new Map( - FIXTURE_AUTHORS.map(a => [a.id, a]), +/** O(1) user lookup by login */ +export const FIXTURE_USERS_BY_LOGIN = new Map( + FIXTURE_USERS.map(u => [u.login, u]), ); /** - * Generate fresh items/authors with distinct IDs for bulk ingestion scenarios. - * Uses `fresh-` prefix so these don't collide with pre-seeded FIXTURE data. + * Generate fresh issues/users with distinct IDs for bulk ingestion scenarios. + * Uses offset numbering so these don't collide with pre-seeded FIXTURE data. */ export function generateFreshData( - itemCount: number, - authorCount = 20, -): { items: Item[]; authors: Author[] } { - const authors: Author[] = []; - for (let i = 0; i < authorCount; i++) { - authors.push({ - id: `fresh-author-${i}`, + issueCount: number, + userCount = 20, +): { issues: Issue[]; users: User[] } { + const users: User[] = []; + for (let i = 0; i < userCount; i++) { + users.push({ + id: 50000 + i, login: `freshuser${i}`, + nodeId: `MDQ6VXNlcjUwMDAw${i}`, + avatarUrl: `https://avatars.githubusercontent.com/u/${50000 + i}?v=4`, + gravatarId: '', + type: 'User', + siteAdmin: false, + htmlUrl: `https://github.com/freshuser${i}`, name: `Fresh User ${i}`, - avatarUrl: `https://avatars.example.com/u/fresh-${i}?s=64`, + company: '', + blog: '', + location: 'Remote', email: `freshuser${i}@example.com`, bio: `Fresh contributor #${i}.`, + publicRepos: (i * 5 + 2) % 100, + publicGists: 0, followers: (i * 89 + 17) % 5000, + following: (i * 7 + 3) % 200, createdAt: new Date(2021, 6, 1 + (i % 28)).toISOString(), + updatedAt: new Date(2024, i % 12, 1 + (i % 28)).toISOString(), }); } - const items: Item[] = []; - for (let i = 0; i < itemCount; i++) { - const author = authors[i % authorCount]; + const issues: Issue[] = []; + for (let i = 0; i < issueCount; i++) { + const user = users[i % userCount]; const created = new Date(2024, i % 12, 1 + (i % 28)).toISOString(); - items.push({ - id: `fresh-item-${i}`, - label: `Fresh Item ${i}`, - description: `Fresh item ${i} description with enough text to be realistic.`, - status: STATUSES[i % STATUSES.length], - priority: (i % 5) + 1, - tags: [TAG_POOL[i % TAG_POOL.length]], + const num = 50000 + i + 1; + issues.push({ + id: 200000 + num, + number: num, + title: `Fresh issue #${num}`, + body: `Fresh issue ${num} description with enough text to be realistic.`, + state: i % 2 === 0 ? 'open' : 'closed', + locked: false, + comments: 0, + labels: [FIXTURE_LABELS[i % FIXTURE_LABELS.length]], + user: { ...user }, + htmlUrl: `https://github.com/owner/repo/issues/${num}`, + repositoryUrl: 'https://api.github.com/repos/owner/repo', + authorAssociation: 'NONE', createdAt: created, updatedAt: created, - author: { ...author }, + closedAt: null, }); } - return { items, authors }; + return { issues, users }; } diff --git a/examples/benchmark-react/src/shared/refStability.ts b/examples/benchmark-react/src/shared/refStability.ts index 8d6efe78a586..9ac682cd772e 100644 --- a/examples/benchmark-react/src/shared/refStability.ts +++ b/examples/benchmark-react/src/shared/refStability.ts @@ -1,64 +1,64 @@ -import type { Author, Item, RefStabilityReport } from './types'; +import type { Issue, RefStabilityReport, User } from './types'; -let currentItems: Item[] = []; -let snapshotRefs: Map | null = null; +let currentIssues: Issue[] = []; +let snapshotRefs: Map | null = null; /** - * Store the current items array. Called from ListView during render. + * Store the current issues array. Called from ListView during render. * Only stores the reference — negligible cost. */ -export function setCurrentItems(items: Item[]): void { - currentItems = items; +export function setCurrentIssues(issues: Issue[]): void { + currentIssues = issues; } /** - * Build a snapshot from current items. Call after mount, before running an update. + * Build a snapshot from current issues. Call after mount, before running an update. */ export function captureSnapshot(): void { snapshotRefs = new Map(); - for (const item of currentItems) { - snapshotRefs.set(item.id, { item, author: item.author }); + for (const issue of currentIssues) { + snapshotRefs.set(issue.number, { issue, user: issue.user }); } } /** - * Compare current items to snapshot and return counts. Call after update completes. + * Compare current issues to snapshot and return counts. Call after update completes. */ export function getReport(): RefStabilityReport { if (!snapshotRefs) { return { - itemRefUnchanged: 0, - itemRefChanged: 0, - authorRefUnchanged: 0, - authorRefChanged: 0, + issueRefUnchanged: 0, + issueRefChanged: 0, + userRefUnchanged: 0, + userRefChanged: 0, }; } - let itemRefUnchanged = 0; - let itemRefChanged = 0; - let authorRefUnchanged = 0; - let authorRefChanged = 0; + let issueRefUnchanged = 0; + let issueRefChanged = 0; + let userRefUnchanged = 0; + let userRefChanged = 0; - for (const item of currentItems) { - const snap = snapshotRefs.get(item.id); + for (const issue of currentIssues) { + const snap = snapshotRefs.get(issue.number); if (!snap) continue; - if (item === snap.item) { - itemRefUnchanged++; + if (issue === snap.issue) { + issueRefUnchanged++; } else { - itemRefChanged++; + issueRefChanged++; } - if (item.author === snap.author) { - authorRefUnchanged++; + if (issue.user === snap.user) { + userRefUnchanged++; } else { - authorRefChanged++; + userRefChanged++; } } return { - itemRefUnchanged, - itemRefChanged, - authorRefUnchanged, - authorRefChanged, + issueRefUnchanged, + issueRefChanged, + userRefUnchanged, + userRefChanged, }; } diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts index 55de2ef8fcb2..ceac6ed6f3ba 100644 --- a/examples/benchmark-react/src/shared/resources.ts +++ b/examples/benchmark-react/src/shared/resources.ts @@ -1,56 +1,92 @@ -import { Entity, All, Query, Collection, unshift } from '@data-client/endpoint'; +import { Entity, Query, Collection, unshift } from '@data-client/endpoint'; import type { PolymorphicInterface } from '@data-client/endpoint'; import { resource } from '@data-client/rest'; -import { sortByLabel } from '@shared/data'; +import { sortByTitle } from '@shared/data'; import { - fetchItem as serverFetchItem, - fetchAuthor as serverFetchAuthor, - fetchItemList as serverFetchItemList, - createItem as serverCreateItem, - updateItem as serverUpdateItem, - deleteItem as serverDeleteItem, - updateAuthor as serverUpdateAuthor, - deleteAuthor as serverDeleteAuthor, + fetchIssue as serverFetchIssue, + fetchUser as serverFetchUser, + fetchIssueList as serverFetchIssueList, + createIssue as serverCreateIssue, + updateIssue as serverUpdateIssue, + deleteIssue as serverDeleteIssue, + updateUser as serverUpdateUser, + deleteUser as serverDeleteUser, } from '@shared/server'; -import { Author } from '@shared/types'; +import { User } from '@shared/types'; -export class AuthorEntity extends Entity { - id = ''; - login = ''; +export class LabelEntity extends Entity { + id = 0; + nodeId = ''; name = ''; + description = ''; + color = '000000'; + default = false; + + pk() { + return `${this.id}`; + } + + static key = 'LabelEntity'; +} + +export class UserEntity extends Entity { + id = 0; + login = ''; + nodeId = ''; avatarUrl = ''; + gravatarId = ''; + type = 'User'; + siteAdmin = false; + htmlUrl = ''; + name = ''; + company = ''; + blog = ''; + location = ''; email = ''; bio = ''; + publicRepos = 0; + publicGists = 0; followers = 0; + following = 0; createdAt = ''; + updatedAt = ''; pk() { - return this.id; + return this.login; } - static key = 'AuthorEntity'; + static key = 'UserEntity'; } -export class ItemEntity extends Entity { - id = ''; - label = ''; - description = ''; - status: 'open' | 'closed' | 'in_progress' = 'open'; - priority = 0; - tags: string[] = []; +export class IssueEntity extends Entity { + id = 0; + number = 0; + title = ''; + body = ''; + state: 'open' | 'closed' = 'open'; + locked = false; + comments = 0; + labels: LabelEntity[] = []; + user = UserEntity.fromJS(); + htmlUrl = ''; + repositoryUrl = ''; + authorAssociation = 'NONE'; createdAt = ''; updatedAt = ''; - author = AuthorEntity.fromJS(); + closedAt: string | null = null; pk() { - return this.id; + return `${this.number}`; } - static key = 'ItemEntity'; - static schema = { author: AuthorEntity }; + static key = 'IssueEntity'; + static schema = { + user: UserEntity, + labels: [LabelEntity], + }; } -class ItemCollection< +class IssueCollection< S extends any[] | PolymorphicInterface = any, Parent extends any[] = [urlParams: any, body?: any], > extends Collection { @@ -64,67 +100,67 @@ class ItemCollection< } } -export const ItemResource = resource({ - path: '/items/:id', - schema: ItemEntity, +export const IssueResource = resource({ + path: '/issues/:number', + schema: IssueEntity, optimistic: true, - Collection: ItemCollection, + Collection: IssueCollection, }).extend(Base => ({ get: Base.get.extend({ - fetch: serverFetchItem as any, + fetch: serverFetchIssue as any, dataExpiryLength: Infinity, }), getList: Base.getList.extend({ - fetch: serverFetchItemList as any, + fetch: serverFetchIssueList as any, dataExpiryLength: Infinity, }), update: Base.update.extend({ fetch: ((params: any, body: any) => - serverUpdateItem({ ...params, ...body })) as any, + serverUpdateIssue({ ...params, ...body })) as any, }), delete: Base.delete.extend({ - fetch: serverDeleteItem as any, + fetch: serverDeleteIssue as any, }), create: Base.getList.unshift.extend({ fetch: ((...args: any[]) => - serverCreateItem(args.length > 1 ? args[1] : args[0])) as any, + serverCreateIssue(args.length > 1 ? args[1] : args[0])) as any, body: {} as { - label: string; - author: Author; + title: string; + user: User; }, }), move: Base.getList.move.extend({ fetch: ((params: any, body: any) => - serverUpdateItem({ ...params, ...body })) as any, + serverUpdateIssue({ ...params, ...body })) as any, }), })); -export const AuthorResource = resource({ - path: '/authors/:id', - schema: AuthorEntity, +export const UserResource = resource({ + path: '/users/:login', + schema: UserEntity, optimistic: true, }).extend(Base => ({ get: Base.get.extend({ - fetch: serverFetchAuthor as any, + fetch: serverFetchUser as any, dataExpiryLength: Infinity, }), update: Base.update.extend({ fetch: ((params: any, body: any) => - serverUpdateAuthor({ ...params, ...body })) as any, + serverUpdateUser({ ...params, ...body })) as any, }), delete: Base.delete.extend({ - fetch: serverDeleteAuthor as any, + fetch: serverDeleteUser as any, }), })); // ── DERIVED QUERIES ───────────────────────────────────────────────────── /** Derived sorted view via Query schema -- globally memoized by MemoCache */ -export const sortedItemsQuery = new Query( - ItemResource.getList.schema, - (entries, { count }: { count?: number } = {}) => sortByLabel(entries, count), +export const sortedIssuesQuery = new Query( + IssueResource.getList.schema, + (entries, { count }: { count?: number } = {}) => sortByTitle(entries, count), ); -export const sortedItemsEndpoint = ItemResource.getList.extend({ - schema: sortedItemsQuery, +export const sortedIssuesEndpoint = IssueResource.getList.extend({ + schema: sortedIssuesQuery, }); diff --git a/examples/benchmark-react/src/shared/server.ts b/examples/benchmark-react/src/shared/server.ts index dd36a32a9e82..83133033100c 100644 --- a/examples/benchmark-react/src/shared/server.ts +++ b/examples/benchmark-react/src/shared/server.ts @@ -1,4 +1,4 @@ -import type { Author, Item } from './types'; +import type { Issue, Label, User } from './types'; // ── WORKER SETUP ───────────────────────────────────────────────────────── @@ -52,78 +52,78 @@ export function flushPendingMutations(): Promise { // ── READ ───────────────────────────────────────────────────────────────── -export function fetchItem(params: { id: string }): Promise { - return sendRequest('fetchItem', params); +export function fetchIssue(params: { number: number }): Promise { + return sendRequest('fetchIssue', params); } -export function fetchAuthor(params: { id: string }): Promise { - return sendRequest('fetchAuthor', params); +export function fetchUser(params: { login: string }): Promise { + return sendRequest('fetchUser', params); } -export function fetchItemList(params?: { +export function fetchIssueList(params?: { count?: number; - status?: string; -}): Promise { - return sendRequest('fetchItemList', params); + state?: string; +}): Promise { + return sendRequest('fetchIssueList', params); } // ── CREATE ─────────────────────────────────────────────────────────────── -export function createItem(body: { - label: string; - author: Author; -}): Promise { - return sendMutation('createItem', body); -} - -export function createAuthor(body: { - login: string; - name: string; -}): Promise { - return sendMutation('createAuthor', body); +export function createIssue(body: { + title: string; + user: User; + labels?: Label[]; +}): Promise { + return sendMutation('createIssue', body); } // ── UPDATE ─────────────────────────────────────────────────────────────── -export function updateItem(params: { - id: string; - label?: string; - status?: Item['status']; - author?: Author; -}): Promise { - return sendMutation('updateItem', params); +export function updateIssue(params: { + number: number; + title?: string; + state?: Issue['state']; + user?: User; +}): Promise { + return sendMutation('updateIssue', params); } -export function updateAuthor(params: { - id: string; - login?: string; +export function updateUser(params: { + login: string; name?: string; -}): Promise { - return sendMutation('updateAuthor', params); +}): Promise { + return sendMutation('updateUser', params); } // ── DELETE ──────────────────────────────────────────────────────────────── -export function deleteItem(params: { id: string }): Promise<{ id: string }> { - return sendMutation('deleteItem', params); +export function deleteIssue(params: { + number: number; +}): Promise<{ id: number; number: number }> { + return sendMutation('deleteIssue', params); } -export function deleteAuthor(params: { id: string }): Promise<{ id: string }> { - return sendMutation('deleteAuthor', params); +export function deleteUser(params: { + login: string; +}): Promise<{ login: string }> { + return sendMutation('deleteUser', params); } // ── DIRECT STORE ACCESS (pre-measurement setup) ───────────────────────── -export function getItem(id: string): Promise { - return sendRequest('getItem', { id }); +export function getIssue(number: number): Promise { + return sendRequest('getIssue', { number }); } -export function patchItem(id: string, patch: Partial): Promise { - return sendRequest('patchItem', { id, patch }); +export function patchIssue( + number: number, + patch: Partial, +): Promise { + return sendRequest('patchIssue', { number, patch }); } -export function seedItemList(items: Item[]): Promise { - return sendRequest('seedItemList', { items }); +export function seedIssueList(issues: Issue[]): Promise { + return sendRequest('seedIssueList', { issues }); } // ── CONTROL ────────────────────────────────────────────────────────────── diff --git a/examples/benchmark-react/src/shared/server.worker.ts b/examples/benchmark-react/src/shared/server.worker.ts index 9f26f6aeb42a..d5aeb09a47b1 100644 --- a/examples/benchmark-react/src/shared/server.worker.ts +++ b/examples/benchmark-react/src/shared/server.worker.ts @@ -1,7 +1,7 @@ /// -import { FIXTURE_AUTHORS, FIXTURE_ITEMS } from './data'; -import type { Author, Item } from './types'; +import { FIXTURE_USERS, FIXTURE_ISSUES } from './data'; +import type { Issue, Label, User } from './types'; declare const self: DedicatedWorkerGlobalScope; @@ -26,143 +26,139 @@ function respondError(id: number, message: string) { // ── IN-MEMORY STORES ───────────────────────────────────────────────────── -const itemStore = new Map(); -const authorStore = new Map(); -let masterList: Item[] = []; -const statusIndex = new Map(); -const idToPosition = new Map(); +const issueStore = new Map(); +const userStore = new Map(); +let masterList: Issue[] = []; +const stateIndex = new Map(); +const numberToPosition = new Map(); -function rebuildStatusIndex() { - statusIndex.clear(); - for (const item of masterList) { - let list = statusIndex.get(item.status); +function rebuildStateIndex() { + stateIndex.clear(); + for (const issue of masterList) { + let list = stateIndex.get(issue.state); if (!list) { list = []; - statusIndex.set(item.status, list); + stateIndex.set(issue.state, list); } - list.push(item); + list.push(issue); } } function rebuildPositionIndex() { - idToPosition.clear(); + numberToPosition.clear(); for (let i = 0; i < masterList.length; i++) { - idToPosition.set(masterList[i].id, i); + numberToPosition.set(masterList[i].number, i); } } // Pre-seed with fixture data -for (const author of FIXTURE_AUTHORS) { - authorStore.set(author.id, { ...author }); +for (const user of FIXTURE_USERS) { + userStore.set(user.login, { ...user }); } -for (const item of FIXTURE_ITEMS) { - const seeded: Item = { ...item, author: { ...item.author } }; - itemStore.set(item.id, seeded); +for (const issue of FIXTURE_ISSUES) { + const seeded: Issue = { + ...issue, + user: { ...issue.user }, + labels: issue.labels.map(l => ({ ...l })), + }; + issueStore.set(issue.number, seeded); masterList.push(seeded); } -rebuildStatusIndex(); +rebuildStateIndex(); rebuildPositionIndex(); // ── READ ───────────────────────────────────────────────────────────────── -function fetchItem({ id }: { id: string }): Item { - const item = itemStore.get(id); - if (!item) throw new Error(`No data for item:${id}`); - if (item.author?.id) { - const latest = authorStore.get(item.author.id); - if (latest && latest !== item.author) return { ...item, author: latest }; +function fetchIssue({ number }: { number: number }): Issue { + const issue = issueStore.get(number); + if (!issue) throw new Error(`No data for issue:${number}`); + if (issue.user?.login) { + const latest = userStore.get(issue.user.login); + if (latest && latest !== issue.user) return { ...issue, user: latest }; } - return item; + return issue; } -function fetchAuthor({ id }: { id: string }): Author { - const author = authorStore.get(id); - if (!author) throw new Error(`No data for author:${id}`); - return author; +function fetchUser({ login }: { login: string }): User { + const user = userStore.get(login); + if (!user) throw new Error(`No data for user:${login}`); + return user; } -function fetchItemList(params?: { count?: number; status?: string }): Item[] { - let items: Item[] = - params?.status ? (statusIndex.get(params.status) ?? []) : masterList; +function fetchIssueList(params?: { count?: number; state?: string }): Issue[] { + let issues: Issue[] = + params?.state ? (stateIndex.get(params.state) ?? []) : masterList; if (params?.count) { - items = items.slice(0, params.count); + issues = issues.slice(0, params.count); } - items = items.map(item => { - if (item.author?.id) { - const latest = authorStore.get(item.author.id); - if (latest && latest !== item.author) return { ...item, author: latest }; + issues = issues.map(issue => { + if (issue.user?.login) { + const latest = userStore.get(issue.user.login); + if (latest && latest !== issue.user) return { ...issue, user: latest }; } - return item; + return issue; }); - return items; + return issues; } // ── CREATE ─────────────────────────────────────────────────────────────── -let createItemCounter = 0; +let createIssueCounter = 0; -function createItem(body: { label: string; author: Author }): Item { - const id = `created-item-${createItemCounter++}`; +function createIssue(body: { + title: string; + user: User; + labels?: Label[]; +}): Issue { + const num = 90000 + createIssueCounter++; const now = new Date().toISOString(); - const item: Item = { - id, - label: body.label, - description: '', - status: 'open', - priority: 3, - tags: [], + const issue: Issue = { + id: 300000 + num, + number: num, + title: body.title, + body: '', + state: 'open', + locked: false, + comments: 0, + labels: body.labels ?? [], + user: body.user, + htmlUrl: `https://github.com/owner/repo/issues/${num}`, + repositoryUrl: 'https://api.github.com/repos/owner/repo', + authorAssociation: 'NONE', createdAt: now, updatedAt: now, - author: body.author, + closedAt: null, }; - itemStore.set(id, item); - masterList.unshift(item); + issueStore.set(num, issue); + masterList.unshift(issue); rebuildPositionIndex(); - const statusList = statusIndex.get(item.status); - if (statusList) { - statusList.unshift(item); + const stateList = stateIndex.get(issue.state); + if (stateList) { + stateList.unshift(issue); } else { - statusIndex.set(item.status, [item]); + stateIndex.set(issue.state, [issue]); } - return item; -} - -let createAuthorCounter = 0; - -function createAuthor(body: { login: string; name: string }): Author { - const id = `created-author-${createAuthorCounter++}`; - const author: Author = { - id, - login: body.login, - name: body.name, - avatarUrl: `https://avatars.example.com/u/${id}?s=64`, - email: `${body.login}@example.com`, - bio: '', - followers: 0, - createdAt: new Date().toISOString(), - }; - authorStore.set(id, author); - return author; + return issue; } // ── UPDATE ─────────────────────────────────────────────────────────────── -function updateItem(params: { - id: string; - label?: string; - status?: Item['status']; - author?: Author; -}): Item { - const existing = itemStore.get(params.id); - if (!existing) throw new Error(`No data for item:${params.id}`); - const updated: Item = { ...existing, ...params }; - itemStore.set(params.id, updated); - const idx = idToPosition.get(params.id) ?? -1; +function updateIssue(params: { + number: number; + title?: string; + state?: Issue['state']; + user?: User; +}): Issue { + const existing = issueStore.get(params.number); + if (!existing) throw new Error(`No data for issue:${params.number}`); + const updated: Issue = { ...existing, ...params }; + issueStore.set(params.number, updated); + const idx = numberToPosition.get(params.number) ?? -1; if (idx >= 0) masterList[idx] = updated; - if (existing.status !== updated.status) { - rebuildStatusIndex(); + if (existing.state !== updated.state) { + rebuildStateIndex(); } else { - const sList = statusIndex.get(updated.status); + const sList = stateIndex.get(updated.state); if (sList) { const si = sList.indexOf(existing); if (si >= 0) sList[si] = updated; @@ -171,53 +167,53 @@ function updateItem(params: { return updated; } -function updateAuthor(params: { - id: string; - login?: string; - name?: string; -}): Author { - const existing = authorStore.get(params.id); - if (!existing) throw new Error(`No data for author:${params.id}`); - const updated: Author = { ...existing, ...params }; - authorStore.set(params.id, updated); +function updateUser(params: { login: string; name?: string }): User { + const existing = userStore.get(params.login); + if (!existing) throw new Error(`No data for user:${params.login}`); + const updated: User = { ...existing, ...params }; + userStore.set(params.login, updated); return updated; } // ── DELETE ──────────────────────────────────────────────────────────────── -function deleteItem({ id }: { id: string }): { id: string } { - itemStore.delete(id); - const idx = idToPosition.get(id) ?? -1; +function deleteIssue({ number }: { number: number }): { + id: number; + number: number; +} { + const existing = issueStore.get(number); + issueStore.delete(number); + const idx = numberToPosition.get(number) ?? -1; if (idx >= 0) { masterList.splice(idx, 1); rebuildPositionIndex(); } - rebuildStatusIndex(); - return { id }; + rebuildStateIndex(); + return { id: existing?.id ?? 0, number }; } -function deleteAuthor({ id }: { id: string }): { id: string } { - authorStore.delete(id); - return { id }; +function deleteUser({ login }: { login: string }): { login: string } { + userStore.delete(login); + return { login }; } // ── DIRECT STORE ACCESS ────────────────────────────────────────────────── -function getItem(id: string): Item | undefined { - return itemStore.get(id); +function getIssue(number: number): Issue | undefined { + return issueStore.get(number); } -function patchItem(id: string, patch: Partial): void { - const existing = itemStore.get(id); +function patchIssue(number: number, patch: Partial): void { + const existing = issueStore.get(number); if (!existing) return; - const updated: Item = { ...existing, ...patch }; - itemStore.set(id, updated); - const idx = idToPosition.get(id) ?? -1; + const updated: Issue = { ...existing, ...patch }; + issueStore.set(number, updated); + const idx = numberToPosition.get(number) ?? -1; if (idx >= 0) masterList[idx] = updated; - if (existing.status !== updated.status) { - rebuildStatusIndex(); + if (existing.state !== updated.state) { + rebuildStateIndex(); } else { - const sList = statusIndex.get(updated.status); + const sList = stateIndex.get(updated.state); if (sList) { const si = sList.indexOf(existing); if (si >= 0) sList[si] = updated; @@ -225,34 +221,33 @@ function patchItem(id: string, patch: Partial): void { } } -function seedItemList(items: Item[]): void { - masterList = items; - itemStore.clear(); - authorStore.clear(); - for (const item of items) { - itemStore.set(item.id, item); - if (item.author) authorStore.set(item.author.id, item.author); +function seedIssueList(issues: Issue[]): void { + masterList = issues; + issueStore.clear(); + userStore.clear(); + for (const issue of issues) { + issueStore.set(issue.number, issue); + if (issue.user) userStore.set(issue.user.login, issue.user); } - rebuildStatusIndex(); + rebuildStateIndex(); rebuildPositionIndex(); } // ── MESSAGE HANDLER ────────────────────────────────────────────────────── const methods: Record unknown> = { - fetchItem, - fetchAuthor, - fetchItemList, - createItem, - createAuthor, - updateItem, - updateAuthor, - deleteItem, - deleteAuthor, - getItem: ({ id }: { id: string }) => getItem(id), - patchItem: ({ id, patch }: { id: string; patch: Partial }) => - patchItem(id, patch), - seedItemList: ({ items }: { items: Item[] }) => seedItemList(items), + fetchIssue, + fetchUser, + fetchIssueList, + createIssue, + updateIssue, + updateUser, + deleteIssue, + deleteUser, + getIssue: ({ number }: { number: number }) => getIssue(number), + patchIssue: ({ number, patch }: { number: number; patch: Partial }) => + patchIssue(number, patch), + seedIssueList: ({ issues }: { issues: Issue[] }) => seedIssueList(issues), setNetworkDelay: ({ ms }: { ms: number }) => { networkDelayMs = ms; methodDelays = {}; diff --git a/examples/benchmark-react/src/shared/types.ts b/examples/benchmark-react/src/shared/types.ts index 52e0e2699ff7..0eb02b094eea 100644 --- a/examples/benchmark-react/src/shared/types.ts +++ b/examples/benchmark-react/src/shared/types.ts @@ -3,23 +3,23 @@ * Smaller "changed" counts = better (normalization keeps referential equality for unchanged entities). */ export interface RefStabilityReport { - itemRefUnchanged: number; - itemRefChanged: number; - authorRefUnchanged: number; - authorRefChanged: number; + issueRefUnchanged: number; + issueRefChanged: number; + userRefUnchanged: number; + userRefChanged: number; } /** * Benchmark API interface exposed by each library app on window.__BENCH__ */ export interface BenchAPI { - /** Show a ListView that auto-fetches count items. Measures fetch + normalization + render pipeline. */ + /** Show a ListView that auto-fetches count issues. Measures fetch + normalization + render pipeline. */ init(count: number): void; - updateEntity(id: string): void; - updateAuthor(id: string): void; + updateEntity(id: number): void; + updateUser(login: string): void; /** Set simulated per-request network latency (ms). 0 disables and clears per-method delays. */ setNetworkDelay(ms: number): void; - /** Set per-method network latency overrides (e.g. { fetchItemList: 80, fetchItem: 50 }). */ + /** Set per-method network latency overrides (e.g. { fetchIssueList: 80, fetchIssue: 50 }). */ setMethodDelays(delays: Record): void; /** Wait for all deferred server mutations to settle before next iteration. */ flushPendingMutations(): Promise; @@ -29,21 +29,21 @@ export interface BenchAPI { getRefStabilityReport(): RefStabilityReport; /** Legacy ids-based mount; optional — prefer init. */ mount?(count: number): void; - /** For memory scenarios: mount n items, unmount, repeat cycles times; resolves when done. */ + /** For memory scenarios: mount n issues, unmount, repeat cycles times; resolves when done. */ mountUnmountCycle?(count: number, cycles: number): Promise; - /** Mount a sorted/derived view of items. Exercises Query memoization (data-client) vs useMemo sort (others). */ + /** Mount a sorted/derived view of issues. Exercises Query memoization (data-client) vs useMemo sort (others). */ mountSortedView?(count: number): void; /** Invalidate a cached endpoint and immediately re-resolve. Measures Suspense round-trip. data-client only. */ - invalidateAndResolve?(id: string): void; - /** Prepend a new item via mutation endpoint. */ + invalidateAndResolve?(id: number): void; + /** Prepend a new issue via mutation endpoint. */ unshiftItem?(): void; - /** Delete an existing item via mutation endpoint. */ - deleteEntity?(id: string): void; - /** Mount three side-by-side lists filtered by status ('open', 'closed', 'in_progress'). */ - initTripleList?(count: number): void; - /** Move an item from one status-filtered list to another. Exercises Collection.move (data-client) vs invalidate+refetch (others). */ - moveItem?(id: string): void; - /** Switch between sorted list view and individual item detail views 10 times (20 renders). Exercises normalized cache lookup (data-client) vs per-navigation fetch (others). */ + /** Delete an existing issue via mutation endpoint. */ + deleteEntity?(id: number): void; + /** Mount two side-by-side lists filtered by state ('open', 'closed'). */ + initDoubleList?(count: number): void; + /** Move an issue from one state-filtered list to another. Exercises Collection.move (data-client) vs invalidate+refetch (others). */ + 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; /** Trigger store garbage collection (data-client only). Used by memory scenarios to flush unreferenced data before heap measurement. */ triggerGC?(): void; @@ -55,42 +55,69 @@ declare global { } } -export interface Author { - id: string; - login: string; +export interface Label { + id: number; + nodeId: string; name: string; + description: string; + color: string; + default: boolean; +} + +export interface User { + id: number; + login: string; + nodeId: string; avatarUrl: string; + gravatarId: string; + type: string; + siteAdmin: boolean; + htmlUrl: string; + name: string; + company: string; + blog: string; + location: string; email: string; bio: string; + publicRepos: number; + publicGists: number; followers: number; + following: number; createdAt: string; + updatedAt: string; } -export interface Item { - id: string; - label: string; - description: string; - status: 'open' | 'closed' | 'in_progress'; - priority: number; - tags: string[]; +export interface Issue { + id: number; + number: number; + title: string; + body: string; + state: 'open' | 'closed'; + locked: boolean; + comments: number; + labels: Label[]; + user: User; + htmlUrl: string; + repositoryUrl: string; + authorAssociation: string; createdAt: string; updatedAt: string; - author: Author; + closedAt: string | null; } export type ScenarioAction = | { action: 'init'; args: [number] } - | { action: 'updateEntity'; args: [string] } - | { action: 'updateAuthor'; args: [string] } + | { action: 'updateEntity'; args: [number] } + | { action: 'updateUser'; args: [string] } | { action: 'unmountAll'; args: [] } | { action: 'unshiftItem'; args: [] } - | { action: 'deleteEntity'; args: [string] } - | { action: 'moveItem'; args: [string] }; + | { action: 'deleteEntity'; args: [number] } + | { action: 'moveItem'; args: [number] }; export type ResultMetric = | 'duration' - | 'itemRefChanged' - | 'authorRefChanged' + | 'issueRefChanged' + | 'userRefChanged' | 'heapDelta'; /** hotPath = JS only, included in CI. memory = heap delta, not CI. startup = page load metrics, not CI. */ @@ -103,13 +130,13 @@ export interface Scenario { name: string; action: keyof BenchAPI; args: unknown[]; - /** Which value to report; default 'duration'. Ref-stability use itemRefChanged/authorRefChanged; memory use heapDelta. */ + /** Which value to report; default 'duration'. Ref-stability use issueRefChanged/userRefChanged; memory use heapDelta. */ resultMetric?: ResultMetric; /** hotPath (default) = run in CI. memory = heap delta. startup = page load metrics. */ category?: ScenarioCategory; /** small (default) = full runs. large = reduced warmup/measurement for expensive scenarios. */ size?: ScenarioSize; - /** For update scenarios: number of items to mount before running the update (default 100). */ + /** For update scenarios: number of issues to mount before running the update (default 100). */ mountCount?: number; /** Use a different BenchAPI method to pre-mount (e.g. 'mountSortedView' instead of 'mount'). */ preMountAction?: keyof BenchAPI; diff --git a/examples/benchmark-react/src/swr/index.tsx b/examples/benchmark-react/src/swr/index.tsx index 258a5d286b4c..f17d81abce24 100644 --- a/examples/benchmark-react/src/swr/index.tsx +++ b/examples/benchmark-react/src/swr/index.tsx @@ -4,106 +4,105 @@ import { useBenchState, } from '@shared/benchHarness'; import { - TRIPLE_LIST_STYLE, - ITEM_HEIGHT, - ItemRow, - ItemsRow, + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, LIST_STYLE, - PlainItemList, + PlainIssueList, } from '@shared/components'; import { - FIXTURE_AUTHORS, - FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS_BY_ID, - sortByLabel, + FIXTURE_USERS, + FIXTURE_USERS_BY_LOGIN, + FIXTURE_ISSUES_BY_NUMBER, + sortByTitle, } from '@shared/data'; -import { setCurrentItems } from '@shared/refStability'; -import { AuthorResource, ItemResource } from '@shared/resources'; -import type { Item } from '@shared/types'; +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 */ const fetcher = (key: string): Promise => { - if (key.startsWith('item:')) return ItemResource.get({ id: key.slice(5) }); - if (key.startsWith('author:')) - return AuthorResource.get({ id: key.slice(7) }); - if (key === 'items:all') return ItemResource.getList(); - if (key.startsWith('items:status:')) { - const [status, count] = key.slice(13).split(':'); - return ItemResource.getList({ - status, + if (key.startsWith('issue:')) + return IssueResource.get({ number: Number(key.slice(6)) }); + if (key.startsWith('user:')) return UserResource.get({ login: key.slice(5) }); + if (key === 'issues:all') return IssueResource.getList(); + if (key.startsWith('issues:state:')) { + const [state, count] = key.slice(13).split(':'); + return IssueResource.getList({ + state, ...(count ? { count: Number(count) } : {}), }); } - if (key.startsWith('items:')) - return ItemResource.getList({ count: Number(key.slice(6)) }); + if (key.startsWith('issues:')) + return IssueResource.getList({ count: Number(key.slice(7)) }); return Promise.reject(new Error(`Unknown key: ${key}`)); }; function SortedListView() { - const { data: items } = useSWR('items:all', fetcher); - const sorted = useMemo(() => (items ? sortByLabel(items) : []), [items]); + const { data: issues } = useSWR('issues:all', fetcher); + const sorted = useMemo(() => (issues ? sortByTitle(issues) : []), [issues]); if (!sorted.length) return null; return (
); } -function DetailView({ id }: { id: string }) { - const { data: item } = useSWR(`item:${id}`, fetcher); - if (!item) return null; +function DetailView({ number }: { number: number }) { + const { data: issue } = useSWR(`issue:${number}`, fetcher); + if (!issue) return null; return ( -
- +
+
); } function ListView({ count }: { count: number }) { - const { data: items } = useSWR(`items:${count}`, fetcher); - if (!items) return null; - setCurrentItems(items); + const { data: issues } = useSWR(`issues:${count}`, fetcher); + if (!issues) return null; + setCurrentIssues(issues); return ( ); } -function StatusListView({ status, count }: { status: string; count: number }) { - const { data: items } = useSWR( - `items:status:${status}:${count}`, +function StateListView({ state, count }: { state: string; count: number }) { + const { data: issues } = useSWR( + `issues:state:${state}:${count}`, fetcher, ); - if (!items) return null; + if (!issues) return null; return ( -
- {items.length} - +
+ {issues.length} +
); } -function TripleListView({ count }: { count: number }) { +function DoubleListView({ count }: { count: number }) { return ( -
- - - +
+ +
); } @@ -113,39 +112,41 @@ function BenchmarkHarness() { const { listViewCount, showSortedView, - showTripleList, - tripleListCount, - detailItemId, + showDoubleList, + doubleListCount, + detailIssueNumber, containerRef, measureUpdate, registerAPI, } = useBenchState(); const updateEntity = useCallback( - (id: string) => { - const item = FIXTURE_ITEMS_BY_ID.get(id); - if (!item) return; + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; measureUpdate(() => - ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => - mutate(key => typeof key === 'string' && key.startsWith('items:')), + IssueResource.update( + { number }, + { title: `${issue.title} (updated)` }, + ).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), ), ); }, [measureUpdate, mutate], ); - const updateAuthor = useCallback( - (authorId: string) => { - const author = FIXTURE_AUTHORS_BY_ID.get(authorId); - if (!author) return; + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; measureUpdate( () => - AuthorResource.update( - { id: authorId }, - { name: `${author.name} (updated)` }, + UserResource.update( + { login }, + { name: `${user.name} (updated)` }, ).then(() => - mutate(key => typeof key === 'string' && key.startsWith('items:')), + mutate(key => typeof key === 'string' && key.startsWith('issues:')), ) as Promise, ); }, @@ -153,19 +154,19 @@ function BenchmarkHarness() { ); const unshiftItem = useCallback(() => { - const author = FIXTURE_AUTHORS[0]; + const user = FIXTURE_USERS[0]; measureUpdate(() => - ItemResource.create({ label: 'New Item', author }).then(() => - mutate(key => typeof key === 'string' && key.startsWith('items:')), + IssueResource.create({ title: 'New Issue', user }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), ), ); }, [measureUpdate, mutate]); const deleteEntity = useCallback( - (id: string) => { + (number: number) => { measureUpdate(() => - ItemResource.delete({ id }).then(() => - mutate(key => typeof key === 'string' && key.startsWith('items:')), + IssueResource.delete({ number }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), ), ); }, @@ -173,13 +174,13 @@ function BenchmarkHarness() { ); const moveItem = useCallback( - (id: string) => { + (number: number) => { measureUpdate( () => - ItemResource.update({ id }, { status: 'closed' }).then(() => - mutate(key => typeof key === 'string' && key.startsWith('items:')), + IssueResource.update({ number }, { state: 'closed' }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), ), - () => moveItemIsReady(containerRef, id), + () => moveItemIsReady(containerRef, number), ); }, [measureUpdate, mutate, containerRef], @@ -187,7 +188,7 @@ function BenchmarkHarness() { registerAPI({ updateEntity, - updateAuthor, + updateUser, unshiftItem, deleteEntity, moveItem, @@ -197,10 +198,10 @@ function BenchmarkHarness() {
{listViewCount != null && } {showSortedView && } - {showTripleList && tripleListCount != null && ( - + {showDoubleList && doubleListCount != null && ( + )} - {detailItemId != null && } + {detailIssueNumber != null && }
); } diff --git a/examples/benchmark-react/src/tanstack-query/index.tsx b/examples/benchmark-react/src/tanstack-query/index.tsx index 780c330194f5..900de1e398f3 100644 --- a/examples/benchmark-react/src/tanstack-query/index.tsx +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -4,22 +4,22 @@ import { useBenchState, } from '@shared/benchHarness'; import { - TRIPLE_LIST_STYLE, - ITEM_HEIGHT, - ItemRow, - ItemsRow, + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, LIST_STYLE, - PlainItemList, + PlainIssueList, } from '@shared/components'; import { - FIXTURE_AUTHORS, - FIXTURE_AUTHORS_BY_ID, - FIXTURE_ITEMS_BY_ID, - sortByLabel, + FIXTURE_USERS, + FIXTURE_USERS_BY_LOGIN, + FIXTURE_ISSUES_BY_NUMBER, + sortByTitle, } from '@shared/data'; -import { setCurrentItems } from '@shared/refStability'; -import { AuthorResource, ItemResource } from '@shared/resources'; -import type { Item } from '@shared/types'; +import { setCurrentIssues } from '@shared/refStability'; +import { UserResource, IssueResource } from '@shared/resources'; +import type { Issue } from '@shared/types'; import { QueryClient, QueryClientProvider, @@ -31,13 +31,14 @@ import { List } from 'react-window'; function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { const [type, id] = queryKey as [string, string | number | undefined]; - if (type === 'item' && id) return ItemResource.get({ id: String(id) }); - if (type === 'author' && id) return AuthorResource.get({ id: String(id) }); - if (type === 'items' && id && typeof id === 'object') - return ItemResource.getList(id as { status?: string; count?: number }); - if (type === 'items' && typeof id === 'number') - return ItemResource.getList({ count: id }); - if (type === 'items') return ItemResource.getList(); + if (type === 'issue' && id != null) + return IssueResource.get({ number: Number(id) }); + if (type === 'user' && id) return UserResource.get({ login: String(id) }); + if (type === 'issues' && id && typeof id === 'object') + return IssueResource.getList(id as { state?: string; count?: number }); + if (type === 'issues' && typeof id === 'number') + return IssueResource.getList({ count: id }); + if (type === 'issues') return IssueResource.getList(); return Promise.reject(new Error(`Unknown queryKey: ${queryKey}`)); } @@ -51,81 +52,80 @@ const queryClient = new QueryClient({ }); function SortedListView() { - const { data: items } = useQuery({ - queryKey: ['items', 'all'], + const { data: issues } = useQuery({ + queryKey: ['issues', 'all'], queryFn, }); const sorted = useMemo( - () => (items ? sortByLabel(items as Item[]) : []), - [items], + () => (issues ? sortByTitle(issues as Issue[]) : []), + [issues], ); if (!sorted.length) return null; return (
); } -function DetailView({ id }: { id: string }) { - const { data: item } = useQuery({ - queryKey: ['item', id], +function DetailView({ number }: { number: number }) { + const { data: issue } = useQuery({ + queryKey: ['issue', number], queryFn, }); - if (!item) return null; + if (!issue) return null; return ( -
- +
+
); } function ListView({ count }: { count: number }) { - const { data: items } = useQuery({ - queryKey: ['items', count], + const { data: issues } = useQuery({ + queryKey: ['issues', count], queryFn, }); - if (!items) return null; - const list = items as Item[]; - setCurrentItems(list); + if (!issues) return null; + const list = issues as Issue[]; + setCurrentIssues(list); return ( ); } -function StatusListView({ status, count }: { status: string; count: number }) { - const { data: items } = useQuery({ - queryKey: ['items', { status, count }], +function StateListView({ state, count }: { state: string; count: number }) { + const { data: issues } = useQuery({ + queryKey: ['issues', { state, count }], queryFn, }); - if (!items) return null; - const list = items as Item[]; + if (!issues) return null; + const list = issues as Issue[]; return ( -
- {list.length} - +
+ {list.length} +
); } -function TripleListView({ count }: { count: number }) { +function DoubleListView({ count }: { count: number }) { return ( -
- - - +
+ +
); } @@ -135,42 +135,42 @@ function BenchmarkHarness() { const { listViewCount, showSortedView, - showTripleList, - tripleListCount, - detailItemId, + showDoubleList, + doubleListCount, + detailIssueNumber, containerRef, measureUpdate, registerAPI, } = useBenchState(); const updateEntity = useCallback( - (id: string) => { - const item = FIXTURE_ITEMS_BY_ID.get(id); - if (!item) return; + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; measureUpdate(() => - ItemResource.update({ id }, { label: `${item.label} (updated)` }).then( - () => - client.invalidateQueries({ - queryKey: ['items'], - }), + IssueResource.update( + { number }, + { title: `${issue.title} (updated)` }, + ).then(() => + client.invalidateQueries({ + queryKey: ['issues'], + }), ), ); }, [measureUpdate, client], ); - const updateAuthor = useCallback( - (authorId: string) => { - const author = FIXTURE_AUTHORS_BY_ID.get(authorId); - if (!author) return; + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; measureUpdate(() => - AuthorResource.update( - { id: authorId }, - { name: `${author.name} (updated)` }, - ).then(() => - client.invalidateQueries({ - queryKey: ['items'], - }), + UserResource.update({ login }, { name: `${user.name} (updated)` }).then( + () => + client.invalidateQueries({ + queryKey: ['issues'], + }), ), ); }, @@ -178,20 +178,20 @@ function BenchmarkHarness() { ); const unshiftItem = useCallback(() => { - const author = FIXTURE_AUTHORS[0]; + const user = FIXTURE_USERS[0]; measureUpdate(() => - ItemResource.create({ label: 'New Item', author }).then(() => - client.invalidateQueries({ queryKey: ['items'] }), + IssueResource.create({ title: 'New Issue', user }).then(() => + client.invalidateQueries({ queryKey: ['issues'] }), ), ); }, [measureUpdate, client]); const deleteEntity = useCallback( - (id: string) => { + (number: number) => { measureUpdate(() => - ItemResource.delete({ id }).then(() => + IssueResource.delete({ number }).then(() => client.invalidateQueries({ - queryKey: ['items'], + queryKey: ['issues'], }), ), ); @@ -200,13 +200,13 @@ function BenchmarkHarness() { ); const moveItem = useCallback( - (id: string) => { + (number: number) => { measureUpdate( () => - ItemResource.update({ id }, { status: 'closed' }).then(() => - client.invalidateQueries({ queryKey: ['items'] }), + IssueResource.update({ number }, { state: 'closed' }).then(() => + client.invalidateQueries({ queryKey: ['issues'] }), ), - () => moveItemIsReady(containerRef, id), + () => moveItemIsReady(containerRef, number), ); }, [measureUpdate, client, containerRef], @@ -214,7 +214,7 @@ function BenchmarkHarness() { registerAPI({ updateEntity, - updateAuthor, + updateUser, unshiftItem, deleteEntity, moveItem, @@ -224,10 +224,10 @@ function BenchmarkHarness() {
{listViewCount != null && } {showSortedView && } - {showTripleList && tripleListCount != null && ( - + {showDoubleList && doubleListCount != null && ( + )} - {detailItemId != null && } + {detailIssueNumber != null && }
); } diff --git a/package.json b/package.json index c51dcf686793..eadfd1e11601 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "ci:build:esmodule": "yarn workspaces foreach -WptivR --from @data-client/react --from @data-client/rest --from @data-client/graphql run build:lib && yarn workspace @data-client/normalizr run build:js:node && yarn workspace @data-client/endpoint run build:js:node", "ci:build:bundlesize": "yarn workspaces foreach -Wptiv --no-private run build:lib && yarn workspace test-bundlesize run build:sizecompare", "build:benchmark": "yarn workspaces foreach -WptivR --from @data-client/core --from @data-client/endpoint --from @data-client/normalizr run build:lib && yarn workspace example-benchmark run build", - "build:benchmark-react": "yarn workspaces foreach -WptivR --from @data-client/core --from @data-client/endpoint --from @data-client/react run build:lib && yarn workspace example-benchmark-react run build", + "build:benchmark-react": "yarn workspaces foreach -WptivR --from @data-client/core --from @data-client/endpoint --from @data-client/react --from @data-client/rest run build:lib && yarn workspace example-benchmark-react run build", "build:copy:ambient": "mkdirp ./packages/endpoint/lib && copyfiles --flat ./packages/endpoint/src/schema.d.ts ./packages/endpoint/lib/ && copyfiles --flat ./packages/endpoint/src/endpoint.d.ts ./packages/endpoint/lib/ && mkdirp ./packages/rest/lib && copyfiles --flat ./packages/rest/src/RestEndpoint.d.ts ./packages/rest/lib && copyfiles --flat ./packages/rest/src/next/RestEndpoint.d.ts ./packages/rest/lib/next && mkdirp ./packages/react/lib && copyfiles --flat ./packages/react/src/server/redux/redux.d.ts ./packages/react/lib/server/redux", "copy:websitetypes": "./scripts/copywebsitetypes.sh", "test": "NODE_ENV=test run jest",