Skip to content

Commit

Permalink
Add routeParams to useMatch (#9793)
Browse files Browse the repository at this point in the history
Make it possible to specify route param values that need to match.

If this is your route: `<Route path="/blog/{year}/{month}/{day}"
page={BlogPostPage} name="blogPost" />`
And you want to only match posts from 2001 you can now do this:

`useMatch('/blog/{year}/{month}/{day}', { routeParams: { year: '2001' }
})`

This is **finally** a solution to matching route paths. The work started
in #7469, but we were never able to come up with an api/dx that we
really liked. This PR and #9755 together however provides a solution
that we're much more happy with, and that also supports the use case
outlined in that original PR.

Here's the example from #7469 as it could be solved with the code in
this PR

```jsx
const Navbar () => {
  const { project } = useParams()
  const routePaths = useRoutePaths()

  const modes = [
    {
      name: "Info",
      route: routes.info({ project }),
      match: useMatch(routePaths.info), // using the hook together with routePaths
    },
    {
      name: "Compare",
      route: routes.compare({ project, id: "1" }),
      match: useMatch(useRoutePath('compare')), // alternative to the above
    },
    // ...
  ]

  return (
    <>
      {modes.map((x) => <Button as={Link} to={x.route} isActive={x.match} />)}
    </>
  )
}
```

And, as described at the top of this description, we can also be more
specific than in that example if needed. Like if we only wanted to match
a specific project on the "Compare" route we could do this:

```jsx
  const modes = [
    {
      name: "Info",
      route: routes.info({ project }),
      match: useMatch(routePaths.info),
    },
    {
      name: "Compare against Alpha",
      route: routes.compare({ project, id: "1" }),
      match: useMatch(useRoutePath('compare'), { routeParams: { project: 'alpha' } }),
    },
    {
      name: "Compare against Beta",
      route: routes.compare({ project, id: "1" }),
      match: useMatch(useRoutePath('compare'), { routeParams: { project: 'beta' } }),
    },
    // ...
  ]
```

Here's another example

```jsx
<Route path="/{dynamic}/{path}" page={ExamplePage} name="example" />

const exampleRoutePath = useRoutePath('example')
// => '/{dynamic}/{path}'

const matchOnlyDog = useMatch(exampleRoutePath, { routeParams: { dynamic: 'dog' }})
const matchFullyDynamic = useMatch(exampleRoutePath)
```

In the above example, if the current page url was
`https://example.org/dog/fido` then both `matchOnlyDog` and
`matchFullyDynamic` would have `match: true`.
If the current page instead was `https://example.org/cat/garfield` then
only `matchFullyDynamic` would match

(This PR replaces #9774)
  • Loading branch information
Tobbe authored Jan 3, 2024
1 parent 660f033 commit c77ebee
Show file tree
Hide file tree
Showing 3 changed files with 294 additions and 4 deletions.
36 changes: 36 additions & 0 deletions docs/docs/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,42 @@ const CustomLink = ({ to, ...rest }) => {
}
```

Passing in `routeParams` you can make it match only on specific route parameter
values.

```jsx
const match = useMatch('/product/{category}/{id}', {
routeParams: { category: 'shirts' }
})
```

The above example will match /product/shirts/213, but not /product/pants/213
(whereas not specifying `routeParams` at all would match both).

To get the path you need to pass to `useMatch` you can use
[`useRoutePaths`](#useroutepaths) or [`useRoutePath`](#useroutepath)

Here's an example:

```jsx
<Route path="/{animal}/{name}" page={AnimalPage} name="animal" />

const animalRoutePath = useRoutePath('animal')
// => '/{animal}/{name}'

const matchOnlyDog = useMatch(animalRoutePath, { routeParams: { animal: 'dog' }})
const matchFullyDynamic = useMatch(animalRoutePath)
```

In the above example, if the current page url was
`https://example.org/dog/fido` then both `matchOnlyDog` and `matchFullyDynamic`
would have `match: true`.

If the current page instead was `https://example.org/cat/garfield` then only
`matchFullyDynamic` would match

See below for more info on route parameters.

## Route parameters

To match variable data in a path, you can use route parameters, which are specified by a parameter name surrounded by curly braces:
Expand Down
224 changes: 223 additions & 1 deletion packages/router/src/__tests__/useMatch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react'

import { render } from '@testing-library/react'
import '@testing-library/jest-dom'

import { render, renderHook as tlrRenderHook } from '@testing-library/react'

import { Link } from '../links'
import { LocationProvider } from '../location'
Expand Down Expand Up @@ -97,4 +99,224 @@ describe('useMatch', () => {

expect(getByText(/Dunder Mifflin/)).toHaveStyle('color: red')
})

describe('routeParams', () => {
const mockLocation = createDummyLocation('/dummy-location')

type CallbackType = () => ReturnType<typeof useMatch>
function renderHook(cb: CallbackType) {
return tlrRenderHook(cb, {
wrapper: ({ children }) => (
<LocationProvider location={mockLocation}>
{children}
</LocationProvider>
),
})
}

function setLocation(pathname: string, search = '') {
mockLocation.pathname = pathname
mockLocation.search = search
}

afterEach(() => {
setLocation('/dummy-location')
})

it('matches a path with literal route param', () => {
setLocation('/test-path/foobar')

const { result } = renderHook(() => useMatch('/test-path/{param}'))

expect(result.current.match).toBeTruthy()
})

it('matches a path with given route param value', () => {
setLocation('/posts/uuid-string')

const { result } = renderHook(() =>
useMatch('/posts/{id}', { routeParams: { id: 'uuid-string' } })
)

expect(result.current.match).toBeTruthy()
})

it("doesn't match a path with different route param value", () => {
setLocation('/posts/uuid-string')

const { result } = renderHook(() =>
useMatch('/posts/{id}', { routeParams: { id: 'other-uuid-string' } })
)

expect(result.current.match).toBeFalsy()
})

it('matches a path with default param type', () => {
setLocation('/posts/123')

const { result } = renderHook(() =>
useMatch('/posts/{id}', { routeParams: { id: '123' } })
)

expect(result.current.match).toBeTruthy()
})

it('matches a path with a specified param type', () => {
setLocation('/posts/123')

const { result } = renderHook(() =>
useMatch('/posts/{id:Int}', { routeParams: { id: 123 } })
)

expect(result.current.match).toBeTruthy()
})

it("doesn't match a path with a specified param type with different value", () => {
setLocation('/posts/123')

const { result } = renderHook(() =>
useMatch('/posts/{id:Int}', { routeParams: { id: '123' } })
)

expect(result.current.match).toBeFalsy()
})

it('matches with a subset of param values specified (year, month)', () => {
setLocation('/year/1970/month/08/day/21')

const { result } = renderHook(() =>
useMatch('/year/{year}/month/{month}/day/{day}', {
routeParams: { year: '1970', month: '08' },
})
)

expect(result.current.match).toBeTruthy()
})

it('matches with a subset of param values specified (month)', () => {
setLocation('/year/1970/month/08/day/21')

const { result } = renderHook(() =>
useMatch('/year/{year}/month/{month}/day/{day}', {
routeParams: { month: '08' },
})
)

expect(result.current.match).toBeTruthy()
})

it('matches with a subset of param values specified (day)', () => {
const useMatchHook = () =>
useMatch('/year/{year}/month/{month}/day/{day}', {
routeParams: { day: '21' },
})

setLocation('/year/1970/month/08/day/21')
const { result: result1970 } = renderHook(useMatchHook)
expect(result1970.current.match).toBeTruthy()

setLocation('/year/1970/month/01/day/21')
const { result: resultJan } = renderHook(useMatchHook)
expect(resultJan.current.match).toBeTruthy()

setLocation('/year/2024/month/08/day/21')
const { result: result2024 } = renderHook(useMatchHook)
expect(result2024.current.match).toBeTruthy()
})

it("doesn't match with a subset of wrong param values specified (month)", () => {
setLocation('/year/1970/month/08/day/21')

const { result } = renderHook(() =>
useMatch('/year/{year}/month/{month}/day/{day}', {
routeParams: { month: '01' },
})
)

expect(result.current.match).toBeFalsy()
})

it("doesn't match with a subset of wrong param values specified (day)", () => {
setLocation('/year/1970/month/08/day/21')

const { result } = renderHook(() =>
useMatch('/year/{year}/month/{month}/day/{day}', {
routeParams: { day: '31' },
})
)

expect(result.current.match).toBeFalsy()
})
})

describe('routeParams + searchParams', () => {
const mockLocation = createDummyLocation('/dummy-location')

type CallbackType = () => ReturnType<typeof useMatch>
function renderHook(cb: CallbackType) {
return tlrRenderHook(cb, {
wrapper: ({ children }) => (
<LocationProvider location={mockLocation}>
{children}
</LocationProvider>
),
})
}

function setLocation(pathname: string, search = '') {
mockLocation.pathname = pathname
mockLocation.search = search
}

afterEach(() => {
setLocation('/dummy-location')
})

it('matches a path with literal route param', () => {
setLocation('/test-path/foobar', '?s1=one&s2=two')

const { result } = renderHook(() => useMatch('/test-path/{param}'))

expect(result.current.match).toBeTruthy()
})

it('matches a path with literal route param and given searchParam', () => {
setLocation('/test-path/foobar', '?s1=one&s2=two')

const { result } = renderHook(() =>
useMatch('/test-path/{param}', {
searchParams: [{ s1: 'one' }],
})
)

expect(result.current.match).toBeTruthy()
})

it("doesn't match a path with wrong route param value and given searchParam", () => {
setLocation('/test-path/foobar', '?s1=one&s2=two')

const { result } = renderHook(() =>
useMatch('/test-path/{param}', {
routeParams: { param: 'wrong' },
searchParams: [{ s1: 'one' }],
})
)

expect(result.current.match).toBeFalsy()
})

it('matches a deeper path with matchSubPaths', () => {
setLocation('/test-path/foobar/fizz/buzz', '?s1=one&s2=two')

const { result } = renderHook(() =>
useMatch('/test-path/{param}/{param-two}', {
routeParams: { ['param-two']: 'fizz' },
searchParams: [{ s1: 'one' }],
matchSubPaths: true,
})
)

expect(result.current.match).toBeTruthy()
})
})
})
38 changes: 35 additions & 3 deletions packages/router/src/useMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { matchPath } from './util'
import type { FlattenSearchParams } from './util'

