Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-router): allow useMatch to not throw if match was not found #1738

Merged
merged 1 commit into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather this be throw to match that API surface with what we are already doing with redirect().

}

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
54 changes: 54 additions & 0 deletions packages/react-router/tests/useMatch.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expectTypeOf, test } from 'vitest'
import { createRootRoute, createRoute, createRouter, useMatch } from '../src'
import type { 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

type TRouteMatch = MakeRouteMatch<DefaultRouter['routeTree']>

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

expectTypeOf(match).toEqualTypeOf<TRouteMatch>()
})

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

expectTypeOf(match).toEqualTypeOf<TRouteMatch | 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', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also probably have a type test in a useMatch.test-d.tsx, to check for the return type when the throw flag is set to false vs what it is when true.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, for sure we need the type test aswell

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()
})
})
})
})