From ff43790013d65d9d29584b5ad145ca66f1b21f68 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 13 Jun 2024 01:26:20 +0200 Subject: [PATCH] feat: allow `useMatch` to not throw if match was not found in this case, `matchRoute` will return undefined --- .../react/api/router/useMatchHook.md | 28 ++++- packages/react-router/src/useMatch.tsx | 14 ++- .../react-router/tests/useMatch.test-d.tsx | 54 +++++++++ packages/react-router/tests/useMatch.test.tsx | 113 ++++++++++++++++++ 4 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 packages/react-router/tests/useMatch.test-d.tsx create mode 100644 packages/react-router/tests/useMatch.test.tsx diff --git a/docs/framework/react/api/router/useMatchHook.md b/docs/framework/react/api/router/useMatchHook.md index 6f9f2c4901..ebbb83b1fb 100644 --- a/docs/framework/react/api/router/useMatchHook.md +++ b/docs/framework/react/api/router/useMatchHook.md @@ -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` to reflect the shared types of all matches. ### `opts.select` option @@ -29,6 +30,13 @@ 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. @@ -36,7 +44,7 @@ The `useMatch` hook accepts a single argument, an `options` object. ## Examples -### accessing a route match +### Accessing a route match ```tsx import { useMatch } from '@tanstack/react-router' @@ -48,7 +56,7 @@ function Component() { } ``` -### accessing the root route's match +### Accessing the root route's match ```tsx import { @@ -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) { + // ... + } +} +``` diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index da2c4cb9f9..ce9d880f98 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -13,8 +13,10 @@ export type UseMatchOptions< TStrict extends boolean, TRouteMatch, TSelected, + TThrow extends boolean, > = StrictOrFrom & { select?: (match: TRouteMatch) => TSelected + shouldThrow?: TThrow } export function useMatch< @@ -23,7 +25,10 @@ export function useMatch< TStrict extends boolean = true, TRouteMatch = MakeRouteMatch, TSelected = TRouteMatch, ->(opts: UseMatchOptions): TSelected { + TThrow extends boolean = true, +>( + opts: UseMatchOptions, +): TThrow extends true ? TSelected : TSelected | undefined { const nearestMatchId = React.useContext(matchContext) const matchSelection = useRouterState({ @@ -31,12 +36,15 @@ export function useMatch< 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 }, }) diff --git a/packages/react-router/tests/useMatch.test-d.tsx b/packages/react-router/tests/useMatch.test-d.tsx new file mode 100644 index 0000000000..d877ca78fb --- /dev/null +++ b/packages/react-router/tests/useMatch.test-d.tsx @@ -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 + +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() + }) + + 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() + }) +}) diff --git a/packages/react-router/tests/useMatch.test.tsx b/packages/react-router/tests/useMatch.test.tsx new file mode 100644 index 0000000000..eed1151b59 --- /dev/null +++ b/packages/react-router/tests/useMatch.test.tsx @@ -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: () => ( + +

IndexTitle

+ Posts +
+ ), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

PostsTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + render() + } + + 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 + } + + 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 + } + 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 + } + 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 + } + setup({ RootComponent }) + expect(await screen.findByText('IndexTitle')).toBeInTheDocument() + expect(select).not.toHaveBeenCalled() + }) + }) + }) +})