Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CodSpeed

on:
# disable for now
# pull_request:
# push:
# branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read
id-token: write

jobs:
client-nav:
name: Client Nav Benchmarks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.1

- name: Setup Tools
uses: tanstack/config/.github/setup@main

- name: Run Client Navigation Benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: |
CI=1 NX_DAEMON=false pnpm nx run tanstack-router-benchmark-client-nav:bench --outputStyle=stream --skipNxCache --skipRemoteCache
39 changes: 39 additions & 0 deletions benchmarks/client-nav/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "tanstack-router-benchmark-client-nav",
"private": true,
"type": "module",
"scripts": {
"bench": "pnpm run bench:react && pnpm run bench:solid && pnpm run bench:vue",
"bench:react": "NODE_ENV=production vitest bench --run --config react/vitest.config.ts react/client-nav.bench.tsx",
"bench:solid": "NODE_ENV=production vitest bench --run --config solid/vitest.config.ts solid/client-nav.bench.tsx",
"bench:vue": "NODE_ENV=production vitest bench --run --config vue/vitest.config.ts vue/client-nav.bench.ts"
},
"dependencies": {
"@tanstack/react-router": "workspace:*",
"@tanstack/solid-router": "workspace:*",
"@tanstack/vue-router": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^1.9.10",
"vue": "^3.5.25"
},
"devDependencies": {
"@codspeed/vitest-plugin": "^5.0.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.17"
},
"nx": {
"targets": {
"bench": {
"dependsOn": [
"^build"
],
"cache": false
}
}
}
}
174 changes: 174 additions & 0 deletions benchmarks/client-nav/react/client-nav.bench.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { createRoot } from 'react-dom/client'
import { bench } from 'vitest'
import { useEffect } from 'react'
import {
Link,
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router'

import {
HOOK_COUNT,
LINK_COUNT,
TARGET_ID,
TIMEOUT,
heavySelect,
parseIntOrZero,
} from '../shared'

function setupBenchmark() {
const rootRoute = createRootRoute({
component: () => {
const selectedParams = Array.from({ length: HOOK_COUNT }, (_, index) =>
rootRoute.useParams({
strict: false,
select: (params) => heavySelect(params.id, index),
}),
)

const selectedSearch = Array.from({ length: HOOK_COUNT }, (_, index) =>
rootRoute.useSearch({
strict: false,
select: (search) => heavySelect(search.n, index + HOOK_COUNT),
}),
)

const links = Array.from({ length: LINK_COUNT }, (_, index) => (
<Link
key={index}
to="/$id"
params={{ id: String(index) }}
search={{ n: index }}
>
Link {index}
</Link>
))

const rootScore =
selectedParams.reduce((sum, value) => sum + value, 0) +
selectedSearch.reduce((sum, value) => sum + value, 0)

return (
<div>
<div style={{ display: 'none' }}>{rootScore}</div>
<div style={{ display: 'none' }}>{links}</div>
<Outlet />
</div>
)
},
})

const idRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/$id',
validateSearch: (search: Record<string, unknown>) => ({
n: parseIntOrZero(search.n),
}),
component: () => {
const id = idRoute.useParams({
select: (params) => parseIntOrZero(params.id),
})
const n = idRoute.useSearch({
select: (search) => parseIntOrZero(search.n),
})
const navigate = idRoute.useNavigate()

useEffect(() => {
if (id < TARGET_ID) {
const next = id + 1
void navigate({
to: '/$id',
params: { id: String(next) },
search: { n: next },
})
}
}, [id, n, navigate])

return null
},
})

const routeTree = rootRoute.addChildren([idRoute])

const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: ['/0?n=0'],
}),
})

const container = document.createElement('div')
document.body.appendChild(container)
const reactRoot = createRoot(container)

reactRoot.render(<RouterProvider router={router} />)

const done = new Promise<void>((resolve, reject) => {
const expectedHref = `/${TARGET_ID}?n=${TARGET_ID}`

let settled = false
let unsubscribe = () => {}

const settle = (error?: Error) => {
if (settled) {
return
}

settled = true
window.clearTimeout(timeoutId)
unsubscribe()

if (error) {
reject(error)
} else {
resolve()
}
}

const timeoutId = window.setTimeout(() => {
settle(
new Error(
`React benchmark timed out at ${router.state.location.href}; expected ${expectedHref}`,
),
)
}, TIMEOUT)

unsubscribe = router.subscribe('onResolved', (event) => {
if (event.toLocation.href === expectedHref) {
settle()
}
})

if (router.state.location.href === expectedHref) {
settle()
}
})

return {
done,
cleanup: () => {
reactRoot.unmount()
container.remove()
},
}
}

bench(
'client-nav.react.10-nav',
async () => {
const { done, cleanup } = setupBenchmark()

try {
await done
} finally {
cleanup()
}
},
{
throws: true,
},
)
17 changes: 17 additions & 0 deletions benchmarks/client-nav/react/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import codspeedPlugin from '@codspeed/vitest-plugin'

export default defineConfig({
plugins: [react(), codspeedPlugin()],
resolve: {
conditions: ['browser'],
},
test: {
environment: 'jsdom',
include: ['react/**/*.bench.tsx'],
benchmark: {
include: ['react/**/*.bench.tsx'],
},
},
})
29 changes: 29 additions & 0 deletions benchmarks/client-nav/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const LINK_COUNT = 20
export const HOOK_COUNT = 20
export const TARGET_ID = 10
export const TIMEOUT = 10_000

export function heavySelect(
seed: string | number | undefined,
salt: number,
): number {
let value =
typeof seed === 'number' ? seed : Number.parseInt(seed ?? '0', 10) || 0

for (let i = 0; i < 5; i++) {
value = (value * 33 + salt + i) % 104_729
value ^= (value << 5) & 0xffff
value &= 0x7fffffff
}

return value
}

export function parseIntOrZero(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}

const parsed = Number.parseInt(String(value ?? '0'), 10)
return Number.isFinite(parsed) ? parsed : 0
}
Loading
Loading