type UseMatchOptions = {
routeParams?: Record<string, any>
searchParams?: FlattenSearchParams
matchSubPaths?: boolean
}

/**
* Returns an object of { match: boolean; params: Record<string, unknown>; }
* if the path matches the current location match will be true.
* If the path matches the current location `match` will be true.
* Params will be an object of the matched params, if there are any.
*
* Provide routeParams options to match specific route param values
* Provide searchParams options to match the current location.search
*
* This is useful for components that need to know "active" state, e.g.
Expand All @@ -30,8 +32,11 @@ type UseMatchOptions = {
*
* Match sub paths
* const match = useMatch('/product', { matchSubPaths: true })
*
* Match only specific route param values
* const match = useMatch('/product/{category}/{id}', { routeParams: { category: 'shirts' } })
*/
export const useMatch = (pathname: string, options?: UseMatchOptions) => {
export const useMatch = (routePath: string, options?: UseMatchOptions) => {
const location = useLocation()
if (!location) {
return { match: false }
Expand All @@ -54,7 +59,34 @@ export const useMatch = (pathname: string, options?: UseMatchOptions) => {
}
}

return matchPath(pathname, location.pathname, {
const matchInfo = matchPath(routePath, location.pathname, {
matchSubPaths: options?.matchSubPaths,
})

if (!matchInfo.match) {
return { match: false }
}

const routeParams = Object.entries(options?.routeParams || {})

if (routeParams.length > 0) {
if (!isMatchWithParams(matchInfo) || !matchInfo.params) {
return { match: false }
}

// If paramValues were given, they must all match
const isParamMatch = routeParams.every(([key, value]) => {
return matchInfo.params[key] === value
})

if (!isParamMatch) {
return { match: false }
}
}

return matchInfo
}

function isMatchWithParams(match: unknown): match is { params: any } {
return match !== null && typeof match === 'object' && 'params' in match
}

0 comments on commit c77ebee

Please sign in to comment.