From 6af9d83b50e9735ebfcf1abcdf94f830c176b24a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 8 Jun 2026 10:01:31 +0200 Subject: [PATCH 1/3] feat(benchmarks): add memory benchmarks --- .github/workflows/memory-benchmarks.yml | 68 ++++ benchmarks/memory/.gitignore | 1 + benchmarks/memory/README.md | 58 ++++ benchmarks/memory/package.json | 124 ++++++++ benchmarks/memory/results/.gitignore | 5 + benchmarks/memory/results/scenarios/.gitkeep | 0 .../client/repeated-navigation/index.html | 12 + .../repeated-navigation/playwright.config.ts | 35 ++ .../client/repeated-navigation/src/main.tsx | 235 ++++++++++++++ .../repeated-navigation/tests/memory.spec.ts | 136 ++++++++ .../client/repeated-navigation/tsconfig.json | 10 + .../client/repeated-navigation/vite.config.ts | 18 ++ .../react/ssr/repeated-requests/bench.ts | 182 +++++++++++ .../repeated-requests/src/routeTree.gen.ts | 146 +++++++++ .../ssr/repeated-requests/src/router.tsx | 16 + .../src/routes/$a.$b.$c.$d.tsx | 6 + .../repeated-requests/src/routes/$a.$b.$c.tsx | 5 + .../repeated-requests/src/routes/$a.$b.tsx | 5 + .../ssr/repeated-requests/src/routes/$a.tsx | 5 + .../repeated-requests/src/routes/__root.tsx | 41 +++ .../ssr/repeated-requests/src/workload.tsx | 65 ++++ .../react/ssr/repeated-requests/tsconfig.json | 4 + .../ssr/repeated-requests/vite.config.ts | 21 ++ benchmarks/memory/tools/report.ts | 46 +++ benchmarks/memory/tools/result-utils.ts | 299 ++++++++++++++++++ benchmarks/memory/tools/summary.ts | 32 ++ benchmarks/memory/tools/tsconfig.json | 4 + benchmarks/memory/tsconfig.json | 15 + package.json | 2 + pnpm-lock.yaml | 37 +++ 30 files changed, 1633 insertions(+) create mode 100644 .github/workflows/memory-benchmarks.yml create mode 100644 benchmarks/memory/.gitignore create mode 100644 benchmarks/memory/README.md create mode 100644 benchmarks/memory/package.json create mode 100644 benchmarks/memory/results/.gitignore create mode 100644 benchmarks/memory/results/scenarios/.gitkeep create mode 100644 benchmarks/memory/scenarios/react/client/repeated-navigation/index.html create mode 100644 benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts create mode 100644 benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx create mode 100644 benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts create mode 100644 benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json create mode 100644 benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json create mode 100644 benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts create mode 100644 benchmarks/memory/tools/report.ts create mode 100644 benchmarks/memory/tools/result-utils.ts create mode 100644 benchmarks/memory/tools/summary.ts create mode 100644 benchmarks/memory/tools/tsconfig.json create mode 100644 benchmarks/memory/tsconfig.json diff --git a/.github/workflows/memory-benchmarks.yml b/.github/workflows/memory-benchmarks.yml new file mode 100644 index 0000000000..395292d7b3 --- /dev/null +++ b/.github/workflows/memory-benchmarks.yml @@ -0,0 +1,68 @@ +name: Memory Benchmarks + +on: + push: + branches: + - main + paths: + - 'packages/**' + - 'benchmarks/**' + - '.github/workflows/memory-benchmarks.yml' + pull_request: + paths: + - 'packages/**' + - 'benchmarks/**' + - '.github/workflows/memory-benchmarks.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +env: + NX_NO_CLOUD: true + MEMORY_BENCH_ITERATIONS: 2000 + MEMORY_BENCH_WARMUP_ITERATIONS: 200 + MEMORY_BENCH_BATCH_SIZE: 100 + +jobs: + benchmarks: + name: Run Memory Benchmarks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Tools + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main + + - name: Install Chromium + run: pnpm exec playwright install chromium + + - name: Run React Memory Benchmarks + run: CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react --outputStyle=stream --skipRemoteCache --parallel=2 + + - name: Build Benchmark Report + run: CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:report --outputStyle=stream --skipRemoteCache + + - name: Publish Benchmark Dashboard + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository_owner == 'TanStack' + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + with: + tool: customSmallerIsBetter + name: Memory Usage (retained heap) + output-file-path: benchmarks/memory/results/benchmark-action.json + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + gh-pages-branch: gh-pages + benchmark-data-dir-path: benchmarks/memory + max-items-in-chart: 200 + summary-always: true + comment-on-alert: false + fail-on-alert: false diff --git a/benchmarks/memory/.gitignore b/benchmarks/memory/.gitignore new file mode 100644 index 0000000000..d0b06dd57a --- /dev/null +++ b/benchmarks/memory/.gitignore @@ -0,0 +1 @@ +scenarios/**/dist diff --git a/benchmarks/memory/README.md b/benchmarks/memory/README.md new file mode 100644 index 0000000000..cc53132e0b --- /dev/null +++ b/benchmarks/memory/README.md @@ -0,0 +1,58 @@ +# Memory Benchmarks + +Memory benchmarks are organized as Nx leaf targets. Each leaf target runs one scenario, prints the result to stdout, and writes one isolated result artifact under `results/scenarios/`. + +## Run + +Run all memory benchmarks: + +```bash +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory --outputStyle=stream --skipRemoteCache --parallel=2 +``` + +Run React scenarios: + +```bash +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react --outputStyle=stream --skipRemoteCache --parallel=2 +``` + +Run one scenario: + +```bash +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react:client:repeated-navigation --outputStyle=stream --skipRemoteCache +CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react:ssr:repeated-requests --outputStyle=stream --skipRemoteCache +``` + +Tune local run sizes with environment variables: + +```bash +MEMORY_BENCH_ITERATIONS=10000 MEMORY_BENCH_WARMUP_ITERATIONS=1000 pnpm nx run @benchmarks/memory:test:memory:react:ssr:repeated-requests +``` + +## Report + +After leaf targets have run, merge isolated scenario artifacts into benchmark-action compatible outputs: + +```bash +pnpm nx run @benchmarks/memory:report +``` + +This writes: + +- `benchmarks/memory/results/current.json` +- `benchmarks/memory/results/benchmark-action.json` + +## CI + +`.github/workflows/memory-benchmarks.yml` runs the React memory benchmark group with Nx parallelism, builds the report, and publishes the main-branch history to GitHub Pages via `benchmark-action/github-action-benchmark`. + +The workflow uses a larger run size than quick local smoke tests: + +```bash +MEMORY_BENCH_ITERATIONS=2000 MEMORY_BENCH_WARMUP_ITERATIONS=200 MEMORY_BENCH_BATCH_SIZE=100 +``` + +## Initial Scenarios + +- `react.client.repeated-navigation`: React Router in Chromium via Playwright. +- `react.ssr.repeated-requests`: React Start SSR in Node. diff --git a/benchmarks/memory/package.json b/benchmarks/memory/package.json new file mode 100644 index 0000000000..2012be1324 --- /dev/null +++ b/benchmarks/memory/package.json @@ -0,0 +1,124 @@ +{ + "name": "@benchmarks/memory", + "private": true, + "type": "module", + "scripts": { + "build:react:client:repeated-navigation": "NODE_ENV=production vite build --config ./scenarios/react/client/repeated-navigation/vite.config.ts", + "build:react:ssr:repeated-requests": "NODE_ENV=production vite build --config ./scenarios/react/ssr/repeated-requests/vite.config.ts", + "test:memory": "node ./tools/summary.ts", + "test:memory:react": "node ./tools/summary.ts --scope react", + "test:memory:react:client": "node ./tools/summary.ts --scope react.client", + "test:memory:react:ssr": "node ./tools/summary.ts --scope react.ssr", + "test:memory:react:client:repeated-navigation": "NODE_ENV=production playwright test --config ./scenarios/react/client/repeated-navigation/playwright.config.ts", + "test:memory:react:ssr:repeated-requests": "NODE_ENV=production node --expose-gc ./scenarios/react/ssr/repeated-requests/bench.ts", + "report": "node ./tools/report.ts", + "test:types": "pnpm run test:types:tools && pnpm run test:types:react:client:repeated-navigation && pnpm run test:types:react:ssr:repeated-requests", + "test:types:tools": "tsc -p ./tools/tsconfig.json --noEmit", + "test:types:react:client:repeated-navigation": "tsc -p ./scenarios/react/client/repeated-navigation/tsconfig.json --noEmit", + "test:types:react:ssr:repeated-requests": "tsc -p ./scenarios/react/ssr/repeated-requests/tsconfig.json --noEmit" + }, + "dependencies": { + "@tanstack/react-router": "workspace:*", + "@tanstack/react-start": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@types/node": "25.0.9", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.2", + "vite": "^8.0.14" + }, + "nx": { + "targets": { + "build:react:client:repeated-navigation": { + "cache": false, + "dependsOn": [ + { + "projects": [ + "@tanstack/react-router" + ], + "target": "build" + } + ], + "outputs": [ + "{projectRoot}/scenarios/react/client/repeated-navigation/dist" + ] + }, + "build:react:ssr:repeated-requests": { + "cache": false, + "dependsOn": [ + { + "projects": [ + "@tanstack/react-start" + ], + "target": "build" + } + ], + "outputs": [ + "{projectRoot}/scenarios/react/ssr/repeated-requests/dist" + ] + }, + "test:memory": { + "cache": false, + "dependsOn": [ + "test:memory:react:client:repeated-navigation", + "test:memory:react:ssr:repeated-requests" + ] + }, + "test:memory:react": { + "cache": false, + "dependsOn": [ + "test:memory:react:client:repeated-navigation", + "test:memory:react:ssr:repeated-requests" + ] + }, + "test:memory:react:client": { + "cache": false, + "dependsOn": [ + "test:memory:react:client:repeated-navigation" + ] + }, + "test:memory:react:ssr": { + "cache": false, + "dependsOn": [ + "test:memory:react:ssr:repeated-requests" + ] + }, + "test:memory:react:client:repeated-navigation": { + "cache": false, + "dependsOn": [ + "build:react:client:repeated-navigation" + ], + "outputs": [ + "{projectRoot}/results/scenarios/react.client.repeated-navigation.json" + ] + }, + "test:memory:react:ssr:repeated-requests": { + "cache": false, + "dependsOn": [ + "build:react:ssr:repeated-requests" + ], + "outputs": [ + "{projectRoot}/results/scenarios/react.ssr.repeated-requests.json" + ] + }, + "report": { + "cache": false, + "outputs": [ + "{projectRoot}/results/current.json", + "{projectRoot}/results/benchmark-action.json" + ] + }, + "test:types": { + "cache": false, + "dependsOn": [ + "^build" + ] + } + } + } +} diff --git a/benchmarks/memory/results/.gitignore b/benchmarks/memory/results/.gitignore new file mode 100644 index 0000000000..30af9d23c2 --- /dev/null +++ b/benchmarks/memory/results/.gitignore @@ -0,0 +1,5 @@ +* +!.gitignore +!scenarios/ +scenarios/* +!scenarios/.gitkeep diff --git a/benchmarks/memory/results/scenarios/.gitkeep b/benchmarks/memory/results/scenarios/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/index.html b/benchmarks/memory/scenarios/react/client/repeated-navigation/index.html new file mode 100644 index 0000000000..b21cbcd6e5 --- /dev/null +++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/index.html @@ -0,0 +1,12 @@ + + + + + + React Router Memory Benchmark + + +
+ + + diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts b/benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts new file mode 100644 index 0000000000..e8cf512c2a --- /dev/null +++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' + +const port = Number.parseInt( + process.env.MEMORY_BENCH_REACT_CLIENT_PORT ?? '42101', + 10, +) +const baseURL = `http://127.0.0.1:${port}` + +export default defineConfig({ + testDir: './tests', + timeout: 120_000, + workers: 1, + reporter: [['line']], + use: { + baseURL, + ...devices['Desktop Chrome'], + }, + webServer: { + command: `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`, + url: baseURL, + reuseExistingServer: false, + stdout: 'pipe', + env: { + NODE_ENV: 'production', + }, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}) diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx b/benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx new file mode 100644 index 0000000000..1f0461d719 --- /dev/null +++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx @@ -0,0 +1,235 @@ +import * as React from 'react' +import ReactDOM from 'react-dom/client' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useParams, + useSearch, +} from '@tanstack/react-router' + +declare global { + interface Window { + __memoryBenchmark: { + runBatch: (iterations: number) => Promise + } + } +} + +type SearchState = { + filter?: string + page?: number + q?: string +} + +const probes = Array.from({ length: 20 }, (_, index) => index) + +function validateSearch(search: Record): SearchState { + const page = Number(search.page) + + return { + filter: typeof search.filter === 'string' ? search.filter : undefined, + page: Number.isFinite(page) ? page : undefined, + q: typeof search.q === 'string' ? search.q : undefined, + } +} + +function ParamsProbe({ index }: { index: number }) { + const params = useParams({ + strict: false, + }) + + return ( + + ) +} + +function SearchProbe({ index }: { index: number }) { + const search = useSearch({ + strict: false, + }) + + return ( + + ) +} + +function LinkProbe({ index }: { index: number }) { + return ( + + item {index} + + ) +} + +function Workload({ label }: { label: string }) { + return ( +
+

