Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ TanStack Router is a type-safe router with built-in caching and URL state manage
- Framework-agnostic core logic separated from React/Solid bindings
- Type-safe routing with search params and path params
- Use workspace protocol for internal dependencies (`workspace:*`)
- Always use curly braces for `if`, `else`, loops, and similar control statements. Never write one-line bodies like `if (foo) x = 1`.

## Dev environment tips

Expand Down
34 changes: 34 additions & 0 deletions benchmarks/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,41 @@ Each package has `minimal` and `full` scenarios:
pnpm nx run @benchmarks/bundle-size:build
```

Run one or more scenarios during local optimization:

```bash
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal,react-router.full
```

Filtered runs build only the package projects needed by selected scenarios. Full runs build all package projects needed by all scenarios. If the required packages are already built and unchanged, skip that step:

```bash
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal --skip-package-builds
```

This writes:

- `benchmarks/bundle-size/results/current.json`
- `benchmarks/bundle-size/results/benchmark-action.json`

`current.json` includes run status, selected package build projects, per-scenario totals, per-file sizes, and the emitted JS files used for measurement. Dist paths use `scenarioDir`/`outDir`, e.g. `react-router.minimal` maps to `benchmarks/bundle-size/dist/react-router-minimal/`.

## Local Query Tools

```bash
pnpm benchmark:bundle-size:query --id react-router.minimal
pnpm benchmark:bundle-size:diff --baseline /tmp/base-current.json --id react-router.minimal
pnpm benchmark:bundle-size:history --id react-router.minimal --top-deltas 20
```

For source attribution, run an analysis build. This uses hidden source maps and writes source estimates into `current.json`; those estimates are for investigation only, not tracking.

```bash
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal --analysis
pnpm benchmark:bundle-size:analyze --id react-router.minimal --top-sources 30
```

## CI Reporting

- PR workflow generates a sticky comment with:
Expand All @@ -56,6 +86,10 @@ The measurement script supports optional interfaces for historical backfilling:
- `--sha`
- `--measured-at`
- `--append-history`
- `--scenario`
- `--analysis`
- `--sourcemap`
- `--skip-package-builds`

These are intended for one-off scripts that replay historical commits and append results to the same history dataset shape used for chart generation.
If `--append-history` points at a `data.js` file, output is written as `window.BENCHMARK_DATA = ...` for direct GitHub Pages compatibility.
8 changes: 8 additions & 0 deletions benchmarks/bundle-size/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
"scripts": {
"build": "node ../../scripts/benchmarks/bundle-size/measure.mjs"
},
"nx": {
"targets": {
"build": {
"cache": false,
"dependsOn": []
}
}
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/solid-router": "workspace:^",
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"test:types": "nx affected --target=test:types --exclude=examples/**",
"test:e2e": "nx run-many --target=test:e2e",
"benchmark:bundle-size": "pnpm nx run @benchmarks/bundle-size:build",
"benchmark:bundle-size:query": "node scripts/benchmarks/bundle-size/query.mjs",
"benchmark:bundle-size:diff": "node scripts/benchmarks/bundle-size/diff.mjs",
"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:ssr": "pnpm nx run @benchmarks/ssr:test:perf",
"build": "nx affected --target=build --exclude=e2e/** --exclude=examples/**",
Expand All @@ -35,6 +39,7 @@
"labeler-generate": "node scripts/generate-labeler-config.ts",
"cleanup-empty-packages": "node scripts/cleanup-empty-packages.mjs",
"test:docs": "node scripts/verify-links.ts",
"ts:symbol-references": "node scripts/ts-symbol-references.mjs",
"vite-ecosystem-ci:build": "nx run-many --targets=build --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start,@tanstack/react-start-client,@tanstack/react-start-server --skipRemoteCache",
"vite-ecosystem-ci:before-test": "pnpm exec playwright install chromium",
"vite-ecosystem-ci:test": "nx run-many --targets=test:unit --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start-client --skipRemoteCache && nx run-many --target=test:e2e --projects=tanstack-router-e2e-react-basic-file-based,tanstack-router-e2e-react-basic-file-based-code-splitting,tanstack-react-start-e2e-basic,tanstack-vue-start-e2e-basic,tanstack-solid-start-e2e-basic --skipRemoteCache"
Expand Down
60 changes: 60 additions & 0 deletions scripts/benchmarks/bundle-size/analyze.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env node

import fs from 'node:fs'
import path from 'node:path'
import { parseArgs } from 'node:util'

const { values } = parseArgs({
allowPositionals: false,
options: {
current: {
type: 'string',
default: 'benchmarks/bundle-size/results/current.json',
},
id: { type: 'string' },
'top-sources': { type: 'string', default: '30' },
json: { type: 'boolean' },
Comment on lines +15 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add validation for --top-sources.

Please validate that --top-sources is a positive integer before applying slice, so bad inputs fail explicitly instead of producing confusing output.

Also applies to: 52-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/benchmarks/bundle-size/analyze.mjs` around lines 15 - 16, Validate
the `--top-sources` option (key 'top-sources') before using it with
Array.prototype.slice: parse the value to a number (e.g., using parseInt or
Number), check Number.isInteger(value) and value > 0, and if the check fails
output a clear error and exit non-zero; update the code paths where
'top-sources' is read (and where it's applied with slice around the slice usage
at the current slice call) to use the validated integer variable so bad inputs
fail explicitly.

},
})

