Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/clean-routes-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/router-core': minor
'@tanstack/react-router': minor
'@tanstack/solid-router': minor
'@tanstack/vue-router': minor
---

Replace the experimental `skipRouteOnParseError` route option with `params.match` and `params.matchPriority` for route selection. `params.match` now gates matching with raw path params, while `params.parse` remains responsible for typed params and `params.stringify` remains responsible for URL generation.
13 changes: 13 additions & 0 deletions docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ The `RouteOptions` type accepts an object with the following properties:
- Optional
- A function that will be called when this route is matched and passed the raw params from the current location and return valid parsed params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's params and the return type will be inferred into the rest of the router.

### `params.match` method (experimental)

- Type: `(rawParams: Record<string, string>) => boolean`
- Optional
- A function that will be called during route matching and passed the raw params from the current location. If this function returns `false` or throws, the route is skipped and matching continues to other candidate routes. If this function returns `true`, the route is matched. Use `params.parse` to transform matching raw params into typed route params.

### `params.matchPriority` property (experimental)

- Type: `number`
- Optional
- Defaults to `0`
- A tie-breaker for routes with `params.match` that would otherwise have the same matching priority. Higher numbers are tried first.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### `params.stringify` method

- Type: `(params: TParams) => Record<string, string>`
Expand Down
11 changes: 11 additions & 0 deletions e2e/react-router/match-params/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
.DS_Store
dist
dist-hash
dist-ssr
*.local

/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
12 changes: 12 additions & 0 deletions e2e/react-router/match-params/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Match Params</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
23 changes: 23 additions & 0 deletions e2e/react-router/match-params/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "tanstack-router-e2e-react-match-params",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"vite": "^8.0.0"
}
}
27 changes: 27 additions & 0 deletions e2e/react-router/match-params/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const PORT = await getTestServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],
use: {
baseURL,
},
webServer: {
command: `pnpm build && pnpm preview --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
229 changes: 229 additions & 0 deletions e2e/react-router/match-params/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import ReactDOM from 'react-dom/client'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router'

const rootRoute = createRootRoute({
component: RootComponent,
notFoundComponent: () => <h1 data-testid="not-found">Root not found</h1>,
})

function RootComponent() {
return (
<>
<nav aria-label="Main navigation">
<Link
data-testid="match-en-link"
to="/match/{$language}"
params={{ language: 'en-US' }}
>
Match EN
</Link>{' '}
<Link
data-testid="numeric-user-link"
to="/users/$userId"
params={{ userId: 456 }}
>
Numeric user
</Link>{' '}
<Link
data-testid="username-link"
to="/users/$username"
params={{ username: 'alice' }}
>
Username
</Link>{' '}
<Link
data-testid="org-settings-link"
to="/orgs/$orgId/settings"
params={{ orgId: 42 }}
>
Org settings
</Link>{' '}
<Link
data-testid="ambiguous-dollar-link"
to="/ambiguous/$value"
params={{ value: 'alpha' }}
>
Ambiguous dollar
</Link>{' '}
<Link
data-testid="ambiguous-curly-link"
to="/ambiguous/{$value}"
params={{ value: 'alpha' }}
>
Ambiguous curly
</Link>
</nav>
<Outlet />
</>
)
}

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <h1>Match params e2e</h1>,
})

const matchLanguageRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/match/{$language}',
params: {
parse: ({ language }) => {
if (language === 'en') return { language: 'en-US' as const }
if (language === 'pl') return { language: 'pl-PL' as const }
return false
},
stringify: ({ language }) => {
if (language === 'en-US') return { language: 'en' }
if (language === 'pl-PL') return { language: 'pl' }
return { language }
},
},
component: MatchLanguageComponent,
})

function MatchLanguageComponent() {
const params = matchLanguageRoute.useParams()
return <h1 data-testid="match-language">Match language {params.language}</h1>
}

const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users',
})

const numericUserRoute = createRoute({
getParentRoute: () => usersRoute,
path: '$userId',
params: {
parse: ({ userId }) => {
const parsed = Number(userId)
if (!Number.isInteger(parsed)) return false
return { userId: parsed }
},
stringify: ({ userId }) => ({ userId: String(userId) }),
},
component: NumericUserComponent,
})

function NumericUserComponent() {
const params = numericUserRoute.useParams()
return <h1 data-testid="numeric-user">Numeric user {params.userId}</h1>
}

const usernameRoute = createRoute({
getParentRoute: () => usersRoute,
path: '$username',
component: UsernameComponent,
})

function UsernameComponent() {
const params = usernameRoute.useParams()
return <h1 data-testid="username">Username {params.username}</h1>
}

const numericOrgRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/orgs/$orgId',
params: {
parse: ({ orgId }) => {
const parsed = Number(orgId)
if (!Number.isInteger(parsed)) return false
return { orgId: parsed }
},
stringify: ({ orgId }) => ({ orgId: String(orgId) }),
},
})

const orgSettingsRoute = createRoute({
getParentRoute: () => numericOrgRoute,
path: 'settings',
component: OrgSettingsComponent,
})

function OrgSettingsComponent() {
const params = orgSettingsRoute.useParams()
return <h1 data-testid="org-settings">Org settings {params.orgId}</h1>
}

const orgAboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/orgs/$slug/about',
component: OrgAboutComponent,
})

function OrgAboutComponent() {
const params = orgAboutRoute.useParams()
return <h1 data-testid="org-about">Org about {params.slug}</h1>
}

const ambiguousRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ambiguous',
})

const ambiguousDollarRoute = createRoute({
getParentRoute: () => ambiguousRoute,
path: '$value',
params: {
parse: ({ value }) => {
if (!value.startsWith('dollar-')) return false
return { value: value.slice(7) }
},
stringify: ({ value }) => ({ value: `dollar-${value}` }),
},
component: AmbiguousDollarComponent,
})

function AmbiguousDollarComponent() {
const params = ambiguousDollarRoute.useParams()
return <h1 data-testid="ambiguous-dollar">Dollar {params.value}</h1>
}

const ambiguousCurlyRoute = createRoute({
getParentRoute: () => ambiguousRoute,
path: '{$value}',
params: {
parse: ({ value }) => {
if (!value.startsWith('curly-')) return false
return { value: value.slice(6) }
},
stringify: ({ value }) => ({ value: `curly-${value}` }),
},
component: AmbiguousCurlyComponent,
})

function AmbiguousCurlyComponent() {
const params = ambiguousCurlyRoute.useParams()
return <h1 data-testid="ambiguous-curly">Curly {params.value}</h1>
}

const routeTree = rootRoute.addChildren([
indexRoute,
matchLanguageRoute,
usersRoute.addChildren([numericUserRoute, usernameRoute]),
numericOrgRoute.addChildren([orgSettingsRoute]),
orgAboutRoute,
ambiguousRoute.addChildren([ambiguousDollarRoute, ambiguousCurlyRoute]),
])

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

const rootElement = document.getElementById('app')!

if (!rootElement.innerHTML) {
ReactDOM.createRoot(rootElement).render(<RouterProvider router={router} />)
}
Loading
Loading