diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 00000000000..08f6e8dc988 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,93 @@ +name: Bundle Size + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + benchmark-pr: + name: Benchmark PR + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.1 + with: + fetch-depth: 0 + + - name: Setup Tools + uses: tanstack/config/.github/setup@main + + - name: Measure Bundle Size + run: pnpm nx run tanstack-router-e2e-bundle-size:build --outputStyle=stream --skipRemoteCache + + - name: Read Historical Data (if available) + run: | + mkdir -p e2e/bundle-size/results + if git fetch --depth=1 origin gh-pages; then + if git show origin/gh-pages:benchmarks/bundle-size/data.js > e2e/bundle-size/results/history-data.js 2>/dev/null; then + echo "Loaded bundle-size history from gh-pages." + else + rm -f e2e/bundle-size/results/history-data.js + echo "No bundle-size history found on gh-pages yet." + fi + fi + + - name: Build PR Report + run: | + node scripts/benchmarks/bundle-size/pr-report.mjs \ + --current e2e/bundle-size/results/current.json \ + --history e2e/bundle-size/results/history-data.js \ + --output e2e/bundle-size/results/pr-comment.md \ + --base-sha "${{ github.event.pull_request.base.sha }}" \ + --dashboard-url "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/benchmarks/bundle-size/" + + - name: Upsert Sticky PR Comment + if: github.event.pull_request.head.repo.fork == false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/benchmarks/common/upsert-pr-comment.mjs \ + --pr "${{ github.event.pull_request.number }}" \ + --body-file e2e/bundle-size/results/pr-comment.md + + benchmark-main: + name: Publish Bundle Size History + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository_owner == 'TanStack' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.1 + with: + fetch-depth: 0 + + - name: Setup Tools + uses: tanstack/config/.github/setup@main + + - name: Measure Bundle Size + run: pnpm nx run tanstack-router-e2e-bundle-size:build --outputStyle=stream --skipRemoteCache + + - name: Publish Benchmark Dashboard + uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7 + with: + tool: customSmallerIsBetter + benchmark-name: Bundle Size (gzip) + output-file-path: e2e/bundle-size/results/benchmark-action.json + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + gh-pages-branch: gh-pages + benchmark-data-dir-path: benchmarks/bundle-size + max-items-in-chart: 200 + summary-always: true + comment-on-alert: false + fail-on-alert: false diff --git a/e2e/bundle-size/.gitignore b/e2e/bundle-size/.gitignore new file mode 100644 index 00000000000..dc7703a1bee --- /dev/null +++ b/e2e/bundle-size/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +scenarios/*/src/routeTree.gen.ts + +results/*.json +results/*.md +results/*.js +!results/.gitkeep diff --git a/e2e/bundle-size/README.md b/e2e/bundle-size/README.md new file mode 100644 index 00000000000..0f8ea0267d9 --- /dev/null +++ b/e2e/bundle-size/README.md @@ -0,0 +1,58 @@ +# Bundle Size Benchmarks + +This workspace contains deterministic bundle-size fixtures for: + +- `@tanstack/react-router` +- `@tanstack/solid-router` +- `@tanstack/vue-router` +- `@tanstack/react-start` +- `@tanstack/solid-start` + +Each package has two scenarios: + +- `minimal`: Small route app with `__root` + index route that renders `hello world` +- `full`: Same route shape plus a broad root-level harness that imports/uses the full hooks/components surface +- Start `full` scenarios also exercise `createServerFn`, `createMiddleware`, and `useServerFn` + +## Design Notes + +- Scenarios use file-based routing as the default app style. +- Router scenarios use `@tanstack/router-plugin/vite` with `autoCodeSplitting: true`. +- Start scenarios use `@tanstack/-start/plugin/vite` with router code-splitting enabled. +- Full-surface coverage is manually maintained (no strict export-coverage gate). +- Metrics are measured from initial-load JS graph only and reported as raw/gzip/brotli bytes. +- Gzip is the primary tracking signal for PR deltas and historical charting. + +## Local Run + +```bash +pnpm nx run tanstack-router-e2e-bundle-size:build +``` + +This writes: + +- `e2e/bundle-size/results/current.json` +- `e2e/bundle-size/results/benchmark-action.json` + +## CI Reporting + +- PR workflow generates a sticky comment with: + - current gzip values + - baseline delta + - inline sparkline trend +- Pushes to `main` publish historical chart data to GitHub Pages via `benchmark-action/github-action-benchmark`. + +## Manual Update Policy + +When router/start public hooks/components evolve, update the corresponding `*-full/src/routes/__root.tsx` harness to keep full scenarios representative. + +## Backfill Readiness + +The measurement script supports optional interfaces for historical backfilling: + +- `--sha` +- `--measured-at` +- `--append-history` + +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. diff --git a/e2e/bundle-size/package.json b/e2e/bundle-size/package.json new file mode 100644 index 00000000000..46975664c74 --- /dev/null +++ b/e2e/bundle-size/package.json @@ -0,0 +1,30 @@ +{ + "name": "tanstack-router-e2e-bundle-size", + "private": true, + "type": "module", + "scripts": { + "build": "node ../../scripts/benchmarks/bundle-size/measure.mjs" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/solid-router": "workspace:^", + "@tanstack/vue-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "solid-js": "^1.9.10", + "vue": "^3.5.25" + }, + "devDependencies": { + "@tanstack/router-plugin": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-vue": "^6.0.1", + "@vitejs/plugin-vue-jsx": "^4.1.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vite-plugin-solid": "^2.11.10" + } +} diff --git a/e2e/bundle-size/results/.gitkeep b/e2e/bundle-size/results/.gitkeep new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/e2e/bundle-size/results/.gitkeep @@ -0,0 +1 @@ + diff --git a/e2e/bundle-size/scenarios/react-router-full/index.html b/e2e/bundle-size/scenarios/react-router-full/index.html new file mode 100644 index 00000000000..1117e754676 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-full/index.html @@ -0,0 +1,12 @@ + + + + + + react-router-full + + +
+ + + diff --git a/e2e/bundle-size/scenarios/react-router-full/src/main.tsx b/e2e/bundle-size/scenarios/react-router-full/src/main.tsx new file mode 100644 index 00000000000..ce55e38d84b --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-full/src/main.tsx @@ -0,0 +1,22 @@ +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ + routeTree, + scrollRestoration: true, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} +if (!rootElement.innerHTML) { + ReactDOM.createRoot(rootElement).render() +} diff --git a/e2e/bundle-size/scenarios/react-router-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-router-full/src/routes/__root.tsx new file mode 100644 index 00000000000..4c5e443555b --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-full/src/routes/__root.tsx @@ -0,0 +1,192 @@ +import { + Asset, + Await, + Block, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + HeadContent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScriptOnce, + Scripts, + ScrollRestoration, + createLink, + createRootRoute, + linkOptions, + useAwaited, + useBlocker, + useCanGoBack, + useElementScrollRestoration, + useHydrated, + useLinkProps, + useLoaderData, + useLoaderDeps, + useLayoutEffect, + useLocation, + useMatch, + useMatchRoute, + useMatches, + useNavigate, + useParams, + useParentMatches, + useChildMatches, + useRouteContext, + useRouter, + useRouterState, + useSearch, + useStableCallback, + useTags, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const router = useRouter() + const hydrated = useHydrated() + const awaited = useAwaited({ promise: Promise.resolve('ready') }) + const linkProps = useLinkProps({ to: '/' } as any) + const matchRoute = useMatchRoute() + const matches = useMatches() + const parentMatches = useParentMatches() + const childMatches = useChildMatches() + const match = useMatch({ strict: false, shouldThrow: false } as any) + const loaderDeps = useLoaderDeps({ strict: false } as any) + const loaderData = useLoaderData({ strict: false } as any) + const params = useParams({ strict: false } as any) + const search = useSearch({ strict: false } as any) + const routeContext = useRouteContext({ strict: false } as any) + const routerState = useRouterState({ select: (state) => state.status } as any) + const location = useLocation() + const canGoBack = useCanGoBack() + const navigate = useNavigate() + const stableCallback = useStableCallback(() => {}) + const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' }) + const tags = useTags() + + useLayoutEffect(() => {}, []) + useBlocker({ + shouldBlockFn: () => false, + disabled: true, + withResolver: false, + }) + + const linkFactoryResult = linkOptions({ to: '/' } as any) + const routeMatchResult = matchRoute({ to: '/' } as any) + const SvgLink = createLink('svg') + + const hooksAndComponents = [ + useAwaited, + useHydrated, + useLinkProps, + useMatchRoute, + useMatches, + useParentMatches, + useChildMatches, + useMatch, + useLoaderDeps, + useLoaderData, + useLayoutEffect, + useBlocker, + useNavigate, + useParams, + useSearch, + useRouteContext, + useRouter, + useRouterState, + useLocation, + useCanGoBack, + useStableCallback, + useElementScrollRestoration, + useTags, + Await, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScrollRestoration, + Block, + ScriptOnce, + Asset, + HeadContent, + Scripts, + ] + + ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = { + hooksAndComponents, + } + + void awaited + void linkFactoryResult + void matches + void parentMatches + void childMatches + void match + void loaderDeps + void loaderData + void params + void search + void routeContext + void routerState + void location + void canGoBack + stableCallback() + void navigate + void scrollEntry + void tags + void routeMatchResult + + return ( + <> + + {'window.__tsr_bundle_size = true'} + + home + + + + {() => } + }> + + + + {() => } + + false} disabled withResolver={false}> + {() => } + + }> + + + + + + + + +
+
hello world
+
+ + ) +} diff --git a/e2e/bundle-size/scenarios/react-router-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-router-full/src/routes/index.tsx new file mode 100644 index 00000000000..76fb3635384 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-full/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/react-router-full/vite.config.ts b/e2e/bundle-size/scenarios/react-router-full/vite.config.ts new file mode 100644 index 00000000000..45838fe2814 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-full/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + ], +}) diff --git a/e2e/bundle-size/scenarios/react-router-minimal/index.html b/e2e/bundle-size/scenarios/react-router-minimal/index.html new file mode 100644 index 00000000000..5de4e4e509f --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-minimal/index.html @@ -0,0 +1,12 @@ + + + + + + react-router-minimal + + +
+ + + diff --git a/e2e/bundle-size/scenarios/react-router-minimal/src/main.tsx b/e2e/bundle-size/scenarios/react-router-minimal/src/main.tsx new file mode 100644 index 00000000000..667de2cdc7d --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-minimal/src/main.tsx @@ -0,0 +1,19 @@ +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ routeTree }) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} +if (!rootElement.innerHTML) { + ReactDOM.createRoot(rootElement).render() +} diff --git a/e2e/bundle-size/scenarios/react-router-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/__root.tsx new file mode 100644 index 00000000000..889395056be --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/e2e/bundle-size/scenarios/react-router-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/index.tsx new file mode 100644 index 00000000000..76fb3635384 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-minimal/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/react-router-minimal/vite.config.ts b/e2e/bundle-size/scenarios/react-router-minimal/vite.config.ts new file mode 100644 index 00000000000..45838fe2814 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-router-minimal/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + ], +}) diff --git a/e2e/bundle-size/scenarios/react-start-full/src/router.tsx b/e2e/bundle-size/scenarios/react-start-full/src/router.tsx new file mode 100644 index 00000000000..9d87d8748b5 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-full/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/bundle-size/scenarios/react-start-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-start-full/src/routes/__root.tsx new file mode 100644 index 00000000000..292383253cb --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-full/src/routes/__root.tsx @@ -0,0 +1,223 @@ +import { + Asset, + Await, + Block, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + HeadContent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScriptOnce, + Scripts, + ScrollRestoration, + createLink, + createRootRoute, + linkOptions, + useAwaited, + useBlocker, + useCanGoBack, + useElementScrollRestoration, + useHydrated, + useLinkProps, + useLoaderData, + useLoaderDeps, + useLayoutEffect, + useLocation, + useMatch, + useMatchRoute, + useMatches, + useNavigate, + useParams, + useParentMatches, + useChildMatches, + useRouteContext, + useRouter, + useRouterState, + useSearch, + useStableCallback, + useTags, +} from '@tanstack/react-router' +import { + createMiddleware, + createServerFn, + useServerFn, +} from '@tanstack/react-start' + +const requestMiddleware = createMiddleware().server(async ({ next }) => { + return next() +}) + +const functionMiddleware = createMiddleware({ type: 'function' }) + .client(async ({ next }) => { + return next() + }) + .server(async ({ next }) => { + return next() + }) + +const helloServerFn = createServerFn({ method: 'GET' }) + .middleware([requestMiddleware, functionMiddleware]) + .handler(async () => { + return 'hello from server fn' + }) + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const router = useRouter() + const hydrated = useHydrated() + const awaited = useAwaited({ promise: Promise.resolve('ready') }) + const linkProps = useLinkProps({ to: '/' } as any) + const matchRoute = useMatchRoute() + const matches = useMatches() + const parentMatches = useParentMatches() + const childMatches = useChildMatches() + const match = useMatch({ strict: false, shouldThrow: false } as any) + const loaderDeps = useLoaderDeps({ strict: false } as any) + const loaderData = useLoaderData({ strict: false } as any) + const params = useParams({ strict: false } as any) + const search = useSearch({ strict: false } as any) + const routeContext = useRouteContext({ strict: false } as any) + const routerState = useRouterState({ select: (state) => state.status } as any) + const location = useLocation() + const canGoBack = useCanGoBack() + const navigate = useNavigate() + const stableCallback = useStableCallback(() => {}) + const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' }) + const tags = useTags() + const invokeServerFn = useServerFn(helloServerFn) + + useLayoutEffect(() => {}, []) + useBlocker({ + shouldBlockFn: () => false, + disabled: true, + withResolver: false, + }) + + const linkFactoryResult = linkOptions({ to: '/' } as any) + const routeMatchResult = matchRoute({ to: '/' } as any) + const SvgLink = createLink('svg') + + const startSurface = [createMiddleware, createServerFn, useServerFn] + const hooksAndComponents = [ + useAwaited, + useHydrated, + useLinkProps, + useMatchRoute, + useMatches, + useParentMatches, + useChildMatches, + useMatch, + useLoaderDeps, + useLoaderData, + useLayoutEffect, + useBlocker, + useNavigate, + useParams, + useSearch, + useRouteContext, + useRouter, + useRouterState, + useLocation, + useCanGoBack, + useStableCallback, + useElementScrollRestoration, + useTags, + Await, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScrollRestoration, + Block, + ScriptOnce, + Asset, + HeadContent, + Scripts, + ] + + ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = { + hooksAndComponents, + startSurface, + } + + void awaited + void linkFactoryResult + void matches + void parentMatches + void childMatches + void match + void loaderDeps + void loaderData + void params + void search + void routeContext + void routerState + void location + void canGoBack + stableCallback() + void navigate + void scrollEntry + void tags + void routeMatchResult + void invokeServerFn + + return ( + + + + + + {'window.__tsr_bundle_size = true'} + + home + + + + {() => } + }> + + + + {() => } + + false} disabled withResolver={false}> + {() => } + + }> + + + + + + + + +
+
hello world
+
+ + + ) +} diff --git a/e2e/bundle-size/scenarios/react-start-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-start-full/src/routes/index.tsx new file mode 100644 index 00000000000..76fb3635384 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-full/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/react-start-full/vite.config.ts b/e2e/bundle-size/scenarios/react-start-full/vite.config.ts new file mode 100644 index 00000000000..d4e4cd980d7 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-full/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [tanstackStart(), viteReact()], +}) diff --git a/e2e/bundle-size/scenarios/react-start-minimal/src/router.tsx b/e2e/bundle-size/scenarios/react-start-minimal/src/router.tsx new file mode 100644 index 00000000000..9d87d8748b5 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-minimal/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/bundle-size/scenarios/react-start-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/__root.tsx new file mode 100644 index 00000000000..ff1da4c3046 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/__root.tsx @@ -0,0 +1,24 @@ +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} diff --git a/e2e/bundle-size/scenarios/react-start-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/index.tsx new file mode 100644 index 00000000000..76fb3635384 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-minimal/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/react-start-minimal/vite.config.ts b/e2e/bundle-size/scenarios/react-start-minimal/vite.config.ts new file mode 100644 index 00000000000..d4e4cd980d7 --- /dev/null +++ b/e2e/bundle-size/scenarios/react-start-minimal/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [tanstackStart(), viteReact()], +}) diff --git a/e2e/bundle-size/scenarios/solid-router-full/index.html b/e2e/bundle-size/scenarios/solid-router-full/index.html new file mode 100644 index 00000000000..80ef4474ee1 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-full/index.html @@ -0,0 +1,12 @@ + + + + + + solid-router-full + + +
+ + + diff --git a/e2e/bundle-size/scenarios/solid-router-full/src/main.tsx b/e2e/bundle-size/scenarios/solid-router-full/src/main.tsx new file mode 100644 index 00000000000..505f79aee0e --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-full/src/main.tsx @@ -0,0 +1,22 @@ +import { render } from 'solid-js/web' +import { RouterProvider, createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ + routeTree, + scrollRestoration: true, +}) + +declare module '@tanstack/solid-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} +if (!rootElement.innerHTML) { + render(() => , rootElement) +} diff --git a/e2e/bundle-size/scenarios/solid-router-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-router-full/src/routes/__root.tsx new file mode 100644 index 00000000000..3b0f7435cb2 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-full/src/routes/__root.tsx @@ -0,0 +1,188 @@ +import { + Asset, + Await, + Block, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + HeadContent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScriptOnce, + Scripts, + ScrollRestoration, + createLink, + createRootRoute, + linkOptions, + useAwaited, + useBlocker, + useCanGoBack, + useChildMatches, + useElementScrollRestoration, + useHydrated, + useLayoutEffect, + useLinkProps, + useLoaderData, + useLoaderDeps, + useLocation, + useMatch, + useMatchRoute, + useMatches, + useNavigate, + useParams, + useParentMatches, + useRouteContext, + useRouter, + useRouterState, + useSearch, + useTags, +} from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const router = useRouter() + const hydrated = useHydrated() + const [awaited] = useAwaited({ promise: Promise.resolve('ready') }) + const linkProps = useLinkProps({ to: '/' } as any) + const matchRoute = useMatchRoute() + const matches = useMatches() + const parentMatches = useParentMatches() + const childMatches = useChildMatches() + const match = useMatch({ strict: false, shouldThrow: false } as any) + const loaderDeps = useLoaderDeps({ strict: false } as any) + const loaderData = useLoaderData({ strict: false } as any) + const params = useParams({ strict: false } as any) + const search = useSearch({ strict: false } as any) + const routeContext = useRouteContext({ strict: false } as any) + const routerState = useRouterState({ select: (state) => state.status } as any) + const location = useLocation() + const canGoBack = useCanGoBack() + const navigate = useNavigate() + const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' }) + const tags = useTags() + + useLayoutEffect(() => {}) + useBlocker({ + shouldBlockFn: () => false, + disabled: true, + withResolver: false, + }) + + const linkFactoryResult = linkOptions({ to: '/' } as any) + const routeMatchResult = matchRoute({ to: '/' } as any) + const SvgLink = createLink('svg') + + const hooksAndComponents = [ + useAwaited, + useHydrated, + useLinkProps, + useMatchRoute, + useMatches, + useParentMatches, + useChildMatches, + useMatch, + useLoaderDeps, + useLoaderData, + useLayoutEffect, + useBlocker, + useNavigate, + useParams, + useSearch, + useRouteContext, + useRouter, + useRouterState, + useLocation, + useCanGoBack, + useElementScrollRestoration, + useTags, + Await, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScrollRestoration, + Block, + ScriptOnce, + Asset, + HeadContent, + Scripts, + ] + + ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = { + hooksAndComponents, + } + + void awaited + void linkFactoryResult + void matches() + void parentMatches() + void childMatches() + void match() + void loaderDeps() + void loaderData() + void params() + void search() + void routeContext() + void routerState() + void location() + void canGoBack() + void navigate + void scrollEntry + void tags() + void routeMatchResult() + + return ( + <> + + {'window.__tsr_bundle_size = true'} + + home + + + + {() => } + }> + + + + {() => } + + false} disabled withResolver={false}> + {() => } + + }> + + + + {() => } + + + + +
+
hello world
+
+ + ) +} diff --git a/e2e/bundle-size/scenarios/solid-router-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-router-full/src/routes/index.tsx new file mode 100644 index 00000000000..01d3b701aec --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-full/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/solid-router-full/vite.config.ts b/e2e/bundle-size/scenarios/solid-router-full/vite.config.ts new file mode 100644 index 00000000000..9729ada0e8b --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-full/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'solid', + autoCodeSplitting: true, + }), + solid(), + ], +}) diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/index.html b/e2e/bundle-size/scenarios/solid-router-minimal/index.html new file mode 100644 index 00000000000..29c6fbd7872 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-minimal/index.html @@ -0,0 +1,12 @@ + + + + + + solid-router-minimal + + +
+ + + diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/src/main.tsx b/e2e/bundle-size/scenarios/solid-router-minimal/src/main.tsx new file mode 100644 index 00000000000..210120b3f18 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-minimal/src/main.tsx @@ -0,0 +1,19 @@ +import { render } from 'solid-js/web' +import { RouterProvider, createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ routeTree }) + +declare module '@tanstack/solid-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} +if (!rootElement.innerHTML) { + render(() => , rootElement) +} diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/__root.tsx new file mode 100644 index 00000000000..cb8d5a688d2 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/index.tsx new file mode 100644 index 00000000000..01d3b701aec --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-minimal/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/solid-router-minimal/vite.config.ts b/e2e/bundle-size/scenarios/solid-router-minimal/vite.config.ts new file mode 100644 index 00000000000..9729ada0e8b --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-router-minimal/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'solid', + autoCodeSplitting: true, + }), + solid(), + ], +}) diff --git a/e2e/bundle-size/scenarios/solid-start-full/src/router.tsx b/e2e/bundle-size/scenarios/solid-start-full/src/router.tsx new file mode 100644 index 00000000000..aa7ead67524 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-full/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/bundle-size/scenarios/solid-start-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-start-full/src/routes/__root.tsx new file mode 100644 index 00000000000..6a986000ed5 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-full/src/routes/__root.tsx @@ -0,0 +1,228 @@ +import { + Asset, + Await, + Block, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + HeadContent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScriptOnce, + Scripts, + ScrollRestoration, + createLink, + createRootRoute, + linkOptions, + useAwaited, + useBlocker, + useCanGoBack, + useChildMatches, + useElementScrollRestoration, + useHydrated, + useLayoutEffect, + useLinkProps, + useLoaderData, + useLoaderDeps, + useLocation, + useMatch, + useMatchRoute, + useMatches, + useNavigate, + useParams, + useParentMatches, + useRouteContext, + useRouter, + useRouterState, + useSearch, + useTags, +} from '@tanstack/solid-router' +import { + createMiddleware, + createServerFn, + useServerFn, +} from '@tanstack/solid-start' + +type BundleSizeKeep = { + hooksAndComponents: ReadonlyArray + startSurface: ReadonlyArray +} + +declare global { + var __TANSTACK_BUNDLE_SIZE_KEEP__: BundleSizeKeep | undefined +} + +const requestMiddleware = createMiddleware().server(async ({ next }) => { + return next() +}) + +const functionMiddleware = createMiddleware({ type: 'function' }) + .client(async ({ next }) => { + return next() + }) + .server(async ({ next }) => { + return next() + }) + +const helloServerFn = createServerFn({ method: 'GET' }) + .middleware([requestMiddleware, functionMiddleware]) + .handler(async () => { + return 'hello from server fn' + }) + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const router = useRouter() + const hydrated = useHydrated() + const [awaited] = useAwaited({ promise: Promise.resolve('ready') }) + const linkProps = useLinkProps({ to: '/' }) + const matchRoute = useMatchRoute() + const matches = useMatches() + const parentMatches = useParentMatches() + const childMatches = useChildMatches() + const match = useMatch({ strict: false, shouldThrow: false }) + const loaderDeps = useLoaderDeps({ strict: false }) + const loaderData = useLoaderData({ strict: false }) + const params = useParams({ strict: false }) + const search = useSearch({ strict: false }) + const routeContext = useRouteContext({ strict: false }) + const routerState = useRouterState({ select: (state) => state.status }) + const location = useLocation() + const canGoBack = useCanGoBack() + const navigate = useNavigate() + const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' }) + const tags = useTags() + const invokeServerFn = useServerFn(helloServerFn) + + useLayoutEffect(() => {}) + useBlocker({ + shouldBlockFn: () => false, + disabled: true, + withResolver: false, + }) + + const linkFactoryResult = linkOptions({ to: '/' }) + const routeMatchResult = matchRoute({ to: '/' }) + const SvgLink = createLink('svg') + + const startSurface = [createMiddleware, createServerFn, useServerFn] + const hooksAndComponents = [ + useAwaited, + useHydrated, + useLinkProps, + useMatchRoute, + useMatches, + useParentMatches, + useChildMatches, + useMatch, + useLoaderDeps, + useLoaderData, + useLayoutEffect, + useBlocker, + useNavigate, + useParams, + useSearch, + useRouteContext, + useRouter, + useRouterState, + useLocation, + useCanGoBack, + useElementScrollRestoration, + useTags, + Await, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScrollRestoration, + Block, + ScriptOnce, + Asset, + HeadContent, + Scripts, + ] + + globalThis.__TANSTACK_BUNDLE_SIZE_KEEP__ = { + hooksAndComponents, + startSurface, + } + + void awaited + void linkFactoryResult + void matches() + void parentMatches() + void childMatches() + void match() + void loaderDeps() + void loaderData() + void params() + void search() + void routeContext() + void routerState() + void location() + void canGoBack() + void navigate + void scrollEntry + void tags() + void routeMatchResult() + void invokeServerFn + + return ( + + + + + + {'window.__tsr_bundle_size = true'} + + home + + + + {() => } + }> + + + + {() => } + + false} disabled withResolver={false}> + {() => } + + }> + + + + {() => } + + + + +
+
hello world
+
+ + + ) +} diff --git a/e2e/bundle-size/scenarios/solid-start-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-start-full/src/routes/index.tsx new file mode 100644 index 00000000000..01d3b701aec --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-full/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/solid-start-full/vite.config.ts b/e2e/bundle-size/scenarios/solid-start-full/vite.config.ts new file mode 100644 index 00000000000..0bd21e64f44 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-full/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' + +export default defineConfig({ + plugins: [tanstackStart(), solid({ ssr: true })], +}) diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/src/router.tsx b/e2e/bundle-size/scenarios/solid-start-minimal/src/router.tsx new file mode 100644 index 00000000000..aa7ead67524 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-minimal/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/__root.tsx new file mode 100644 index 00000000000..e59de722362 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/__root.tsx @@ -0,0 +1,24 @@ +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/index.tsx new file mode 100644 index 00000000000..01d3b701aec --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-minimal/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/solid-start-minimal/vite.config.ts b/e2e/bundle-size/scenarios/solid-start-minimal/vite.config.ts new file mode 100644 index 00000000000..0bd21e64f44 --- /dev/null +++ b/e2e/bundle-size/scenarios/solid-start-minimal/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' + +export default defineConfig({ + plugins: [tanstackStart(), solid({ ssr: true })], +}) diff --git a/e2e/bundle-size/scenarios/vue-router-full/index.html b/e2e/bundle-size/scenarios/vue-router-full/index.html new file mode 100644 index 00000000000..583379e36c4 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-full/index.html @@ -0,0 +1,12 @@ + + + + + + vue-router-full + + +
+ + + diff --git a/e2e/bundle-size/scenarios/vue-router-full/src/main.tsx b/e2e/bundle-size/scenarios/vue-router-full/src/main.tsx new file mode 100644 index 00000000000..84d5f29df5c --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-full/src/main.tsx @@ -0,0 +1,26 @@ +import { createApp } from 'vue' +import { RouterProvider, createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ + routeTree, + scrollRestoration: true, +}) + +declare module '@tanstack/vue-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} +if (!rootElement.innerHTML) { + createApp({ + setup() { + return () => + }, + }).mount('#app') +} diff --git a/e2e/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx b/e2e/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx new file mode 100644 index 00000000000..77e81f6bbb1 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx @@ -0,0 +1,191 @@ +import { + Asset, + Await, + Block, + Body, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + HeadContent, + Html, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScriptOnce, + Scripts, + ScrollRestoration, + createLink, + createRootRoute, + linkOptions, + useAwaited, + useBlocker, + useCanGoBack, + useChildMatches, + useElementScrollRestoration, + useLayoutEffect, + useLinkProps, + useLoaderData, + useLoaderDeps, + useLocation, + useMatch, + useMatchRoute, + useMatches, + useNavigate, + useParams, + useParentMatches, + useRouteContext, + useRouter, + useRouterState, + useSearch, + useTags, +} from '@tanstack/vue-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const router = useRouter() + const [awaited] = useAwaited({ promise: Promise.resolve('ready') }) + const linkProps = useLinkProps({ to: '/' } as any) + const matchRoute = useMatchRoute() + const matches = useMatches() + const parentMatches = useParentMatches() + const childMatches = useChildMatches() + const match = useMatch({ strict: false, shouldThrow: false } as any) + const loaderDeps = useLoaderDeps({ strict: false } as any) + const loaderData = useLoaderData({ strict: false } as any) + const params = useParams({ strict: false } as any) + const search = useSearch({ strict: false } as any) + const routeContext = useRouteContext({ strict: false } as any) + const routerState = useRouterState({ select: (state) => state.status } as any) + const location = useLocation() + const canGoBack = useCanGoBack() + const navigate = useNavigate() + const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' }) + const tags = useTags() + const layoutEffectRunner = useLayoutEffect(() => {}) + + useBlocker({ + shouldBlockFn: () => false, + disabled: true, + withResolver: false, + }) + + const linkFactoryResult = linkOptions({ to: '/' } as any) + const routeMatchResult = matchRoute({ to: '/' } as any) + const SvgLink = createLink('svg') + + const hooksAndComponents = [ + useAwaited, + useLinkProps, + useMatchRoute, + useMatches, + useParentMatches, + useChildMatches, + useMatch, + useLoaderDeps, + useLoaderData, + useLayoutEffect, + useBlocker, + useNavigate, + useParams, + useSearch, + useRouteContext, + useRouter, + useRouterState, + useLocation, + useCanGoBack, + useElementScrollRestoration, + useTags, + Await, + CatchBoundary, + CatchNotFound, + ClientOnly, + DefaultGlobalNotFound, + ErrorComponent, + Link, + Match, + MatchRoute, + Matches, + Navigate, + Outlet, + RouterContextProvider, + ScrollRestoration, + Block, + ScriptOnce, + Asset, + HeadContent, + Scripts, + Body, + Html, + ] + + ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = { + hooksAndComponents, + } + + void awaited + void linkFactoryResult + void matches.value + void parentMatches.value + void childMatches.value + void match.value + void loaderDeps.value + void loaderData.value + void params.value + void search.value + void routeContext.value + void routerState.value + void location.value + void canGoBack.value + void navigate + void scrollEntry + void tags() + void layoutEffectRunner + void routeMatchResult.value + + return ( + <> + + {'window.__tsr_bundle_size = true'} + + home + + + + {() => } + }> + + + } + /> + false} disabled withResolver={false}> + {() => } + + }> + + + + + + + + +
+
hello world
+
+ + ) +} diff --git a/e2e/bundle-size/scenarios/vue-router-full/src/routes/index.tsx b/e2e/bundle-size/scenarios/vue-router-full/src/routes/index.tsx new file mode 100644 index 00000000000..3899022bdd5 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-full/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/vue-router-full/vite.config.ts b/e2e/bundle-size/scenarios/vue-router-full/vite.config.ts new file mode 100644 index 00000000000..5556bfaa07f --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-full/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'vue', + autoCodeSplitting: true, + }), + vue(), + vueJsx(), + ], +}) diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/index.html b/e2e/bundle-size/scenarios/vue-router-minimal/index.html new file mode 100644 index 00000000000..e3fe534a219 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-minimal/index.html @@ -0,0 +1,12 @@ + + + + + + vue-router-minimal + + +
+ + + diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/src/main.tsx b/e2e/bundle-size/scenarios/vue-router-minimal/src/main.tsx new file mode 100644 index 00000000000..1fc66c27b13 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-minimal/src/main.tsx @@ -0,0 +1,23 @@ +import { createApp } from 'vue' +import { RouterProvider, createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ routeTree }) + +declare module '@tanstack/vue-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app') +if (!rootElement) { + throw new Error('Root element `#app` not found') +} +if (!rootElement.innerHTML) { + createApp({ + setup() { + return () => + }, + }).mount('#app') +} diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/__root.tsx b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/__root.tsx new file mode 100644 index 00000000000..91296e6f841 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/__root.tsx @@ -0,0 +1,9 @@ +import { Outlet, createRootRoute } from '@tanstack/vue-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return +} diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/index.tsx b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/index.tsx new file mode 100644 index 00000000000..3899022bdd5 --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-minimal/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return
hello world
+} diff --git a/e2e/bundle-size/scenarios/vue-router-minimal/vite.config.ts b/e2e/bundle-size/scenarios/vue-router-minimal/vite.config.ts new file mode 100644 index 00000000000..5556bfaa07f --- /dev/null +++ b/e2e/bundle-size/scenarios/vue-router-minimal/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'vue', + autoCodeSplitting: true, + }), + vue(), + vueJsx(), + ], +}) diff --git a/e2e/bundle-size/tsconfig.json b/e2e/bundle-size/tsconfig.json new file mode 100644 index 00000000000..ec9f5c598c7 --- /dev/null +++ b/e2e/bundle-size/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "allowJs": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index 15e059571c6..5b6c19258f9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:build": "nx affected --target=test:build --exclude=examples/**", "test:types": "nx affected --target=test:types --exclude=examples/**", "test:e2e": "nx run-many --target=test:e2e", + "benchmark:bundle-size": "pnpm nx run tanstack-router-e2e-bundle-size:build", "build": "nx affected --target=build --exclude=e2e/** --exclude=examples/**", "build:all": "nx run-many --target=build --exclude=examples/** --exclude=e2e/**", "watch": "pnpm run build:all && nx watch --all -- pnpm run build:all", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3afb217f4d..1ce263e6a1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,64 @@ importers: specifier: ^4.0.17 version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.7.0(@types/node@25.0.9)(typescript@5.9.3))(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/bundle-size: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../packages/react-start + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../packages/solid-router + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../packages/solid-start + '@tanstack/vue-router': + specifier: workspace:* + version: link:../../packages/vue-router + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + solid-js: + specifier: 1.9.10 + version: 1.9.10 + vue: + specifier: ^3.5.25 + version: 3.5.25(typescript@5.9.3) + devDependencies: + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../packages/router-plugin + '@types/react': + specifier: ^19.2.8 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@vitejs/plugin-vue': + specifier: ^6.0.1 + version: 6.0.3(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': + specifier: ^4.1.2 + version: 4.2.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.10)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/e2e-utils: devDependencies: get-port-please: @@ -25830,7 +25888,7 @@ snapshots: '@emotion/memoize': 0.9.0 '@emotion/unitless': 0.10.0 '@emotion/utils': 1.4.2 - csstype: 3.1.3 + csstype: 3.2.3 '@emotion/sheet@1.4.0': {} @@ -27382,7 +27440,7 @@ snapshots: '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 - csstype: 3.1.3 + csstype: 3.2.3 prop-types: 15.8.1 react: 19.2.3 optionalDependencies: @@ -27397,7 +27455,7 @@ snapshots: '@mui/types': 7.2.21(@types/react@19.2.8) '@mui/utils': 6.4.6(@types/react@19.2.8)(react@19.2.3) clsx: 2.1.1 - csstype: 3.1.3 + csstype: 3.2.3 prop-types: 15.8.1 react: 19.2.3 optionalDependencies: @@ -31111,6 +31169,12 @@ snapshots: vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) vue: 3.5.25(typescript@5.9.2) + '@vitejs/plugin-vue@6.0.3(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.53 + vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vue: 3.5.25(typescript@5.9.3) + '@vitest/browser@4.0.17(msw@2.7.0(@types/node@25.0.9)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.17)': dependencies: '@vitest/mocker': 4.0.17(msw@2.7.0(@types/node@25.0.9)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -32911,7 +32975,7 @@ snapshots: dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.26.7 - csstype: 3.1.3 + csstype: 3.2.3 dom-serializer@1.4.1: dependencies: @@ -37385,7 +37449,7 @@ snapshots: solid-js@1.9.10: dependencies: - csstype: 3.1.3 + csstype: 3.2.3 seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da7d07317b4..fe84aeb0379 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ packages: - 'examples/react/router-monorepo-simple/packages/*' - 'examples/react/router-monorepo-simple-lazy/packages/*' - 'e2e/e2e-utils' + - 'e2e/bundle-size' - 'e2e/react-router/*' - 'e2e/solid-router/*' - 'e2e/vue-router/*' diff --git a/scripts/benchmarks/bundle-size/measure.mjs b/scripts/benchmarks/bundle-size/measure.mjs new file mode 100644 index 00000000000..00b76c9f2d7 --- /dev/null +++ b/scripts/benchmarks/bundle-size/measure.mjs @@ -0,0 +1,516 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs as parseNodeArgs } from 'node:util' +import { brotliCompressSync, gzipSync } from 'node:zlib' +import { execSync } from 'node:child_process' + +import { build } from 'vite' + +const BENCHMARK_NAME = 'Bundle Size (gzip)' + +const SCENARIOS = [ + { + id: 'react-router.minimal', + dir: 'react-router-minimal', + framework: 'react', + packageName: '@tanstack/react-router', + case: 'minimal', + }, + { + id: 'react-router.full', + dir: 'react-router-full', + framework: 'react', + packageName: '@tanstack/react-router', + case: 'full', + }, + { + id: 'solid-router.minimal', + dir: 'solid-router-minimal', + framework: 'solid', + packageName: '@tanstack/solid-router', + case: 'minimal', + }, + { + id: 'solid-router.full', + dir: 'solid-router-full', + framework: 'solid', + packageName: '@tanstack/solid-router', + case: 'full', + }, + { + id: 'vue-router.minimal', + dir: 'vue-router-minimal', + framework: 'vue', + packageName: '@tanstack/vue-router', + case: 'minimal', + }, + { + id: 'vue-router.full', + dir: 'vue-router-full', + framework: 'vue', + packageName: '@tanstack/vue-router', + case: 'full', + }, + { + id: 'react-start.minimal', + dir: 'react-start-minimal', + framework: 'react', + packageName: '@tanstack/react-start', + case: 'minimal', + }, + { + id: 'react-start.full', + dir: 'react-start-full', + framework: 'react', + packageName: '@tanstack/react-start', + case: 'full', + }, + { + id: 'solid-start.minimal', + dir: 'solid-start-minimal', + framework: 'solid', + packageName: '@tanstack/solid-start', + case: 'minimal', + }, + { + id: 'solid-start.full', + dir: 'solid-start-full', + framework: 'solid', + packageName: '@tanstack/solid-start', + case: 'full', + }, +] + +function parseArgs(argv) { + const { values } = parseNodeArgs({ + args: argv, + allowPositionals: false, + strict: true, + options: { + sha: { type: 'string' }, + 'measured-at': { type: 'string' }, + 'append-history': { type: 'string' }, + 'results-dir': { type: 'string' }, + 'dist-dir': { type: 'string' }, + }, + }) + + return { + sha: values.sha, + measuredAt: values['measured-at'], + appendHistory: values['append-history'], + resultsDir: values['results-dir'], + distDir: values['dist-dir'], + } +} + +function toIsoDate(value) { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${value}`) + } + + return date.toISOString() +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function parseMaybeDataJs(raw) { + const trimmed = raw.trim() + + if (trimmed.startsWith('window.BENCHMARK_DATA')) { + const withoutPrefix = trimmed + .replace(/^window\.BENCHMARK_DATA\s*=\s*/, '') + .replace(/;\s*$/, '') + return JSON.parse(withoutPrefix) + } + + return JSON.parse(trimmed) +} + +function resolveManifestChunkKey(manifest, keyOrFile) { + if (manifest[keyOrFile]) { + return keyOrFile + } + + for (const [key, value] of Object.entries(manifest)) { + if (value?.file === keyOrFile) { + return key + } + } + + return undefined +} + +function collectInitialJsFiles(manifest, entryKey) { + const visitedKeys = new Set() + const files = new Set() + + function visitByKey(chunkKey) { + if (!chunkKey || visitedKeys.has(chunkKey)) { + return + } + + visitedKeys.add(chunkKey) + const chunk = manifest[chunkKey] + + if (!chunk) { + return + } + + if (typeof chunk.file === 'string' && chunk.file.endsWith('.js')) { + files.add(chunk.file) + } + + for (const imported of chunk.imports || []) { + const resolvedKey = resolveManifestChunkKey(manifest, imported) + if (resolvedKey) { + visitByKey(resolvedKey) + } + } + } + + visitByKey(entryKey) + + return [...files].sort() +} + +function bytesForFiles(baseDir, fileList) { + let rawBytes = 0 + let gzipBytes = 0 + let brotliBytes = 0 + + for (const relativeFile of fileList) { + const fullPath = path.join(baseDir, relativeFile) + const content = fs.readFileSync(fullPath) + + rawBytes += content.byteLength + gzipBytes += gzipSync(content).byteLength + brotliBytes += brotliCompressSync(content).byteLength + } + + return { + rawBytes, + gzipBytes, + brotliBytes, + } +} + +async function findManifestFiles(rootDir) { + const manifestPaths = [] + + async function visit(currentDir) { + const entries = await fsp.readdir(currentDir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name) + + if (entry.isDirectory()) { + await visit(fullPath) + continue + } + + if (entry.isFile() && entry.name === 'manifest.json') { + const parent = path.basename(path.dirname(fullPath)) + if (parent === '.vite') { + manifestPaths.push(fullPath) + } + } + } + } + + await visit(rootDir) + return manifestPaths +} + +function rankManifestPath(manifestPath) { + let score = 0 + + if (manifestPath.includes(`${path.sep}client${path.sep}`)) { + score += 100 + } + + if (manifestPath.includes(`${path.sep}server${path.sep}`)) { + score -= 100 + } + + return score +} + +function pickManifestEntryKey(manifest) { + const entries = Object.entries(manifest) + + const htmlEntry = entries.find( + ([key, value]) => key.endsWith('.html') && value?.isEntry, + ) + if (htmlEntry) { + return htmlEntry[0] + } + + const jsEntries = entries.filter( + ([, value]) => + value?.isEntry && + typeof value.file === 'string' && + value.file.endsWith('.js'), + ) + + const preferredPatterns = [ + /virtual:tanstack-start-client-entry/i, + /src[\\/]main\./i, + /src[\\/]client\./i, + /client/i, + ] + + for (const pattern of preferredPatterns) { + const preferred = jsEntries.find( + ([key, value]) => pattern.test(key) || pattern.test(value.file), + ) + + if (preferred) { + return preferred[0] + } + } + + if (jsEntries.length > 0) { + return jsEntries[0][0] + } + + const anyEntry = entries.find(([, value]) => value?.isEntry) + if (anyEntry) { + return anyEntry[0] + } + + return undefined +} + +async function resolveManifestAndEntry(outDir, scenarioId) { + const manifestPaths = await findManifestFiles(outDir) + + if (manifestPaths.length === 0) { + throw new Error(`No Vite manifest files found for scenario: ${scenarioId}`) + } + + manifestPaths.sort((a, b) => { + const scoreDiff = rankManifestPath(b) - rankManifestPath(a) + if (scoreDiff !== 0) { + return scoreDiff + } + + return a.length - b.length + }) + + for (const manifestPath of manifestPaths) { + const manifest = readJson(manifestPath) + const entryKey = pickManifestEntryKey(manifest) + + if (!entryKey) { + continue + } + + const manifestOutDir = path.dirname(path.dirname(manifestPath)) + + return { + manifest, + entryKey, + manifestPath, + manifestOutDir, + } + } + + throw new Error( + `Could not determine manifest entry for scenario: ${scenarioId}`, + ) +} + +function getCurrentSha(providedSha) { + if (providedSha) { + return providedSha + } + + if (process.env.GITHUB_SHA) { + return process.env.GITHUB_SHA + } + + return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim() +} + +function buildCommitUrl(sha) { + const repo = process.env.GITHUB_REPOSITORY + + if (!repo) { + return '' + } + + return `https://github.com/${repo}/commit/${sha}` +} + +async function appendHistoryFile({ historyPath, measuredAtIso, sha, benches }) { + const measuredAtMs = Date.parse(measuredAtIso) + let writeAsDataJs = historyPath.endsWith('.js') + + let history = { + lastUpdate: measuredAtMs, + entries: {}, + } + + if (fs.existsSync(historyPath)) { + const raw = await fsp.readFile(historyPath, 'utf8') + writeAsDataJs = raw.trim().startsWith('window.BENCHMARK_DATA') + history = parseMaybeDataJs(raw) + history.entries ||= {} + } + + const entry = { + commit: { + id: sha, + message: `bundle-size snapshot ${sha.slice(0, 12)}`, + timestamp: measuredAtIso, + url: buildCommitUrl(sha), + }, + date: measuredAtMs, + tool: 'customSmallerIsBetter', + benches, + } + + const group = history.entries[BENCHMARK_NAME] || [] + group.push(entry) + history.entries[BENCHMARK_NAME] = group + history.lastUpdate = measuredAtMs + + await fsp.mkdir(path.dirname(historyPath), { recursive: true }) + const serialized = JSON.stringify(history, null, 2) + const output = writeAsDataJs + ? `window.BENCHMARK_DATA = ${serialized};\n` + : `${serialized}\n` + await fsp.writeFile(historyPath, output, 'utf8') +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + const scriptDir = path.dirname(fileURLToPath(import.meta.url)) + const repoRoot = path.resolve(scriptDir, '../../../') + const scenariosRoot = path.join(repoRoot, 'e2e/bundle-size/scenarios') + const resultsDir = args.resultsDir + ? path.resolve(args.resultsDir) + : path.join(repoRoot, 'e2e/bundle-size/results') + const distDir = args.distDir + ? path.resolve(args.distDir) + : path.join(repoRoot, 'e2e/bundle-size/dist') + + const measuredAtIso = args.measuredAt + ? toIsoDate(args.measuredAt) + : new Date().toISOString() + const sha = getCurrentSha(args.sha) + + await fsp.mkdir(resultsDir, { recursive: true }) + await fsp.mkdir(distDir, { recursive: true }) + + const metrics = [] + + for (const scenario of SCENARIOS) { + const root = path.join(scenariosRoot, scenario.dir) + const outDir = path.join(distDir, scenario.dir) + const configFile = path.join(root, 'vite.config.ts') + + const previousCwd = process.cwd() + process.chdir(root) + + try { + await build({ + root, + configFile, + logLevel: 'silent', + define: { + 'process.env.NODE_ENV': '"production"', + }, + build: { + outDir, + emptyOutDir: true, + target: 'es2022', + minify: 'esbuild', + sourcemap: false, + reportCompressedSize: false, + manifest: true, + }, + }) + } finally { + process.chdir(previousCwd) + } + + const manifestInfo = await resolveManifestAndEntry(outDir, scenario.id) + + const jsFiles = collectInitialJsFiles( + manifestInfo.manifest, + manifestInfo.entryKey, + ) + const sizes = bytesForFiles(manifestInfo.manifestOutDir, jsFiles) + + metrics.push({ + id: scenario.id, + scenarioDir: scenario.dir, + framework: scenario.framework, + packageName: scenario.packageName, + case: scenario.case, + entryKey: manifestInfo.entryKey, + manifestPath: path.relative(outDir, manifestInfo.manifestPath), + jsFiles, + ...sizes, + }) + } + + const current = { + schemaVersion: 1, + benchmarkName: BENCHMARK_NAME, + measuredAt: measuredAtIso, + generatedAt: new Date().toISOString(), + sha, + metrics, + } + + const benchmarkActionRows = metrics.map((metric) => ({ + name: metric.id, + unit: 'bytes', + value: metric.gzipBytes, + extra: `raw=${metric.rawBytes}; brotli=${metric.brotliBytes}`, + })) + + const currentPath = path.join(resultsDir, 'current.json') + const benchmarkActionPath = path.join(resultsDir, 'benchmark-action.json') + + await fsp.writeFile( + currentPath, + JSON.stringify(current, null, 2) + '\n', + 'utf8', + ) + await fsp.writeFile( + benchmarkActionPath, + JSON.stringify(benchmarkActionRows, null, 2) + '\n', + 'utf8', + ) + + if (args.appendHistory) { + await appendHistoryFile({ + historyPath: path.resolve(args.appendHistory), + measuredAtIso, + sha, + benches: benchmarkActionRows, + }) + } + + process.stdout.write( + `Measured ${metrics.length} scenarios. Wrote ${path.relative(repoRoot, currentPath)} and ${path.relative(repoRoot, benchmarkActionPath)}\n`, + ) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/benchmarks/bundle-size/pr-report.mjs b/scripts/benchmarks/bundle-size/pr-report.mjs new file mode 100644 index 00000000000..ea7585815ee --- /dev/null +++ b/scripts/benchmarks/bundle-size/pr-report.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { parseArgs as parseNodeArgs } from 'node:util' + +const DEFAULT_MARKER = '' +const INT_FORMAT = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const FIXED_2_FORMAT = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) +const PERCENT_FORMAT = new Intl.NumberFormat('en-US', { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +function parseArgs(argv) { + const { values } = parseNodeArgs({ + args: argv, + allowPositionals: false, + strict: true, + options: { + current: { type: 'string' }, + baseline: { type: 'string' }, + history: { type: 'string' }, + output: { type: 'string' }, + 'dashboard-url': { type: 'string' }, + 'base-sha': { type: 'string' }, + marker: { type: 'string' }, + 'trend-points': { type: 'string' }, + }, + }) + + const args = { + current: values.current, + baseline: values.baseline, + history: values.history, + output: values.output, + dashboardUrl: values['dashboard-url'], + baseSha: values['base-sha'], + marker: values.marker ?? DEFAULT_MARKER, + trendPoints: values['trend-points'] + ? Number.parseInt(values['trend-points'], 10) + : 12, + } + + if (!Number.isFinite(args.trendPoints) || args.trendPoints < 2) { + throw new Error(`Invalid trend points: ${values['trend-points']}`) + } + + if (!args.current) { + throw new Error('Missing required argument: --current') + } + + if (!args.output) { + throw new Error('Missing required argument: --output') + } + + return args +} + +function parseMaybeDataJs(raw) { + const trimmed = raw.trim() + + if (trimmed.startsWith('window.BENCHMARK_DATA')) { + return JSON.parse( + trimmed + .replace(/^window\.BENCHMARK_DATA\s*=\s*/, '') + .replace(/;\s*$/, ''), + ) + } + + return JSON.parse(trimmed) +} + +function readJsonMaybeData(filePath) { + return parseMaybeDataJs(fs.readFileSync(filePath, 'utf8')) +} + +function readOptionalJsonMaybeData(filePath) { + if (!filePath || !fs.existsSync(filePath)) { + return undefined + } + + const raw = fs.readFileSync(filePath, 'utf8') + if (!raw.trim()) { + return undefined + } + + return parseMaybeDataJs(raw) +} + +function formatBytes(bytes, opts = {}) { + const signed = opts.signed === true + + if (!Number.isFinite(bytes)) { + return 'n/a' + } + + const sign = signed && bytes !== 0 ? (bytes > 0 ? '+' : '-') : '' + const absBytes = Math.abs(bytes) + + let value + if (absBytes < 1024) { + value = `${INT_FORMAT.format(absBytes)} B` + } else { + const kib = absBytes / 1024 + if (kib < 1024) { + value = `${FIXED_2_FORMAT.format(kib)} KiB` + } else { + const mib = kib / 1024 + value = `${FIXED_2_FORMAT.format(mib)} MiB` + } + } + + return `${sign}${value}` +} + +function formatDelta(current, baseline) { + if (!Number.isFinite(current) || !Number.isFinite(baseline)) { + return 'n/a' + } + + const delta = current - baseline + const ratio = baseline === 0 ? 0 : Math.abs(delta / baseline) + const sign = delta > 0 ? '+' : delta < 0 ? '-' : '' + return `${formatBytes(delta, { signed: true })} (${sign}${PERCENT_FORMAT.format(ratio)})` +} + +function sparkline(values) { + if (!values.length) { + return 'n/a' + } + + const blocks = '▁▂▃▄▅▆▇█' + const min = Math.min(...values) + const max = Math.max(...values) + + if (max === min) { + return '▅'.repeat(values.length) + } + + return values + .map((value) => { + const normalized = (value - min) / (max - min) + const idx = Math.min( + blocks.length - 1, + Math.max(0, Math.round(normalized * (blocks.length - 1))), + ) + return blocks[idx] + }) + .join('') +} + +function normalizeHistoryEntries(history, benchmarkName) { + if (!history || typeof history !== 'object' || !history.entries) { + return [] + } + + const byName = history.entries[benchmarkName] + if (Array.isArray(byName)) { + return byName + } + + const firstEntry = Object.values(history.entries).find((value) => + Array.isArray(value), + ) + return Array.isArray(firstEntry) ? firstEntry : [] +} + +function buildSeriesByScenario(historyEntries) { + const map = new Map() + + for (const entry of historyEntries) { + for (const bench of entry?.benches || []) { + if (typeof bench?.name !== 'string' || !Number.isFinite(bench?.value)) { + continue + } + + if (!map.has(bench.name)) { + map.set(bench.name, []) + } + + map.get(bench.name).push(Number(bench.value)) + } + } + + return map +} + +function resolveBaselineFromHistory(historyEntries, baseSha) { + if (!historyEntries.length) { + return { + source: 'none', + benchesByName: new Map(), + } + } + + const baseEntry = + (baseSha && + historyEntries.find( + (entry) => + entry?.commit?.id === baseSha || + entry?.commit?.id?.startsWith(baseSha), + )) || + historyEntries[historyEntries.length - 1] + + const benchesByName = new Map() + for (const bench of baseEntry?.benches || []) { + if (typeof bench?.name === 'string' && Number.isFinite(bench?.value)) { + benchesByName.set(bench.name, Number(bench.value)) + } + } + + const commitId = baseEntry?.commit?.id || 'unknown' + + return { + source: `history:${commitId.slice(0, 12)}`, + benchesByName, + } +} + +function resolveBaselineFromCurrentJson(currentJson) { + const benchesByName = new Map() + for (const metric of currentJson?.metrics || []) { + if (typeof metric?.id === 'string' && Number.isFinite(metric?.gzipBytes)) { + benchesByName.set(metric.id, Number(metric.gzipBytes)) + } + } + + const sourceSha = + typeof currentJson?.sha === 'string' ? currentJson.sha : 'unknown' + + return { + source: `current:${sourceSha.slice(0, 12)}`, + benchesByName, + } +} + +function formatShortSha(value) { + if (!value || typeof value !== 'string') { + return 'unknown' + } + + return value.slice(0, 12) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const currentPath = path.resolve(args.current) + const outputPath = path.resolve(args.output) + const baselinePath = args.baseline ? path.resolve(args.baseline) : undefined + const historyPath = args.history ? path.resolve(args.history) : undefined + + const current = readJsonMaybeData(currentPath) + const history = readOptionalJsonMaybeData(historyPath) + const baselineCurrent = readOptionalJsonMaybeData(baselinePath) + + const historyEntries = normalizeHistoryEntries(history, current.benchmarkName) + const seriesByScenario = buildSeriesByScenario(historyEntries) + + const baseline = + baselineCurrent != null + ? resolveBaselineFromCurrentJson(baselineCurrent) + : resolveBaselineFromHistory(historyEntries, args.baseSha) + + const rows = [] + + for (const metric of current.metrics || []) { + const baselineValue = baseline.benchesByName.get(metric.id) + const historySeries = (seriesByScenario.get(metric.id) || []).slice( + // Reserve one slot for the current metric so the sparkline stays at trendPoints. + -args.trendPoints + 1, + ) + + if ( + !historySeries.length || + historySeries[historySeries.length - 1] !== metric.gzipBytes + ) { + historySeries.push(metric.gzipBytes) + } + + rows.push({ + id: metric.id, + current: metric.gzipBytes, + raw: metric.rawBytes, + brotli: metric.brotliBytes, + deltaCell: formatDelta(metric.gzipBytes, baselineValue), + trendCell: sparkline(historySeries.slice(-args.trendPoints)), + }) + } + + const lines = [] + lines.push(args.marker) + lines.push('## Bundle Size Benchmarks') + lines.push('') + lines.push(`- Commit: \`${formatShortSha(current.sha)}\``) + lines.push( + `- Measured at: \`${current.measuredAt || current.generatedAt || 'unknown'}\``, + ) + lines.push(`- Baseline source: \`${baseline.source}\``) + if (args.dashboardUrl) { + lines.push(`- Dashboard: [bundle-size history](${args.dashboardUrl})`) + } + lines.push('') + lines.push( + '| Scenario | Current (gzip) | Delta vs baseline | Raw | Brotli | Trend |', + ) + lines.push('| --- | ---: | ---: | ---: | ---: | --- |') + + for (const row of rows) { + lines.push( + `| \`${row.id}\` | ${formatBytes(row.current)} | ${row.deltaCell} | ${formatBytes(row.raw)} | ${formatBytes(row.brotli)} | ${row.trendCell} |`, + ) + } + + lines.push('') + lines.push( + '_Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better._', + ) + + const markdown = lines.join('\n') + '\n' + await fsp.mkdir(path.dirname(outputPath), { recursive: true }) + await fsp.writeFile(outputPath, markdown, 'utf8') + + process.stdout.write(`Wrote PR benchmark report: ${outputPath}\n`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/benchmarks/common/upsert-pr-comment.mjs b/scripts/benchmarks/common/upsert-pr-comment.mjs new file mode 100644 index 00000000000..9637e213259 --- /dev/null +++ b/scripts/benchmarks/common/upsert-pr-comment.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { parseArgs as parseNodeArgs } from 'node:util' + +const DEFAULT_MARKER = '' + +function parseArgs(argv) { + const { values } = parseNodeArgs({ + args: argv, + allowPositionals: false, + strict: true, + options: { + pr: { type: 'string' }, + 'body-file': { type: 'string' }, + repo: { type: 'string' }, + token: { type: 'string' }, + marker: { type: 'string' }, + 'api-url': { type: 'string' }, + }, + }) + + const args = { + pr: values.pr ? Number.parseInt(values.pr, 10) : undefined, + bodyFile: values['body-file'], + repo: values.repo ?? process.env.GITHUB_REPOSITORY, + marker: values.marker ?? DEFAULT_MARKER, + token: values.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN), + apiUrl: + values['api-url'] ?? + (process.env.GITHUB_API_URL || 'https://api.github.com'), + } + + if (!Number.isFinite(args.pr) || args.pr <= 0) { + throw new Error('Missing required argument: --pr') + } + + if (!args.bodyFile) { + throw new Error('Missing required argument: --body-file') + } + + if (!args.repo || !args.repo.includes('/')) { + throw new Error( + 'Missing repository context. Provide --repo or GITHUB_REPOSITORY.', + ) + } + + if (!args.token) { + throw new Error('Missing token. Provide --token or GITHUB_TOKEN.') + } + + return args +} + +async function githubRequest({ apiUrl, token, method, endpoint, body }) { + const url = `${apiUrl.replace(/\/$/, '')}${endpoint}` + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'tanstack-router-bundle-size-bot', + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error( + `${method} ${endpoint} failed (${response.status} ${response.statusText}): ${text}`, + ) + } + + if (response.status === 204) { + return undefined + } + + return response.json() +} + +async function listIssueComments({ apiUrl, token, repo, pr }) { + const comments = [] + let page = 1 + const perPage = 100 + + for (;;) { + const data = await githubRequest({ + apiUrl, + token, + method: 'GET', + endpoint: `/repos/${repo}/issues/${pr}/comments?per_page=${perPage}&page=${page}`, + }) + + comments.push(...data) + + if (!Array.isArray(data) || data.length < perPage) { + break + } + + page += 1 + } + + return comments +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const bodyPath = path.resolve(args.bodyFile) + const rawBody = await fsp.readFile(bodyPath, 'utf8') + const body = rawBody.includes(args.marker) + ? rawBody + : `${args.marker}\n${rawBody}` + + const comments = await listIssueComments({ + apiUrl: args.apiUrl, + token: args.token, + repo: args.repo, + pr: args.pr, + }) + + const existing = comments.find( + (comment) => + typeof comment?.body === 'string' && comment.body.includes(args.marker), + ) + + if (existing) { + await githubRequest({ + apiUrl: args.apiUrl, + token: args.token, + method: 'PATCH', + endpoint: `/repos/${args.repo}/issues/comments/${existing.id}`, + body: { body }, + }) + + process.stdout.write( + `Updated PR #${args.pr} bundle-size comment (${existing.id}).\n`, + ) + return + } + + const created = await githubRequest({ + apiUrl: args.apiUrl, + token: args.token, + method: 'POST', + endpoint: `/repos/${args.repo}/issues/${args.pr}/comments`, + body: { body }, + }) + + process.stdout.write( + `Created PR #${args.pr} bundle-size comment (${created?.id ?? 'unknown'}).\n`, + ) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +})