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/.cursor/rules/benchmarking.mdc b/.cursor/rules/benchmarking.mdc index 473d27d63351..6ecfa7b2a3a8 100644 --- a/.cursor/rules/benchmarking.mdc +++ b/.cursor/rules/benchmarking.mdc @@ -1,12 +1,79 @@ --- -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 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. + +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: + +- **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-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 user at scale) + +- **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) + +- **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** | `getlist-*`, `update-single-entity`, `ref-stability-*`, `sorted-view-mount-*` | 2–5% | +| **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. + +### 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`) ## Optimization workflow diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml new file mode 100644 index 000000000000..0940bcc3aed7 --- /dev/null +++ b/.github/workflows/benchmark-react.yml @@ -0,0 +1,101 @@ +name: Benchmark React + +on: + pull_request: + branches: + - master + paths: + - 'packages/react/src/**' + - 'packages/core/src/**' + - 'packages/endpoint/src/schemas/**' + - 'packages/normalizr/src/**' + - 'examples/benchmark-react/**' + - '.github/workflows/benchmark-react.yml' + push: + branches: + - master + paths: + - 'packages/react/src/**' + - 'packages/core/src/**' + - 'packages/endpoint/src/schemas/**' + - 'packages/normalizr/src/**' + - 'examples/benchmark-react/**' + - '.github/workflows/benchmark-react.yml' + +concurrency: + 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: + contents: write + pull-requests: write + +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/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e80305dd3521..344bfd6921fe 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -19,6 +19,14 @@ on: - 'packages/core/src/**' - 'examples/benchmark/**' - '.github/workflows/benchmark.yml' +permissions: + contents: write + pull-requests: write + +concurrency: + 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: benchmark: @@ -48,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 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/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/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/.babelrc.js b/examples/benchmark-react/.babelrc.js new file mode 100644 index 000000000000..bfd95e4230c7 --- /dev/null +++ b/examples/benchmark-react/.babelrc.js @@ -0,0 +1,8 @@ +const options = { polyfillMethod: false }; +if (process.env.REACT_COMPILER === 'true') { + options.reactCompiler = {}; +} + +module.exports = { + presets: [['@anansi', options]], +}; diff --git a/examples/benchmark-react/README.md b/examples/benchmark-react/README.md new file mode 100644 index 000000000000..b8ce66acd76d --- /dev/null +++ b/examples/benchmark-react/README.md @@ -0,0 +1,180 @@ +# React Rendering 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. `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. + +## Scenario categories + +- **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`. + +## Scenarios + +**Hot path (CI)** + +- **Get list** (`getlist-100`, `getlist-500`) — Time to show a ListView component that auto-fetches 100 or 500 issues from the list endpoint, then renders (unit: ms). Exercises the full fetch + normalization + render pipeline. +- **Update single entity** (`update-single-entity`) — Time to update one issue and propagate to the UI (unit: ms). +- **Update shared user (scaling)** (`update-shared-user-500-mounted`, `update-shared-user-10000-mounted`) — Update one shared user with 500 or 10,000 mounted issues to test subscriber scaling. Normalized cache: one store update, all views of that user update. +- **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 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 issues, unmount, repeat 10 times; report JS heap delta (bytes) via CDP. Surfaces leaks or unbounded growth. + +**Startup (local only)** + +- **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 | +|---|---|---|---|---| +| `getlist-100` | ~similar | ~similar | ~similar | ~similar | +| `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-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. + +## 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 `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`, `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`. + +## 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 env PATH="$PATH" npx playwright install-deps chromium + ``` + + 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** + + ```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`. + +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 + - `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. 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: + + ```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 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 + ``` + + 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): `getlist-100`, `update-single-entity`, `ref-stability-*`, `invalidate-and-resolve`, `unshift-item`, `delete-item` + - **Large** (1 warmup + 4 measurement): `getlist-500`, `update-shared-user-500-mounted`, `update-shared-user-10000-mounted`, `update-shared-user-with-network`, `sorted-view-mount-500`, `sorted-view-update-entity` + - **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. + +## 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/measure.ts b/examples/benchmark-react/bench/measure.ts new file mode 100644 index 000000000000..f753c1f5c41d --- /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.findLast(x => x.name === name); + return m?.duration ?? 0; +} 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..66631d13daef --- /dev/null +++ b/examples/benchmark-react/bench/report-viewer.html @@ -0,0 +1,272 @@ + + + + + + React benchmark report + + + + +

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/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..27d5e678ad61 --- /dev/null +++ b/examples/benchmark-react/bench/runner.ts @@ -0,0 +1,748 @@ +/// +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, + LIBRARIES, + RUN_CONFIG, + ACTION_GROUPS, + NETWORK_SIM_DELAYS, +} from './scenarios.js'; +import { computeStats, isConverged } from './stats.js'; +import { parseTraceDuration } from './tracing.js'; +import type { Scenario, ScenarioSize } from '../src/shared/types.js'; + +// --------------------------------------------------------------------------- +// CLI + env var parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { + libs?: string[]; + size?: ScenarioSize; + actions?: string[]; + scenario?: string; + networkSim: boolean; +} { + 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 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, networkSim }; +} + +function filterScenarios(scenarios: Scenario[]): { + filtered: Scenario[]; + libraries: string[]; + networkSim: boolean; +} { + const { + libs, + size, + actions, + scenario: scenarioFilter, + networkSim, + } = 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 !== '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) { + 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, networkSim }; +} + +// --------------------------------------------------------------------------- +// 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 = ['issueRefChanged', 'userRefChanged'] as const; + +function isRefStabilityScenario(scenario: Scenario): scenario is Scenario & { + resultMetric: (typeof REF_STABILITY_METRICS)[number]; +} { + return ( + scenario.resultMetric === 'issueRefChanged' || + scenario.resultMetric === 'userRefChanged' + ); +} + +interface ScenarioResult { + value: number; + reactCommit?: number; + traceDuration?: number; +} + +async function runScenario( + page: Page, + lib: string, + scenario: Scenario, + networkSim: boolean, +): Promise { + const appPath = `/${lib}/`; + await page.goto(`${BASE_URL}${appPath}`, { + waitUntil: 'networkidle', + timeout: 120000, + }); + await page.waitForSelector('[data-app-ready]', { + timeout: 120000, + state: 'attached', + }); + + const harness = page.locator('[data-bench-harness]'); + await harness.waitFor({ state: 'attached' }); + + const bench = await page.evaluateHandle('window.__BENCH__'); + 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'; + 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 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`, + ); + } + 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 }; + } + + const isUpdate = + scenario.action === 'updateEntity' || + scenario.action === 'updateUser' || + scenario.action === 'invalidateAndResolve' || + scenario.action === 'unshiftItem' || + scenario.action === 'deleteEntity' || + scenario.action === 'moveItem'; + const isRefStability = isRefStabilityScenario(scenario); + const isInit = scenario.action === 'init'; + + const mountCount = scenario.mountCount ?? 100; + if (isUpdate || isRefStability) { + const preMountAction = scenario.preMountAction ?? 'init'; + 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], + ); + await page.waitForSelector('[data-bench-complete]', { + 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(); + }); + } + + if (isRefStability) { + await (bench as any).evaluate((api: any) => api.captureRefSnapshot()); + } + + await harness.evaluate(el => { + el.removeAttribute('data-bench-complete'); + el.removeAttribute('data-bench-timeout'); + }); + 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 page.evaluate(() => { + performance.clearMarks(); + performance.clearMeasures(); + }); + + await (bench as any).evaluate( + (api: any, { action, args }: { action: string; args: unknown[] }) => { + api[action](...args); + }, + { action: scenario.action, args: scenario.args }, + ); + + 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; + if (cdpTracing) { + try { + 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(() => {}); + } + } + + if (isRefStability && scenario.resultMetric) { + const report = await (bench as any).evaluate((api: any) => + api.getRefStabilityReport(), + ); + await bench.dispose(); + return { value: report[scenario.resultMetric] as number }; + } + + const measures = await collectMeasures(page); + const isMountLike = + isInit || + scenario.action === 'mountSortedView' || + scenario.action === 'initDoubleList' || + scenario.action === 'listDetailSwitch'; + const duration = + isMountLike ? + getMeasureDuration(measures, 'mount-duration') + : getMeasureDuration(measures, 'update-duration'); + // 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 { + value: duration, + reactCommit: reactCommit > 0 ? reactCommit : undefined, + traceDuration, + }; +} + +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +interface StartupMetrics { + fcp: number; + taskDuration: number; +} + +async function runStartupScenario( + page: Page, + lib: string, +): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + const appPath = `/${lib}/`; + await page.goto(`${BASE_URL}${appPath}`, { + waitUntil: 'networkidle', + timeout: 120000, + }); + await page.waitForSelector('[data-app-ready]', { + timeout: 120000, + state: 'attached', + }); + const { metrics } = await cdp.send('Performance.getMetrics'); + const fcp = + metrics.find( + (m: { name: string; value: number }) => m.name === 'FirstContentfulPaint', + )?.value ?? 0; + const taskDuration = + metrics.find( + (m: { name: string; value: number }) => m.name === 'TaskDuration', + )?.value ?? 0; + return { fcp, taskDuration }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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; +} + +function scenarioUnit(scenario: Scenario): string { + if ( + scenario.resultMetric === 'issueRefChanged' || + scenario.resultMetric === 'userRefChanged' + ) + return 'count'; + if (scenario.resultMetric === 'heapDelta') return 'bytes'; + return 'ms'; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + 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'); + process.exit(1); + } + + // Separate memory into its own category (run in a distinct phase) + const memoryScenarios = SCENARIOS_TO_RUN.filter(s => s.category === 'memory'); + const mainScenarios = SCENARIOS_TO_RUN.filter(s => s.category !== 'memory'); + + // Group main scenarios by size for differentiated run counts + const bySize: Record = { small: [], large: [] }; + for (const s of mainScenarios) { + bySize[s.size ?? 'small'].push(s); + } + const sizeGroups = ( + Object.entries(bySize) as [ScenarioSize, Scenario[]][] + ).filter(([, arr]) => arr.length > 0); + + const results: Record = {}; + const reactCommitResults: Record = {}; + const traceResults: Record = {}; + for (const scenario of SCENARIOS_TO_RUN) { + results[scenario.name] = []; + reactCommitResults[scenario.name] = []; + traceResults[scenario.name] = []; + } + + const browser = await chromium.launch({ headless: true }); + + // Run deterministic scenarios once (no warmup needed) — main scenarios only + const deterministicNames = new Set(); + const deterministicScenarios = mainScenarios.filter(s => s.deterministic); + if (deterministicScenarios.length > 0) { + process.stderr.write( + `\n── Deterministic scenarios (${deterministicScenarios.length}) ──\n`, + ); + for (const lib of libraries) { + const libScenarios = deterministicScenarios.filter(s => + s.name.startsWith(`${lib}:`), + ); + if (libScenarios.length === 0) continue; + const context = await browser.newContext(); + const page = await context.newPage(); + for (const scenario of libScenarios) { + try { + const result = await runScenario(page, lib, scenario, networkSim); + results[scenario.name].push(result.value); + reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); + traceResults[scenario.name].push(result.traceDuration ?? NaN); + process.stderr.write( + ` ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}\n`, + ); + } catch (err) { + console.error( + ` ${scenario.name} FAILED:`, + err instanceof Error ? err.message : err, + ); + } + deterministicNames.add(scenario.name); + } + await context.close(); + } + } + + // 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), + ); // main scenarios only (memory runs in its own phase) + 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}/${maxRounds} (${phase} ${phaseRound}/${phaseTotal}, ${active.length}/${nonDeterministic.length} active) ──\n`, + ); + let scenarioDone = 0; + for (const lib of shuffle([...libraries])) { + const libScenarios = active.filter(s => s.name.startsWith(`${lib}:`)); + if (libScenarios.length === 0) continue; + + const context = await browser.newContext(); + const page = await context.newPage(); + + for (const scenario of libScenarios) { + try { + const result = await runScenario(page, lib, scenario, networkSim); + results[scenario.name].push(result.value); + reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); + traceResults[scenario.name].push(result.traceDuration ?? NaN); + scenarioDone++; + process.stderr.write( + ` [${scenarioDone}/${active.length}] ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}${result.reactCommit != null ? ` (commit ${result.reactCommit.toFixed(2)} ms)` : ''}\n`, + ); + } catch (err) { + scenarioDone++; + console.error( + ` [${scenarioDone}/${active.length}] ${scenario.name} FAILED:`, + err instanceof Error ? err.message : err, + ); + } + } + + await context.close(); + } + + // 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; + } + } + } + } + + // 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, networkSim); + results[scenario.name].push(result.value); + reactCommitResults[scenario.name].push(result.reactCommit ?? NaN); + traceResults[scenario.name].push(result.traceDuration ?? NaN); + process.stderr.write( + ` ${scenario.name}: ${result.value.toFixed(2)} ${scenarioUnit(scenario)}\n`, + ); + } catch (err) { + console.error( + ` ${scenario.name} FAILED:`, + err instanceof Error ? err.message : err, + ); + } + } + await context.close(); + } + } + } + + // Startup scenarios (fast; only locally) + const startupResults: Record = {}; + const includeStartup = false; // Bench not set up for startup metrics (FCP/task duration) + if (includeStartup) { + for (const lib of libraries) { + startupResults[lib] = { fcp: [], tbt: [] }; + } + const STARTUP_RUNS = 5; + for (let round = 0; round < STARTUP_RUNS; round++) { + process.stderr.write( + `\n── Startup round ${round + 1}/${STARTUP_RUNS} ──\n`, + ); + for (const lib of shuffle([...libraries])) { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + const m = await runStartupScenario(page, lib); + startupResults[lib].fcp.push(m.fcp * 1000); + startupResults[lib].tbt.push(m.taskDuration * 1000); + process.stderr.write( + ` ${lib}: fcp ${(m.fcp * 1000).toFixed(2)} ms, task ${(m.taskDuration * 1000).toFixed(2)} ms\n`, + ); + } catch (err) { + console.error( + ` ${lib} startup FAILED:`, + err instanceof Error ? err.message : err, + ); + } + await context.close(); + } + } + } + + await browser.close(); + + // --------------------------------------------------------------------------- + // Report + // --------------------------------------------------------------------------- + const report: BenchmarkResult[] = []; + for (const scenario of SCENARIOS_TO_RUN) { + const samples = results[scenario.name]; + const warmupRuns = + scenario.deterministic ? 0 + : scenario.category === 'memory' ? MEMORY_WARMUP + : RUN_CONFIG[scenario.size ?? 'small'].warmup; + if (samples.length <= warmupRuns) continue; + const { median, range } = computeStats(samples, warmupRuns); + const unit = scenarioUnit(scenario); + report.push({ + name: scenario.name, + unit, + value: Math.round(median * 100) / 100, + range, + }); + const reactSamples = reactCommitResults[scenario.name] + .slice(warmupRuns) + .filter(x => !Number.isNaN(x)); + if ( + reactSamples.length > 0 && + (scenario.action === 'init' || + scenario.action === 'initDoubleList' || + scenario.action === 'updateEntity' || + scenario.action === 'updateUser' || + scenario.action === 'mountSortedView' || + scenario.action === 'listDetailSwitch' || + scenario.action === 'invalidateAndResolve' || + scenario.action === 'unshiftItem' || + scenario.action === 'deleteEntity' || + scenario.action === 'moveItem') + ) { + const { median: rcMedian, range: rcRange } = computeStats( + reactSamples, + 0, + ); + report.push({ + name: `${scenario.name} (react commit)`, + unit: 'ms', + value: Math.round(rcMedian * 100) / 100, + range: rcRange, + }); + } + const traceSamples = traceResults[scenario.name] + .slice(warmupRuns) + .filter(x => !Number.isNaN(x)); + if (traceSamples.length > 0) { + const { median: trMedian, range: trRange } = computeStats( + traceSamples, + 0, + ); + report.push({ + name: `${scenario.name} (trace)`, + unit: 'ms', + value: Math.round(trMedian * 100) / 100, + range: trRange, + }); + } + } + + if (includeStartup) { + for (const lib of libraries) { + const s = startupResults[lib]; + if (s && s.fcp.length > 0) { + const fcpStats = computeStats(s.fcp, 0); + report.push({ + name: `${lib}: startup-fcp`, + unit: 'ms', + value: Math.round(fcpStats.median * 100) / 100, + range: fcpStats.range, + }); + } + if (s && s.tbt.length > 0) { + const tbtStats = computeStats(s.tbt, 0); + report.push({ + name: `${lib}: startup-task-duration`, + unit: 'ms', + value: Math.round(tbtStats.median * 100) / 100, + range: tbtStats.range, + }); + } + } + } + + if (BENCH_LABEL) { + for (const entry of report) { + entry.name += BENCH_LABEL; + } + } + 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)); +} + +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..fc7d0bda1ca4 --- /dev/null +++ b/examples/benchmark-react/bench/scenarios.ts @@ -0,0 +1,198 @@ +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 = { + fetchIssueList: 80, + fetchIssue: 50, + fetchUser: 50, + createIssue: 50, + updateIssue: 50, + updateUser: 50, + deleteIssue: 50, + deleteUser: 50, +}; + +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 = { + mount: ['init', 'initDoubleList', 'mountSortedView', 'listDetailSwitch'], + update: ['updateEntity', 'updateUser'], + mutation: ['unshiftItem', 'deleteEntity', 'invalidateAndResolve', 'moveItem'], + memory: ['mountUnmountCycle'], +}; + +interface BaseScenario { + nameSuffix: string; + action: Scenario['action']; + args: unknown[]; + resultMetric?: Scenario['resultMetric']; + category: NonNullable; + size?: ScenarioSize; + mountCount?: number; + /** Use a different BenchAPI method to pre-mount items (e.g. 'mountSortedView' instead of 'mount'). */ + preMountAction?: keyof BenchAPI; + /** Only run for these libraries. Omit to run for all. */ + onlyLibs?: string[]; + /** Result is deterministic (zero variance); run exactly once with no warmup. */ + deterministic?: boolean; +} + +const BASE_SCENARIOS: BaseScenario[] = [ + { + nameSuffix: 'getlist-100', + action: 'init', + args: [100], + category: 'hotPath', + }, + { + nameSuffix: 'getlist-500', + action: 'init', + args: [500], + category: 'hotPath', + size: 'large', + }, + { + nameSuffix: 'update-single-entity', + action: 'updateEntity', + args: [1], + category: 'hotPath', + }, + { + nameSuffix: 'ref-stability-issue-changed', + action: 'updateEntity', + args: [1], + resultMetric: 'issueRefChanged', + category: 'hotPath', + deterministic: true, + }, + { + nameSuffix: 'ref-stability-user-changed', + action: 'updateUser', + args: ['user0'], + resultMetric: 'userRefChanged', + category: 'hotPath', + deterministic: true, + }, + { + nameSuffix: 'update-shared-user-500-mounted', + action: 'updateUser', + args: ['user0'], + category: 'hotPath', + mountCount: 500, + size: 'large', + }, + { + nameSuffix: 'memory-mount-unmount-cycle', + action: 'mountUnmountCycle', + args: [500, 10], + resultMetric: 'heapDelta', + category: 'memory', + size: 'large', + }, + { + nameSuffix: 'sorted-view-mount-500', + action: 'mountSortedView', + args: [500], + category: 'hotPath', + size: 'large', + }, + { + nameSuffix: 'sorted-view-update-entity', + action: 'updateEntity', + args: [1], + category: 'hotPath', + mountCount: 500, + preMountAction: 'mountSortedView', + size: 'large', + }, + { + nameSuffix: 'list-detail-switch', + action: 'listDetailSwitch', + args: [500], + category: 'hotPath', + size: 'large', + }, + { + nameSuffix: 'update-shared-user-10000-mounted', + action: 'updateUser', + args: ['user0'], + category: 'hotPath', + mountCount: 10000, + size: 'large', + }, + { + nameSuffix: 'invalidate-and-resolve', + action: 'invalidateAndResolve', + args: [1], + category: 'hotPath', + onlyLibs: ['data-client'], + }, + { + nameSuffix: 'unshift-item', + action: 'unshiftItem', + args: [], + category: 'hotPath', + mountCount: 100, + }, + { + nameSuffix: 'delete-item', + action: 'deleteEntity', + args: [1], + category: 'hotPath', + mountCount: 100, + }, + { + nameSuffix: 'move-item', + action: 'moveItem', + args: [1], + category: 'hotPath', + mountCount: 100, + preMountAction: 'initDoubleList', + }, +]; + +export const LIBRARIES = [ + 'data-client', + 'tanstack-query', + 'swr', + 'baseline', +] as const; + +export const SCENARIOS: Scenario[] = LIBRARIES.flatMap(lib => + BASE_SCENARIOS.filter( + base => !base.onlyLibs || base.onlyLibs.includes(lib), + ).map( + (base): Scenario => ({ + name: `${lib}: ${base.nameSuffix}`, + action: base.action, + args: base.args, + resultMetric: base.resultMetric, + category: base.category, + size: base.size, + mountCount: base.mountCount, + preMountAction: base.preMountAction, + deterministic: base.deterministic, + }), + ), +); diff --git a/examples/benchmark-react/bench/stats.ts b/examples/benchmark-react/bench/stats.ts new file mode 100644 index 000000000000..d9b339abb122 --- /dev/null +++ b/examples/benchmark-react/bench/stats.ts @@ -0,0 +1,50 @@ +/** + * 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 - 1), + ); + 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. + */ +export function computeStats( + samples: number[], + warmupCount: number, +): { median: number; p95: number; range: string } { + const trimmed = samples.slice(warmupCount); + if (trimmed.length <= 1) { + const v = trimmed[0] ?? 0; + return { median: v, p95: v, range: '± 0' }; + } + const sorted = [...trimmed].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] ?? 0; + const p95Idx = Math.floor(sorted.length * 0.95); + const p95 = sorted[Math.min(p95Idx, sorted.length - 1)] ?? median; + const mean = trimmed.reduce((sum, x) => sum + x, 0) / trimmed.length; + const stdDev = Math.sqrt( + trimmed.reduce((sum, x) => sum + (x - mean) ** 2, 0) / (trimmed.length - 1), + ); + const margin = 1.96 * (stdDev / Math.sqrt(trimmed.length)); + 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..db8596eb26e0 --- /dev/null +++ b/examples/benchmark-react/bench/tracing.ts @@ -0,0 +1,66 @@ +/** + * 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'), + ); + + 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 { + 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/bench/validate.ts b/examples/benchmark-react/bench/validate.ts new file mode 100644 index 000000000000..15a69b8cb504 --- /dev/null +++ b/examples/benchmark-react/bench/validate.ts @@ -0,0 +1,700 @@ +/// +/** + * 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_ISSUE_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 getIssueTitles(page: Page): Promise> { + return page.evaluate(() => { + const out: Record = {}; + for (const el of document.querySelectorAll('[data-bench-item]')) { + const num = Number((el as HTMLElement).dataset.issueNumber ?? '0'); + out[num] = el.querySelector('[data-title]')?.textContent?.trim() ?? ''; + } + return out; + }); +} + +async function getIssueCount(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 issues and wait until at least one appears in the DOM. */ +async function initAndWaitForIssues( + page: Page, + 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 getIssueCount(page)) > 0, + `issues 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 issues with correct titles', async (page, lib) => { + await initAndWaitForIssues(page); + + 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( + titles[1] != null && titles[1].length > 0, + lib, + 'init', + `issue #1 title missing or empty, got "${titles[1]}"`, + ); + + const renderedCount = await page.evaluate(() => + window.__BENCH__!.getRenderedCount(), + ); + assert( + renderedCount === TEST_ISSUE_COUNT, + lib, + 'init getRenderedCount', + `expected ${TEST_ISSUE_COUNT}, got ${renderedCount}`, + ); +}); + +// ── updateEntity ───────────────────────────────────────────────────────── + +test('updateEntity changes issue title in DOM', async (page, lib) => { + await initAndWaitForIssues(page); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateEntity(1)); + await waitForComplete(page); + + await waitFor( + page, + async () => (await getIssueTitles(page))[1]?.includes('(updated)') ?? false, + 'issue #1 title contains "(updated)"', + ); + + const titles = await getIssueTitles(page); + assert( + titles[1]?.includes('(updated)'), + lib, + 'updateEntity', + `issue #1 should contain "(updated)", got "${titles[1]}"`, + ); + assert( + !titles[2]?.includes('(updated)'), + lib, + 'updateEntity unchanged', + `issue #2 should be unchanged, got "${titles[2]}"`, + ); +}); + +// ── updateUser ─────────────────────────────────────────────────────────── + +test('updateUser propagates to DOM', async (page, _lib) => { + await initAndWaitForIssues(page); + + const titlesBefore = await getIssueTitles(page); + const countBefore = Object.keys(titlesBefore).length; + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateUser('user0')); + await waitForComplete(page); + + await waitFor( + page, + async () => (await getIssueCount(page)) >= countBefore, + 'issues still rendered after updateUser', + 5000, + ); +}); + +// ── ref-stability: updateEntity ────────────────────────────────────────── + +test('ref-stability after updateEntity', async (page, lib) => { + await initAndWaitForIssues(page); + + await page.evaluate(() => window.__BENCH__!.captureRefSnapshot()); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateEntity(1)); + await waitForComplete(page); + + await waitFor( + page, + 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.issueRefChanged + r.issueRefUnchanged; + assert( + total === TEST_ISSUE_COUNT, + lib, + 'ref-stability total', + `expected ${TEST_ISSUE_COUNT} issues in report, got ${total} (changed=${r.issueRefChanged} unchanged=${r.issueRefUnchanged})`, + ); + assert( + r.issueRefChanged >= 1, + lib, + 'ref-stability changed', + `expected ≥1 issueRefChanged, got ${r.issueRefChanged}. ` + + `setCurrentIssues may not have been called with updated data before measurement.`, + ); + process.stderr.write( + ` issueRefChanged=${r.issueRefChanged} userRefChanged=${r.userRefChanged}\n`, + ); +}); + +// ── ref-stability: updateUser ──────────────────────────────────────────── + +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__!.updateUser('user0')); + await waitForComplete(page); + + await waitFor( + page, + async () => { + const r = await page.evaluate(() => + window.__BENCH__!.getRefStabilityReport(), + ); + return r.userRefChanged > 0; + }, + 'userRefChanged > 0', + 5000, + ); + + const r = await page.evaluate(() => + window.__BENCH__!.getRefStabilityReport(), + ); + const total = r.userRefChanged + r.userRefUnchanged; + assert( + total === TEST_ISSUE_COUNT, + lib, + 'ref-stability-user total', + `expected ${TEST_ISSUE_COUNT} issues, got ${total}`, + ); + // 20 issues ÷ 20 users = 1 issue per user + const expectedMin = Math.floor(TEST_ISSUE_COUNT / 20); + assert( + r.userRefChanged >= expectedMin, + lib, + 'ref-stability-user count', + `expected ≥${expectedMin} userRefChanged, got ${r.userRefChanged}`, + ); + process.stderr.write( + ` issueRefChanged=${r.issueRefChanged} userRefChanged=${r.userRefChanged}\n`, + ); +}); + +// ── unshiftItem ────────────────────────────────────────────────────────── + +test('unshiftItem adds an issue', async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.unshiftItem === 'function', + )) + ) + return; + + await initAndWaitForIssues(page, 10); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.unshiftItem!()); + await waitForComplete(page); + + await waitFor( + page, + async () => { + const titles = await getIssueTitles(page); + return Object.values(titles).some(t => t === 'New Issue'); + }, + '"New Issue" appears in DOM', + 5000, + ); +}); + +// ── deleteEntity ───────────────────────────────────────────────────────── + +test('deleteEntity removes an issue', async (page, _lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.deleteEntity === 'function', + )) + ) + return; + + await initAndWaitForIssues(page, 10); + + const titlesBefore = await getIssueTitles(page); + assert(1 in titlesBefore, _lib, 'deleteEntity setup', 'issue #1 missing'); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.deleteEntity!(1)); + await waitForComplete(page); + + await waitFor( + page, + async () => !(1 in (await getIssueTitles(page))), + 'issue #1 removed from DOM', + 5000, + ); +}); + +// ── 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(IssueEntity) from the normalised + // store, so we must populate the store first via init. + await initAndWaitForIssues(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 initAndWaitForIssues(page, 10); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.invalidateAndResolve!(1)); + await waitForComplete(page, 15000); + }, + { onlyLibs: ['data-client'] }, +); + +// ── moveItem ───────────────────────────────────────────────────────── + +test('moveItem moves issue between state lists', async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.moveItem === 'function', + )) + ) + return; + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.initDoubleList!(20)); + await waitForComplete(page); + + await waitFor( + page, + async () => + page.evaluate( + () => + document.querySelector( + '[data-state-list="open"] [data-bench-item]', + ) !== null && + document.querySelector( + '[data-state-list="closed"] [data-bench-item]', + ) !== null, + ), + 'both state lists rendered', + 5000, + ); + + // Issue #1 has state 'open' in fixture data (number 1 => index 0, i%3!==0 => open) + const inOpen = await page.evaluate( + () => + document.querySelector( + '[data-state-list="open"] [data-issue-number="1"]', + ) !== null, + ); + assert(inOpen, lib, 'moveItem setup', 'issue #1 not in open list'); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.moveItem!(1)); + await waitForComplete(page); + + await waitFor( + page, + async () => + page.evaluate( + () => + document.querySelector( + '[data-state-list="closed"] [data-issue-number="1"]', + ) !== null, + ), + 'issue #1 in closed list after move', + 5000, + ); + + const inOpenAfter = await page.evaluate( + () => + document.querySelector( + '[data-state-list="open"] [data-issue-number="1"]', + ) !== null, + ); + assert( + !inOpenAfter, + lib, + 'moveItem removed from source', + 'issue #1 still in open list after move', + ); +}); + +// ── 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); + + 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', + ); + + 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', + ); + + 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 +// 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 initAndWaitForIssues(page); + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.updateEntity(1)); + await waitForComplete(page); + + const titles = await getIssueTitles(page); + assert( + titles[1]?.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('unshiftItem timing: DOM reflects change at measurement end', async (page, lib) => { + if ( + !(await page.evaluate( + () => typeof window.__BENCH__?.unshiftItem === 'function', + )) + ) + return; + + await initAndWaitForIssues(page, 10); + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.unshiftItem!()); + await waitForComplete(page); + + const titles = await getIssueTitles(page); + assert( + Object.values(titles).some(t => t === 'New Issue'), + lib, + 'unshiftItem timing', + `"New Issue" 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 initAndWaitForIssues(page, 10); + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(100)); + + await clearComplete(page); + await page.evaluate(() => window.__BENCH__!.deleteEntity!(1)); + await waitForComplete(page); + + const titles = await getIssueTitles(page); + assert( + !(1 in titles), + lib, + 'deleteEntity timing', + `issue #1 still in DOM when data-bench-complete fired. ` + + `Ensure measureUpdate callback returns its promise chain.`, + ); + + await page.evaluate(() => window.__BENCH__!.setNetworkDelay(0)); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 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 new file mode 100644 index 000000000000..64a4eab378aa --- /dev/null +++ b/examples/benchmark-react/package.json @@ -0,0 +1,51 @@ +{ + "name": "example-benchmark-react", + "version": "0.1.0", + "private": true, + "description": "React rendering benchmark comparing @data-client/react against other data libraries", + "scripts": { + "build": "BROWSERSLIST_ENV=2026 webpack --mode=production", + "build:compiler": "BROWSERSLIST_ENV=2026 REACT_COMPILER=true webpack --mode=production", + "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", + "validate": "npx tsx bench/validate.ts", + "validate:run": "yarn build && (yarn preview &) && sleep 5 && yarn validate" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@data-client/core": "workspace:*", + "@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-window": "^2.2.7", + "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.58.2", + "@types/node": "24.11.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "playwright": "1.58.2", + "serve": "14.2.6", + "tsx": "4.19.2", + "typescript": "6.0.1-rc", + "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/baseline/index.tsx b/examples/benchmark-react/src/baseline/index.tsx new file mode 100644 index 000000000000..1a33fac37974 --- /dev/null +++ b/examples/benchmark-react/src/baseline/index.tsx @@ -0,0 +1,267 @@ +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; +import { + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, + LIST_STYLE, + PlainIssueList, +} from '@shared/components'; +import { + FIXTURE_USERS, + FIXTURE_USERS_BY_LOGIN, + FIXTURE_ISSUES_BY_NUMBER, + sortByTitle, +} from '@shared/data'; +import { setCurrentIssues } from '@shared/refStability'; +import { UserResource, IssueResource } from '@shared/resources'; +import type { Issue } from '@shared/types'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { List } from 'react-window'; + +const IssuesContext = React.createContext<{ + issues: Issue[]; + setIssues: React.Dispatch>; +}>(null as any); + +function SortedListView() { + const { issues, setIssues } = useContext(IssuesContext); + useEffect(() => { + IssueResource.getList().then(setIssues); + }, [setIssues]); + const sorted = useMemo(() => sortByTitle(issues), [issues]); + if (!sorted.length) return null; + return ( +
+ +
+ ); +} + +function ListView() { + const { issues } = useContext(IssuesContext); + if (!issues.length) return null; + setCurrentIssues(issues); + return ( + + ); +} + +const DoubleListContext = React.createContext<{ + openIssues: Issue[]; + closedIssues: Issue[]; + setOpenIssues: React.Dispatch>; + setClosedIssues: React.Dispatch>; +}>(null as any); + +function DoubleListView() { + const { openIssues, closedIssues } = useContext(DoubleListContext); + return ( +
+ {openIssues.length > 0 && ( +
+ {openIssues.length} + +
+ )} + {closedIssues.length > 0 && ( +
+ {closedIssues.length} + +
+ )} +
+ ); +} + +function DetailView({ number }: { number: number }) { + const [issue, setIssue] = useState(null); + useEffect(() => { + IssueResource.get({ number }).then(setIssue); + }, [number]); + if (!issue) return null; + return ( +
+ +
+ ); +} + +function BenchmarkHarness() { + const [issues, setIssues] = useState([]); + const [openIssues, setOpenIssues] = useState([]); + const [closedIssues, setClosedIssues] = useState([]); + const { + listViewCount, + showSortedView, + showDoubleList, + doubleListCount, + detailIssueNumber, + containerRef, + measureUpdate, + unmountAll: unmountBase, + registerAPI, + } = useBenchState(); + + useEffect(() => { + if (listViewCount != null) { + IssueResource.getList({ count: listViewCount }).then(setIssues); + } + }, [listViewCount]); + + useEffect(() => { + if (showDoubleList && doubleListCount != null) { + IssueResource.getList({ state: 'open', count: doubleListCount }).then( + setOpenIssues, + ); + IssueResource.getList({ state: 'closed', count: doubleListCount }).then( + setClosedIssues, + ); + } + }, [showDoubleList, doubleListCount]); + + const unmountAll = useCallback(() => { + unmountBase(); + setIssues([]); + setOpenIssues([]); + setClosedIssues([]); + }, [unmountBase]); + + const refetchActiveList = useCallback(() => { + if (doubleListCount != null) { + return Promise.all([ + IssueResource.getList({ + state: 'open', + count: doubleListCount, + }).then(setOpenIssues), + IssueResource.getList({ + state: 'closed', + count: doubleListCount, + }).then(setClosedIssues), + ]); + } + return IssueResource.getList({ count: listViewCount! }).then(setIssues); + }, [listViewCount, doubleListCount]); + + const updateEntity = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + measureUpdate(() => + IssueResource.update( + { number }, + { title: `${issue.title} (updated)` }, + ).then(refetchActiveList), + ); + }, + [measureUpdate, refetchActiveList], + ); + + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; + measureUpdate(() => + UserResource.update({ login }, { name: `${user.name} (updated)` }).then( + refetchActiveList, + ), + ); + }, + [measureUpdate, refetchActiveList], + ); + + const unshiftItem = useCallback(() => { + const user = FIXTURE_USERS[0]; + measureUpdate(() => + IssueResource.create({ title: 'New Issue', user }).then( + refetchActiveList, + ), + ); + }, [measureUpdate, refetchActiveList]); + + const deleteEntity = useCallback( + (number: number) => { + measureUpdate(() => + IssueResource.delete({ number }).then(refetchActiveList), + ); + }, + [measureUpdate, refetchActiveList], + ); + + const moveItem = useCallback( + (number: number) => { + measureUpdate( + () => + IssueResource.update({ number }, { state: 'closed' }).then(() => + Promise.all([ + IssueResource.getList({ + state: 'open', + count: doubleListCount!, + }).then(setOpenIssues), + IssueResource.getList({ + state: 'closed', + count: doubleListCount!, + }).then(setClosedIssues), + ]), + ), + () => moveItemIsReady(containerRef, number), + ); + }, + [measureUpdate, doubleListCount, containerRef], + ); + + registerAPI({ + updateEntity, + updateUser, + unmountAll, + unshiftItem, + deleteEntity, + moveItem, + }); + + return ( + + +
+ {listViewCount != null && } + {showSortedView && } + {showDoubleList && doubleListCount != null && } + {detailIssueNumber != null && ( + + )} +
+
+
+ ); +} + +renderBenchApp(BenchmarkHarness); diff --git a/examples/benchmark-react/src/data-client/index.tsx b/examples/benchmark-react/src/data-client/index.tsx new file mode 100644 index 000000000000..a33c0f261983 --- /dev/null +++ b/examples/benchmark-react/src/data-client/index.tsx @@ -0,0 +1,264 @@ +import { + DataProvider, + GCPolicy, + useController, + useDLE, + useSuspense, +} from '@data-client/react'; +import type { Controller } from '@data-client/react'; +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; +import { + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, + LIST_STYLE, + PlainIssueList, +} from '@shared/components'; +import { + FIXTURE_USERS, + FIXTURE_ISSUES_BY_NUMBER, + FIXTURE_USERS_BY_LOGIN, +} from '@shared/data'; +import { setCurrentIssues } from '@shared/refStability'; +import { + UserResource, + IssueResource, + sortedIssuesEndpoint, +} from '@shared/resources'; +import { getIssue, patchIssue } from '@shared/server'; +import type { Issue } from '@shared/types'; +import React, { useCallback } from 'react'; +import { List } from 'react-window'; + +/** GCPolicy with no interval (won't fire during timing scenarios) and instant + * expiry so an explicit sweep() collects all unreferenced data immediately. */ +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 issues from the list endpoint (models rendering a list fetch response). */ +function ListView({ count }: { count: number }) { + const { data: issues } = useDLE(IssueResource.getList, { count }); + if (!issues) return null; + const list = issues as Issue[]; + setCurrentIssues(list); + return ( + + ); +} + +/** Renders issues sorted by title via Query schema (memoized by MemoCache). */ +function SortedListView({ count }: { count: number }) { + const { data: issues } = useDLE(sortedIssuesEndpoint, { count }); + if (!issues?.length) return null; + return ( +
+ +
+ ); +} + +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} + +
+ ); +} + +function DoubleListView({ count }: { count: number }) { + return ( +
+ + +
+ ); +} + +function DetailView({ number }: { number: number }) { + const issue = useSuspense(IssueResource.get, { number }); + return ( +
+ +
+ ); +} + +function BenchmarkHarness() { + const controller = useController(); + const { + listViewCount, + showSortedView, + sortedViewCount, + showDoubleList, + doubleListCount, + detailIssueNumber, + containerRef, + measureUpdate, + registerAPI, + } = useBenchState(); + + const updateEntity = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + measureUpdate(() => { + controller.fetch( + IssueResource.update, + { number }, + { title: `${issue.title} (updated)` }, + ); + }); + }, + [measureUpdate, controller], + ); + + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; + measureUpdate(() => { + controller.fetch( + UserResource.update, + { login }, + { name: `${user.name} (updated)` }, + ); + }); + }, + [measureUpdate, controller], + ); + + const unshiftItem = useCallback(() => { + const user = FIXTURE_USERS[0]; + measureUpdate(() => { + (controller.fetch as any)( + IssueResource.create, + { state: 'open' }, + { + title: 'New Issue', + user, + }, + ); + }); + }, [measureUpdate, controller]); + + const deleteEntity = useCallback( + (number: number) => { + measureUpdate(() => { + controller.fetch(IssueResource.delete, { number }); + }); + }, + [measureUpdate, controller], + ); + + const moveItem = useCallback( + (number: number) => { + measureUpdate( + () => { + controller.fetch(IssueResource.move, { number }, { state: 'closed' }); + }, + () => moveItemIsReady(containerRef, number), + ); + }, + [measureUpdate, controller, containerRef], + ); + + const invalidateAndResolve = useCallback( + async (number: number) => { + const issue = await getIssue(number); + if (issue) { + await patchIssue(number, { title: `${issue.title} (refetched)` }); + } + measureUpdate( + () => { + if (doubleListCount != null) { + controller.invalidate(IssueResource.getList, { + state: 'open', + count: doubleListCount, + }); + } else { + controller.invalidate(IssueResource.getList, { + count: listViewCount!, + }); + } + }, + () => { + const el = containerRef.current!.querySelector( + `[data-issue-number="${number}"] [data-title]`, + ); + return el?.textContent?.includes('(refetched)') ?? false; + }, + ); + }, + [measureUpdate, controller, containerRef, doubleListCount, listViewCount], + ); + + registerAPI({ + updateEntity, + updateUser, + invalidateAndResolve, + unshiftItem, + deleteEntity, + moveItem, + triggerGC: () => benchGC.sweep(), + }); + + return ( +
+ {listViewCount != null && } + {showSortedView && sortedViewCount != null && ( + + )} + {showDoubleList && doubleListCount != null && ( + + )} + {detailIssueNumber != null && ( + Loading...
}> + + + )} + + ); +} + +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 new file mode 100644 index 000000000000..d1ea05ecccbe --- /dev/null +++ b/examples/benchmark-react/src/shared/benchHarness.tsx @@ -0,0 +1,355 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { FIXTURE_ISSUES } from './data'; +import { captureSnapshot, getReport } from './refStability'; +import { + flushPendingMutations, + seedIssueList, + setMethodDelays, + setNetworkDelay, +} from './server'; +import type { BenchAPI } 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, + }); +} + +const OBSERVE_MUTATIONS: MutationObserverInit = { + childList: true, + subtree: true, + characterData: true, +}; + +/** Check whether an issue has moved from the "open" to the "closed" state list. */ +export function moveItemIsReady( + containerRef: React.RefObject, + number: number, +): boolean { + const source = containerRef.current?.querySelector( + '[data-state-list="open"]', + ); + const dest = containerRef.current?.querySelector( + '[data-state-list="closed"]', + ); + return ( + source?.querySelector(`[data-issue-number="${number}"]`) == null && + dest?.querySelector(`[data-issue-number="${number}"]`) != null + ); +} + +/** + * 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 (init, 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 [listViewCount, setListViewCount] = useState(); + const [showSortedView, setShowSortedView] = useState(false); + const [sortedViewCount, setSortedViewCount] = useState(); + 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); + + const setComplete = useCallback(() => { + completeResolveRef.current?.(); + completeResolveRef.current = null; + 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: () => unknown) => { + 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, OBSERVE_MUTATIONS); + const timer = setTimeout(() => { + observer.disconnect(); + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + container.setAttribute('data-bench-timeout', 'true'); + setComplete(); + }, 30000); + performance.mark('mount-start'); + fn(); + }, + [setComplete], + ); + + /** + * 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. + * + * For multi-phase scenarios like invalidateAndResolve (issues disappear + * then reappear), pass an `isReady` predicate to wait for the final state. + */ + const measureUpdate = useCallback( + (fn: () => unknown, 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(); + } + }); + observer.observe(container, OBSERVE_MUTATIONS); + 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'); + fn(); + }, + [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, OBSERVE_MUTATIONS); + const timer = setTimeout(() => { + observer.disconnect(); + resolve(); + }, 30000); + }); + }, []); + + const init = useCallback( + (n: number) => { + measureMount(() => { + setListViewCount(n); + }); + }, + [measureMount], + ); + + const unmountAll = useCallback(() => { + setListViewCount(undefined); + setShowSortedView(false); + setSortedViewCount(undefined); + setShowDoubleList(false); + setDoubleListCount(undefined); + setDetailIssueNumber(null); + }, []); + + const initDoubleList = useCallback( + (n: number) => { + measureMount(() => { + setDoubleListCount(n); + setShowDoubleList(true); + }); + }, + [measureMount], + ); + + const mountUnmountCycle = useCallback( + async (n: number, cycles: number) => { + for (let i = 0; i < cycles; i++) { + const p = new Promise(r => { + completeResolveRef.current = r; + }); + init(n); + await p; + apiRef.current?.unmountAll?.(); + await waitForPaint(); + } + setComplete(); + }, + [init, setComplete], + ); + + const mountSortedView = useCallback( + async (n: number) => { + await seedIssueList(FIXTURE_ISSUES.slice(0, n)); + measureMount(() => { + setSortedViewCount(n); + setShowSortedView(true); + }); + }, + [measureMount, setSortedViewCount, setShowSortedView], + ); + + const listDetailSwitch = useCallback( + async (n: number) => { + 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); + setDetailIssueNumber(1); + await waitForElement('[data-detail-view]'); + setDetailIssueNumber(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + + performance.mark('mount-start'); + for (let i = 2; i <= 11; i++) { + setShowSortedView(false); + setDetailIssueNumber(i); + await waitForElement('[data-detail-view]'); + + setDetailIssueNumber(null); + setShowSortedView(true); + await waitForElement('[data-sorted-list]'); + } + performance.mark('mount-end'); + performance.measure('mount-duration', 'mount-start', 'mount-end'); + setComplete(); + }, + [ + setSortedViewCount, + setShowSortedView, + setDetailIssueNumber, + waitForElement, + setComplete, + ], + ); + + const getRenderedCount = useCallback( + () => listViewCount ?? 0, + [listViewCount], + ); + 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 + * (init, unmountAll, etc.) are included automatically. + */ + const registerAPI = (libraryActions: LibraryActions) => { + apiRef.current = { + init, + initDoubleList, + unmountAll, + mountUnmountCycle, + mountSortedView, + listDetailSwitch, + getRenderedCount, + captureRefSnapshot, + getRefStabilityReport, + setNetworkDelay, + setMethodDelays, + flushPendingMutations, + ...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 { + listViewCount, + showSortedView, + sortedViewCount, + showDoubleList, + doubleListCount, + detailIssueNumber, + containerRef, + + measureMount, + measureUpdate, + waitForElement, + setComplete, + completeResolveRef, + + setListViewCount, + setShowSortedView, + setSortedViewCount, + setShowDoubleList, + setDoubleListCount, + setDetailIssueNumber, + + unmountAll, + 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/components.tsx b/examples/benchmark-react/src/shared/components.tsx new file mode 100644 index 000000000000..aa5853a61e7c --- /dev/null +++ b/examples/benchmark-react/src/shared/components.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import type { RowComponentProps } from 'react-window'; + +import type { Issue, User } from './types'; + +export const ISSUE_HEIGHT = 30; +export const VISIBLE_COUNT = 40; +export const LIST_STYLE = { height: ISSUE_HEIGHT * VISIBLE_COUNT } as const; +export const DOUBLE_LIST_STYLE = { display: 'flex', gap: 8 } as const; + +function djb2(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return hash >>> 0; +} + +/** + * Expensive memoized user component. Simulates a realistic rich user + * card: avatar color derivation, bio truncation, follower formatting, date + * parsing. Libraries that preserve user referential equality skip this + * entirely on unrelated updates; those that don't pay per row. + */ +function UserView({ user }: { user: User }) { + const hash = djb2(user.login + user.email + user.bio); + const hue = hash % 360; + const initials = user.name + .split(' ') + .map(w => w[0]) + .join('') + .toUpperCase(); + const bioWords = user.bio.split(/\s+/); + const truncatedBio = + bioWords.length > 12 ? bioWords.slice(0, 12).join(' ') + '…' : user.bio; + const followerStr = + user.followers >= 1000 ? + `${(user.followers / 1000).toFixed(1)}k` + : String(user.followers); + const joinYear = new Date(user.createdAt).getFullYear(); + + return ( + + {initials} + {user.name} + {truncatedBio} + {followerStr} + {joinYear} + + ); +} + +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 UserView on every render. + */ +export function IssueRow({ issue }: { issue: Issue }) { + const labelStr = issue.labels.map(l => l.name).join(', '); + return ( +
+ {issue.title} + + {STATE_ICONS[issue.state] ?? issue.state} + {issue.comments} + {labelStr} + {issue.body} +
+ ); +} + +/** Generic react-window row that renders an IssueRow from an issues array. */ +export function IssuesRow({ + index, + style, + issues, +}: RowComponentProps<{ issues: Issue[] }>) { + return ( +
+ +
+ ); +} + +/** Plain (non-virtualized) list keyed by issue number. Renders up to VISIBLE_COUNT issues. */ +export function PlainIssueList({ issues }: { issues: Issue[] }) { + const visible = + issues.length > VISIBLE_COUNT ? issues.slice(0, VISIBLE_COUNT) : issues; + return ( +
+ {visible.map(issue => ( + + ))} +
+ ); +} diff --git a/examples/benchmark-react/src/shared/data.ts b/examples/benchmark-react/src/shared/data.ts new file mode 100644 index 000000000000..67f1f858c59b --- /dev/null +++ b/examples/benchmark-react/src/shared/data.ts @@ -0,0 +1,252 @@ +import type { Issue, Label, User } from './types'; + +/** 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.title.localeCompare(b.title)); + return limit ? sorted.slice(0, limit) : sorted; +} + +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', +]; + +/** + * 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++) { + 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}`, + 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, + 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 users; +} + +/** + * Generate issues with nested user entities (shared references) and labels. + * Issues cycle through users so many issues share the same user. + */ +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 user = users[i % users.length]; + const created = new Date(2023, i % 12, 1 + (i % 28)).toISOString(); + 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(), + closedAt: + state === 'closed' ? new Date(2024, i % 12, 5).toISOString() : null, + }); + } + return issues; +} + +/** Unique users from fixture (for seeding and updateUser scenarios) */ +export const FIXTURE_USERS = generateUsers(20); + +/** Pre-generated fixture for benchmark - 10000 issues, 20 shared users */ +export const FIXTURE_ISSUES = generateIssues(10000, FIXTURE_USERS); + +/** O(1) issue lookup by number */ +export const FIXTURE_ISSUES_BY_NUMBER = new Map( + FIXTURE_ISSUES.map(i => [i.number, i]), +); + +/** O(1) user lookup by login */ +export const FIXTURE_USERS_BY_LOGIN = new Map( + FIXTURE_USERS.map(u => [u.login, u]), +); + +/** + * 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( + 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}`, + 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 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(); + 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, + closedAt: null, + }); + } + return { issues, users }; +} diff --git a/examples/benchmark-react/src/shared/refStability.ts b/examples/benchmark-react/src/shared/refStability.ts new file mode 100644 index 000000000000..9ac682cd772e --- /dev/null +++ b/examples/benchmark-react/src/shared/refStability.ts @@ -0,0 +1,64 @@ +import type { Issue, RefStabilityReport, User } from './types'; + +let currentIssues: Issue[] = []; +let snapshotRefs: Map | null = null; + +/** + * Store the current issues array. Called from ListView during render. + * Only stores the reference — negligible cost. + */ +export function setCurrentIssues(issues: Issue[]): void { + currentIssues = issues; +} + +/** + * Build a snapshot from current issues. Call after mount, before running an update. + */ +export function captureSnapshot(): void { + snapshotRefs = new Map(); + for (const issue of currentIssues) { + snapshotRefs.set(issue.number, { issue, user: issue.user }); + } +} + +/** + * Compare current issues to snapshot and return counts. Call after update completes. + */ +export function getReport(): RefStabilityReport { + if (!snapshotRefs) { + return { + issueRefUnchanged: 0, + issueRefChanged: 0, + userRefUnchanged: 0, + userRefChanged: 0, + }; + } + + let issueRefUnchanged = 0; + let issueRefChanged = 0; + let userRefUnchanged = 0; + let userRefChanged = 0; + + for (const issue of currentIssues) { + const snap = snapshotRefs.get(issue.number); + if (!snap) continue; + + if (issue === snap.issue) { + issueRefUnchanged++; + } else { + issueRefChanged++; + } + if (issue.user === snap.user) { + userRefUnchanged++; + } else { + userRefChanged++; + } + } + + return { + issueRefUnchanged, + issueRefChanged, + userRefUnchanged, + userRefChanged, + }; +} diff --git a/examples/benchmark-react/src/shared/resources.ts b/examples/benchmark-react/src/shared/resources.ts new file mode 100644 index 000000000000..ceac6ed6f3ba --- /dev/null +++ b/examples/benchmark-react/src/shared/resources.ts @@ -0,0 +1,166 @@ +import { Entity, Query, Collection, unshift } from '@data-client/endpoint'; +import type { PolymorphicInterface } from '@data-client/endpoint'; +import { resource } from '@data-client/rest'; +import { sortByTitle } from '@shared/data'; +import { + 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 { User } from '@shared/types'; + +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.login; + } + + static key = 'UserEntity'; +} + +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 = ''; + closedAt: string | null = null; + + pk() { + return `${this.number}`; + } + + static key = 'IssueEntity'; + static schema = { + user: UserEntity, + labels: [LabelEntity], + }; +} + +class IssueCollection< + 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(unshift); + } + + nonFilterArgumentKeys(key: string) { + return key === 'count'; + } +} + +export const IssueResource = resource({ + path: '/issues/:number', + schema: IssueEntity, + optimistic: true, + Collection: IssueCollection, +}).extend(Base => ({ + get: Base.get.extend({ + fetch: serverFetchIssue as any, + dataExpiryLength: Infinity, + }), + getList: Base.getList.extend({ + fetch: serverFetchIssueList as any, + dataExpiryLength: Infinity, + }), + update: Base.update.extend({ + fetch: ((params: any, body: any) => + serverUpdateIssue({ ...params, ...body })) as any, + }), + delete: Base.delete.extend({ + fetch: serverDeleteIssue as any, + }), + create: Base.getList.unshift.extend({ + fetch: ((...args: any[]) => + serverCreateIssue(args.length > 1 ? args[1] : args[0])) as any, + body: {} as { + title: string; + user: User; + }, + }), + move: Base.getList.move.extend({ + fetch: ((params: any, body: any) => + serverUpdateIssue({ ...params, ...body })) as any, + }), +})); + +export const UserResource = resource({ + path: '/users/:login', + schema: UserEntity, + optimistic: true, +}).extend(Base => ({ + get: Base.get.extend({ + fetch: serverFetchUser as any, + dataExpiryLength: Infinity, + }), + update: Base.update.extend({ + fetch: ((params: any, body: any) => + serverUpdateUser({ ...params, ...body })) as any, + }), + delete: Base.delete.extend({ + fetch: serverDeleteUser as any, + }), +})); + +// ── DERIVED QUERIES ───────────────────────────────────────────────────── + +/** Derived sorted view via Query schema -- globally memoized by MemoCache */ +export const sortedIssuesQuery = new Query( + IssueResource.getList.schema, + (entries, { count }: { count?: number } = {}) => sortByTitle(entries, count), +); + +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 new file mode 100644 index 000000000000..83133033100c --- /dev/null +++ b/examples/benchmark-react/src/shared/server.ts @@ -0,0 +1,145 @@ +import type { Issue, Label, User } from './types'; + +// ── 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 } +>(); + +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 }); + }); +} + +// ── PENDING MUTATION TRACKING ──────────────────────────────────────────── + +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; +} + +export function flushPendingMutations(): Promise { + if (pendingMutations.size === 0) return Promise.resolve(); + return Promise.allSettled([...pendingMutations]).then(() => {}); +} + +// ── READ ───────────────────────────────────────────────────────────────── + +export function fetchIssue(params: { number: number }): Promise { + return sendRequest('fetchIssue', params); +} + +export function fetchUser(params: { login: string }): Promise { + return sendRequest('fetchUser', params); +} + +export function fetchIssueList(params?: { + count?: number; + state?: string; +}): Promise { + return sendRequest('fetchIssueList', params); +} + +// ── CREATE ─────────────────────────────────────────────────────────────── + +export function createIssue(body: { + title: string; + user: User; + labels?: Label[]; +}): Promise { + return sendMutation('createIssue', body); +} + +// ── UPDATE ─────────────────────────────────────────────────────────────── + +export function updateIssue(params: { + number: number; + title?: string; + state?: Issue['state']; + user?: User; +}): Promise { + return sendMutation('updateIssue', params); +} + +export function updateUser(params: { + login: string; + name?: string; +}): Promise { + return sendMutation('updateUser', params); +} + +// ── DELETE ──────────────────────────────────────────────────────────────── + +export function deleteIssue(params: { + number: number; +}): Promise<{ id: number; number: number }> { + return sendMutation('deleteIssue', params); +} + +export function deleteUser(params: { + login: string; +}): Promise<{ login: string }> { + return sendMutation('deleteUser', params); +} + +// ── DIRECT STORE ACCESS (pre-measurement setup) ───────────────────────── + +export function getIssue(number: number): Promise { + return sendRequest('getIssue', { number }); +} + +export function patchIssue( + number: number, + patch: Partial, +): Promise { + return sendRequest('patchIssue', { number, patch }); +} + +export function seedIssueList(issues: Issue[]): Promise { + return sendRequest('seedIssueList', { issues }); +} + +// ── CONTROL ────────────────────────────────────────────────────────────── + +export function setNetworkDelay(ms: number): void { + worker.postMessage({ + id: nextId++, + method: 'setNetworkDelay', + 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 new file mode 100644 index 000000000000..d5aeb09a47b1 --- /dev/null +++ b/examples/benchmark-react/src/shared/server.worker.ts @@ -0,0 +1,279 @@ +/// + +import { FIXTURE_USERS, FIXTURE_ISSUES } from './data'; +import type { Issue, Label, User } from './types'; + +declare const self: DedicatedWorkerGlobalScope; + +// ── NETWORK DELAY ──────────────────────────────────────────────────────── + +let networkDelayMs = 0; +let methodDelays: Record = {}; + +function respond(id: number, method: string, value: unknown) { + const json = JSON.stringify(value); + const delay = methodDelays[method] ?? networkDelayMs; + if (delay <= 0) { + self.postMessage({ id, result: json }); + } else { + setTimeout(() => self.postMessage({ id, result: json }), delay); + } +} + +function respondError(id: number, message: string) { + self.postMessage({ id, error: message }); +} + +// ── IN-MEMORY STORES ───────────────────────────────────────────────────── + +const issueStore = new Map(); +const userStore = new Map(); +let masterList: Issue[] = []; +const stateIndex = new Map(); +const numberToPosition = new Map(); + +function rebuildStateIndex() { + stateIndex.clear(); + for (const issue of masterList) { + let list = stateIndex.get(issue.state); + if (!list) { + list = []; + stateIndex.set(issue.state, list); + } + list.push(issue); + } +} + +function rebuildPositionIndex() { + numberToPosition.clear(); + for (let i = 0; i < masterList.length; i++) { + numberToPosition.set(masterList[i].number, i); + } +} + +// Pre-seed with fixture data +for (const user of FIXTURE_USERS) { + userStore.set(user.login, { ...user }); +} +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); +} +rebuildStateIndex(); +rebuildPositionIndex(); + +// ── READ ───────────────────────────────────────────────────────────────── + +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 issue; +} + +function fetchUser({ login }: { login: string }): User { + const user = userStore.get(login); + if (!user) throw new Error(`No data for user:${login}`); + return user; +} + +function fetchIssueList(params?: { count?: number; state?: string }): Issue[] { + let issues: Issue[] = + params?.state ? (stateIndex.get(params.state) ?? []) : masterList; + if (params?.count) { + issues = issues.slice(0, params.count); + } + 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 issue; + }); + return issues; +} + +// ── CREATE ─────────────────────────────────────────────────────────────── + +let createIssueCounter = 0; + +function createIssue(body: { + title: string; + user: User; + labels?: Label[]; +}): Issue { + const num = 90000 + createIssueCounter++; + const now = new Date().toISOString(); + 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, + closedAt: null, + }; + issueStore.set(num, issue); + masterList.unshift(issue); + rebuildPositionIndex(); + const stateList = stateIndex.get(issue.state); + if (stateList) { + stateList.unshift(issue); + } else { + stateIndex.set(issue.state, [issue]); + } + return issue; +} + +// ── UPDATE ─────────────────────────────────────────────────────────────── + +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.state !== updated.state) { + rebuildStateIndex(); + } else { + const sList = stateIndex.get(updated.state); + if (sList) { + const si = sList.indexOf(existing); + if (si >= 0) sList[si] = updated; + } + } + return 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 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(); + } + rebuildStateIndex(); + return { id: existing?.id ?? 0, number }; +} + +function deleteUser({ login }: { login: string }): { login: string } { + userStore.delete(login); + return { login }; +} + +// ── DIRECT STORE ACCESS ────────────────────────────────────────────────── + +function getIssue(number: number): Issue | undefined { + return issueStore.get(number); +} + +function patchIssue(number: number, patch: Partial): void { + const existing = issueStore.get(number); + if (!existing) return; + const updated: Issue = { ...existing, ...patch }; + issueStore.set(number, updated); + const idx = numberToPosition.get(number) ?? -1; + if (idx >= 0) masterList[idx] = updated; + if (existing.state !== updated.state) { + rebuildStateIndex(); + } else { + const sList = stateIndex.get(updated.state); + if (sList) { + const si = sList.indexOf(existing); + if (si >= 0) sList[si] = updated; + } + } +} + +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); + } + rebuildStateIndex(); + rebuildPositionIndex(); +} + +// ── MESSAGE HANDLER ────────────────────────────────────────────────────── + +const methods: Record unknown> = { + 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 = {}; + }, + setMethodDelays: ({ delays }: { delays: Record }) => { + methodDelays = delays; + }, +}; + +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, 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 new file mode 100644 index 000000000000..0eb02b094eea --- /dev/null +++ b/examples/benchmark-react/src/shared/types.ts @@ -0,0 +1,145 @@ +/** + * 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 { + 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 issues. Measures fetch + normalization + render pipeline. */ + init(count: number): 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. { fetchIssueList: 80, fetchIssue: 50 }). */ + setMethodDelays(delays: Record): void; + /** Wait for all deferred server mutations to settle before next iteration. */ + flushPendingMutations(): Promise; + unmountAll(): void; + getRenderedCount(): number; + captureRefSnapshot(): void; + getRefStabilityReport(): RefStabilityReport; + /** Legacy ids-based mount; optional — prefer init. */ + mount?(count: number): void; + /** 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 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: number): void; + /** Prepend a new issue via mutation endpoint. */ + unshiftItem?(): void; + /** 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; +} + +declare global { + interface Window { + __BENCH__?: BenchAPI; + } +} + +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 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; + closedAt: string | null; +} + +export type ScenarioAction = + | { action: 'init'; args: [number] } + | { action: 'updateEntity'; args: [number] } + | { action: 'updateUser'; args: [string] } + | { action: 'unmountAll'; args: [] } + | { action: 'unshiftItem'; args: [] } + | { action: 'deleteEntity'; args: [number] } + | { action: 'moveItem'; args: [number] }; + +export type ResultMetric = + | 'duration' + | 'issueRefChanged' + | 'userRefChanged' + | 'heapDelta'; + +/** 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'; + +export interface Scenario { + name: string; + action: keyof BenchAPI; + args: unknown[]; + /** 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 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; + /** 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 new file mode 100644 index 000000000000..f17d81abce24 --- /dev/null +++ b/examples/benchmark-react/src/swr/index.tsx @@ -0,0 +1,223 @@ +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; +import { + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, + LIST_STYLE, + PlainIssueList, +} from '@shared/components'; +import { + FIXTURE_USERS, + FIXTURE_USERS_BY_LOGIN, + FIXTURE_ISSUES_BY_NUMBER, + sortByTitle, +} from '@shared/data'; +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('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('issues:')) + return IssueResource.getList({ count: Number(key.slice(7)) }); + return Promise.reject(new Error(`Unknown key: ${key}`)); +}; + +function SortedListView() { + const { data: issues } = useSWR('issues:all', fetcher); + const sorted = useMemo(() => (issues ? sortByTitle(issues) : []), [issues]); + if (!sorted.length) return null; + return ( +
+ +
+ ); +} + +function DetailView({ number }: { number: number }) { + const { data: issue } = useSWR(`issue:${number}`, fetcher); + if (!issue) return null; + return ( +
+ +
+ ); +} + +function ListView({ count }: { count: number }) { + const { data: issues } = useSWR(`issues:${count}`, fetcher); + if (!issues) return null; + setCurrentIssues(issues); + return ( + + ); +} + +function StateListView({ state, count }: { state: string; count: number }) { + const { data: issues } = useSWR( + `issues:state:${state}:${count}`, + fetcher, + ); + if (!issues) return null; + return ( +
+ {issues.length} + +
+ ); +} + +function DoubleListView({ count }: { count: number }) { + return ( +
+ + +
+ ); +} + +function BenchmarkHarness() { + const { mutate } = useSWRConfig(); + const { + listViewCount, + showSortedView, + showDoubleList, + doubleListCount, + detailIssueNumber, + containerRef, + measureUpdate, + registerAPI, + } = useBenchState(); + + const updateEntity = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + measureUpdate(() => + IssueResource.update( + { number }, + { title: `${issue.title} (updated)` }, + ).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), + ), + ); + }, + [measureUpdate, mutate], + ); + + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; + measureUpdate( + () => + UserResource.update( + { login }, + { name: `${user.name} (updated)` }, + ).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), + ) as Promise, + ); + }, + [measureUpdate, mutate], + ); + + const unshiftItem = useCallback(() => { + const user = FIXTURE_USERS[0]; + measureUpdate(() => + IssueResource.create({ title: 'New Issue', user }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), + ), + ); + }, [measureUpdate, mutate]); + + const deleteEntity = useCallback( + (number: number) => { + measureUpdate(() => + IssueResource.delete({ number }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), + ), + ); + }, + [measureUpdate, mutate], + ); + + const moveItem = useCallback( + (number: number) => { + measureUpdate( + () => + IssueResource.update({ number }, { state: 'closed' }).then(() => + mutate(key => typeof key === 'string' && key.startsWith('issues:')), + ), + () => moveItemIsReady(containerRef, number), + ); + }, + [measureUpdate, mutate, containerRef], + ); + + registerAPI({ + updateEntity, + updateUser, + unshiftItem, + deleteEntity, + moveItem, + }); + + return ( +
+ {listViewCount != null && } + {showSortedView && } + {showDoubleList && doubleListCount != null && ( + + )} + {detailIssueNumber != null && } +
+ ); +} + +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 new file mode 100644 index 000000000000..900de1e398f3 --- /dev/null +++ b/examples/benchmark-react/src/tanstack-query/index.tsx @@ -0,0 +1,241 @@ +import { + moveItemIsReady, + renderBenchApp, + useBenchState, +} from '@shared/benchHarness'; +import { + DOUBLE_LIST_STYLE, + ISSUE_HEIGHT, + IssueRow, + IssuesRow, + LIST_STYLE, + PlainIssueList, +} from '@shared/components'; +import { + FIXTURE_USERS, + FIXTURE_USERS_BY_LOGIN, + FIXTURE_ISSUES_BY_NUMBER, + sortByTitle, +} from '@shared/data'; +import { setCurrentIssues } from '@shared/refStability'; +import { UserResource, IssueResource } from '@shared/resources'; +import type { Issue } from '@shared/types'; +import { + QueryClient, + QueryClientProvider, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import React, { useCallback, useMemo } from 'react'; +import { List } from 'react-window'; + +function queryFn({ queryKey }: { queryKey: readonly unknown[] }): Promise { + const [type, id] = queryKey as [string, string | number | undefined]; + 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}`)); +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: Infinity, + }, + }, +}); + +function SortedListView() { + const { data: issues } = useQuery({ + queryKey: ['issues', 'all'], + queryFn, + }); + const sorted = useMemo( + () => (issues ? sortByTitle(issues as Issue[]) : []), + [issues], + ); + if (!sorted.length) return null; + return ( +
+ +
+ ); +} + +function DetailView({ number }: { number: number }) { + const { data: issue } = useQuery({ + queryKey: ['issue', number], + queryFn, + }); + if (!issue) return null; + return ( +
+ +
+ ); +} + +function ListView({ count }: { count: number }) { + const { data: issues } = useQuery({ + queryKey: ['issues', count], + queryFn, + }); + if (!issues) return null; + const list = issues as Issue[]; + setCurrentIssues(list); + return ( + + ); +} + +function StateListView({ state, count }: { state: string; count: number }) { + const { data: issues } = useQuery({ + queryKey: ['issues', { state, count }], + queryFn, + }); + if (!issues) return null; + const list = issues as Issue[]; + return ( +
+ {list.length} + +
+ ); +} + +function DoubleListView({ count }: { count: number }) { + return ( +
+ + +
+ ); +} + +function BenchmarkHarness() { + const client = useQueryClient(); + const { + listViewCount, + showSortedView, + showDoubleList, + doubleListCount, + detailIssueNumber, + containerRef, + measureUpdate, + registerAPI, + } = useBenchState(); + + const updateEntity = useCallback( + (number: number) => { + const issue = FIXTURE_ISSUES_BY_NUMBER.get(number); + if (!issue) return; + measureUpdate(() => + IssueResource.update( + { number }, + { title: `${issue.title} (updated)` }, + ).then(() => + client.invalidateQueries({ + queryKey: ['issues'], + }), + ), + ); + }, + [measureUpdate, client], + ); + + const updateUser = useCallback( + (login: string) => { + const user = FIXTURE_USERS_BY_LOGIN.get(login); + if (!user) return; + measureUpdate(() => + UserResource.update({ login }, { name: `${user.name} (updated)` }).then( + () => + client.invalidateQueries({ + queryKey: ['issues'], + }), + ), + ); + }, + [measureUpdate, client], + ); + + const unshiftItem = useCallback(() => { + const user = FIXTURE_USERS[0]; + measureUpdate(() => + IssueResource.create({ title: 'New Issue', user }).then(() => + client.invalidateQueries({ queryKey: ['issues'] }), + ), + ); + }, [measureUpdate, client]); + + const deleteEntity = useCallback( + (number: number) => { + measureUpdate(() => + IssueResource.delete({ number }).then(() => + client.invalidateQueries({ + queryKey: ['issues'], + }), + ), + ); + }, + [measureUpdate, client], + ); + + const moveItem = useCallback( + (number: number) => { + measureUpdate( + () => + IssueResource.update({ number }, { state: 'closed' }).then(() => + client.invalidateQueries({ queryKey: ['issues'] }), + ), + () => moveItemIsReady(containerRef, number), + ); + }, + [measureUpdate, client, containerRef], + ); + + registerAPI({ + updateEntity, + updateUser, + unshiftItem, + deleteEntity, + moveItem, + }); + + return ( +
+ {listViewCount != null && } + {showSortedView && } + {showDoubleList && doubleListCount != null && ( + + )} + {detailIssueNumber != null && } +
+ ); +} + +function BenchProvider({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +renderBenchApp(BenchmarkHarness, BenchProvider); diff --git a/examples/benchmark-react/tsconfig.json b/examples/benchmark-react/tsconfig.json new file mode 100644 index 000000000000..c7f0e2350887 --- /dev/null +++ b/examples/benchmark-react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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, + "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..91e5f676a9d5 --- /dev/null +++ b/examples/benchmark-react/webpack.config.cjs @@ -0,0 +1,60 @@ +const { makeConfig } = require('@anansi/webpack-config'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const path = require('path'); + +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); + +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'), + 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'; + + 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; +}; + +module.exports.options = options; diff --git a/package.json b/package.json index 9f113c70ca4a..eadfd1e11601 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 --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", 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/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..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) => { @@ -88,6 +92,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 @@ -147,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, 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']; diff --git a/yarn.lock b/yarn.lock index 3407b5aa37f7..8ce5b5774989 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.58.2": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" + dependencies: + playwright: "npm:1.58.2" + bin: + playwright: cli.js + checksum: 10c0/2164c03ad97c3653ff02e8818a71f3b2bbc344ac07924c9d8e31cd57505d6d37596015a41f51396b3ed8de6840f59143eaa9c21bf65515963da20740119811da + 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" @@ -7108,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" @@ -7941,6 +8308,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" @@ -9821,6 +10197,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" @@ -13514,7 +13902,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 +14153,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 +14805,36 @@ __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.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.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" + typescript: "npm:6.0.1-rc" + 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 +15652,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 +15672,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 +15846,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 +21006,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" @@ -22614,6 +23155,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" + bin: + playwright-core: cli.js + checksum: 10c0/5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b + languageName: node + linkType: hard + +"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.58.2" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/d060d9b7cc124bd8b5dffebaab5e84f6b34654a553758fe7b19cc598dfbee93f6ecfbdc1832b40a6380ae04eade86ef3285ba03aa0b136799e83402246dc0727 + 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 +24364,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" @@ -24667,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" @@ -27414,7 +28000,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: @@ -27431,7 +28017,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: @@ -27540,6 +28126,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 +28589,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" @@ -29326,6 +29950,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"