SIGN IN SIGN UP

feat(benchmarks): client-side CPU benchmark scenario suite (#7732)

* feat(benchmarks): add client-side CPU benchmark scenario suite

Add 11 client-side CPU benchmark scenarios to benchmarks/client-nav, each
implemented identically across react/solid/vue and tracked by CodSpeed
through the existing Benchmarks workflow (no CI changes needed):

async-pipeline, control-flow, head, history, links, loaders, mount,
nested-params, preload, route-tree-scale, search-params

Each scenario is an isolated file-based-routing app (route trees generated
by @tanstack/router-plugin) built for production and driven in jsdom by a
shared harness (scenarios/harness.ts): circular step sequences of real
<Link> clicks (plus hover/preload/history-traversal/invalidate steps),
each synchronized on the router's onRendered event, with a warm-up lap
that asserts every step's observable output. Determinism rules: no
wall-clock timers (counted 0ms hops only), staleTime/gcTime pinned to 0 or
1e9, deterministic preload staleness, stationary DOM and history depth.

The existing baseline apps and bench names are untouched for CodSpeed
continuity; baseline vite configs only gain an explicit root/setupFiles so
they resolve identically under the new per-framework aggregate configs.

Notable constraints encoded in the scenarios:
- Component-level Await/Suspense is excluded from async-pipeline: React 19
  throttles every Suspense reveal by ~300ms wall-clock, which is
  inherently non-deterministic to benchmark.
- control-flow/react silences React 19's onCaughtError reporting, which
  otherwise dominates the measured loop with console I/O.
- Vue route components must be defined before their createFileRoute call
  (bundled var hoisting silently yields component: undefined otherwise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: apply automated fixes

* chore(benchmarks): prefix client-nav bench names with "client-"

All benches in @benchmarks/client-nav now start with "client" so they are
easy to identify on the CodSpeed dashboard (the baseline already did).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(benchmarks): address review findings on client-nav scenario stability

Fixes from an adversarial review of the scenario suite:

- mount: create the browser history explicitly and destroy() it on unmount.
  The default per-router createBrowserHistory() monkey-patches
  window.history.pushState/replaceState, chaining one wrapper per router —
  per-mount cost degraded 1.8ms -> 6ms over 3000 mounts (O(N^2) drift in
  the cold-start bench). Verified flat (~1.86ms) after the fix.
- links: replace the five <MatchRoute> components with setup-scoped
  useMatchRoute probes in all three apps. vue-router's MatchRoute calls
  useMatchRoute() inside its render function, leaking one undisposed
  watcher per render — the Vue bench accumulated ~5 dead subscribers per
  navigation. Verified flat per-tick cost over 2000 ticks after the fix.
- all scenarios: bound the scroll-restoration cache with
  getScrollRestorationKey: (location) => location.pathname. The default
  key is a fresh random per-entry location key, so the module-level cache
  grew one entry per push navigation for the whole run.
- harness hover step: dispatch a single mouseover instead of
  mouseover+mouseenter. Solid/Vue attach intent-preload handlers to BOTH
  events, so the old double dispatch ran the preload pipeline twice for
  them but once for React. Verified all three adapters preload exactly
  once per hover via cached-matches probes.
- README: correct the loaders row (the invalidate step means cached routes
  re-run once per lap), the links row, and document the session-history
  growth characteristics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(benchmarks): cover client rewrites, lazy route chunks, encoded params

Closes the three coverage gaps identified in the scenario-suite analysis:

- new `rewrites` scenario (react/solid/vue): router basepath '/app' composed
  with a locale input/output rewrite pair — the client analog of the SSR
  rewrites scenario. Every href build runs the output rewrite and every
  committed location runs the input rewrite; assertions check both the
  internal router pathname and the external window pathname. The harness
  gained an `initialUrl` option since a basepath app cannot start at '/'.
- route-tree-scale now builds with `autoCodeSplitting: true`, so its
  navigations also exercise lazy route-chunk resolution — the default shape
  of real file-based apps, previously uncovered.
- nested-params and route-tree-scale param sets now include characters that
  need percent-encoding (spaces, `&`, `%`, `+`, unicode) so the segment
  encode/decode paths run on every navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(benchmarks): reduce syscall time in measured loops, lengthen mount bench

CodSpeed excludes syscall time from simulation measures, and past a
threshold it warns ("cannot be consistently instrumented") and skips the
benchmark; the exclusion is also inconsistent run-to-run, which was the
root cause of the two same-code CI swings (route-tree-scale react 13%,
async-pipeline react 3.6%) and of skipped runs on hop-heavy benches like
async-pipeline solid (579 calls / 12.1ms).

- Replace all counted setTimeout(0) hops with setImmediate hops (harness
  timerHop + async-pipeline hopDelay). Both are deterministic event-loop
  turns that yield to timers and the React Node scheduler, but a timer
  costs ~3-4 syscalls (timerfd + epoll) per hop vs ~1 for an immediate.
  Same-machine instrumented before/after: preload solid 39.8ms -> 1.2ms of
  syscall time, preload vue 30.1 -> 1.1, async-pipeline solid 23.5 -> 1.1,
  async-pipeline vue 17.9 -> 0.9. head/route-tree-scale are unchanged
  (their residual syscalls are GC/scheduler, and their measures were
  already stable); history's syscalls come from jsdom's internal
  timer-queued traversal and are not reachable from bench code (its
  measured values are stable regardless).

- mount: 6 ticks per iteration instead of 2. A single mount simulates to
  only ~8ms and very short measures amplify allocator/GC quantization;
  the bench now measures ~50ms+ of simulated CPU.

All 39 benches pass; full nx typecheck green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(benchmarks): stop history benches sleeping in jsdom's traversal timers

The history benches were skip-warned by CodSpeed for excessive syscall
time (5-26ms, highly variable across runs). Root cause: jsdom delivers
history traversals through two nested window.setTimeout(0) tasks, and Node
clamps zero timers to 1ms of wall time — so while awaiting onRendered
after back/forward/go, the event loop blocked in epoll_wait until each
timer expired. That blocked wall-time is recorded as syscall time, and its
duration depends on host timer behavior, hence the variability.

The harness now reroutes zero-delay window.setTimeout calls onto
setImmediate for the duration of a benchmark (non-zero delays pass
through; clearTimeout handles both). Zero-delay timeouts carry no ordering
semantics a check-phase immediate doesn't satisfy, and the suite's
conventions already forbid real-delay timers in measured code.

Instrumented A/A verification (2 runs per framework): history syscall time
react 23-26ms -> 2.5ms, solid 14-23ms -> 1.1ms, vue 11-12ms -> 1.2ms, with
bit-identical syscall counts across runs and measured values stable to
0.06%. The benches also stopped measuring sleep: locally an iteration
dropped from ~42-49ms to ~5-11ms of actual CPU work. All 39 benches pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
F
Flo committed
208100b7c0a8491fc93c26358ffe30badec4b138
Parent: a6337fb
Committed by GitHub <noreply@github.com> on 7/3/2026, 6:18:08 AM