if (!values.id) {
throw new Error('Missing required argument: --id')
}

const current = JSON.parse(
fs.readFileSync(path.resolve(values.current), 'utf8'),
)
const metric = (current.metrics || []).find((item) => item.id === values.id)

if (!metric) {
throw new Error(`Unknown bundle-size metric: ${values.id}`)
}

if (!metric.sources) {
throw new Error(
`No source attribution found for ${values.id}. Re-run measure with --analysis.`,
)
}

const sourceBytes = new Map()
for (const chunk of metric.sources) {
for (const source of chunk.sources || []) {
sourceBytes.set(
source.source,
(sourceBytes.get(source.source) || 0) + source.estimatedBytes,
)
}
}

const rows = [...sourceBytes]
.map(([source, estimatedBytes]) => ({ source, estimatedBytes }))
.sort((a, b) => b.estimatedBytes - a.estimatedBytes)
.slice(0, Number.parseInt(values['top-sources'], 10))

if (values.json) {
process.stdout.write(JSON.stringify(rows, null, 2) + '\n')
} else {
for (const row of rows) {
process.stdout.write(`${row.estimatedBytes} ${row.source}\n`)
}
}
78 changes: 78 additions & 0 deletions scripts/benchmarks/bundle-size/diff.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node

import fs from 'node:fs'
import path from 'node:path'
import { parseArgs } from 'node:util'

const { values } = parseArgs({
allowPositionals: false,
options: {
baseline: { type: 'string' },
current: {
type: 'string',
default: 'benchmarks/bundle-size/results/current.json',
},
id: { type: 'string' },
json: { type: 'boolean' },
},
})

if (!values.baseline) {
throw new Error('Missing required argument: --baseline')
}

function readCurrent(filePath) {
return JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'))
}

function byId(current) {
return new Map((current.metrics || []).map((metric) => [metric.id, metric]))
}

const baselineById = byId(readCurrent(values.baseline))
const currentById = byId(readCurrent(values.current))
const ids = values.id
? [values.id]
: [...new Set([...baselineById.keys(), ...currentById.keys()])].sort()

Comment on lines +34 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fail fast on unknown --id.

When --id is provided but absent from both baseline and current, returning an n/a row hides typos. Throwing a clear error matches the behavior of the other bundle-size CLIs.

Suggested guard
 const baselineById = byId(readCurrent(values.baseline))
 const currentById = byId(readCurrent(values.current))
+
+if (
+  values.id &&
+  !baselineById.has(values.id) &&
+  !currentById.has(values.id)
+) {
+  throw new Error(`Unknown bundle-size metric: ${values.id}`)
+}
+
 const ids = values.id
   ? [values.id]
   : [...new Set([...baselineById.keys(), ...currentById.keys()])].sort()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ids = values.id
