Skip to content
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
258 changes: 258 additions & 0 deletions packages/react-router/tests/useParams.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { expect, test } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
useParams,
} from '../src'

test('useParams must return parsed result if applicable.', async () => {
const posts = [
{
id: 1,
title: 'First Post',
category: 'one',
},
{
id: 2,
title: 'Second Post',
category: 'two',
},
]

const rootRoute = createRootRoute()

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

const postCategoryRoute = createRoute({
getParentRoute: () => postsRoute,
path: 'category_{$category}',
component: PostCategoryComponent,
params: {
parse: (params) => {
return {
...params,
category:
params.category === 'first'
? 'one'
: params.category === 'second'
? 'two'
: params.category,
}
},
stringify: (params) => {
return {
category:
params.category === 'one'
? 'first'
: params.category === 'two'
? 'second'
: params.category,
}
},
},
loader: ({ params }) => ({
posts:
params.category === 'all'
? posts
: posts.filter((post) => post.category === params.category),
}),
})

const postRoute = createRoute({
getParentRoute: () => postCategoryRoute,
path: '$postId',
params: {
parse: (params) => {
return {
...params,
id: params.postId === 'one' ? 1 : 2,
}
},
stringify: (params) => {
return {
postId: params.id === 1 ? 'one' : 'two',
}
},
},
component: PostComponent,
loader: ({ params }) => ({
post: posts.find((post) => post.id === params.id),
}),
})

function PostsComponent() {
return (
<div>
<h1 data-testid="posts-heading">Posts</h1>
<Link
data-testid="all-category-link"
to={postCategoryRoute.fullPath}
params={{ category: 'all' }}
>
All Categories
</Link>
<Link
data-testid="first-category-link"
to={postCategoryRoute.fullPath}
params={{ category: 'first' }}
>
First Category
</Link>
<Outlet />
</div>
)
}

function PostCategoryComponent() {
const data = postCategoryRoute.useLoaderData()

return (
<div>
<h1 data-testid="post-category-heading">Post Categories</h1>
{data.posts.map((post: (typeof posts)[number]) => {
const id = post.id === 1 ? 'one' : 'two'
return (
<Link
key={id}
from={postCategoryRoute.fullPath}
to={`./${id}`}
data-testid={`post-${id}-link`}
>
{post.title}
</Link>
)
})}
<Outlet />
</div>
)
}

function PostComponent() {
const params = useParams({ from: postRoute.fullPath })

const data = postRoute.useLoaderData()

return (
<div>
<h1 data-testid="post-heading">Post Route</h1>
<div>
Category_Param:{' '}
<span data-testid="param_category_value">{params.category}</span>
</div>
<div>
PostId_Param:{' '}
<span data-testid="param_postId_value">{params.postId}</span>
</div>
<div>
Id_Param: <span data-testid="param_id_value">{params.id}</span>
</div>
<div>
PostId: <span data-testid="post_id_value">{data.post.id}</span>
</div>
<div>
Title: <span data-testid="post_title_value">{data.post.title}</span>
</div>
<div>
Category:{' '}
<span data-testid="post_category_value">{data.post.category}</span>
</div>
</div>
)
}

window.history.replaceState({}, '', '/posts')

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

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

await act(() => router.load())

expect(await screen.findByTestId('posts-heading')).toBeInTheDocument()

const firstCategoryLink = await screen.findByTestId('first-category-link')

expect(firstCategoryLink).toBeInTheDocument()

await act(() => fireEvent.click(firstCategoryLink))

expect(window.location.pathname).toBe('/posts/category_first')

const firstPostLink = await screen.findByTestId('post-one-link')

expect(await screen.findByTestId('post-category-heading')).toBeInTheDocument()

await act(() => fireEvent.click(firstPostLink))

let paramCategoryValue = await screen.findByTestId('param_category_value')
let paramPostIdValue = await screen.findByTestId('param_postId_value')
let paramIdValue = await screen.findByTestId('param_id_value')
let postCategory = await screen.findByTestId('post_category_value')
let postTitleValue = await screen.findByTestId('post_title_value')
let postIdValue = await screen.findByTestId('post_id_value')

expect(window.location.pathname).toBe('/posts/category_first/one')
expect(await screen.findByTestId('post-heading')).toBeInTheDocument()

let renderedPost = {
id: parseInt(postIdValue.textContent),
title: postTitleValue.textContent,
category: postCategory.textContent,
}

expect(renderedPost).toEqual(posts[0])
expect(renderedPost.category).toBe('one')
expect(paramCategoryValue.textContent).toBe('one')
expect(paramPostIdValue.textContent).toBe('one')
expect(paramIdValue.textContent).toBe('1')

const allCategoryLink = await screen.findByTestId('all-category-link')

expect(allCategoryLink).toBeInTheDocument()

await act(() => fireEvent.click(allCategoryLink))

expect(window.location.pathname).toBe('/posts/category_all')

const secondPostLink = await screen.findByTestId('post-two-link')

expect(await screen.findByTestId('post-category-heading')).toBeInTheDocument()
expect(secondPostLink).toBeInTheDocument()

await act(() => fireEvent.click(secondPostLink))

paramCategoryValue = await screen.findByTestId('param_category_value')
paramPostIdValue = await screen.findByTestId('param_postId_value')
paramIdValue = await screen.findByTestId('param_id_value')
postCategory = await screen.findByTestId('post_category_value')
postTitleValue = await screen.findByTestId('post_title_value')
postIdValue = await screen.findByTestId('post_id_value')

expect(window.location.pathname).toBe('/posts/category_all/two')
expect(await screen.findByTestId('post-heading')).toBeInTheDocument()

renderedPost = {
id: parseInt(postIdValue.textContent),
title: postTitleValue.textContent,
category: postCategory.textContent,
}

expect(renderedPost).toEqual(posts[1])
expect(renderedPost.category).toBe('two')
expect(paramCategoryValue.textContent).toBe('all')
expect(paramPostIdValue.textContent).toBe('two')
expect(paramIdValue.textContent).toBe('2')
})
50 changes: 38 additions & 12 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1210,27 +1210,53 @@ export class RouterCore<

