Skip to content

Commit

Permalink
feat: allow useMatch to not throw if match was not found
Browse files Browse the repository at this point in the history
in this case, `matchRoute` will return undefined
  • Loading branch information
schiller-manuel committed Jul 12, 2024
1 parent d6b8823 commit 7a16ada
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 6 deletions.
28 changes: 25 additions & 3 deletions docs/framework/react/api/router/useMatchHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ The `useMatch` hook accepts a single argument, an `options` object.
### `opts.strict` option

- Type: `boolean`
- Optional - `default: true`
- Optional
- `default: true`
- If `false`, the `opts.from` must not be set and types will be loosened to `Partial<RouteMatch>` to reflect the shared types of all matches.

### `opts.select` option
Expand All @@ -29,14 +30,21 @@ The `useMatch` hook accepts a single argument, an `options` object.
- `(match: RouteMatch) => TSelected`
- If supplied, this function will be called with the route match and the return value will be returned from `useMatch`. This value will also be used to determine if the hook should re-render its parent component using shallow equality checks.

### `opts.shouldThrow` option

- Type: `boolean`
- Optional
- `default: true`
- If `false`,`useMatch` will not throw an invariant exception in case a match was not found in the currently rendered matches; in this case, it wil return `undefined`.

## useMatch returns

- If a `select` function is provided, the return value of the `select` function.
- If no `select` function is provided, the [`RouteMatch`](../RouteMatchType) object or a loosened version of the `RouteMatch` object if `opts.strict` is `false`.

## Examples

### accessing a route match
### Accessing a route match

```tsx
import { useMatch } from '@tanstack/react-router'
Expand All @@ -48,7 +56,7 @@ function Component() {
}
```

### accessing the root route's match
### Accessing the root route's match

```tsx
import {
Expand All @@ -62,3 +70,17 @@ function Component() {
// ...
}
```

### Checking if a specific route is currently rendered

```tsx
import { useMatch } from '@tanstack/react-router'

function Component() {
const match = useMatch({ from: '/posts', shouldThrow: false })
// ^? RouteMatch | undefined
if (match !== undefined) {
// ...
}
}
```
14 changes: 11 additions & 3 deletions packages/react-router/src/useMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export type UseMatchOptions<
TStrict extends boolean,
TRouteMatch,
TSelected,
TThrow extends boolean,
> = StrictOrFrom<TFrom, TStrict> & {
select?: (match: TRouteMatch) => TSelected
shouldThrow?: TThrow
}

export function useMatch<
Expand All @@ -23,20 +25,26 @@ export function useMatch<
TStrict extends boolean = true,
TRouteMatch = MakeRouteMatch<TRouteTree, TFrom, TStrict>,
TSelected = TRouteMatch,
>(opts: UseMatchOptions<TFrom, TStrict, TRouteMatch, TSelected>): TSelected {
TThrow extends boolean = true,
>(
opts: UseMatchOptions<TFrom, TStrict, TRouteMatch, TSelected, TThrow>,
): TThrow extends true ? TSelected : TSelected | undefined {
const nearestMatchId = React.useContext(matchContext)

const matchSelection = useRouterState({
select: (state) => {
const match = state.matches.find((d) =>
opts.from ? opts.from === d.routeId : d.id === nearestMatchId,
)

invariant(
match,
!((opts.shouldThrow ?? true) && !match),
`Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`,
)

if (match === undefined) {
return undefined
}

return opts.select ? opts.select(match as any) : match
},
})
Expand Down
52 changes: 52 additions & 0 deletions packages/react-router/tests/useMatch.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expectTypeOf, test } from 'vitest'
import { createRootRoute, createRoute, createRouter, useMatch } from '../src'
import type { AnyRouteMatch, MakeRouteMatch } from '../src/Matches'

const rootRoute = createRootRoute()

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

const invoicesRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'invoices',
})

const routeTree = rootRoute.addChildren([invoicesRoute, indexRoute])

const defaultRouter = createRouter({ routeTree })

type DefaultRouter = typeof defaultRouter

describe('useMatch', () => {
const from = '/invoices'
test('return type is `RouteMatch` when shouldThrow = true', () => {
const shouldThrow = true
const match = useMatch<
DefaultRouter['routeTree'],
typeof from,
true,
MakeRouteMatch<DefaultRouter['routeTree']>,
MakeRouteMatch<DefaultRouter['routeTree']>,
typeof shouldThrow
>({ from, shouldThrow })

expectTypeOf(match).toMatchTypeOf<AnyRouteMatch>()
})

test('return type is `RouteMatch | undefined` when shouldThrow = false', () => {
const shouldThrow = false
const match = useMatch<
DefaultRouter['routeTree'],
typeof from,
true,
MakeRouteMatch<DefaultRouter['routeTree']>,
MakeRouteMatch<DefaultRouter['routeTree']>,
typeof shouldThrow
>({ from, shouldThrow })

expectTypeOf(match).toMatchTypeOf<AnyRouteMatch | undefined>()
})
})
113 changes: 113 additions & 0 deletions packages/react-router/tests/useMatch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, test, vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
import React from 'react'
import { render, screen } from '@testing-library/react'
import {
Link,
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
useMatch,
} from '../src'
import type { RouteComponent, RouterHistory } from '../src'

describe('useMatch', () => {
function setup({
RootComponent,
history,
}: {
RootComponent: RouteComponent
history?: RouterHistory
}) {
const rootRoute = createRootRoute({
component: RootComponent,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<React.Fragment>
<h1>IndexTitle</h1>
<Link to="/posts">Posts</Link>
</React.Fragment>
),
})

const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
component: () => <h1>PostsTitle</h1>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history,
})

render(<RouterProvider router={router} />)
}

describe('when match is found', () => {
test.each([true, false, undefined])(
'returns the match if shouldThrow = %s',
async (shouldThrow) => {
function RootComponent() {
const match = useMatch({ from: '/posts', shouldThrow })
expect(match).toBeDefined()
expect(match!.routeId).toBe('/posts')
return <Outlet />
}

setup({
RootComponent,
history: createMemoryHistory({ initialEntries: ['/posts'] }),
})
await screen.findByText('PostsTitle')
},
)
})

describe('when match is not found', () => {
test.each([undefined, true])(
'throws if shouldThrow = %s',
async (shouldThrow) => {
function RootComponent() {
useMatch({ from: '/posts', shouldThrow })
return <Outlet />
}
setup({ RootComponent })
expect(
await screen.findByText(
'Invariant failed: Could not find an active match from "/posts"',
),
).toBeInTheDocument()
},
)

describe('returns undefined if shouldThrow = false', () => {
test('without select function', async () => {
function RootComponent() {
const match = useMatch({ from: 'posts', shouldThrow: false })
expect(match).toBeUndefined()
return <Outlet />
}
setup({ RootComponent })
expect(await screen.findByText('IndexTitle')).toBeInTheDocument()
})
test('with select function', async () => {
const select = vi.fn()
function RootComponent() {
const match = useMatch({ from: 'posts', shouldThrow: false, select })
expect(match).toBeUndefined()
return <Outlet />
}
setup({ RootComponent })
expect(await screen.findByText('IndexTitle')).toBeInTheDocument()
expect(select).not.toHaveBeenCalled()
})
})
})
})

0 comments on commit 7a16ada

Please sign in to comment.