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') +})