{label}

+ {probes.map((probe) => ( + + ))} + {probes.map((probe) => ( + + ))} + +
+ ) +} + +const rootRoute = createRootRoute({ + component: RootComponent, + validateSearch, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , +}) + +const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/items/$itemId', + validateSearch, + component: () => , +}) + +const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/search', + validateSearch, + component: () => , +}) + +const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested/$a/$b/$c', + validateSearch, + component: () => , +}) + +const routeTree = rootRoute.addChildren([ + indexRoute, + itemRoute, + searchRoute, + nestedRoute, +]) + +const router = createRouter({ + routeTree, + defaultPreload: false, + scrollRestoration: false, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +function RootComponent() { + return +} + +function waitForRendered() { + return new Promise((resolve) => { + const unsubscribe = router.subscribe('onRendered', () => { + unsubscribe() + resolve() + }) + }) +} + +async function navigateAndWait(options: any) { + const rendered = waitForRendered() + await router.navigate(options) + await rendered +} + +let actionIndex = 0 + +window.__memoryBenchmark = { + async runBatch(iterations) { + for (let index = 0; index < iterations; index++) { + const currentIndex = actionIndex + actionIndex += 1 + + const value = String(currentIndex % 10_000) + const step = currentIndex % 4 + + if (step === 0) { + await navigateAndWait({ + to: '/items/$itemId', + params: { itemId: value }, + search: { + filter: 'all', + page: currentIndex % 25, + q: `item-${value}`, + }, + replace: true, + }) + } else if (step === 1) { + await navigateAndWait({ + to: '/search', + search: { + filter: 'active', + page: currentIndex % 50, + q: `search-${value}`, + }, + replace: true, + }) + } else if (step === 2) { + await navigateAndWait({ + to: '/nested/$a/$b/$c', + params: { a: `a-${value}`, b: `b-${value}`, c: `c-${value}` }, + search: { + filter: 'nested', + page: currentIndex % 75, + q: `nested-${value}`, + }, + replace: true, + }) + } else { + await navigateAndWait({ + to: '/', + search: { + filter: 'home', + page: currentIndex % 100, + q: `home-${value}`, + }, + replace: true, + }) + } + } + + await navigateAndWait({ + to: '/', + search: { filter: 'stable', page: 0, q: 'stable' }, + replace: true, + }) + }, +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} + +ReactDOM.createRoot(rootElement).render() diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts b/benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts new file mode 100644 index 0000000000..524eb94eb3 --- /dev/null +++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts @@ -0,0 +1,136 @@ +import { test } from '@playwright/test' +import type { CDPSession } from '@playwright/test' +import { + computeSlopeBytesPerOperation, + createScenarioResult, + printScenarioResult, + readPositiveIntegerEnv, + writeScenarioResult, +} from '../../../../../tools/result-utils.ts' +import type { MemorySample } from '../../../../../tools/result-utils.ts' + +const scenario = { + id: 'react.client.repeated-navigation', + label: 'React client repeated navigation', + framework: 'react', + runtime: 'client', + scenario: 'repeated-navigation', +} + +interface BrowserMemory { + heapUsedBytes: number + heapTotalBytes: number + documents: number + domNodes: number + jsEventListeners: number +} + +async function forceBrowserGc(client: CDPSession) { + await client.send('HeapProfiler.collectGarbage') +} + +async function readBrowserMemory(client: CDPSession): Promise { + const heap = await client.send('Runtime.getHeapUsage') + const domCounters = await client.send('Memory.getDOMCounters') + + return { + heapUsedBytes: heap.usedSize, + heapTotalBytes: heap.totalSize, + documents: domCounters.documents, + domNodes: domCounters.nodes, + jsEventListeners: domCounters.jsEventListeners, + } +} + +test('react client repeated navigation memory', async ({ page }) => { + const startedAt = new Date() + const iterations = readPositiveIntegerEnv('MEMORY_BENCH_ITERATIONS', 1000) + const warmupIterations = readPositiveIntegerEnv( + 'MEMORY_BENCH_WARMUP_ITERATIONS', + 100, + ) + const batchSize = readPositiveIntegerEnv('MEMORY_BENCH_BATCH_SIZE', 50) + const client = await page.context().newCDPSession(page) + + await client.send('HeapProfiler.enable') + await client.send('Runtime.enable') + + await page.goto('/') + await page.waitForFunction(() => { + return typeof (window as any).__memoryBenchmark?.runBatch === 'function' + }) + + await page.evaluate( + (count) => (window as any).__memoryBenchmark.runBatch(count), + warmupIterations, + ) + await forceBrowserGc(client) + + const baseline = await readBrowserMemory(client) + const samples: Array = [] + let peakHeapUsedBytes = baseline.heapUsedBytes + + for (let completed = 0; completed < iterations; completed += batchSize) { + const nextBatchSize = Math.min(batchSize, iterations - completed) + await page.evaluate( + (count) => (window as any).__memoryBenchmark.runBatch(count), + nextBatchSize, + ) + + const beforeGc = await readBrowserMemory(client) + peakHeapUsedBytes = Math.max(peakHeapUsedBytes, beforeGc.heapUsedBytes) + + await forceBrowserGc(client) + const afterGc = await readBrowserMemory(client) + const iteration = completed + nextBatchSize + samples.push({ + iteration, + heapUsedBytes: afterGc.heapUsedBytes, + heapTotalBytes: afterGc.heapTotalBytes, + retainedHeapDeltaBytes: afterGc.heapUsedBytes - baseline.heapUsedBytes, + documents: afterGc.documents, + domNodes: afterGc.domNodes, + domNodeDelta: afterGc.domNodes - baseline.domNodes, + jsEventListeners: afterGc.jsEventListeners, + jsEventListenerDelta: + afterGc.jsEventListeners - baseline.jsEventListeners, + peakHeapUsedBytes, + }) + } + + const finalSample = samples[samples.length - 1]! + const retainedHeapDeltaBytes = + finalSample.heapUsedBytes - baseline.heapUsedBytes + const retainedHeapBytesPerOperation = retainedHeapDeltaBytes / iterations + const retainedHeapSlopeBytesPerOperation = computeSlopeBytesPerOperation( + samples, + (sample) => sample.retainedHeapDeltaBytes, + ) + const result = createScenarioResult({ + ...scenario, + startedAt, + config: { + iterations, + warmupIterations, + batchSize, + }, + metrics: { + baselineHeapUsedBytes: baseline.heapUsedBytes, + finalHeapUsedBytes: finalSample.heapUsedBytes, + retainedHeapDeltaBytes, + retainedHeapBytesPerOperation, + retainedHeapSlopeBytesPerOperation, + peakHeapUsedBytes, + baselineDomNodes: baseline.domNodes, + finalDomNodes: finalSample.domNodes, + domNodeDelta: finalSample.domNodeDelta, + baselineJsEventListeners: baseline.jsEventListeners, + finalJsEventListeners: finalSample.jsEventListeners, + jsEventListenerDelta: finalSample.jsEventListenerDelta, + }, + samples, + }) + + writeScenarioResult(result) + printScenarioResult(result) +}) diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json b/benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json new file mode 100644 index 0000000000..e2bcedfe24 --- /dev/null +++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "playwright.config.ts", + "vite.config.ts" + ] +} diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts b/benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts new file mode 100644 index 0000000000..1ba2e24830 --- /dev/null +++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts @@ -0,0 +1,18 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + plugins: [react()], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + }, +}) diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts new file mode 100644 index 0000000000..339de52416 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts @@ -0,0 +1,182 @@ +import { + computeSlopeBytesPerOperation, + createScenarioResult, + printScenarioResult, + readPositiveIntegerEnv, + writeScenarioResult, +} from '../../../../tools/result-utils.ts' +import type { MemorySample } from '../../../../tools/result-utils.ts' + +interface StartRequestHandler { + fetch: (request: Request) => Promise | Response +} + +const scenario = { + id: 'react.ssr.repeated-requests', + label: 'React SSR repeated requests', + framework: 'react', + runtime: 'ssr', + scenario: 'repeated-requests', +} + +const requestInit = { + method: 'GET', + headers: { + accept: 'text/html', + }, +} satisfies RequestInit + +function requestUrlForIteration(iteration: number) { + const slot = iteration % 32 + const q = `q-${slot}` + const page = slot % 8 + + if (slot % 4 === 0) { + return `http://localhost/a-${slot}/b-${slot}/c-${slot}/d-${slot}?q=${q}&page=${page}&filter=memory` + } + + if (slot % 4 === 1) { + return `http://localhost/a-${slot}/b-${slot}/c-${slot}/d-next?filter=memory&page=${page}&q=${q}` + } + + if (slot % 4 === 2) { + return `http://localhost/a-${slot}/b-next/c-${slot}/d-${slot}?page=${page}&filter=memory&q=${q}` + } + + return `http://localhost/a-next/b-${slot}/c-${slot}/d-${slot}?q=${q}&filter=memory&page=${page}` +} + +async function runRequests( + handler: StartRequestHandler, + count: number, + startIteration: number, +) { + for (let index = 0; index < count; index++) { + const iteration = startIteration + index + const requestUrl = requestUrlForIteration(iteration) + const response = await handler.fetch(new Request(requestUrl, requestInit)) + + if (response.status !== 200) { + throw new Error( + `Request failed with non-200 status ${response.status} (${requestUrl})`, + ) + } + + await response.text() + } +} + +function forceGc() { + const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc + if (typeof gc !== 'function') { + throw new Error('SSR memory benchmarks must run with node --expose-gc') + } + + for (let index = 0; index < 6; index++) { + gc() + } +} + +function readNodeMemory() { + const memory = process.memoryUsage() + + return { + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + externalBytes: memory.external, + arrayBuffersBytes: memory.arrayBuffers, + } +} + +const startedAt = new Date() +const iterations = readPositiveIntegerEnv('MEMORY_BENCH_ITERATIONS', 1000) +const warmupIterations = readPositiveIntegerEnv( + 'MEMORY_BENCH_WARMUP_ITERATIONS', + 100, +) +const batchSize = readPositiveIntegerEnv('MEMORY_BENCH_BATCH_SIZE', 50) +const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href +const { default: handler } = (await import(appModuleUrl)) as { + default: StartRequestHandler +} + +await runRequests(handler, warmupIterations, 0) +forceGc() + +const baseline = readNodeMemory() +const samples: Array = [] +let peakRssBytes = baseline.rssBytes +let peakHeapUsedBytes = baseline.heapUsedBytes +let peakExternalBytes = baseline.externalBytes +let peakArrayBuffersBytes = baseline.arrayBuffersBytes + +for (let completed = 0; completed < iterations; completed += batchSize) { + const nextBatchSize = Math.min(batchSize, iterations - completed) + await runRequests(handler, nextBatchSize, warmupIterations + completed) + + const beforeGc = readNodeMemory() + peakRssBytes = Math.max(peakRssBytes, beforeGc.rssBytes) + peakHeapUsedBytes = Math.max(peakHeapUsedBytes, beforeGc.heapUsedBytes) + peakExternalBytes = Math.max(peakExternalBytes, beforeGc.externalBytes) + peakArrayBuffersBytes = Math.max( + peakArrayBuffersBytes, + beforeGc.arrayBuffersBytes, + ) + + forceGc() + const afterGc = readNodeMemory() + const iteration = completed + nextBatchSize + samples.push({ + iteration, + rssBytes: afterGc.rssBytes, + heapUsedBytes: afterGc.heapUsedBytes, + heapTotalBytes: afterGc.heapTotalBytes, + retainedHeapDeltaBytes: afterGc.heapUsedBytes - baseline.heapUsedBytes, + externalBytes: afterGc.externalBytes, + arrayBuffersBytes: afterGc.arrayBuffersBytes, + peakRssBytes, + peakHeapUsedBytes, + peakExternalBytes, + peakArrayBuffersBytes, + }) +} + +const finalSample = samples[samples.length - 1]! +const retainedHeapDeltaBytes = + finalSample.heapUsedBytes - baseline.heapUsedBytes +const retainedHeapBytesPerOperation = retainedHeapDeltaBytes / iterations +const retainedHeapSlopeBytesPerOperation = computeSlopeBytesPerOperation( + samples, + (sample) => sample.retainedHeapDeltaBytes, +) +const result = createScenarioResult({ + ...scenario, + startedAt, + config: { + iterations, + warmupIterations, + batchSize, + }, + metrics: { + baselineRssBytes: baseline.rssBytes, + finalRssBytes: finalSample.rssBytes, + peakRssBytes, + baselineHeapUsedBytes: baseline.heapUsedBytes, + finalHeapUsedBytes: finalSample.heapUsedBytes, + retainedHeapDeltaBytes, + retainedHeapBytesPerOperation, + retainedHeapSlopeBytesPerOperation, + peakHeapUsedBytes, + baselineExternalBytes: baseline.externalBytes, + finalExternalBytes: finalSample.externalBytes, + peakExternalBytes, + baselineArrayBuffersBytes: baseline.arrayBuffersBytes, + finalArrayBuffersBytes: finalSample.arrayBuffersBytes, + peakArrayBuffersBytes, + }, + samples, +}) + +writeScenarioResult(result) +printScenarioResult(result) diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts new file mode 100644 index 0000000000..9f110bba31 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts @@ -0,0 +1,146 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ARouteImport } from './routes/$a' +import { Route as ABRouteImport } from './routes/$a.$b' +import { Route as ABCRouteImport } from './routes/$a.$b.$c' +import { Route as ABCDRouteImport } from './routes/$a.$b.$c.$d' + +const ARoute = ARouteImport.update({ + id: '/$a', + path: '/$a', + getParentRoute: () => rootRouteImport, +} as any) +const ABRoute = ABRouteImport.update({ + id: '/$b', + path: '/$b', + getParentRoute: () => ARoute, +} as any) +const ABCRoute = ABCRouteImport.update({ + id: '/$c', + path: '/$c', + getParentRoute: () => ABRoute, +} as any) +const ABCDRoute = ABCDRouteImport.update({ + id: '/$d', + path: '/$d', + getParentRoute: () => ABCRoute, +} as any) + +export interface FileRoutesByFullPath { + '/$a': typeof ARouteWithChildren + '/$a/$b': typeof ABRouteWithChildren + '/$a/$b/$c': typeof ABCRouteWithChildren + '/$a/$b/$c/$d': typeof ABCDRoute +} +export interface FileRoutesByTo { + '/$a': typeof ARouteWithChildren + '/$a/$b': typeof ABRouteWithChildren + '/$a/$b/$c': typeof ABCRouteWithChildren + '/$a/$b/$c/$d': typeof ABCDRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/$a': typeof ARouteWithChildren + '/$a/$b': typeof ABRouteWithChildren + '/$a/$b/$c': typeof ABCRouteWithChildren + '/$a/$b/$c/$d': typeof ABCDRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/$a' | '/$a/$b' | '/$a/$b/$c' | '/$a/$b/$c/$d' + fileRoutesByTo: FileRoutesByTo + to: '/$a' | '/$a/$b' | '/$a/$b/$c' | '/$a/$b/$c/$d' + id: '__root__' | '/$a' | '/$a/$b' | '/$a/$b/$c' | '/$a/$b/$c/$d' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + ARoute: typeof ARouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/$a': { + id: '/$a' + path: '/$a' + fullPath: '/$a' + preLoaderRoute: typeof ARouteImport + parentRoute: typeof rootRouteImport + } + '/$a/$b': { + id: '/$a/$b' + path: '/$b' + fullPath: '/$a/$b' + preLoaderRoute: typeof ABRouteImport + parentRoute: typeof ARoute + } + '/$a/$b/$c': { + id: '/$a/$b/$c' + path: '/$c' + fullPath: '/$a/$b/$c' + preLoaderRoute: typeof ABCRouteImport + parentRoute: typeof ABRoute + } + '/$a/$b/$c/$d': { + id: '/$a/$b/$c/$d' + path: '/$d' + fullPath: '/$a/$b/$c/$d' + preLoaderRoute: typeof ABCDRouteImport + parentRoute: typeof ABCRoute + } + } +} + +interface ABCRouteChildren { + ABCDRoute: typeof ABCDRoute +} + +const ABCRouteChildren: ABCRouteChildren = { + ABCDRoute: ABCDRoute, +} + +const ABCRouteWithChildren = ABCRoute._addFileChildren(ABCRouteChildren) + +interface ABRouteChildren { + ABCRoute: typeof ABCRouteWithChildren +} + +const ABRouteChildren: ABRouteChildren = { + ABCRoute: ABCRouteWithChildren, +} + +const ABRouteWithChildren = ABRoute._addFileChildren(ABRouteChildren) + +interface ARouteChildren { + ABRoute: typeof ABRouteWithChildren +} + +const ARouteChildren: ARouteChildren = { + ABRoute: ABRouteWithChildren, +} + +const ARouteWithChildren = ARoute._addFileChildren(ARouteChildren) + +const rootRouteChildren: RootRouteChildren = { + ARoute: ARouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx new file mode 100644 index 0000000000..7c4eb0babe --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + defaultPreload: false, + scrollRestoration: false, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx new file mode 100644 index 0000000000..d90ef22e09 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { RouteWorkload } from '../workload' + +export const Route = createFileRoute('/$a/$b/$c/$d')({ + component: RouteWorkload, +}) diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx new file mode 100644 index 0000000000..4af8ef800d --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx @@ -0,0 +1,5 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$a/$b/$c')({ + component: Outlet, +}) diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx new file mode 100644 index 0000000000..970118ea15 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx @@ -0,0 +1,5 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$a/$b')({ + component: Outlet, +}) diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx new file mode 100644 index 0000000000..69d1483b77 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx @@ -0,0 +1,5 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$a')({ + component: Outlet, +}) diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx new file mode 100644 index 0000000000..dbc9e2fcdb --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx @@ -0,0 +1,41 @@ +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +type SearchState = { + filter?: string + page?: number + q?: string +} + +function validateSearch(search: Record): SearchState { + const page = Number(search.page) + + return { + filter: typeof search.filter === 'string' ? search.filter : undefined, + page: Number.isFinite(page) ? page : undefined, + q: typeof search.q === 'string' ? search.q : undefined, + } +} + +export const Route = createRootRoute({ + component: RootComponent, + validateSearch, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx new file mode 100644 index 0000000000..96ff488ab9 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx @@ -0,0 +1,65 @@ +import { Link, useParams, useSearch } from '@tanstack/react-router' + +const probes = Array.from({ length: 24 }, (_, index) => index) + +function ParamsProbe({ index }: { index: number }) { + const params = useParams({ + strict: false, + }) + + return ( + + ) +} + +function SearchProbe({ index }: { index: number }) { + const search = useSearch({ + strict: false, + }) + + return ( + + ) +} + +function LinkProbe({ salt }: { salt: number }) { + const value = String((salt % 97) + 1) + + return ( + + Link {value} + + ) +} + +export function RouteWorkload() { + return ( +
+ {probes.map((probe) => ( + + ))} + {probes.map((probe) => ( + + ))} + +
+ ) +} diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json b/benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json new file mode 100644 index 0000000000..3fcb59196e --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "bench.ts", "vite.config.ts"] +} diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts new file mode 100644 index 0000000000..1c999f2d92 --- /dev/null +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import react from '@vitejs/plugin-react' + +const rootDir = fileURLToPath(new URL('.', import.meta.url)) + +export default defineConfig({ + root: rootDir, + plugins: [ + tanstackStart({ + srcDirectory: 'src', + }), + react(), + ], + build: { + outDir: './dist', + emptyOutDir: true, + minify: false, + }, +}) diff --git a/benchmarks/memory/tools/report.ts b/benchmarks/memory/tools/report.ts new file mode 100644 index 0000000000..1695735f4d --- /dev/null +++ b/benchmarks/memory/tools/report.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import path from 'node:path' +import { + benchmarkName, + getGitInfo, + getProjectRoot, + printScenarioResult, + readScenarioResults, + schemaVersion, + toBenchmarkAction, + writeJson, +} from './result-utils.ts' + +const projectRoot = getProjectRoot() +const results = readScenarioResults() + +if (results.length === 0) { + throw new Error('No memory benchmark scenario results found') +} + +const generatedAt = new Date().toISOString() +const git = getGitInfo() +const current = { + schemaVersion, + benchmarkName, + generatedAt, + sha: git.sha, + status: { + state: 'success', + command: 'pnpm nx run @benchmarks/memory:report', + git, + }, + metrics: results, +} + +writeJson(path.join(projectRoot, 'results', 'current.json'), current) +writeJson( + path.join(projectRoot, 'results', 'benchmark-action.json'), + toBenchmarkAction(results), +) + +process.stdout.write('Memory Benchmarks\n') +for (const result of results) { + printScenarioResult(result) +} diff --git a/benchmarks/memory/tools/result-utils.ts b/benchmarks/memory/tools/result-utils.ts new file mode 100644 index 0000000000..91cfeda68c --- /dev/null +++ b/benchmarks/memory/tools/result-utils.ts @@ -0,0 +1,299 @@ +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +export const schemaVersion = 1 +export const benchmarkName = 'Memory Usage (retained heap)' + +export interface MemorySample { + iteration: number + [key: string]: number +} + +export interface ScenarioConfig { + iterations: number + warmupIterations: number + batchSize: number + [key: string]: number +} + +export interface ScenarioMetrics { + retainedHeapDeltaBytes: number + retainedHeapBytesPerOperation: number + retainedHeapSlopeBytesPerOperation: number + peakHeapUsedBytes: number + peakRssBytes?: number + peakExternalBytes?: number + domNodeDelta?: number + jsEventListenerDelta?: number + [key: string]: number | undefined +} + +export interface ScenarioResult { + schemaVersion: number + id: string + label: string + framework: string + runtime: string + scenario: string + measuredAt: string + generatedAt: string + durationMs: number + config: ScenarioConfig + metrics: ScenarioMetrics + samples: Array +} + +export interface GitInfo { + sha?: string + branch?: string + dirty: boolean +} + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +) +const scenarioResultsDir = path.join(projectRoot, 'results', 'scenarios') + +const intFormat = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) + +const fixedFormat = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +export function readPositiveIntegerEnv(name: string, fallback: number) { + const raw = process.env[name] + if (!raw) { + return fallback + } + + const value = Number.parseInt(raw, 10) + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${raw}`) + } + + return value +} + +export function readScenarioResults(): Array { + if (!fs.existsSync(scenarioResultsDir)) { + return [] + } + + return fs + .readdirSync(scenarioResultsDir) + .filter((fileName) => fileName.endsWith('.json')) + .map((fileName) => { + const filePath = path.join(scenarioResultsDir, fileName) + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as ScenarioResult + }) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +export function writeScenarioResult(result: ScenarioResult) { + fs.mkdirSync(scenarioResultsDir, { recursive: true }) + + const filePath = path.join(scenarioResultsDir, `${result.id}.json`) + fs.writeFileSync(filePath, `${JSON.stringify(result, null, 2)}\n`) + return filePath +} + +export function createScenarioResult({ + id, + label, + framework, + runtime, + scenario, + config, + startedAt, + metrics, + samples, +}: { + id: string + label: string + framework: string + runtime: string + scenario: string + config: ScenarioConfig + startedAt: Date + metrics: ScenarioMetrics + samples: Array +}): ScenarioResult { + return { + schemaVersion, + id, + label, + framework, + runtime, + scenario, + measuredAt: startedAt.toISOString(), + generatedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt.getTime(), + config, + metrics, + samples, + } +} + +export function computeSlopeBytesPerOperation( + samples: Array, + getBytes: (sample: TSample) => number, +) { + const points = samples + .map((sample) => ({ + x: sample.iteration, + y: getBytes(sample), + })) + .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)) + + if (points.length < 2) { + return 0 + } + + const meanX = points.reduce((sum, point) => sum + point.x, 0) / points.length + const meanY = points.reduce((sum, point) => sum + point.y, 0) / points.length + + let numerator = 0 + let denominator = 0 + for (const point of points) { + const dx = point.x - meanX + numerator += dx * (point.y - meanY) + denominator += dx * dx + } + + return denominator === 0 ? 0 : numerator / denominator +} + +export function formatBytes( + bytes: number | undefined, + opts: { signed?: boolean } = {}, +) { + if (typeof bytes !== 'number' || !Number.isFinite(bytes)) { + return 'n/a' + } + + const signed = opts.signed === true + const sign = signed && bytes !== 0 ? (bytes > 0 ? '+' : '-') : '' + const absBytes = Math.abs(bytes) + + if (absBytes < 1024) { + return `${sign}${intFormat.format(absBytes)} B` + } + + const kib = absBytes / 1024 + if (kib < 1024) { + return `${sign}${fixedFormat.format(kib)} KiB` + } + + return `${sign}${fixedFormat.format(kib / 1024)} MiB` +} + +function formatNumber( + value: number | undefined, + opts: { signed?: boolean } = {}, +) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 'n/a' + } + + const signed = opts.signed === true + const sign = signed && value !== 0 ? (value > 0 ? '+' : '-') : '' + return `${sign}${intFormat.format(Math.abs(value))}` +} + +export function printScenarioResult(result: ScenarioResult) { + const { metrics } = result + const lines = [ + '', + result.id, + ` iterations ${formatNumber(result.config.iterations)}`, + ` warmup iterations ${formatNumber(result.config.warmupIterations)}`, + ` retained heap delta ${formatBytes(metrics.retainedHeapDeltaBytes, { signed: true })}`, + ` retained heap / op ${formatBytes(metrics.retainedHeapBytesPerOperation)}/op`, + ` retained heap slope ${formatBytes(metrics.retainedHeapSlopeBytesPerOperation, { signed: true })}/op`, + ` peak heap used ${formatBytes(metrics.peakHeapUsedBytes)}`, + ] + + if (Number.isFinite(metrics.peakRssBytes)) { + lines.push( + ` peak rss ${formatBytes(metrics.peakRssBytes)}`, + ) + } + + if (Number.isFinite(metrics.peakExternalBytes)) { + lines.push( + ` peak external ${formatBytes(metrics.peakExternalBytes)}`, + ) + } + + if (Number.isFinite(metrics.domNodeDelta)) { + lines.push( + ` DOM node delta ${formatNumber(metrics.domNodeDelta, { signed: true })}`, + ) + } + + if (Number.isFinite(metrics.jsEventListenerDelta)) { + lines.push( + ` JS event listener delta ${formatNumber(metrics.jsEventListenerDelta, { signed: true })}`, + ) + } + + lines.push('') + process.stdout.write(`${lines.join('\n')}\n`) +} + +export function getGitInfo(): GitInfo { + const git = (args: Array) => { + try { + return execFileSync('git', args, { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return undefined + } + } + + const sha = git(['rev-parse', 'HEAD']) + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']) + const dirty = Boolean(git(['status', '--porcelain'])) + + return { + sha, + branch, + dirty, + } +} + +export function toBenchmarkAction(results: Array) { + return results.map((result) => ({ + name: result.id, + unit: 'bytes', + value: Math.max(0, Math.round(result.metrics.retainedHeapDeltaBytes)), + extra: [ + `slope=${Math.round(result.metrics.retainedHeapSlopeBytesPerOperation)} B/op`, + `peak_heap=${Math.round(result.metrics.peakHeapUsedBytes)} B`, + typeof result.metrics.peakRssBytes === 'number' && + Number.isFinite(result.metrics.peakRssBytes) + ? `peak_rss=${Math.round(result.metrics.peakRssBytes)} B` + : undefined, + ] + .filter(Boolean) + .join('; '), + })) +} + +export function writeJson(filePath: string, value: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +export function getProjectRoot() { + return projectRoot +} diff --git a/benchmarks/memory/tools/summary.ts b/benchmarks/memory/tools/summary.ts new file mode 100644 index 0000000000..1f4678d60b --- /dev/null +++ b/benchmarks/memory/tools/summary.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import { parseArgs } from 'node:util' +import { printScenarioResult, readScenarioResults } from './result-utils.ts' + +const { values } = parseArgs({ + args: process.argv.slice(2), + allowPositionals: false, + strict: false, + options: { + scope: { type: 'string' }, + }, +}) + +const scope = values.scope +const results = readScenarioResults().filter((result) => { + return !scope || result.id === scope || result.id.startsWith(`${scope}.`) +}) + +if (results.length === 0) { + process.stdout.write( + scope + ? `No memory benchmark results found for ${scope}.\n` + : 'No memory benchmark results found.\n', + ) + process.exit(0) +} + +process.stdout.write('Memory Benchmarks\n') +for (const result of results) { + printScenarioResult(result) +} diff --git a/benchmarks/memory/tools/tsconfig.json b/benchmarks/memory/tools/tsconfig.json new file mode 100644 index 0000000000..e6f83d5e30 --- /dev/null +++ b/benchmarks/memory/tools/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["*.ts"] +} diff --git a/benchmarks/memory/tsconfig.json b/benchmarks/memory/tsconfig.json new file mode 100644 index 0000000000..231a7a11bf --- /dev/null +++ b/benchmarks/memory/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "allowJs": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["node", "vite/client"] + }, + "exclude": ["node_modules", "**/dist"] +} diff --git a/package.json b/package.json index 742fe4a82c..1b1085ad92 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "benchmark:bundle-size:history": "node scripts/benchmarks/bundle-size/history.mjs", "benchmark:bundle-size:analyze": "node scripts/benchmarks/bundle-size/analyze.mjs", "benchmark:client-nav": "pnpm nx run @benchmarks/client-nav:test:perf", + "benchmark:memory": "pnpm nx run @benchmarks/memory:test:memory", + "benchmark:memory:react": "pnpm nx run @benchmarks/memory:test:memory:react", "benchmark:ssr": "pnpm nx run @benchmarks/ssr:test:perf", "build": "nx affected --target=build --exclude=e2e/** --exclude=examples/**", "build:all": "nx run-many --target=build --exclude=examples/** --exclude=e2e/**", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7938cd51f4..096927c073 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -313,6 +313,43 @@ importers: specifier: ^4.1.4 version: 4.1.4(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@27.0.0(postcss@8.5.15))(msw@2.7.0(@types/node@25.0.9)(typescript@6.0.2))(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + benchmarks/memory: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + benchmarks/ssr: dependencies: '@tanstack/react-router': From 734f36beb969af996888dfdcc07d77aa4196b3ed Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 8 Jun 2026 10:05:39 +0200 Subject: [PATCH 2/3] fix(benchmarks): align memory lockfile peers --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 096927c073..b52d41ea8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,13 +342,13 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.14 - version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) benchmarks/ssr: dependencies: From bae5e406be7ab89911ac3a5e7d8d1985b15682ba Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 8 Jun 2026 11:27:24 +0200 Subject: [PATCH 3/3] wait for pending macrotasks before retained-heap GC sampling --- benchmarks/memory/README.md | 4 ++++ .../scenarios/react/ssr/repeated-requests/bench.ts | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/benchmarks/memory/README.md b/benchmarks/memory/README.md index cc53132e0b..517c03f9bf 100644 --- a/benchmarks/memory/README.md +++ b/benchmarks/memory/README.md @@ -29,6 +29,10 @@ Tune local run sizes with environment variables: MEMORY_BENCH_ITERATIONS=10000 MEMORY_BENCH_WARMUP_ITERATIONS=1000 pnpm nx run @benchmarks/memory:test:memory:react:ssr:repeated-requests ``` +The SSR benchmark also waits for pending macrotasks before retained-heap GC +sampling. Tune this with `MEMORY_BENCH_SETTLE_TICKS` when investigating runtime +scheduler behavior. + ## Report After leaf targets have run, merge isolated scenario artifacts into benchmark-action compatible outputs: diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts index 339de52416..514b1161fe 100644 --- a/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts +++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts @@ -77,6 +77,12 @@ function forceGc() { } } +async function settlePendingWork(ticks: number) { + for (let index = 0; index < ticks; index++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + function readNodeMemory() { const memory = process.memoryUsage() @@ -96,12 +102,14 @@ const warmupIterations = readPositiveIntegerEnv( 100, ) const batchSize = readPositiveIntegerEnv('MEMORY_BENCH_BATCH_SIZE', 50) +const settleTicks = readPositiveIntegerEnv('MEMORY_BENCH_SETTLE_TICKS', 5) const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href const { default: handler } = (await import(appModuleUrl)) as { default: StartRequestHandler } await runRequests(handler, warmupIterations, 0) +await settlePendingWork(settleTicks) forceGc() const baseline = readNodeMemory() @@ -124,6 +132,7 @@ for (let completed = 0; completed < iterations; completed += batchSize) { beforeGc.arrayBuffersBytes, ) + await settlePendingWork(settleTicks) forceGc() const afterGc = readNodeMemory() const iteration = completed + nextBatchSize @@ -157,6 +166,7 @@ const result = createScenarioResult({ iterations, warmupIterations, batchSize, + settleTicks, }, metrics: { baselineRssBytes: baseline.rssBytes,