? [values.id]
: [...new Set([...baselineById.keys(), ...currentById.keys()])].sort()
const baselineById = byId(readCurrent(values.baseline))
const currentById = byId(readCurrent(values.current))
if (
values.id &&
!baselineById.has(values.id) &&
!currentById.has(values.id)
) {
throw new Error(`Unknown bundle-size metric: ${values.id}`)
}
const ids = values.id
? [values.id]
: [...new Set([...baselineById.keys(), ...currentById.keys()])].sort()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/benchmarks/bundle-size/diff.mjs` around lines 34 - 37, The code
currently builds ids from values.id or from union of baselineById/currentById,
which hides typos when a user passes --id that doesn't exist; add a guard: if
values.id is truthy and neither baselineById.has(values.id) nor
currentById.has(values.id), throw a clear error (or exit non‑zero) indicating
the requested id is unknown; update the logic around the ids assignment
(referencing values.id, baselineById, currentById, and the ids variable) to
perform this existence check before proceeding.

const rows = ids.map((id) => {
const baseline = baselineById.get(id)
const current = currentById.get(id)
return {
id,
baseline: baseline?.gzipBytes,
current: current?.gzipBytes,
delta:
Number.isFinite(baseline?.gzipBytes) &&
Number.isFinite(current?.gzipBytes)
? current.gzipBytes - baseline.gzipBytes
: undefined,
initialDelta:
Number.isFinite(baseline?.initialGzipBytes) &&
Number.isFinite(current?.initialGzipBytes)
? current.initialGzipBytes - baseline.initialGzipBytes
: undefined,
rawDelta:
Number.isFinite(baseline?.rawBytes) && Number.isFinite(current?.rawBytes)
? current.rawBytes - baseline.rawBytes
: undefined,
brotliDelta:
Number.isFinite(baseline?.brotliBytes) &&
Number.isFinite(current?.brotliBytes)
? current.brotliBytes - baseline.brotliBytes
: undefined,
}
})

if (values.json) {
process.stdout.write(JSON.stringify(rows, null, 2) + '\n')
} else {
for (const row of rows) {
const delta = Number.isFinite(row.delta)
? `${row.delta >= 0 ? '+' : ''}${row.delta}`
: 'n/a'
process.stdout.write(
`${row.id} ${row.baseline ?? 'n/a'} -> ${row.current ?? 'n/a'} (${delta}) initial=${row.initialDelta ?? 'n/a'} raw=${row.rawDelta ?? 'n/a'} brotli=${row.brotliDelta ?? 'n/a'}\n`,
)
}
}
85 changes: 85 additions & 0 deletions scripts/benchmarks/bundle-size/history.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env node

import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
import vm from 'node:vm'
import { parseArgs } from 'node:util'

const HISTORY_PATH = 'benchmarks/bundle-size/data.js'

const { values } = parseArgs({
allowPositionals: false,
options: {
history: { type: 'string' },
id: { type: 'string' },
'top-deltas': { type: 'string', default: '20' },
json: { type: 'boolean' },
Comment on lines +15 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate --top-deltas input before slicing.

Invalid or negative values currently degrade into ambiguous output. Add a finite positive-integer check and throw a clear error.

Also applies to: 75-75

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/benchmarks/bundle-size/history.mjs` around lines 15 - 16, The CLI
option 'top-deltas' must be validated before being used for array slicing:
locate the option definition (key 'top-deltas') and the code that reads
argv['top-deltas'] (and any usage like .slice(0, topDeltas) around the history
processing) and add a check that the parsed value is a finite positive integer
(Number.isFinite and Number.isInteger and > 0). If the check fails, throw or
exit with a clear error message stating that --top-deltas must be a positive
integer. Apply the same validation at both usage sites referenced in the diff
(the option definition and the slicing usage).

},
})