const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : ''

const { usedParams, interpolatedPath } = interpolatePath({
const { interpolatedPath } = interpolatePath({
path: route.fullPath,
params: routeParams,
decodeCharMap: this.pathParamsDecodeCharMap,
})

const matchId =
interpolatePath({
path: route.id,
params: routeParams,
leaveWildcards: true,
decodeCharMap: this.pathParamsDecodeCharMap,
parseCache: this.parsePathnameCache,
}).interpolatedPath + loaderDepsHash
const interpolatePathResult = interpolatePath({
path: route.id,
params: routeParams,
leaveWildcards: true,
decodeCharMap: this.pathParamsDecodeCharMap,
parseCache: this.parsePathnameCache,
})

const strictParams = interpolatePathResult.usedParams

let paramsError = parseErrors[index]

const strictParseParams =
route.options.params?.parse ?? route.options.parseParams

if (strictParseParams) {
try {
Object.assign(strictParams, strictParseParams(strictParams as any))
} catch (err: any) {
// any param errors should already have been dealt with above, if this
// somehow differs, let's report this in the same manner
if (!paramsError) {
paramsError = new PathParamError(err.message, {
cause: err,
})

if (opts?.throwOnError) {
throw paramsError
}
}
}
}

// Waste not, want not. If we already have a match for this route,
// reuse it. This is important for layout routes, which might stick
// around between navigation actions that only change leaf routes.

// Existing matches are matches that are already loaded along with
// pending matches that are still loading
const matchId = interpolatePathResult.interpolatedPath + loaderDepsHash

const existingMatch = this.getMatch(matchId)

const previousMatch = this.state.matches.find(
Expand All @@ -1248,7 +1274,7 @@ export class RouterCore<
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
_strictParams: usedParams,
_strictParams: strictParams,
search: previousMatch
? replaceEqualDeep(previousMatch.search, preMatchSearch)
: replaceEqualDeep(existingMatch.search, preMatchSearch),
Expand All @@ -1270,7 +1296,7 @@ export class RouterCore<
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
_strictParams: usedParams,
_strictParams: strictParams,
pathname: joinPaths([this.basepath, interpolatedPath]),
updatedAt: Date.now(),
search: previousMatch
Expand All @@ -1281,7 +1307,7 @@ export class RouterCore<
status,
isFetching: false,
error: undefined,
paramsError: parseErrors[index],
paramsError,
__routeContext: undefined,
_nonReactive: {
loadPromise: createControlledPromise(),
Expand Down
Loading
Loading