diff --git a/packages/router-core/package.json b/packages/router-core/package.json index 8b91122685c..618f6b00ad9 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -88,6 +88,7 @@ "tiny-warning": "^1.0.3" }, "devDependencies": { - "esbuild": "^0.25.0" + "esbuild": "^0.25.0", + "zod": "^3.24.2" } } diff --git a/packages/router-core/tests/navigation.bench.ts b/packages/router-core/tests/navigation.bench.ts new file mode 100644 index 00000000000..31602d07b4d --- /dev/null +++ b/packages/router-core/tests/navigation.bench.ts @@ -0,0 +1,215 @@ +import { bench, describe } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { z } from 'zod' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' + +// ============================================================================ +// Router with Zod search validation (realistic scenario) +// ============================================================================ +function createRouter() { + const rootRoute = new BaseRootRoute({}) + + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const usersRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/users', + validateSearch: z.object({ + filter: z.enum(['active', 'inactive', 'all']).optional(), + sort: z.enum(['name', 'date', 'email']).optional(), + page: z.number().optional(), + }), + }) + + const userRoute = new BaseRoute({ + getParentRoute: () => usersRoute, + path: '/$userId', + }) + + const userSettingsRoute = new BaseRoute({ + getParentRoute: () => userRoute, + path: '/settings', + validateSearch: z.object({ + tab: z.enum(['general', 'security', 'notifications']).optional(), + }), + }) + + const productsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/products', + validateSearch: z.object({ + category: z.string().optional(), + minPrice: z.number().optional(), + maxPrice: z.number().optional(), + inStock: z.boolean().optional(), + }), + }) + + const productRoute = new BaseRoute({ + getParentRoute: () => productsRoute, + path: '/$productId', + }) + + const productReviewsRoute = new BaseRoute({ + getParentRoute: () => productRoute, + path: '/reviews', + validateSearch: z.object({ + rating: z.number().optional(), + verified: z.boolean().optional(), + }), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + usersRoute.addChildren([userRoute.addChildren([userSettingsRoute])]), + productsRoute.addChildren([ + productRoute.addChildren([productReviewsRoute]), + ]), + ]) + + return new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) +} + +// ============================================================================ +// Real Navigation Benchmarks - Tests actual navigate() calls +// ============================================================================ + +describe('Navigation: search param updates (same route)', () => { + bench('update page number 1000x', async () => { + const router = createRouter() + await router.load() + + // Navigate to users route first + await router.navigate({ + to: '/users', + search: { filter: 'active', sort: 'name', page: 1 }, + }) + + // Update page param repeatedly + for (let i = 0; i < 1000; i++) { + await router.navigate({ + to: '/users', + search: { filter: 'active', sort: 'name', page: i % 100 }, + }) + } + }) + + bench('toggle filter param 1000x', async () => { + const router = createRouter() + await router.load() + + await router.navigate({ + to: '/users', + search: { filter: 'active', sort: 'name', page: 1 }, + }) + + const filters = ['active', 'inactive', 'all'] as const + for (let i = 0; i < 1000; i++) { + await router.navigate({ + to: '/users', + search: { filter: filters[i % 3], sort: 'name', page: 1 }, + }) + } + }) +}) + +describe('Navigation: route changes with params', () => { + bench('navigate between user profiles 1000x', async () => { + const router = createRouter() + await router.load() + + for (let i = 0; i < 1000; i++) { + await router.navigate({ + to: '/users/$userId/settings', + params: { userId: `user-${i % 100}` }, + search: { tab: 'security' }, + }) + } + }) + + bench('navigate between products 1000x', async () => { + const router = createRouter() + await router.load() + + for (let i = 0; i < 1000; i++) { + await router.navigate({ + to: '/products/$productId/reviews', + params: { productId: `prod-${i % 100}` }, + search: { rating: (i % 5) + 1, verified: i % 2 === 0 }, + }) + } + }) +}) + +describe('Navigation: mixed navigation patterns', () => { + bench('alternating routes 1000x', async () => { + const router = createRouter() + await router.load() + + for (let i = 0; i < 1000; i++) { + if (i % 2 === 0) { + await router.navigate({ + to: '/users/$userId/settings', + params: { userId: `user-${i}` }, + search: { tab: 'general' }, + }) + } else { + await router.navigate({ + to: '/products/$productId/reviews', + params: { productId: `prod-${i}` }, + search: { rating: 5, verified: true }, + }) + } + } + }) + + bench('deep to shallow navigation 1000x', async () => { + const router = createRouter() + await router.load() + + for (let i = 0; i < 1000; i++) { + if (i % 2 === 0) { + // Deep route + await router.navigate({ + to: '/users/$userId/settings', + params: { userId: `user-${i}` }, + search: { tab: 'security' }, + }) + } else { + // Shallow route + await router.navigate({ + to: '/', + }) + } + } + }) +}) + +describe('Navigation: back/forward simulation', () => { + bench('push 500 then back 500', async () => { + const router = createRouter() + await router.load() + + // Push 500 navigations + for (let i = 0; i < 500; i++) { + await router.navigate({ + to: '/users/$userId/settings', + params: { userId: `user-${i}` }, + search: { tab: i % 2 === 0 ? 'general' : 'security' }, + }) + } + + // Go back 500 times + for (let i = 0; i < 500; i++) { + router.history.back() + // Wait for history to settle + await new Promise((r) => setTimeout(r, 0)) + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8928c6d9984..6e07585a9c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10518,7 +10518,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(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)) @@ -11626,6 +11626,9 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.4 + zod: + specifier: ^3.24.2 + version: 3.25.57 packages/router-devtools: dependencies: @@ -27005,13 +27008,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -27079,12 +27082,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -27174,9 +27177,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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: 7.1.7(@types/node@22.10.2)(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) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -27204,9 +27207,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(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))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.1.7(@types/node@22.10.2)(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) @@ -27234,13 +27237,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -30495,7 +30498,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5)