diff --git a/docs/router/config.json b/docs/router/config.json index 808c3fe38f9..d8d6419a53b 100644 --- a/docs/router/config.json +++ b/docs/router/config.json @@ -237,6 +237,10 @@ "label": "Path Params", "to": "framework/react/guide/path-params" }, + { + "label": "Validating Path Params", + "to": "framework/react/guide/validating-path-params" + }, { "label": "Search Params", "to": "framework/react/guide/search-params" @@ -354,6 +358,10 @@ "label": "Path Params", "to": "framework/solid/guide/path-params" }, + { + "label": "Validating Path Params", + "to": "framework/solid/guide/validating-path-params" + }, { "label": "Search Params", "to": "framework/solid/guide/search-params" diff --git a/docs/router/framework/react/api/router/RouteOptionsType.md b/docs/router/framework/react/api/router/RouteOptionsType.md index 215e21c9ef4..11737707770 100644 --- a/docs/router/framework/react/api/router/RouteOptionsType.md +++ b/docs/router/framework/react/api/router/RouteOptionsType.md @@ -88,6 +88,27 @@ The `RouteOptions` type accepts an object with the following properties: - Type: `(params: TParams) => Record` - A function that will be called when this route's parsed params are being used to build a location. This function should return a valid object of `Record` mapping. +### `skipRouteOnParseError` property (⚠️ experimental) + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. + +- Type: + +```tsx +type skipRouteOnParseError = { + params?: boolean + priority?: number +} +``` + +- Optional +- By default, when a route's `params.parse` function throws an error, the route will match and then show an error state during render. With `skipRouteOnParseError.params` enabled, the router will skip routes whose `params.parse` function throws and continue searching for alternative matching routes. +- See [Guides > Path Params > Validating path parameters during matching](../../guide/path-params#validating-path-parameters-during-matching) for detailed usage examples. + +> [!IMPORTANT] +> **Performance impact**: This option has a **non-negligible performance cost** and should only be enabled when needed. Routes with `skipRouteOnParseError` are placed on separate branches in the route matching tree instead of sharing nodes with other dynamic routes. This reduces the tree's ability to efficiently narrow down matches and requires testing more routes, even for routes that wouldn't match the path structure alone. + ### `beforeLoad` method - Type: diff --git a/docs/router/framework/react/guide/path-params.md b/docs/router/framework/react/guide/path-params.md index 4ebd477ace2..2feb6b5e040 100644 --- a/docs/router/framework/react/guide/path-params.md +++ b/docs/router/framework/react/guide/path-params.md @@ -738,6 +738,18 @@ function ShopComponent() { Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. +## Validating and Transforming Path Parameters + +Path parameters are captured from URLs as strings, but you often need to transform them to other types (numbers, dates) or validate they meet specific criteria (UUIDs, patterns). TanStack Router provides `params.parse` and `params.stringify` options for this purpose. + +For a comprehensive guide on validating and transforming path parameters, including: + +- Using `params.parse` to transform and validate parameters +- Understanding error handling with `errorComponent` +- Using the experimental `skipRouteOnParseError` feature for type-specific routes + +See the dedicated [Validating Path Params](./validating-path-params.md) guide. + ## Allowed Characters By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property). diff --git a/docs/router/framework/react/guide/validating-path-params.md b/docs/router/framework/react/guide/validating-path-params.md new file mode 100644 index 00000000000..bf9e1c79972 --- /dev/null +++ b/docs/router/framework/react/guide/validating-path-params.md @@ -0,0 +1,547 @@ +--- +title: Validating Path Params +--- + +Path parameters are captured from URLs as strings. Often, you need to transform or validate these strings before using them in your application - converting them to numbers, parsing dates, validating UUIDs, or ensuring they meet specific criteria. + +TanStack Router provides `params.parse` and `params.stringify` options for transforming and validating path parameters, with flexible error handling strategies to suit different use cases. + +## Parsing Path Parameters + +The `params.parse` function transforms and validates path parameters as they're extracted from the URL. This is useful for: + +- **Type conversion**: Converting string parameters to numbers, dates, or other types +- **Validation**: Ensuring parameters meet specific criteria (e.g., UUIDs, email formats) +- **Normalization**: Cleaning or standardizing parameter values + +### Basic Example + +```tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + }, + loader: async ({ params }) => { + // params.id is now a number + return fetchUser(params.id) + }, + component: UserComponent, +}) + +function UserComponent() { + const { id } = Route.useParams() + // TypeScript knows id is a number + return
User ID: {id}
+} +``` + +### Validation with Error Handling + +When `params.parse` throws an error, the route's `errorComponent` is displayed by default: + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + params: { + parse: (params) => { + const postId = parseInt(params.postId, 10) + if (isNaN(postId) || postId <= 0) { + throw new Error('Post ID must be a positive number') + } + return { postId } + }, + }, + errorComponent: ({ error }) => { + return
Invalid post ID: {error.message}
+ }, + component: PostComponent, +}) +``` + +With this setup: + +- `/posts/123` → Renders `PostComponent` with `params.postId = 123` +- `/posts/abc` → Renders `errorComponent` with the validation error + +### Complex Validation Examples + +#### UUID Validation + +```tsx +export const Route = createFileRoute('/resources/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + if (!uuidRegex.test(params.uuid)) { + throw new Error('Invalid UUID format') + } + + return { uuid: params.uuid } + }, + }, + loader: async ({ params }) => fetchByUuid(params.uuid), +}) +``` + +#### Date Parsing + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + + if (isNaN(date.getTime())) { + throw new Error('Invalid date format. Use YYYY-MM-DD') + } + + return { date } + }, + }, + loader: async ({ params }) => { + // params.date is a Date object + return fetchEventsByDate(params.date) + }, +}) +``` + +#### Using Validation Libraries + +You can integrate validation libraries like Zod, Valibot, or ArkType: + +```tsx +import { z } from 'zod' + +const paramsSchema = z.object({ + userId: z.coerce.number().positive(), +}) + +export const Route = createFileRoute('/users/$userId')({ + params: { + parse: (params) => paramsSchema.parse(params), + }, +}) +``` + +## Stringifying Path Parameters + +The `params.stringify` function is the inverse of `params.parse` - it transforms your typed parameters back into URL-safe strings for navigation. + +### Basic Example + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + stringify: (params) => ({ + id: String(params.id), + }), + }, +}) + +// When navigating +function Component() { + return ( + + {/* Router calls stringify to convert 123 to "123" */} + Go to User + + ) +} +``` + +### Date Stringification + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => ({ + date: new Date(params.date), + }), + stringify: (params) => ({ + date: params.date.toISOString().split('T')[0], // YYYY-MM-DD + }), + }, +}) + +function Component() { + return ( + + {/* Converts to /events/2024-01-15 */} + View Event + + ) +} +``` + +## Error Handling Strategies + +When parameter validation fails, TanStack Router offers two error handling strategies: + +### Default Behavior: Show Error Component + +By default, when `params.parse` throws: + +1. The route matches based on URL structure +2. `params.parse` runs during the route lifecycle +3. If parsing fails, the route enters an error state +4. The route's `errorComponent` is displayed + +This is useful when: + +- You have a single route handling all variations of a parameter +- You want to show error UI for invalid parameters +- The route structure is clear and you don't need fallbacks + +### Alternative: Skip Route on Parse Error (⚠️ Experimental) + +Sometimes you want the router to try alternative routes when validation fails. For example, you might have: + +- Different routes for numeric IDs vs. string slugs at the same URL path +- Routes that match only specific parameter formats (UUIDs, dates, etc.) + +This is where `skipRouteOnParseError` comes in. + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. +> +> **Performance cost**: This feature has a **non-negligible performance cost** and should only be enabled when needed. It creates additional branches in the route matching tree, reducing matching efficiency and requiring more route evaluations. Use it only when you genuinely need type-specific routes at the same path level. + +## Validating During Route Matching + +With `skipRouteOnParseError.params` enabled, parameter validation becomes part of the route matching process: + +1. Route structure matches the URL path +2. `params.parse` runs immediately during matching +3. If parsing fails, the route is skipped +4. The router continues searching for other matching routes +5. If no routes match, `notFoundComponent` is shown + +### When to Use This Feature + +Use `skipRouteOnParseError.params` when you need: + +- **Type-specific routes**: Different routes for UUIDs vs. slugs at the same path (e.g., `/$uuid` and `/$slug`) +- **Format-specific routes**: Date-formatted paths vs. regular slugs (e.g., `/posts/2024-01-15` vs. `/posts/my-post`) +- **Numeric vs. string routes**: Different behavior for numeric IDs vs. usernames (e.g., `/users/123` vs. `/users/johndoe`) + +Before using `skipRouteOnParseError.params`, consider whether you can achieve your goals with standard route matching: + +- Using a static route prefix (e.g., `/id/$id` vs. `/username/$username`) +- Using a prefix or suffix in the path (e.g., `/user-{$id}` vs. `/$username`) + +### Basic Example: Numeric IDs with String Fallback + +```tsx +// routes/$id.tsx - Only matches numeric IDs +export const Route = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + throw new Error('ID must be numeric') + } + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchUserById(params.id), + component: UserByIdComponent, +}) + +// routes/$username.tsx - Matches any string +export const UsernameRoute = createFileRoute('/$username')({ + // No params.parse - accepts any string + loader: async ({ params }) => fetchUserByUsername(params.username), + component: UserByUsernameComponent, +}) +``` + +Results: + +- `/123` → Matches `/$id` route (validation passes), `params.id` is a number +- `/johndoe` → Skips `/$id` (validation fails), matches `/$username` route + +### Pattern-Based Validation + +#### UUID vs. Slug Routes + +```tsx +// routes/$uuid.tsx - Only matches valid UUIDs +export const Route = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid)) { + throw new Error('Not a valid UUID') + } + return { uuid: params.uuid } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchByUuid(params.uuid), + component: UuidResourceComponent, +}) + +// routes/$slug.tsx - Matches any string +export const SlugRoute = createFileRoute('/$slug')({ + loader: async ({ params }) => fetchBySlug(params.slug), + component: SlugResourceComponent, +}) +``` + +Results: + +- `/550e8400-e29b-41d4-a716-446655440000` → Matches UUID route +- `/my-blog-post` → Matches slug route + +#### Date-Formatted Posts + +```tsx +// routes/posts/$date.tsx - Only matches YYYY-MM-DD format +export const Route = createFileRoute('/posts/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + if (isNaN(date.getTime())) { + throw new Error('Invalid date format') + } + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchPostsByDate(params.date), + component: DatePostsComponent, +}) + +// routes/posts/$slug.tsx - Matches any string +export const PostSlugRoute = createFileRoute('/posts/$slug')({ + loader: async ({ params }) => fetchPostBySlug(params.slug), + component: PostComponent, +}) +``` + +Results: + +- `/posts/2024-01-15` → Matches date route, `params.date` is a Date object +- `/posts/my-first-post` → Matches slug route + +### Route Priority + +When multiple routes could match the same URL, TanStack Router uses this priority order: + +1. **Static routes** (highest priority) - e.g., `/settings` +2. **Dynamic routes** - e.g., `/$slug` +3. **Optional routes** - e.g., `/{-$lang}` +4. **Wildcard routes** (lowest priority) - e.g., `/$` + +When `skipRouteOnParseError` is used, validated routes are treated as having higher priority than non-validated routes _of the same category_. + +Example demonstrating priority: + +```tsx +// Static route - always matches /settings first +export const SettingsRoute = createFileRoute('/settings')({ + component: SettingsComponent, +}) + +// Validated route - matches numeric IDs +export const IdRoute = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) throw new Error('Not numeric') + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + component: IdComponent, +}) + +// Non-validated route - fallback for any string +export const SlugRoute = createFileRoute('/$slug')({ + component: SlugComponent, +}) +``` + +Matching results: + +- `/settings` → Static route (highest priority) +- `/123` → Validated dynamic route (`/$id`) +- `/hello` → Non-validated dynamic route (`/$slug`) + +### Custom Priority Between Validated Routes + +When you have multiple validated routes at the same level, use `skipRouteOnParseError.priority` as a tie-breaker. Higher numbers mean higher priority (default is 0). + +```tsx +// routes/$uuid.tsx +export const UuidRoute = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + if (!isUuid(params.uuid)) throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { + params: true, + priority: 10, // Try this first + }, + component: UuidComponent, +}) + +// routes/$number.tsx +export const NumberRoute = createFileRoute('/$number')({ + params: { + parse: (params) => ({ + number: parseInt(params.number, 10), + }), + }, + skipRouteOnParseError: { + params: true, + priority: 5, // Try this second + }, + component: NumberComponent, +}) + +// routes/$slug.tsx +export const SlugRoute = createFileRoute('/$slug')({ + // No validation - lowest priority by default + component: SlugComponent, +}) +``` + +Matching order: + +1. Check UUID validation (priority 10) +2. Check number validation (priority 5) +3. Fall back to slug route (no validation) + +### Nested Routes with Validation + +Parent route validation gates access to child routes: + +```tsx +// routes/$orgId.tsx - Parent route, only matches numeric org IDs +export const OrgRoute = createFileRoute('/$orgId')({ + params: { + parse: (params) => ({ + orgId: parseInt(params.orgId, 10), + }), + }, + skipRouteOnParseError: { params: true }, + component: OrgLayoutComponent, +}) + +// routes/$orgId/settings.tsx - Child route +export const OrgSettingsRoute = createFileRoute('/$orgId/settings')({ + component: OrgSettingsComponent, +}) + +// routes/$slug/settings.tsx - Alternative route +export const SlugSettingsRoute = createFileRoute('/$slug/settings')({ + component: SettingsComponent, +}) +``` + +Results: + +- `/123/settings` → Matches `/$orgId/settings` (parent validation passes) +- `/my-org/settings` → Matches `/$slug/settings` (`/$orgId` validation fails) + +### Working with Optional Parameters + +`skipRouteOnParseError` works with optional parameters too: + +```tsx +// routes/{-$lang}/home.tsx - Validates language codes +export const Route = createFileRoute('/{-$lang}/home')({ + params: { + parse: (params) => { + const validLangs = ['en', 'fr', 'es', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language code') + } + return { lang: params.lang || 'en' } + }, + }, + skipRouteOnParseError: { params: true }, + component: HomeComponent, +}) +``` + +Results: + +- `/home` → Matches (optional param skipped, defaults to 'en') +- `/en/home` → Matches (validation passes) +- `/fr/home` → Matches (validation passes) +- `/it/home` → No match (validation fails, 'it' not in valid list) + +## Best Practices + +### When to Use params.parse + +Use `params.parse` for: + +- Converting string parameters to appropriate types (numbers, dates, booleans) +- Validating parameter formats (UUIDs, emails, patterns) +- Normalizing parameter values +- Applying business logic constraints + +### When to Add skipRouteOnParseError + +Only use `skipRouteOnParseError.params` when you need: + +- Multiple routes at the same URL path with different parameter requirements +- Automatic fallback to alternative routes when validation fails + +Consider simpler alternatives first: + +- Static prefixes or suffixes in route paths +- Separate URL paths for different parameter types +- Client-side validation without route-level enforcement + +### Performance Considerations + +Be aware that `skipRouteOnParseError`: + +- Adds overhead to route matching +- Creates additional branches in the routing tree +- Can slow down navigation when you have many validated routes + +Use it judiciously and only when the routing flexibility justifies the performance cost. + +### Type Safety + +TanStack Router infers types from your `params.parse` return value: + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), // Returns number + }), + }, + loader: ({ params }) => { + // params.id is typed as number + return fetchUser(params.id) + }, +}) +``` + +The types automatically flow through to: + +- `loader` functions +- `beforeLoad` functions +- `useParams()` hooks +- `Link` components (when navigating) + +This ensures type safety throughout your application. diff --git a/docs/router/framework/solid/guide/path-params.md b/docs/router/framework/solid/guide/path-params.md index 7851e94bd8d..e08a9269460 100644 --- a/docs/router/framework/solid/guide/path-params.md +++ b/docs/router/framework/solid/guide/path-params.md @@ -744,6 +744,18 @@ function ShopComponent() { Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. +## Validating and Transforming Path Parameters + +Path parameters are captured from URLs as strings, but you often need to transform them to other types (numbers, dates) or validate they meet specific criteria (UUIDs, patterns). TanStack Router provides `params.parse` and `params.stringify` options for this purpose. + +For a comprehensive guide on validating and transforming path parameters, including: + +- Using `params.parse` to transform and validate parameters +- Understanding error handling with `errorComponent` +- Using the experimental `skipRouteOnParseError` feature for type-specific routes + +See the dedicated [Validating Path Params](./validating-path-params.md) guide. + ## Allowed Characters By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property). diff --git a/docs/router/framework/solid/guide/validating-path-params.md b/docs/router/framework/solid/guide/validating-path-params.md new file mode 100644 index 00000000000..7ec52c310b2 --- /dev/null +++ b/docs/router/framework/solid/guide/validating-path-params.md @@ -0,0 +1,547 @@ +--- +title: Validating Path Params +--- + +Path parameters are captured from URLs as strings. Often, you need to transform or validate these strings before using them in your application - converting them to numbers, parsing dates, validating UUIDs, or ensuring they meet specific criteria. + +TanStack Router provides `params.parse` and `params.stringify` options for transforming and validating path parameters, with flexible error handling strategies to suit different use cases. + +## Parsing Path Parameters + +The `params.parse` function transforms and validates path parameters as they're extracted from the URL. This is useful for: + +- **Type conversion**: Converting string parameters to numbers, dates, or other types +- **Validation**: Ensuring parameters meet specific criteria (e.g., UUIDs, email formats) +- **Normalization**: Cleaning or standardizing parameter values + +### Basic Example + +```tsx +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + }, + loader: async ({ params }) => { + // params.id is now a number + return fetchUser(params.id) + }, + component: UserComponent, +}) + +function UserComponent() { + const { id } = Route.useParams() + // TypeScript knows id is a number + return
User ID: {id}
+} +``` + +### Validation with Error Handling + +When `params.parse` throws an error, the route's `errorComponent` is displayed by default: + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + params: { + parse: (params) => { + const postId = parseInt(params.postId, 10) + if (isNaN(postId) || postId <= 0) { + throw new Error('Post ID must be a positive number') + } + return { postId } + }, + }, + errorComponent: ({ error }) => { + return
Invalid post ID: {error.message}
+ }, + component: PostComponent, +}) +``` + +With this setup: + +- `/posts/123` → Renders `PostComponent` with `params.postId = 123` +- `/posts/abc` → Renders `errorComponent` with the validation error + +### Complex Validation Examples + +#### UUID Validation + +```tsx +export const Route = createFileRoute('/resources/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + if (!uuidRegex.test(params.uuid)) { + throw new Error('Invalid UUID format') + } + + return { uuid: params.uuid } + }, + }, + loader: async ({ params }) => fetchByUuid(params.uuid), +}) +``` + +#### Date Parsing + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + + if (isNaN(date.getTime())) { + throw new Error('Invalid date format. Use YYYY-MM-DD') + } + + return { date } + }, + }, + loader: async ({ params }) => { + // params.date is a Date object + return fetchEventsByDate(params.date) + }, +}) +``` + +#### Using Validation Libraries + +You can integrate validation libraries like Zod, Valibot, or ArkType: + +```tsx +import { z } from 'zod' + +const paramsSchema = z.object({ + userId: z.coerce.number().positive(), +}) + +export const Route = createFileRoute('/users/$userId')({ + params: { + parse: (params) => paramsSchema.parse(params), + }, +}) +``` + +## Stringifying Path Parameters + +The `params.stringify` function is the inverse of `params.parse` - it transforms your typed parameters back into URL-safe strings for navigation. + +### Basic Example + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + stringify: (params) => ({ + id: String(params.id), + }), + }, +}) + +// When navigating +function Component() { + return ( + + {/* Router calls stringify to convert 123 to "123" */} + Go to User + + ) +} +``` + +### Date Stringification + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => ({ + date: new Date(params.date), + }), + stringify: (params) => ({ + date: params.date.toISOString().split('T')[0], // YYYY-MM-DD + }), + }, +}) + +function Component() { + return ( + + {/* Converts to /events/2024-01-15 */} + View Event + + ) +} +``` + +## Error Handling Strategies + +When parameter validation fails, TanStack Router offers two error handling strategies: + +### Default Behavior: Show Error Component + +By default, when `params.parse` throws: + +1. The route matches based on URL structure +2. `params.parse` runs during the route lifecycle +3. If parsing fails, the route enters an error state +4. The route's `errorComponent` is displayed + +This is useful when: + +- You have a single route handling all variations of a parameter +- You want to show error UI for invalid parameters +- The route structure is clear and you don't need fallbacks + +### Alternative: Skip Route on Parse Error (⚠️ Experimental) + +Sometimes you want the router to try alternative routes when validation fails. For example, you might have: + +- Different routes for numeric IDs vs. string slugs at the same URL path +- Routes that match only specific parameter formats (UUIDs, dates, etc.) + +This is where `skipRouteOnParseError` comes in. + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. +> +> **Performance cost**: This feature has a **non-negligible performance cost** and should only be enabled when needed. It creates additional branches in the route matching tree, reducing matching efficiency and requiring more route evaluations. Use it only when you genuinely need type-specific routes at the same path level. + +## Validating During Route Matching + +With `skipRouteOnParseError.params` enabled, parameter validation becomes part of the route matching process: + +1. Route structure matches the URL path +2. `params.parse` runs immediately during matching +3. If parsing fails, the route is skipped +4. The router continues searching for other matching routes +5. If no routes match, `notFoundComponent` is shown + +### When to Use This Feature + +Use `skipRouteOnParseError.params` when you need: + +- **Type-specific routes**: Different routes for UUIDs vs. slugs at the same path (e.g., `/$uuid` and `/$slug`) +- **Format-specific routes**: Date-formatted paths vs. regular slugs (e.g., `/posts/2024-01-15` vs. `/posts/my-post`) +- **Numeric vs. string routes**: Different behavior for numeric IDs vs. usernames (e.g., `/users/123` vs. `/users/johndoe`) + +Before using `skipRouteOnParseError.params`, consider whether you can achieve your goals with standard route matching: + +- Using a static route prefix (e.g., `/id/$id` vs. `/username/$username`) +- Using a prefix or suffix in the path (e.g., `/user-{$id}` vs. `/$username`) + +### Basic Example: Numeric IDs with String Fallback + +```tsx +// routes/$id.tsx - Only matches numeric IDs +export const Route = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + throw new Error('ID must be numeric') + } + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchUserById(params.id), + component: UserByIdComponent, +}) + +// routes/$username.tsx - Matches any string +export const UsernameRoute = createFileRoute('/$username')({ + // No params.parse - accepts any string + loader: async ({ params }) => fetchUserByUsername(params.username), + component: UserByUsernameComponent, +}) +``` + +Results: + +- `/123` → Matches `/$id` route (validation passes), `params.id` is a number +- `/johndoe` → Skips `/$id` (validation fails), matches `/$username` route + +### Pattern-Based Validation + +#### UUID vs. Slug Routes + +```tsx +// routes/$uuid.tsx - Only matches valid UUIDs +export const Route = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid)) { + throw new Error('Not a valid UUID') + } + return { uuid: params.uuid } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchByUuid(params.uuid), + component: UuidResourceComponent, +}) + +// routes/$slug.tsx - Matches any string +export const SlugRoute = createFileRoute('/$slug')({ + loader: async ({ params }) => fetchBySlug(params.slug), + component: SlugResourceComponent, +}) +``` + +Results: + +- `/550e8400-e29b-41d4-a716-446655440000` → Matches UUID route +- `/my-blog-post` → Matches slug route + +#### Date-Formatted Posts + +```tsx +// routes/posts/$date.tsx - Only matches YYYY-MM-DD format +export const Route = createFileRoute('/posts/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + if (isNaN(date.getTime())) { + throw new Error('Invalid date format') + } + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchPostsByDate(params.date), + component: DatePostsComponent, +}) + +// routes/posts/$slug.tsx - Matches any string +export const PostSlugRoute = createFileRoute('/posts/$slug')({ + loader: async ({ params }) => fetchPostBySlug(params.slug), + component: PostComponent, +}) +``` + +Results: + +- `/posts/2024-01-15` → Matches date route, `params.date` is a Date object +- `/posts/my-first-post` → Matches slug route + +### Route Priority + +When multiple routes could match the same URL, TanStack Router uses this priority order: + +1. **Static routes** (highest priority) - e.g., `/settings` +2. **Dynamic routes** - e.g., `/$slug` +3. **Optional routes** - e.g., `/{-$lang}` +4. **Wildcard routes** (lowest priority) - e.g., `/$` + +When `skipRouteOnParseError` is used, validated routes are treated as having higher priority than non-validated routes _of the same category_. + +Example demonstrating priority: + +```tsx +// Static route - always matches /settings first +export const SettingsRoute = createFileRoute('/settings')({ + component: SettingsComponent, +}) + +// Validated route - matches numeric IDs +export const IdRoute = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) throw new Error('Not numeric') + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + component: IdComponent, +}) + +// Non-validated route - fallback for any string +export const SlugRoute = createFileRoute('/$slug')({ + component: SlugComponent, +}) +``` + +Matching results: + +- `/settings` → Static route (highest priority) +- `/123` → Validated dynamic route (`/$id`) +- `/hello` → Non-validated dynamic route (`/$slug`) + +### Custom Priority Between Validated Routes + +When you have multiple validated routes at the same level, use `skipRouteOnParseError.priority` as a tie-breaker. Higher numbers mean higher priority (default is 0). + +```tsx +// routes/$uuid.tsx +export const UuidRoute = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + if (!isUuid(params.uuid)) throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { + params: true, + priority: 10, // Try this first + }, + component: UuidComponent, +}) + +// routes/$number.tsx +export const NumberRoute = createFileRoute('/$number')({ + params: { + parse: (params) => ({ + number: parseInt(params.number, 10), + }), + }, + skipRouteOnParseError: { + params: true, + priority: 5, // Try this second + }, + component: NumberComponent, +}) + +// routes/$slug.tsx +export const SlugRoute = createFileRoute('/$slug')({ + // No validation - lowest priority by default + component: SlugComponent, +}) +``` + +Matching order: + +1. Check UUID validation (priority 10) +2. Check number validation (priority 5) +3. Fall back to slug route (no validation) + +### Nested Routes with Validation + +Parent route validation gates access to child routes: + +```tsx +// routes/$orgId.tsx - Parent route, only matches numeric org IDs +export const OrgRoute = createFileRoute('/$orgId')({ + params: { + parse: (params) => ({ + orgId: parseInt(params.orgId, 10), + }), + }, + skipRouteOnParseError: { params: true }, + component: OrgLayoutComponent, +}) + +// routes/$orgId/settings.tsx - Child route +export const OrgSettingsRoute = createFileRoute('/$orgId/settings')({ + component: OrgSettingsComponent, +}) + +// routes/$slug/settings.tsx - Alternative route +export const SlugSettingsRoute = createFileRoute('/$slug/settings')({ + component: SettingsComponent, +}) +``` + +Results: + +- `/123/settings` → Matches `/$orgId/settings` (parent validation passes) +- `/my-org/settings` → Matches `/$slug/settings` (`/$orgId` validation fails) + +### Working with Optional Parameters + +`skipRouteOnParseError` works with optional parameters too: + +```tsx +// routes/{-$lang}/home.tsx - Validates language codes +export const Route = createFileRoute('/{-$lang}/home')({ + params: { + parse: (params) => { + const validLangs = ['en', 'fr', 'es', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language code') + } + return { lang: params.lang || 'en' } + }, + }, + skipRouteOnParseError: { params: true }, + component: HomeComponent, +}) +``` + +Results: + +- `/home` → Matches (optional param skipped, defaults to 'en') +- `/en/home` → Matches (validation passes) +- `/fr/home` → Matches (validation passes) +- `/it/home` → No match (validation fails, 'it' not in valid list) + +## Best Practices + +### When to Use params.parse + +Use `params.parse` for: + +- Converting string parameters to appropriate types (numbers, dates, booleans) +- Validating parameter formats (UUIDs, emails, patterns) +- Normalizing parameter values +- Applying business logic constraints + +### When to Add skipRouteOnParseError + +Only use `skipRouteOnParseError.params` when you need: + +- Multiple routes at the same URL path with different parameter requirements +- Automatic fallback to alternative routes when validation fails + +Consider simpler alternatives first: + +- Static prefixes or suffixes in route paths +- Separate URL paths for different parameter types +- Client-side validation without route-level enforcement + +### Performance Considerations + +Be aware that `skipRouteOnParseError`: + +- Adds overhead to route matching +- Creates additional branches in the routing tree +- Can slow down navigation when you have many validated routes + +Use it judiciously and only when the routing flexibility justifies the performance cost. + +### Type Safety + +TanStack Router infers types from your `params.parse` return value: + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), // Returns number + }), + }, + loader: ({ params }) => { + // params.id is typed as number + return fetchUser(params.id) + }, +}) +``` + +The types automatically flow through to: + +- `loader` functions +- `beforeLoad` functions +- `useParams()` hooks +- `` components (when navigating) + +This ensures type safety throughout your application. diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1cfcc8aa536..ca9d75d61be 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -8,6 +8,7 @@ export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 const SEGMENT_TYPE_INDEX = 4 +const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information /** * All the kinds of segments that can be present in a route path. @@ -21,7 +22,10 @@ export type SegmentKind = /** * All the kinds of segments that can be present in the segment tree. */ -type ExtendedSegmentKind = SegmentKind | typeof SEGMENT_TYPE_INDEX +type ExtendedSegmentKind = + | SegmentKind + | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHLESS const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix @@ -183,6 +187,10 @@ function parseSegments( const path = route.fullPath ?? route.from const length = path.length const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive + const skipOnParamError = !!( + route.options?.params?.parse && + route.options?.skipRouteOnParseError?.params + ) while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -241,12 +249,15 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic?.find( - (s) => - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) + const existingNode = + !skipOnParamError && + node.dynamic?.find( + (s) => + !s.skipOnParamError && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -280,12 +291,15 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional?.find( - (s) => - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) + const existingNode = + !skipOnParamError && + node.optional?.find( + (s) => + !s.skipOnParamError && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -336,8 +350,27 @@ function parseSegments( node = nextNode } - const isLeaf = (route.path || !route.children) && !route.isRoot + // create pathless node + if ( + skipOnParamError && + route.children && + !route.isRoot && + route.id && + route.id.charCodeAt(route.id.lastIndexOf('/') + 1) === 95 /* '_' */ + ) { + const pathlessNode = createStaticNode( + route.fullPath ?? route.from, + ) + pathlessNode.kind = SEGMENT_TYPE_PATHLESS + pathlessNode.parent = node + depth++ + pathlessNode.depth = depth + node.pathless ??= [] + node.pathless.push(pathlessNode) + node = pathlessNode + } + const isLeaf = (route.path || !route.children) && !route.isRoot // create index node if (isLeaf && path.endsWith('/')) { const indexNode = createStaticNode( @@ -351,6 +384,10 @@ function parseSegments( node = indexNode } + node.parse = route.options?.params?.parse ?? null + node.skipOnParamError = skipOnParamError + node.parsingPriority = route.options?.skipRouteOnParseError?.priority ?? 0 + // make node "matchable" if (isLeaf && !node.route) { node.route = route @@ -372,9 +409,30 @@ function parseSegments( } function sortDynamic( - a: { prefix?: string; suffix?: string; caseSensitive: boolean }, - b: { prefix?: string; suffix?: string; caseSensitive: boolean }, + a: { + prefix?: string + suffix?: string + caseSensitive: boolean + skipOnParamError: boolean + parsingPriority: number + }, + b: { + prefix?: string + suffix?: string + caseSensitive: boolean + skipOnParamError: boolean + parsingPriority: number + }, ) { + if (a.skipOnParamError && !b.skipOnParamError) return -1 + if (!a.skipOnParamError && b.skipOnParamError) return 1 + if ( + a.skipOnParamError && + b.skipOnParamError && + a.parsingPriority && + b.parsingPriority + ) + return b.parsingPriority - a.parsingPriority if (a.prefix && b.prefix && a.prefix !== b.prefix) { if (a.prefix.startsWith(b.prefix)) return -1 if (b.prefix.startsWith(a.prefix)) return 1 @@ -396,6 +454,11 @@ function sortDynamic( } function sortTreeNodes(node: SegmentNode) { + if (node.pathless) { + for (const child of node.pathless) { + sortTreeNodes(child) + } + } if (node.static) { for (const child of node.static.values()) { sortTreeNodes(child) @@ -432,6 +495,7 @@ function createStaticNode( return { kind: SEGMENT_TYPE_PATHNAME, depth: 0, + pathless: null, index: null, static: null, staticInsensitive: null, @@ -441,6 +505,9 @@ function createStaticNode( route: null, fullPath, parent: null, + parse: null, + skipOnParamError: false, + parsingPriority: 0, } } @@ -461,6 +528,7 @@ function createDynamicNode( return { kind, depth: 0, + pathless: null, index: null, static: null, staticInsensitive: null, @@ -470,6 +538,9 @@ function createDynamicNode( route: null, fullPath, parent: null, + parse: null, + skipOnParamError: false, + parsingPriority: 0, caseSensitive, prefix, suffix, @@ -477,7 +548,10 @@ function createDynamicNode( } type StaticSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_INDEX + kind: + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { @@ -497,6 +571,8 @@ type AnySegmentNode = type SegmentNode = { kind: ExtendedSegmentKind + pathless: Array> | null + /** Exact index segment (highest priority) */ index: StaticSegmentNode | null @@ -524,15 +600,32 @@ type SegmentNode = { parent: AnySegmentNode | null depth: number + + /** route.options.params.parse function, set on the last node of the route */ + parse: null | ((params: Record) => any) + + /** options.skipRouteOnParseError.params ?? false */ + skipOnParamError: boolean + + /** options.skipRouteOnParseError.priority ?? 0 */ + parsingPriority: number } type RouteLike = { + id?: string path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { + skipRouteOnParseError?: { + params?: boolean + priority?: number + } caseSensitive?: boolean + params?: { + parse?: (params: Record) => any + } } } & // router tree @@ -621,7 +714,8 @@ export function findSingleMatch( type RouteMatch> = { route: T - params: Record + rawParams: Record + parsedParams?: Record branch: ReadonlyArray } @@ -718,32 +812,57 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, -): { route: T; params: Record } | null { +): { + route: T + /** + * The raw (unparsed) params extracted from the path. + * This will be the exhaustive list of all params defined in the route's path. + */ + rawParams: Record + /** + * The accumlulated parsed params of each route in the branch that had `skipRouteOnParseError` enabled. + * Will not contain all params defined in the route's path. Those w/ a `params.parse` but no `skipRouteOnParseError` will need to be parsed separately. + */ + parsedParams?: Record +} | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null - const params = extractParams(path, parts, leaf) - if ('**' in leaf) params['**'] = leaf['**']! - const route = leaf.node.route! + const [rawParams] = extractParams(path, parts, leaf) return { - route, - params, + route: leaf.node.route!, + rawParams, + parsedParams: leaf.parsedParams, } } +/** + * This function is "resumable": + * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call + * - the returned `state` can be passed back as `extract` in a future call to continue extracting params from where we left off + * + * Inputs are *not* mutated. + */ function extractParams( path: string, parts: Array, - leaf: { node: AnySegmentNode; skipped: number }, -) { + leaf: { + node: AnySegmentNode + skipped: number + extract?: { part: number; node: number; path: number } + rawParams?: Record + }, +): [ + rawParams: Record, + state: { part: number; node: number; path: number }, +] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null - const params: Record = {} - for ( - let partIndex = 0, nodeIndex = 0, pathIndex = 0; - nodeIndex < list.length; - partIndex++, nodeIndex++, pathIndex++ - ) { + const rawParams: Record = {} + let partIndex = leaf.extract?.part ?? 0 + let nodeIndex = leaf.extract?.node ?? 0 + let pathIndex = leaf.extract?.path ?? 0 + for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { const node = list[nodeIndex]! const part = parts[partIndex] const currentPathIndex = pathIndex @@ -762,10 +881,10 @@ function extractParams( nodePart.length - sufLength - 1, ) const value = part!.substring(preLength, part!.length - sufLength) - params[name] = decodeURIComponent(value) + rawParams[name] = decodeURIComponent(value) } else { const name = nodePart.substring(1) - params[name] = decodeURIComponent(part!) + rawParams[name] = decodeURIComponent(part!) } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { @@ -784,7 +903,7 @@ function extractParams( node.suffix || node.prefix ? part!.substring(preLength, part!.length - sufLength) : part - if (value) params[name] = decodeURIComponent(value) + if (value) rawParams[name] = decodeURIComponent(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const value = path.substring( @@ -793,12 +912,13 @@ function extractParams( ) const splat = decodeURIComponent(value) // TODO: Deprecate * - params['*'] = splat - params._splat = splat + rawParams['*'] = splat + rawParams._splat = splat break } } - return params + if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) + return [rawParams, { part: partIndex, node: nodeIndex, path: pathIndex }] } function buildRouteBranch(route: T) { @@ -836,6 +956,11 @@ type MatchStackFrame = { statics: number dynamics: number optionals: number + /** intermediary state for param extraction */ + extract?: { part: number; node: number; path: number } + /** intermediary params from param extraction */ + rawParams?: Record + parsedParams?: Record } function getNodeMatch( @@ -847,7 +972,10 @@ function getNodeMatch( // quick check for root index // this is an optimization, algorithm should work correctly without this block if (path === '/' && segmentTree.index) - return { node: segmentTree.index, skipped: 0 } + return { node: segmentTree.index, skipped: 0 } as Pick< + Frame, + 'node' | 'skipped' | 'parsedParams' + > const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -880,8 +1008,16 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! - // eslint-disable-next-line prefer-const - let { node, index, skipped, depth, statics, dynamics, optionals } = frame + const { node, index, skipped, depth, statics, dynamics, optionals } = frame + let { extract, rawParams, parsedParams } = frame + + if (node.skipOnParamError) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue + rawParams = frame.rawParams + extract = frame.extract + parsedParams = frame.parsedParams + } // In fuzzy mode, track the best partial match we've found so far if ( @@ -898,8 +1034,9 @@ function getNodeMatch( if (node.route && !pathIsIndex && isFrameMoreSpecific(bestMatch, frame)) { bestMatch = frame } - // beyond the length of the path parts, only index segments, or skipped optional segments, or wildcard segments can match - if (!node.optional && !node.wildcard && !node.index) continue + // beyond the length of the path parts, only some segment types can match + if (!node.optional && !node.wildcard && !node.index && !node.pathless) + continue } const part = isBeyondPath ? undefined : parts[index]! @@ -915,6 +1052,13 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + rawParams, + parsedParams, + } + if (node.index.skipOnParamError) { + const result = validateMatchParams(path, parts, indexFrame) + if (!result) continue } // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block @@ -946,7 +1090,7 @@ function getNodeMatch( } // the first wildcard match is the highest priority one // wildcard matches skip the stack because they cannot have children - wildcardMatch = { + const frame = { node: segment, index: partsLength, skipped, @@ -954,7 +1098,15 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + rawParams, + parsedParams, } + if (segment.skipOnParamError) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue + } + wildcardMatch = frame break } } @@ -974,6 +1126,9 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + rawParams, + parsedParams, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -995,6 +1150,9 @@ function getNodeMatch( statics, dynamics, optionals: optionals + 1, + extract, + rawParams, + parsedParams, }) } } @@ -1020,6 +1178,9 @@ function getNodeMatch( statics, dynamics: dynamics + 1, optionals, + extract, + rawParams, + parsedParams, }) } } @@ -1038,6 +1199,9 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + rawParams, + parsedParams, }) } } @@ -1054,6 +1218,29 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + rawParams, + parsedParams, + }) + } + } + + // 0. Try pathless match + if (node.pathless) { + const nextDepth = depth + 1 + for (let i = node.pathless.length - 1; i >= 0; i--) { + const segment = node.pathless[i]! + stack.push({ + node: segment, + index, + skipped, + depth: nextDepth, + statics, + dynamics, + optionals, + extract, + rawParams, + parsedParams, }) } } @@ -1075,16 +1262,31 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - return { - node: bestFuzzy.node, - skipped: bestFuzzy.skipped, - '**': decodeURIComponent(splat), - } + bestFuzzy.rawParams ??= {} + bestFuzzy.rawParams['**'] = decodeURIComponent(splat) + return bestFuzzy } return null } +function validateMatchParams( + path: string, + parts: Array, + frame: MatchStackFrame, +) { + try { + const [rawParams, state] = extractParams(path, parts, frame) + frame.rawParams = rawParams + frame.extract = state + const parsed = frame.node.parse!(rawParams) + frame.parsedParams = Object.assign({}, frame.parsedParams, parsed) + return true + } catch { + return null + } +} + function isFrameMoreSpecific( // the stack frame previously saved as "best match" prev: MatchStackFrame | null, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 53d726ef05a..b77e1bf55b4 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1188,9 +1188,42 @@ export interface UpdatableRouteOptions< in out TBeforeLoadFn, > extends UpdatableStaticRouteOption, UpdatableRouteOptionsExtensions { - // If true, this route will be matched as case-sensitive + /** + * Options to control route matching behavior with runtime code. + */ + skipRouteOnParseError?: { + /** + * If `true`, skip this route during matching if `params.parse` fails. + * + * Without this option, a `/$param` route could match *any* value for `param`, + * and only later during the route lifecycle would `params.parse` run and potentially + * show the `errorComponent` if validation failed. + * + * With this option enabled, the route will only match if `params.parse` succeeds. + * If it fails, the router will continue trying to match other routes, potentially + * finding a different route that works, or ultimately showing the `notFoundComponent`. + * + * @default false + */ + params?: boolean + /** + * In cases where multiple routes would need to run `params.parse` during matching + * to determine which route to pick, this priority number can be used as a tie-breaker + * for which route to try first. Higher number = higher priority. + * + * @default 0 + */ + priority?: number + } + /** + * If true, this route will be matched as case-sensitive + * + * @default false + */ caseSensitive?: boolean - // If true, this route will be forcefully wrapped in a suspense boundary + /** + * If true, this route will be forcefully wrapped in a suspense boundary + */ wrapInSuspense?: boolean // The content to be rendered when the route is matched. If no component is provided, defaults to `` diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d9d808a3185..1e837bd4261 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -698,8 +698,12 @@ export type ParseLocationFn = ( export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray + /** exhaustive params, still in their string form */ routeParams: Record + /** partial params, parsed from routeParams during matching */ + parsedParams: Record | undefined foundRoute: AnyRoute | undefined + parseError?: unknown } export type EmitFn = (routerEvent: RouterEvent) => void @@ -1260,7 +1264,7 @@ export class RouterCore< opts?: MatchRoutesOpts, ): Array { const matchedRoutesResult = this.getMatchedRoutes(next.pathname) - const { foundRoute, routeParams } = matchedRoutesResult + const { foundRoute, routeParams, parsedParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult let isGlobalNotFound = false @@ -1401,26 +1405,34 @@ export class RouterCore< let paramsError: unknown = undefined if (!existingMatch) { - const strictParseParams = - route.options.params?.parse ?? route.options.parseParams - - if (strictParseParams) { - try { - Object.assign( - strictParams, - strictParseParams(strictParams as Record), - ) - } catch (err: any) { - if (isNotFound(err) || isRedirect(err)) { - paramsError = err - } else { - paramsError = new PathParamError(err.message, { - cause: err, - }) + if (route.options.skipRouteOnParseError) { + for (const key in usedParams) { + if (key in parsedParams!) { + strictParams[key] = parsedParams![key] } + } + } else { + const strictParseParams = + route.options.params?.parse ?? route.options.parseParams - if (opts?.throwOnError) { - throw paramsError + if (strictParseParams) { + try { + Object.assign( + strictParams, + strictParseParams(strictParams as Record), + ) + } catch (err: any) { + if (isNotFound(err) || isRedirect(err)) { + paramsError = err + } else { + paramsError = new PathParamError(err.message, { + cause: err, + }) + } + + if (opts?.throwOnError) { + throw paramsError + } } } } @@ -1802,7 +1814,7 @@ export class RouterCore< this.processedTree, ) if (match) { - Object.assign(params, match.params) // Copy params, because they're cached + Object.assign(params, match.rawParams) // Copy params, because they're cached const { from: _from, params: maskParams, @@ -2601,18 +2613,18 @@ export class RouterCore< } if (location.params) { - if (!deepEqual(match.params, location.params, { partial: true })) { + if (!deepEqual(match.rawParams, location.params, { partial: true })) { return false } } if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match.params + ? match.rawParams : false } - return match.params + return match.rawParams } ssr?: { @@ -2719,15 +2731,17 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined + let parsedParams: Record | undefined = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route - Object.assign(routeParams, match.params) // Copy params, because they're cached + Object.assign(routeParams, match.rawParams) // Copy params, because they're cached + parsedParams = Object.assign({}, match.parsedParams) } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - return { matchedRoutes, routeParams, foundRoute } + return { matchedRoutes, routeParams, foundRoute, parsedParams } } function applySearchMiddleware({ diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts index 6184a205e7f..c52a6b33ec6 100644 --- a/packages/router-core/tests/curly-params-smoke.test.ts +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -136,6 +136,6 @@ describe('curly params smoke tests', () => { } const processed = processRouteTree(tree) const res = findRouteMatch(nav, processed.processedTree) - expect(res?.params).toEqual(params) + expect(res?.rawParams).toEqual(params) }) }) diff --git a/packages/router-core/tests/foo.test.ts b/packages/router-core/tests/foo.test.ts new file mode 100644 index 00000000000..5ba3b59b04b --- /dev/null +++ b/packages/router-core/tests/foo.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' + +describe('params extract viz', () => { + const rootRoute = new BaseRootRoute() + + const order: Array = [] + const args: Record = {} + + const parseA = vi.fn((params: { a: string }) => { + order.push('a') + args.a = { ...params } + if (params.a !== 'one') throw new Error('Invalid param a') + return { a: 1 } + }) + const a = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/$a', + params: { + parse: parseA, + }, + }) + + const parseB = vi.fn((params: { b: string }) => { + order.push('b') + args.b = { ...params } + if (params.b !== 'two') throw new Error('Invalid param b') + return { b: 2 } + }) + const b = new BaseRoute({ + getParentRoute: () => a, + path: '/$b', + params: { + parse: parseB, + }, + skipRouteOnParseError: { + params: true, + }, + }) + a.addChildren([b]) + + const parseC = vi.fn((params: { c: string }) => { + order.push('c') + args.c = { ...params } + if (params.c !== 'three') throw new Error('Invalid param c') + return { c: 3 } + }) + const c = new BaseRoute({ + getParentRoute: () => b, + path: '/$c', + params: { + parse: parseC, + }, + }) + b.addChildren([c]) + + const routeTree = rootRoute.addChildren([a]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + it('should extract params correctly', () => { + const matches = router.matchRoutes('/one/two/three') + + expect(matches).toHaveLength(4) + + // B is called first, because if's called in the matching phase because of `skipRouteOnParseError` + expect(order).toEqual(['b', 'a', 'c']) + + // A is called only once + expect(parseA).toHaveBeenCalledTimes(1) + // since it's the first, it only gets its own raw params + expect(args.a).toEqual({ a: 'one' }) + + // B is called only once + expect(parseB).toHaveBeenCalledTimes(1) + // since it's called in the matching phase, it gets all parent raw params + expect(args.b).toEqual({ a: 'one', b: 'two' }) + + // C is called only once + expect(parseC).toHaveBeenCalledTimes(1) + // since it's called last, after the matching phase, it gets parsed parent params, and its own raw param + expect(args.c).toEqual({ a: 1, b: 2, c: 'three' }) + + expect(matches[0]).toEqual( + expect.objectContaining({ + routeId: '__root__', + params: { a: 1, b: 2, c: 3 }, + _strictParams: {}, + }), + ) + + expect(matches[1]).toEqual( + expect.objectContaining({ + routeId: '/$a', + params: { a: 1, b: 2, c: 3 }, + _strictParams: { a: 1 }, + }), + ) + + expect(matches[2]).toEqual( + expect.objectContaining({ + routeId: '/$a/$b', + params: { a: 1, b: 2, c: 3 }, + _strictParams: { a: 1, b: 2 }, + }), + ) + + expect(matches[3]).toEqual( + expect.objectContaining({ + routeId: '/$a/$b/$c', + params: { a: 1, b: 2, c: 3 }, + _strictParams: { a: 1, b: 2, c: 3 }, + }), + ) + }) +}) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index ad108080381..f742c22df9a 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -26,7 +26,7 @@ describe('default path matching', () => { ['/b', '/a', undefined], ])('static %s %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -37,7 +37,7 @@ describe('default path matching', () => { ['/a/1/b/2', '/a/$id/b/$id', { id: '2' }], ])('params %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it('params support more than alphanumeric characters', () => { @@ -49,7 +49,7 @@ describe('default path matching', () => { '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', processedTree, ) - expect(anyValueResult?.params).toEqual({ + expect(anyValueResult?.rawParams).toEqual({ id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', }) // in the key: basically everything except / and % and $ @@ -60,7 +60,7 @@ describe('default path matching', () => { '/a/1', processedTree, ) - expect(anyKeyResult?.params).toEqual({ + expect(anyKeyResult?.rawParams).toEqual({ '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1', }) }) @@ -77,7 +77,7 @@ describe('default path matching', () => { ['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }], ])('optional %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -87,7 +87,7 @@ describe('default path matching', () => { ['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }], ])('wildcard %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) }) @@ -106,7 +106,7 @@ describe('case insensitive path matching', () => { ['/', '/b', '/A', undefined], ])('static %s %s => %s', (base, path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -116,7 +116,7 @@ describe('case insensitive path matching', () => { ['/a/1/b/2', '/A/$id/B/$id', { id: '2' }], ])('params %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -133,7 +133,7 @@ describe('case insensitive path matching', () => { ['/a/1/b/2_', '/A/{-$id}/B/{-$id}', { id: '2_' }], ])('optional %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -143,7 +143,7 @@ describe('case insensitive path matching', () => { ['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }], ])('wildcard %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) }) @@ -167,7 +167,7 @@ describe('fuzzy path matching', () => { ['/', '/a', '/b', undefined], ])('static %s %s => %s', (base, path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -178,7 +178,7 @@ describe('fuzzy path matching', () => { ['/a/1/b/2/c', '/a/$id/b/$other', { id: '1', other: '2', '**': 'c' }], ])('params %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -193,7 +193,7 @@ describe('fuzzy path matching', () => { ['/a/1/b/2/c', '/a/{-$id}/b/{-$other}', { id: '1', other: '2', '**': 'c' }], ])('optional %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -203,6 +203,6 @@ describe('fuzzy path matching', () => { ['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }], ])('wildcard %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) }) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 47083567e21..f3c1ac82fc5 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -111,13 +111,13 @@ describe('findRouteMatch', () => { const tree = makeTree(['/{-$id}']) const res = findRouteMatch('/', tree) expect(res?.route.id).toBe('/{-$id}') - expect(res?.params).toEqual({}) + expect(res?.rawParams).toEqual({}) }) it('wildcard at the root matches /', () => { const tree = makeTree(['/$']) const res = findRouteMatch('/', tree) expect(res?.route.id).toBe('/$') - expect(res?.params).toEqual({ '*': '', _splat: '' }) + expect(res?.rawParams).toEqual({ '*': '', _splat: '' }) }) it('dynamic at the root DOES NOT match /', () => { const tree = makeTree(['/$id']) @@ -457,13 +457,16 @@ describe('findRouteMatch', () => { }) it('multiple optionals at the end -> favor earlier segments', () => { const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}/{-$e}']) - expect(findRouteMatch('/a/b/c', tree)?.params).toEqual({ b: 'b', c: 'c' }) + expect(findRouteMatch('/a/b/c', tree)?.rawParams).toEqual({ + b: 'b', + c: 'c', + }) }) it('optional and wildcard at the end can still be omitted', () => { const tree = makeTree(['/a/{-$id}/$']) const result = findRouteMatch('/a', tree) expect(result?.route.id).toBe('/a/{-$id}/$') - expect(result?.params).toEqual({ '*': '', _splat: '' }) + expect(result?.rawParams).toEqual({ '*': '', _splat: '' }) }) it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) @@ -544,7 +547,7 @@ describe('findRouteMatch', () => { const { processedTree } = processRouteTree(tree) const res = findRouteMatch('/a/b/foo', processedTree, true) expect(res?.route.id).toBe('/a/b/$') - expect(res?.params).toEqual({ _splat: 'foo', '*': 'foo' }) + expect(res?.rawParams).toEqual({ _splat: 'foo', '*': 'foo' }) }) describe('edge-case #5969: trailing empty wildcard should match', () => { it('basic', () => { @@ -636,7 +639,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/b/c', '/a/b', '/a']) const match = findRouteMatch('/a/b/x/y', tree, true) expect(match?.route?.id).toBe('/a/b') - expect(match?.params).toMatchInlineSnapshot(` + expect(match?.rawParams).toMatchInlineSnapshot(` { "**": "x/y", } @@ -697,7 +700,7 @@ describe('findRouteMatch', () => { true, ) expect(match?.route.id).toBe('/dashboard') - expect(match?.params).toEqual({ '**': 'foo' }) + expect(match?.rawParams).toEqual({ '**': 'foo' }) }) it('cannot use an index route as a fuzzy match', () => { @@ -769,7 +772,7 @@ describe('findRouteMatch', () => { true, ) expect(match?.route.id).toBe('/dashboard') - expect(match?.params).toEqual({ '**': 'foo' }) + expect(match?.rawParams).toEqual({ '**': 'foo' }) const actualMatch = findRouteMatch('/dashboard', processed.processedTree) expect(actualMatch?.route.id).toBe('/dashboard/') }) @@ -797,7 +800,7 @@ describe('findRouteMatch', () => { (char, encoded) => { const tree = makeTree([`/a/$id`]) const result = findRouteMatch(`/a/${encoded}`, tree) - expect(result?.params).toEqual({ id: char }) + expect(result?.rawParams).toEqual({ id: char }) }, ) it.each(URISyntaxCharacters)( @@ -805,7 +808,7 @@ describe('findRouteMatch', () => { (char, encoded) => { const tree = makeTree([`/a/{-$id}`]) const result = findRouteMatch(`/a/${encoded}`, tree) - expect(result?.params).toEqual({ id: char }) + expect(result?.rawParams).toEqual({ id: char }) }, ) it.each(URISyntaxCharacters)( @@ -813,7 +816,7 @@ describe('findRouteMatch', () => { (char, encoded) => { const tree = makeTree([`/a/$`]) const result = findRouteMatch(`/a/${encoded}`, tree) - expect(result?.params).toEqual({ '*': char, _splat: char }) + expect(result?.rawParams).toEqual({ '*': char, _splat: char }) }, ) it('wildcard splat supports multiple URI encoded characters in multiple URL segments', () => { @@ -821,14 +824,14 @@ describe('findRouteMatch', () => { const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') const decoded = URISyntaxCharacters.map(([char]) => char).join('/') const result = findRouteMatch(`/a/${path}`, tree) - expect(result?.params).toEqual({ '*': decoded, _splat: decoded }) + expect(result?.rawParams).toEqual({ '*': decoded, _splat: decoded }) }) it('fuzzy splat supports multiple URI encoded characters in multiple URL segments', () => { const tree = makeTree(['/a']) const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') const decoded = URISyntaxCharacters.map(([char]) => char).join('/') const result = findRouteMatch(`/a/${path}`, tree, true) - expect(result?.params).toEqual({ '**': decoded }) + expect(result?.rawParams).toEqual({ '**': decoded }) }) }) describe('edge-cases', () => { @@ -859,10 +862,232 @@ describe('findRouteMatch', () => { const { processedTree } = processRouteTree(tree) const result = findRouteMatch(`/sv`, processedTree) expect(result?.route.id).toBe('/_pathless/{-$language}/') - expect(result?.params).toEqual({ language: 'sv' }) + expect(result?.rawParams).toEqual({ language: 'sv' }) }) }) }) + describe('pathless routes', () => { + it('builds segment tree correctly', () => { + const tree = { + path: '/', + isRoot: true, + id: '__root__', + fullPath: '/', + children: [ + { + path: '/', + id: '/', + fullPath: '/', + options: {}, + }, + { + id: '/$foo/_layout', + path: '$foo', + fullPath: '/$foo', + options: { + params: { parse: () => {} }, + skipRouteOnParseError: { + params: true, + }, + }, + children: [ + { + id: '/$foo/_layout/bar', + path: 'bar', + fullPath: '/$foo/bar', + options: {}, + }, + { + id: '/$foo/_layout/', + path: '/', + fullPath: '/$foo/', + options: {}, + }, + ], + }, + { + id: '/$foo/hello', + path: '$foo/hello', + fullPath: '/$foo/hello', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(processedTree.segmentTree).toMatchInlineSnapshot(` + { + "depth": 0, + "dynamic": [ + { + "caseSensitive": false, + "depth": 1, + "dynamic": null, + "fullPath": "/$foo", + "index": null, + "kind": 1, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": [ + { + "depth": 2, + "dynamic": null, + "fullPath": "/$foo", + "index": { + "depth": 3, + "dynamic": null, + "fullPath": "/$foo/", + "index": null, + "kind": 4, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/$foo/", + "id": "/$foo/_layout/", + "options": {}, + "path": "/", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "kind": 5, + "optional": null, + "parent": [Circular], + "parse": [Function], + "parsingPriority": 0, + "pathless": null, + "route": { + "children": [ + { + "fullPath": "/$foo/bar", + "id": "/$foo/_layout/bar", + "options": {}, + "path": "bar", + }, + { + "fullPath": "/$foo/", + "id": "/$foo/_layout/", + "options": {}, + "path": "/", + }, + ], + "fullPath": "/$foo", + "id": "/$foo/_layout", + "options": { + "params": { + "parse": [Function], + }, + "skipRouteOnParseError": { + "params": true, + }, + }, + "path": "$foo", + }, + "skipOnParamError": true, + "static": null, + "staticInsensitive": Map { + "bar" => { + "depth": 3, + "dynamic": null, + "fullPath": "/$foo/bar", + "index": null, + "kind": 0, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/$foo/bar", + "id": "/$foo/_layout/bar", + "options": {}, + "path": "bar", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "wildcard": null, + }, + ], + "prefix": undefined, + "route": null, + "skipOnParamError": false, + "static": null, + "staticInsensitive": Map { + "hello" => { + "depth": 2, + "dynamic": null, + "fullPath": "/$foo/hello", + "index": null, + "kind": 0, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/$foo/hello", + "id": "/$foo/hello", + "options": {}, + "path": "$foo/hello", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "suffix": undefined, + "wildcard": null, + }, + ], + "fullPath": "/", + "index": { + "depth": 1, + "dynamic": null, + "fullPath": "/", + "index": null, + "kind": 4, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/", + "id": "/", + "options": {}, + "path": "/", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "kind": 0, + "optional": null, + "parent": null, + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": null, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + } + `) + }) + }) }) describe('processRouteMasks', { sequential: true }, () => { @@ -894,16 +1119,16 @@ describe('processRouteMasks', { sequential: true }, () => { it('can match dynamic route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/123/d', processedTree) expect(res?.route.from).toBe('/a/$param/d') - expect(res?.params).toEqual({ param: '123' }) + expect(res?.rawParams).toEqual({ param: '123' }) }) it('can match optional route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/d', processedTree) expect(res?.route.from).toBe('/a/{-$optional}/d') - expect(res?.params).toEqual({}) + expect(res?.rawParams).toEqual({}) }) it('can match prefix/suffix wildcard route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/b/file/path.txt', processedTree) expect(res?.route.from).toBe('/a/b/{$}.txt') - expect(res?.params).toEqual({ '*': 'file/path', _splat: 'file/path' }) + expect(res?.rawParams).toEqual({ '*': 'file/path', _splat: 'file/path' }) }) }) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index d8c1411e96b..e3a09c2cf25 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -155,7 +155,7 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { from, processedTree, ) - const result = match ? match.params : undefined + const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} } diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index 304e9aebc08..9969cb90c0a 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -366,7 +366,7 @@ describe('Optional Path Parameters', () => { from, processedTree, ) - const result = match ? match.params : undefined + const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} } diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 6503eb0b755..2d47c1bd76a 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -581,7 +581,7 @@ describe('matchPathname', () => { from, processedTree, ) - const result = match ? match.params : undefined + const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} } diff --git a/packages/router-core/tests/skip-route-on-parse-error.test.ts b/packages/router-core/tests/skip-route-on-parse-error.test.ts new file mode 100644 index 00000000000..9c40af7c996 --- /dev/null +++ b/packages/router-core/tests/skip-route-on-parse-error.test.ts @@ -0,0 +1,857 @@ +import { describe, expect, it, vi } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' + +describe('skipRouteOnParseError', () => { + describe('basic matching with parse validation', () => { + it('matches route when parse succeeds', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => ({ + id: parseInt(params.id!, 10), + }), + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$id') + // params contains raw string values for interpolatePath + expect(result?.rawParams).toEqual({ id: '123' }) + // parsedParams contains the transformed values from parse + expect(result?.parsedParams).toEqual({ id: 123 }) + }) + + it('skips route when parse throws and finds no alternative', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/abc', processedTree) + expect(result).toBeNull() + }) + + it('skips route when parse throws and finds alternative match', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + // numeric should match the validated route + const numericResult = findRouteMatch('/123', processedTree) + expect(numericResult?.route.id).toBe('/$id') + // params contains raw string values for interpolatePath + expect(numericResult?.rawParams).toEqual({ id: '123' }) + // parsedParams contains the transformed values from parse + expect(numericResult?.parsedParams).toEqual({ id: 123 }) + + // non-numeric should fall through to the non-validated route + const slugResult = findRouteMatch('/hello-world', processedTree) + expect(slugResult?.route.id).toBe('/$slug') + expect(slugResult?.rawParams).toEqual({ slug: 'hello-world' }) + }) + }) + + describe('priority: validated routes take precedence', () => { + it('validated dynamic route has priority over non-validated dynamic route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + // validated route should be tried first + const numericResult = findRouteMatch('/123', processedTree) + expect(numericResult?.route.id).toBe('/$id') + }) + + it('static route still has priority over validated dynamic route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/settings', + fullPath: '/settings', + path: 'settings', + options: {}, + }, + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/settings', processedTree) + expect(result?.route.id).toBe('/settings') + }) + + it('deep validated route can still fallback to sibling', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + children: [ + { + id: '/$a/$b/$c', + fullPath: '/$a/$b/$c', + path: '$a/$b/$c', + options: { + params: { + parse: (params: Record) => { + // if (params.a !== 'one') throw new Error('Invalid a') + // if (params.b !== 'two') throw new Error('Invalid b') + if (params.c !== 'three') throw new Error('Invalid c') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$x/$y/$z', + fullPath: '/$x/$y/$z', + path: '$x/$y/$z', + }, + ], + } + const { processedTree } = processRouteTree(tree) + { + const result = findRouteMatch('/one/two/three', processedTree) + expect(result?.route.id).toBe('/$a/$b/$c') + } + { + const result = findRouteMatch('/one/two/wrong', processedTree) + expect(result?.route.id).toBe('/$x/$y/$z') + } + }) + }) + + describe('regex-like validation patterns', () => { + it('uuid validation pattern', () => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$uuid', + fullPath: '/$uuid', + path: '$uuid', + options: { + params: { + parse: (params: Record) => { + if (!uuidRegex.test(params.uuid!)) + throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const uuidResult = findRouteMatch( + '/550e8400-e29b-41d4-a716-446655440000', + processedTree, + ) + expect(uuidResult?.route.id).toBe('/$uuid') + + const slugResult = findRouteMatch('/my-blog-post', processedTree) + expect(slugResult?.route.id).toBe('/$slug') + }) + + it('date validation pattern (YYYY-MM-DD)', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/posts/$date', + fullPath: '/posts/$date', + path: 'posts/$date', + options: { + params: { + parse: (params: Record) => { + const date = new Date(params.date!) + if (date.toString() === 'Invalid Date') + throw new Error('Not a date') + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/posts/$slug', + fullPath: '/posts/$slug', + path: 'posts/$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const dateResult = findRouteMatch('/posts/2024-01-15', processedTree) + expect(dateResult?.route.id).toBe('/posts/$date') + // params contains raw string values for interpolatePath + expect(dateResult?.rawParams.date).toBe('2024-01-15') + // parsedParams contains the transformed values from parse + expect(dateResult?.parsedParams?.date).toBeInstanceOf(Date) + + const slugResult = findRouteMatch('/posts/my-first-post', processedTree) + expect(slugResult?.route.id).toBe('/posts/$slug') + }) + }) + + describe('nested routes with skipRouteOnParseError', () => { + it('parent validation failure prevents child matching', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$orgId', + fullPath: '/$orgId', + path: '$orgId', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.orgId!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { orgId: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + children: [ + { + id: '/$orgId/settings', + fullPath: '/$orgId/settings', + path: 'settings', + options: {}, + }, + ], + }, + { + id: '/$slug/about', + fullPath: '/$slug/about', + path: '$slug/about', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // numeric org should match the validated route + const numericResult = findRouteMatch('/123/settings', processedTree) + expect(numericResult?.route.id).toBe('/$orgId/settings') + + // non-numeric should not match /$orgId/settings, should match /$slug/about + const slugResult = findRouteMatch('/my-org/about', processedTree) + expect(slugResult?.route.id).toBe('/$slug/about') + }) + + it('child validation failure falls back to sibling', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/users', + fullPath: '/users', + path: 'users', + options: {}, + children: [ + { + id: '/users/$userId', + fullPath: '/users/$userId', + path: '$userId', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.userId!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { userId: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/users/$username', + fullPath: '/users/$username', + path: '$username', + options: {}, + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const numericResult = findRouteMatch('/users/42', processedTree) + expect(numericResult?.route.id).toBe('/users/$userId') + // params contains raw string values for interpolatePath + expect(numericResult?.rawParams).toEqual({ userId: '42' }) + // parsedParams contains the transformed values from parse + expect(numericResult?.parsedParams).toEqual({ userId: 42 }) + + const usernameResult = findRouteMatch('/users/johndoe', processedTree) + expect(usernameResult?.route.id).toBe('/users/$username') + // Non-validated route: params are raw strings, parsedParams is undefined + expect(usernameResult?.rawParams).toEqual({ username: 'johndoe' }) + expect(usernameResult?.parsedParams).toBeUndefined() + }) + }) + + describe('pathless routes with skipRouteOnParseError', () => { + // Pathless layouts with skipRouteOnParseError should gate their children + it('pathless layout with validation gates children', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + options: {}, + }, + { + id: '/$foo/_layout', + fullPath: '/$foo', + path: '$foo', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.foo!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { foo: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + children: [ + { + id: '/$foo/_layout/bar', + fullPath: '/$foo/bar', + path: 'bar', + options: {}, + }, + { + id: '/$foo/_layout/', + fullPath: '/$foo/', + path: '/', + options: {}, + }, + ], + }, + { + id: '/$foo/hello', + fullPath: '/$foo/hello', + path: '$foo/hello', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // numeric foo should match through the validated layout + const numericBarResult = findRouteMatch('/123/bar', processedTree) + expect(numericBarResult?.route.id).toBe('/$foo/_layout/bar') + + const numericIndexResult = findRouteMatch('/123', processedTree) + expect(numericIndexResult?.route.id).toBe('/$foo/_layout/') + expect(numericIndexResult?.rawParams).toEqual({ foo: '123' }) + expect(numericIndexResult?.parsedParams).toEqual({ foo: 123 }) + + // non-numeric foo should fall through to the non-validated route + const helloResult = findRouteMatch('/abc/hello', processedTree) + expect(helloResult?.route.id).toBe('/$foo/hello') + expect(helloResult?.rawParams).toEqual({ foo: 'abc' }) + + // non-numeric foo should NOT match the children of the validated layout + const barResult = findRouteMatch('/abc/bar', processedTree) + expect(barResult).toBeNull() + }) + }) + + describe('optional params with skipRouteOnParseError', () => { + it('optional param with static fallback', () => { + // Optional param with validation, with a static fallback + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/{-$lang}/home', + fullPath: '/{-$lang}/home', + path: '{-$lang}/home', + options: { + params: { + parse: (params: Record) => { + const validLangs = ['en', 'es', 'fr', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language') + } + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/home', + fullPath: '/home', + path: 'home', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // valid language should match the validated route + const enResult = findRouteMatch('/en/home', processedTree) + expect(enResult?.route.id).toBe('/{-$lang}/home') + expect(enResult?.parsedParams).toEqual({ lang: 'en' }) + + // root path + home - both routes can match + // The optional route (with skipped param) has greater depth, so it wins + // This is the expected behavior per the priority system + const rootResult = findRouteMatch('/home', processedTree) + expect(rootResult?.route.id).toBe('/{-$lang}/home') + + // invalid language should NOT match the validated optional route + // and since there's no dynamic fallback, it should return null + const invalidResult = findRouteMatch('/it/home', processedTree) + expect(invalidResult).toBeNull() + }) + + it('optional param at root with validation', () => { + // Optional param that validates and allows skipping + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/{-$lang}', + fullPath: '/{-$lang}', + path: '{-$lang}', + options: { + params: { + parse: (params: Record) => { + const validLangs = ['en', 'es', 'fr', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language') + } + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // valid language should match + const enResult = findRouteMatch('/en', processedTree) + expect(enResult?.route.id).toBe('/{-$lang}') + expect(enResult?.parsedParams).toEqual({ lang: 'en' }) + + // root path should match (optional skipped) + const rootResult = findRouteMatch('/', processedTree) + expect(rootResult?.route.id).toBe('/{-$lang}') + expect(rootResult?.parsedParams).toEqual({}) + + // invalid language should NOT match (no fallback route) + const invalidResult = findRouteMatch('/about', processedTree) + expect(invalidResult).toBeNull() + }) + }) + + describe('wildcard routes with skipRouteOnParseError', () => { + it('wildcard with validation', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/files/$', + fullPath: '/files/$', + path: 'files/$', + options: { + params: { + parse: (params: Record) => { + if (params._splat!.includes('..')) { + throw new Error('Upward navigation not allowed') + } + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/files', + fullPath: '/files', + path: 'files', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // path should match the validated wildcard route + const txtResult = findRouteMatch('/files/docs/readme.txt', processedTree) + expect(txtResult?.route.id).toBe('/files/$') + + // path with upward navigation should fall through to the static /files route + const otherResult = findRouteMatch( + '/files/../../secret/photo.jpg', + processedTree, + true, + ) + expect(otherResult?.route.id).toBe('/files') + expect(otherResult?.rawParams['**']).toBe('../../secret/photo.jpg') + }) + }) + + describe('multiple validated routes competing', () => { + it('first matching validated route wins', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$uuid', + fullPath: '/$uuid', + path: '$uuid', + options: { + params: { + parse: (params: Record) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid!)) + throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$number', + fullPath: '/$number', + path: '$number', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.number!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { number: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const uuidResult = findRouteMatch( + '/550e8400-e29b-41d4-a716-446655440000', + processedTree, + ) + expect(uuidResult?.route.id).toBe('/$uuid') + + const numberResult = findRouteMatch('/42', processedTree) + expect(numberResult?.route.id).toBe('/$number') + // params contains raw string values for interpolatePath + expect(numberResult?.rawParams).toEqual({ number: '42' }) + // parsedParams contains the transformed values from parse + expect(numberResult?.parsedParams).toEqual({ number: 42 }) + + const slugResult = findRouteMatch('/hello-world', processedTree) + expect(slugResult?.route.id).toBe('/$slug') + }) + it('priority option can be used to influence order', () => { + const alphabetical = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$a', + fullPath: '/$a', + path: '$a', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: 1, // higher priority than /$z + }, + }, + }, + { + id: '/$z', + fullPath: '/$z', + path: '$z', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: -1, // lower priority than /$a + }, + }, + }, + ], + } + { + const { processedTree } = processRouteTree(alphabetical) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$a') + } + const reverse = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$a', + fullPath: '/$a', + path: '$a', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: -1, // lower priority than /$z + }, + }, + }, + { + id: '/$z', + fullPath: '/$z', + path: '$z', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: 1, // higher priority than /$a + }, + }, + }, + ], + } + { + const { processedTree } = processRouteTree(reverse) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$z') + } + }) + }) + + describe('params.parse without skipRouteOnParseError', () => { + it('params.parse is NOT called during matching when skipRouteOnParseError is false', () => { + const parse = vi.fn() + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { parse }, + // skipRouteOnParseError is NOT set + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$id') + // parse should NOT be called during matching + expect(parse).not.toHaveBeenCalled() + // params should be raw strings + expect(result?.rawParams).toEqual({ id: '123' }) + }) + }) + + describe('edge cases', () => { + it('validation error type does not matter', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: () => { + throw 'string error' // not an Error object + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$fallback', + fullPath: '/$fallback', + path: '$fallback', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/test', processedTree) + expect(result?.route.id).toBe('/$fallback') + }) + }) +})