diff --git a/packages/react-router/tests/useParams.test.tsx b/packages/react-router/tests/useParams.test.tsx
new file mode 100644
index 00000000000..8aa2618bf6c
--- /dev/null
+++ b/packages/react-router/tests/useParams.test.tsx
@@ -0,0 +1,261 @@
+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 (
+
+
Posts
+
+ All Categories
+
+
+ First Category
+
+
+
+ )
+ }
+
+ function PostCategoryComponent() {
+ const data = postCategoryRoute.useLoaderData()
+
+ return (
+
+
Post Categories
+ {data.posts.map((post: (typeof posts)[number]) => {
+ const id = post.id === 1 ? 'one' : 'two'
+ return (
+
+ {post.title}
+
+ )
+ })}
+
+
+ )
+ }
+
+ function PostComponent() {
+ const params = useParams({ from: postRoute.fullPath })
+
+ const data = postRoute.useLoaderData()
+
+ return (
+
+
Post Route
+
+ Category_Param:{' '}
+ {params.category}
+
+
+ PostId_Param:{' '}
+ {params.postId}
+
+
+ Id_Param: {params.id}
+
+
+ PostId: {data.post.id}
+
+
+ Title: {data.post.title}
+
+
+ Category:{' '}
+ {data.post.category}
+
+
+ )
+ }
+
+ window.history.replaceState({}, '', '/posts')
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ postsRoute.addChildren([postCategoryRoute.addChildren([postRoute])]),
+ ]),
+ })
+
+ render()
+
+ 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 postCategoryHeading = await screen.findByTestId('post-category-heading')
+ const firstPostLink = await screen.findByTestId('post-one-link')
+
+ expect(postCategoryHeading).toBeInTheDocument()
+
+ fireEvent.click(firstPostLink)
+
+ let postHeading = await screen.findByTestId('post-heading')
+ 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(postHeading).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(postCategoryHeading).toBeInTheDocument()
+ expect(secondPostLink).toBeInTheDocument()
+
+ fireEvent.click(secondPostLink)
+
+ postHeading = await screen.findByTestId('post-heading')
+ 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(postHeading).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')
+})
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index a662e5d9e36..00c3f8fd9bf 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1301,20 +1301,44 @@ 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
@@ -1322,6 +1346,8 @@ export class RouterCore<
// 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(
@@ -1339,7 +1365,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),
@@ -1361,7 +1387,7 @@ export class RouterCore<
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
- _strictParams: usedParams,
+ _strictParams: strictParams,
pathname: interpolatedPath,
updatedAt: Date.now(),
search: previousMatch
@@ -1372,7 +1398,7 @@ export class RouterCore<
status,
isFetching: false,
error: undefined,
- paramsError: parseErrors[index],
+ paramsError: paramsError,
__routeContext: undefined,
_nonReactive: {
loadPromise: createControlledPromise(),
diff --git a/packages/solid-router/tests/useParams.test.tsx b/packages/solid-router/tests/useParams.test.tsx
new file mode 100644
index 00000000000..8cab883e44f
--- /dev/null
+++ b/packages/solid-router/tests/useParams.test.tsx
@@ -0,0 +1,257 @@
+import { expect, test } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'
+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 (
+
+
Posts
+
+ All Categories
+
+
+ First Category
+
+
+
+ )
+ }
+
+ function PostCategoryComponent() {
+ const data = postCategoryRoute.useLoaderData()
+
+ return (
+
+
Post Categories
+ {data().posts.map((post: (typeof posts)[number]) => {
+ const id = post.id === 1 ? 'one' : 'two'
+ return (
+
+ {post.title}
+
+ )
+ })}
+
+
+ )
+ }
+
+ function PostComponent() {
+ const params = useParams({ from: postRoute.fullPath })
+
+ const data = postRoute.useLoaderData()
+
+ return (
+
+
Post Route
+
+ Category_Param:{' '}
+ {params().category}
+
+
+ PostId_Param:{' '}
+ {params().postId}
+
+
+ Id_Param: {params().id}
+
+
+ PostId: {data().post.id}
+
+
+ Title: {data().post.title}
+
+
+ Category:{' '}
+ {data().post.category}
+
+
+ )
+ }
+
+ window.history.replaceState({}, '', '/posts')
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ postsRoute.addChildren([postCategoryRoute.addChildren([postRoute])]),
+ ]),
+ })
+
+ render(() => )
+
+ await waitFor(() => router.load())
+
+ expect(await screen.findByTestId('posts-heading')).toBeInTheDocument()
+
+ const firstCategoryLink = await screen.findByTestId('first-category-link')
+
+ expect(firstCategoryLink).toBeInTheDocument()
+
+ await waitFor(() => 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 waitFor(() => 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 waitFor(() => 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 waitFor(() => 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')
+})