Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
854ac95
benchmark: memory (codspeed) (#7623)
Sheraff Jun 14, 2026
a415471
perf(router-core): cache lightweight route matches (#7601)
Sheraff Jun 14, 2026
734f907
ci: pnpm trustPolicy no-downgrade exclude chokidar@4.0.3 (#7643)
Sheraff Jun 16, 2026
e499164
feat(start-plugin-core): support Rsbuild preview SSR middleware (#7372)
elecmonkey Jun 16, 2026
1362bf2
ci: pnpm trustPolicy no-downgrade exclude semver@6.3.1 (#7645)
Sheraff Jun 16, 2026
f23ed0f
ci: Version Packages (#7576)
github-actions[bot] Jun 16, 2026
ac821f4
docs(start): clarify server functions vs server routes (#7651)
Sheraff Jun 18, 2026
279a849
update better-auth in solid convex example so @noble/ciphers passes `…
Sheraff Jun 19, 2026
ba52d2b
perf(start-client-core): zero-copy frame payload extraction (#7662)
anonrig Jun 22, 2026
7538668
ci: bundle-size workdlow outputs its report to stdout (#7677)
Sheraff Jun 22, 2026
d71af0b
add undici 6 to trustPolicyExclude for vite ecosystem ci (#7680)
Sheraff Jun 23, 2026
80d098f
fix pnpm trustPolicy (for good) (#7681)
Sheraff Jun 23, 2026
3d86aee
pnpm: fix minReleaseAge to 1 day (#7686)
Sheraff Jun 24, 2026
bb2daa6
Reduce postinstall wrangler noise (#7696)
Sheraff Jun 25, 2026
9809a06
fix(router-core): preserve percent-encoded URL-unsafe chars in decode…
CDillinger Jun 30, 2026
572fb2b
fix(ci): release workflow OOM during update-example-deps lockfile upd…
Sheraff Jul 1, 2026
8bdb21e
fix(ci): release workflow OOM increase node max-old-space (#7723)
Sheraff Jul 1, 2026
ecbbd9a
ci: Version Packages (#7676)
github-actions[bot] Jul 1, 2026
a6337fb
bench(memory): stabilize CodSpeed memory benchmarks (#7730)
Sheraff Jul 2, 2026
208100b
feat(benchmarks): client-side CPU benchmark scenario suite (#7732)
Sheraff Jul 3, 2026
d644dbb
bench(memory): settle-until-quiescent GC barrier between iterations (…
Sheraff Jul 3, 2026
a3e24c3
bench(memory): prefix client and server benchmark names (#7743)
Sheraff Jul 3, 2026
adc6ed5
Merge remote-tracking branch 'upstream/main' into solid-router-v2-pre
brenelz Jul 4, 2026
01dbb52
fix(lockfile): restore @rspack/binding@2.0.8 platform binaries
brenelz Jul 4, 2026
26d94d9
fix(benchmarks): adapt new solid benchmarks from main to Solid v2
brenelz Jul 4, 2026
2bb318b
fix(benchmarks): Solid v2 type fixes for new benchmark scenarios
brenelz Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 0 additions & 5 deletions .changeset/proud-clubs-buy.md

This file was deleted.

1 change: 1 addition & 0 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ jobs:
--output benchmarks/bundle-size/results/pr-comment.md \
--base-sha "$BASE_SHA" \
--dashboard-url "https://${REPOSITORY_OWNER}.github.io/${REPOSITORY_NAME}/benchmarks/bundle-size/"
cat benchmarks/bundle-size/results/pr-comment.md

- name: Upsert Sticky PR Comment
env:
Expand Down
39 changes: 34 additions & 5 deletions .github/workflows/client-nav-benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ name: Benchmarks

on:
push:
# Broad on purpose: CodSpeed compares each PR against the run on its
# merge-base commit. Skipping main pushes that don't touch packages/**
# (pnpm config, CI, root tooling) leaves those commits without a baseline
# run, and CodSpeed silently falls back to an older, possibly outlier run —
# which then flags phantom regressions on every PR until the next eligible
# push. Only paths that cannot affect benchmark results are excluded.
branches:
- 'main'
paths:
- 'packages/**'
- 'benchmarks/**'
paths-ignore:
- 'docs/**'
- 'examples/**'
- 'e2e/**'
pull_request:
paths:
- 'packages/**'
Expand Down Expand Up @@ -36,6 +43,17 @@ jobs:
benchmark:
- client-nav
- ssr
- memory-server
- memory-client
include:
- benchmark: client-nav
mode: simulation
- benchmark: ssr
mode: simulation
- benchmark: memory-server
mode: memory
- benchmark: memory-client
mode: memory
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -46,8 +64,19 @@ jobs:
- name: Setup Tools
uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main

# Run the build outside of the CodSpeed action to avoid being slowed by intrumentation overhead.
# (and then run the task itself with --excludeTaskDependencies)
- name: Prepare ${{ matrix.benchmark }}:${{ matrix.framework }} benchmark
run: >-
pnpm nx run
@benchmarks/${{ matrix.benchmark }}:build:${{ matrix.framework }}

- name: Run ${{ matrix.benchmark }}:${{ matrix.framework }} CodSpeed benchmark
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
with:
mode: simulation
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/${{ matrix.benchmark }}:test:perf:${{ matrix.framework }}
mode: ${{ matrix.mode }}
run: >-
WITH_INSTRUMENTATION=1
pnpm nx run
@benchmarks/${{ matrix.benchmark }}:test:perf:${{ matrix.framework }}
--excludeTaskDependencies
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
commit: 'ci: changeset release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Create GitHub Release
if: steps.changesets.outputs.published == 'true'
run: node scripts/create-github-release.mjs ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ vite.config.ts.timestamp_*

# eslint-plugin-start perf fixtures
/e2e/eslint-plugin-start/src/perf/generated

# local memory flame profiles
benchmarks/memory/**/.profiles/
2 changes: 1 addition & 1 deletion benchmarks/bundle-size/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"vue": "^3.5.16"
},
"devDependencies": {
"@rsbuild/core": "^2.0.8",
"@rsbuild/core": "^2.0.11",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/router-plugin": "workspace:^",
"@types/react": "^19.0.8",
Expand Down
86 changes: 80 additions & 6 deletions benchmarks/client-nav/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,80 @@
# Client Navigation Benchmarks

Cross-framework client-side navigation benchmarks for:
Cross-framework client-side CPU benchmarks for:

- `@tanstack/react-router`
- `@tanstack/solid-router`
- `@tanstack/vue-router`

The benchmarks run in jsdom against production builds of real apps, and are
tracked in CI by CodSpeed (simulation mode).

> **Scope:** these benchmarks cover the standalone client router only. The
> client side of TanStack Start (hydration of a server-rendered document,
> streamed payload consumption, server-function calls from the client, ...) is
> not covered here; the server side of Start is covered by `benchmarks/ssr`.

## Layout

- `react/` - React benchmark + Vitest config
- `solid/` - Solid benchmark + Vitest config
- `vue/` - Vue benchmark + Vitest config
- `react/`, `solid/`, `vue/` - baseline benchmark (mixed navigation loop) + Vitest config
- `vitest.react.config.ts`, `vitest.solid.config.ts`, `vitest.vue.config.ts` - per-framework aggregate configs that run the baseline first, then scenario projects
- `scenarios/harness.ts` - shared scenario runner (mount, link-click steps, `onRendered` synchronization)
- `scenarios/<scenario>/shared.ts` - framework-agnostic scenario definition (workload data, step sequence, assertions, bench options)
- `scenarios/<scenario>/<framework>/` - isolated scenario apps

Scenario app layout:

```text
scenarios/<scenario>/<framework>/
vite.config.ts
speed.bench.ts
speed.flame.ts
setup.ts
project.json
tsconfig.json
src/
main.tsx
routeTree.gen.ts
routes/
```

Scenario apps use file-based routing (`@tanstack/router-plugin`) with a
generated `routeTree.gen.ts`, like a regular user app. Each scenario uses one
app per framework instead of sharing routes in the baseline app. This keeps
route-tree size and router options isolated so one scenario cannot shift
another scenario's numbers. The existing baseline apps and bench names stay
stable for CodSpeed continuity.

## Scenario Responsibilities

Each scenario isolates one client-side responsibility so benchmark changes can
be attributed to a specific feature area.

| Scenario | Client-side responsibility |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `react/`, `solid/`, `vue/` baseline apps | Mixed navigation loop: path params, search params, route context, and `useParams`/`useSearch`/`useLoaderData` selector subscriptions. |
| `async-pipeline` | The router's async pipeline via counted 0ms timer hops: async loaders (transition-held navigation), async `beforeLoad` context, and parallel nested async loaders. Component-level `Await`/Suspense is excluded: React 19 throttles Suspense reveals by ~300ms wall-clock, which is inherently non-deterministic to benchmark. |
| `control-flow` | Loader-thrown `redirect` (including a 2-hop chain), `notFound()` with `notFoundComponent`, loader errors with `errorComponent`, and boundary reset on recovery navigation. |
| `head` | `HeadContent` per-navigation work: nested route `head()` evaluation, title/meta/link dedupe across matches, and head tag DOM updates during navigation. |
| `history` | History push/replace/back/forward traversal, location masking, registered-but-never-blocking `useBlocker`, and `useCanGoBack`/`useLocation` subscriptions. |
| `links` | Per-navigation cost of ~200 mounted `<Link>`s: link prop building, active-state recompute across `activeOptions` variants, `activeProps` swaps, and `useMatchRoute` probes (the `MatchRoute` component is avoided: vue-router's implementation leaks one subscription per render). |
| `loaders` | Client loader dispatch: always-stale re-runs (`staleTime: 0`), cached revisits (re-run once per lap by the `invalidate` step), `loaderDeps`-keyed caching, `router.invalidate()`, and `useLoaderData` selectors. |
| `mount` | Cold start: `createRouter` (route-tree processing) + first render + initial `router.load()` + unmount, with a fresh router per mount. The only scenario measuring router creation. |
| `nested-params` | Deep nesting (8 dynamic levels): per-level `params.parse`/`stringify`, `beforeLoad` context accumulation across matches, and per-level `useParams`/`useRouteContext` subscriptions. Param values include characters requiring percent-encoding (as do `route-tree-scale`'s), so segment encode/decode paths run on every navigation. |
| `preload` | Intent preloading from hover events, programmatic `router.preloadRoute`, deterministic preload cache behavior (`defaultPreloadStaleTime: 0`), and commit-time cache maintenance. |
| `rewrites` | Composed client-side location rewrites: router `basepath` plus a locale input/output rewrite pair, running on every href build and location parse (the client analog of the SSR `rewrites` scenario). |
| `route-tree-scale` | Route matching and link-target resolution on a wide (~40 route) tree mixing static, dynamic, prefixed-param, splat, pathless-layout, and route-group paths, with `autoCodeSplitting` enabled so navigations also resolve lazy route chunks. |
| `search-params` | `validateSearch` execution, search middlewares (`retainSearchParams`/`stripSearchParams`), functional search updaters, structural sharing, and `useSearch` selector subscriptions. |

## Conventions

- Apps are built with `NODE_ENV=production` (`minify: false`) into `dist/app.js`; benches import the built bundle, so production package builds and production JSX output are measured, not dev transforms.
- Scenarios behave like a real user app: navigation happens through `<Link>` clicks dispatched on real anchor elements (unless a scenario specifically measures the imperative API), the router uses the default browser history, and `scrollRestoration` is enabled.
- Each benchmark iteration advances a fixed, circular sequence of steps; every step awaits the router's `onRendered` event, so render work is included and steps cannot overlap. No two consecutive steps may target the same location, and the sequence ends back on the initial route.
- Setup runs one warm-up lap through the sequence and asserts each step's observable output (e.g. `document.title`), so a scenario that silently stops doing its work fails instead of reporting a fast time.
- Determinism: no wall-clock timers (async work is resolved promises or counted `setTimeout(0)` hops), `staleTime`/`gcTime` only ever `0` or effectively-infinite, no `Math.random`/`Date.now` in the measured loop. Scroll restoration uses `getScrollRestorationKey: (location) => location.pathname` so its cache stays bounded (the default random per-entry key grows one cache entry per push navigation). The `mount` scenario opts out of `scrollRestoration` entirely and destroys its history on unmount — both register page-lifetime globals that would leak across its mount/unmount loop.
- Push-only step laps grow jsdom's session-history entry list over a run (jsdom never truncates entries behind the current index). Per-push cost stays O(1) so timings are unaffected; only the `history` scenario needs — and has — a lap that is depth-stationary.
- Calibration: pick per-iteration step counts so a bench's `vitest bench` run stays roughly between 8 and 30 seconds (long enough to average out, short enough for CI).

## Run

Expand All @@ -20,23 +84,33 @@ Run all benchmarks through Nx so dependency builds are part of the graph:
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf --outputStyle=stream --skipRemoteCache
```

Run framework-specific benchmarks:
Run framework-specific benchmarks (baseline + all scenarios):

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:react --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:solid --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:vue --outputStyle=stream --skipRemoteCache
```

Run a single scenario app manually (after building it through Nx):

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-<scenario>-<framework>:build:client --outputStyle=stream --skipRemoteCache
cd benchmarks/client-nav && NODE_ENV=production vitest bench --config ./scenarios/<scenario>/<framework>/vite.config.ts
```

Run framework-specific flame benchmarks (10 second loop, profiled with `@platformatic/flame`, forced to `NODE_ENV=production`):

```bash
# baseline
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:flame:react --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:flame:solid --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:flame:vue --outputStyle=stream --skipRemoteCache
# scenarios
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-<scenario>-<framework>:test:flame --outputStyle=stream --skipRemoteCache
```

Typecheck benchmark sources:
Typecheck benchmark sources (baseline + scenarios):

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:types --outputStyle=stream --skipRemoteCache
Expand Down
6 changes: 6 additions & 0 deletions benchmarks/client-nav/jsdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ setGlobal('MutationObserver', window.MutationObserver)
setGlobal('sessionStorage', window.sessionStorage)
setGlobal('localStorage', window.localStorage)
setGlobal('getComputedStyle', window.getComputedStyle.bind(window))
setGlobal('addEventListener', window.addEventListener.bind(window))
setGlobal('removeEventListener', window.removeEventListener.bind(window))
setGlobal('dispatchEvent', window.dispatchEvent.bind(window))
setGlobal('CustomEvent', window.CustomEvent)
setGlobal('Event', window.Event)

setGlobal(
'requestAnimationFrame',
Expand All @@ -45,5 +50,6 @@ setGlobal(
)

window.scrollTo = () => {}
setGlobal('scrollTo', () => {})

export { window }
Loading
Loading