function parseHistory(raw) {
const trimmed = raw.trim()
if (trimmed.startsWith('window.BENCHMARK_DATA')) {
const sandbox = { window: {} }
vm.runInNewContext(trimmed, sandbox, { timeout: 1000 })
return sandbox.window.BENCHMARK_DATA
Comment on lines +22 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What do Node.js docs say about using node:vm with untrusted code, and is it recommended as a security boundary?

💡 Result:

The Node.js documentation explicitly states that the node:vm module is not a security mechanism and should not be used to run untrusted code [1][2]. The official documentation repeatedly emphasizes this warning, clarifying that the term "sandbox" is used in the context of the module only to refer to a separate execution context, not to provide security guarantees [3][4]. It is not recommended as a security boundary because it does not provide true isolation [5]. The node:vm module allows code to be compiled and run within V8 virtual machine contexts, which is primarily a tool for scoping variables and creating separate global environments [1][2]. Malicious code can easily escape these contexts by walking up the prototype chain to access host-realm objects or the host process, effectively bypassing the intended isolation [5]. Consequently, attempting to use node:vm to safely execute untrusted code is considered inherently insecure and is not a valid approach for building secure sandboxes [6][5].

Citations:


🏁 Script executed:

# Find the file and examine the parseHistory function and surrounding context
cat -n scripts/benchmarks/bundle-size/history.mjs

Repository: TanStack/router

Length of output: 2937


🏁 Script executed:

# Search for CLI argument handling related to history
rg -i "history" scripts/benchmarks/bundle-size/ -A 2 -B 2

Repository: TanStack/router

Length of output: 13412


🏁 Script executed:

# Search for how the history file is used in the benchmark script
rg -i "\.mjs|history" --type javascript | grep -i benchmark | head -20

Repository: TanStack/router

Length of output: 94


Do not execute history file payloads with vm.runInNewContext.

The --history CLI argument (line 44-45) accepts user-provided files and passes them to parseHistory, which evaluates code via vm.runInNewContext (lines 22-25). Node.js documentation explicitly warns that the vm module is not a security mechanism and should not be used to run untrusted code—it does not provide true isolation.

Since the benchmark data is JSON (optionally wrapped with window.BENCHMARK_DATA = ), replace the code execution with string extraction and JSON.parse:

if (trimmed.startsWith('window.BENCHMARK_DATA')) {
  const jsonStr = trimmed.replace(/^window\.BENCHMARK_DATA\s*=\s*/, '').replace(/;$/, '')
  return JSON.parse(jsonStr)
}

This eliminates arbitrary code execution while parsing the data safely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/benchmarks/bundle-size/history.mjs` around lines 22 - 25, The code in
parseHistory uses vm.runInNewContext to evaluate user-provided payloads (the
block where trimmed.startsWith('window.BENCHMARK_DATA') creates sandbox and
calls vm.runInNewContext), which allows arbitrary code execution; instead, strip
the optional "window.BENCHMARK_DATA =" prefix and any trailing semicolon from
the trimmed string and parse the remaining content with JSON.parse to return the
benchmark object (i.e., remove the sandbox/vm.runInNewContext logic and replace
it with safe string extraction + JSON.parse).

}
return JSON.parse(trimmed)
}

function readHistoryFromGit() {
for (const ref of ['origin/gh-pages', 'gh-pages']) {
try {
return execFileSync('git', ['show', `${ref}:${HISTORY_PATH}`], {
encoding: 'utf8',
})
} catch {}
}

throw new Error(
`Could not read ${HISTORY_PATH} from origin/gh-pages or gh-pages. Run: git fetch origin gh-pages`,
)
}

const raw = values.history
? fs.readFileSync(values.history, 'utf8')
: readHistoryFromGit()
const history = parseHistory(raw)
const entries = history.entries?.['Bundle Size (gzip)'] || []
const previous = new Map()
const deltas = []

for (const entry of entries) {
for (const bench of entry.benches || []) {
if (values.id && bench.name !== values.id) {
continue
}

const prior = previous.get(bench.name)
if (prior !== undefined && prior !== bench.value) {
deltas.push({
id: bench.name,
delta: bench.value - prior,
value: bench.value,
sha: entry.commit?.id,
message: String(entry.commit?.message || '').split('\n')[0],
timestamp: entry.commit?.timestamp,
})
}

previous.set(bench.name, bench.value)
}
}

deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
const rows = deltas.slice(0, Number.parseInt(values['top-deltas'], 10))

if (values.json) {
process.stdout.write(JSON.stringify(rows, null, 2) + '\n')
} else {
for (const row of rows) {
process.stdout.write(
`${row.id} ${row.delta >= 0 ? '+' : ''}${row.delta} => ${row.value} ${row.sha?.slice(0, 12)} ${row.message}\n`,
)
}
}
Loading
Loading