From c9f8f748ec24102e60aa43ce321a1f1216cfb955 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Wed, 29 Jan 2025 00:33:30 +0100 Subject: [PATCH] feat(react-router): add `remountDeps` --- docs/eslint/create-route-property-order.md | 2 +- .../react/api/router/RouteOptionsType.md | 32 +++ .../react/api/router/RouterOptionsType.md | 32 +++ .../create-route-property-order/constants.ts | 11 +- packages/react-router/src/Match.tsx | 28 +- packages/react-router/src/Matches.tsx | 2 + packages/react-router/src/index.tsx | 2 + packages/react-router/src/route.ts | 39 ++- packages/react-router/src/router.ts | 43 ++- packages/react-router/tests/link.bench.tsx | 2 +- packages/react-router/tests/router.test.tsx | 263 ++++++++++++++++-- packages/router-core/src/path.ts | 29 +- packages/router-core/tests/path.test.ts | 2 +- 13 files changed, 427 insertions(+), 60 deletions(-) diff --git a/docs/eslint/create-route-property-order.md b/docs/eslint/create-route-property-order.md index 56f27821f8..b0a664eb2a 100644 --- a/docs/eslint/create-route-property-order.md +++ b/docs/eslint/create-route-property-order.md @@ -17,7 +17,7 @@ The correct property order is as follows - `context` - `beforeLoad` - `loader` -- `onEnter`, `onStay`, `onLeave`, `meta`, `links`, `scripts`, `headers` +- `onEnter`, `onStay`, `onLeave`, `meta`, `links`, `scripts`, `headers`, `remountDeps` All other properties are insensitive to the order as they do not depend on type inference. diff --git a/docs/framework/react/api/router/RouteOptionsType.md b/docs/framework/react/api/router/RouteOptionsType.md index e486579df5..a614261ee4 100644 --- a/docs/framework/react/api/router/RouteOptionsType.md +++ b/docs/framework/react/api/router/RouteOptionsType.md @@ -266,3 +266,35 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Type: `(error: Error, errorInfo: ErrorInfo) => void` - Optional - Defaults to `routerOptions.defaultOnCatch` - A function that will be called when errors are caught when the route encounters an error. + +### `remountDeps` method + +- Type: + +```tsx +type remountDeps = (opts: RemountDepsOptions) => any + +interface RemountDepsOptions< + in out TRouteId, + in out TFullSearchSchema, + in out TAllParams, + in out TLoaderDeps, +> { + routeId: TRouteId + search: TFullSearchSchema + params: TAllParams + loaderDeps: TLoaderDeps +} +``` + +- Optional +- A function that will be called to determine whether a route component shall be remounted after navigation. If this function returns a different value than previously, it will remount. +- The return value needs to be JSON serializable. +- By default, a route component will not be remounted if it stays active after a navigation + +Example: +If you want to configure to remount a route component upon `params` change, use: + +```tsx +remountDeps: ({ params }) => params +``` diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md index 5de8c3bdaa..eeafe9aa5f 100644 --- a/docs/framework/react/api/router/RouterOptionsType.md +++ b/docs/framework/react/api/router/RouterOptionsType.md @@ -296,3 +296,35 @@ const router = createRouter({ - Defaults to `false` - Configures whether structural sharing is enabled by default for fine-grained selectors. - See the [Render Optimizations guide](../../guide/render-optimizations.md) for more information. + +### `defaultRemountDeps` property + +- Type: + +```tsx +type defaultRemountDeps = (opts: RemountDepsOptions) => any + +interface RemountDepsOptions< + in out TRouteId, + in out TFullSearchSchema, + in out TAllParams, + in out TLoaderDeps, +> { + routeId: TRouteId + search: TFullSearchSchema + params: TAllParams + loaderDeps: TLoaderDeps +} +``` + +- Optional +- A default function that will be called to determine whether a route component shall be remounted after navigation. If this function returns a different value than previously, it will remount. +- The return value needs to be JSON serializable. +- By default, a route component will not be remounted if it stays active after a navigation + +Example: +If you want to configure to remount all route components upon `params` change, use: + +```tsx +remountDeps: ({ params }) => params +``` diff --git a/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts b/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts index 1bcd282575..dcead43347 100644 --- a/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts +++ b/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts @@ -24,7 +24,16 @@ export const sortRules = [ [['beforeLoad'], ['loader']], [ ['loader'], - ['onEnter', 'onStay', 'onLeave', 'meta', 'links', 'scripts', 'headers'], + [ + 'onEnter', + 'onStay', + 'onLeave', + 'meta', + 'links', + 'scripts', + 'headers', + 'remountDeps', + ], ], ] as const diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5240072ad1..aa46d1095f 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -114,26 +114,41 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }): any { const router = useRouter() - const { match, matchIndex, routeId } = useRouterState({ + const { match, key, routeId } = useRouterState({ select: (s) => { const matchIndex = s.matches.findIndex((d) => d.id === matchId) const match = s.matches[matchIndex]! const routeId = match.routeId as string + + const remountFn = + (router.routesById[routeId] as AnyRoute).options.remountDeps ?? + router.options.defaultRemountDeps + const remountDeps = remountFn?.({ + routeId, + loaderDeps: match.loaderDeps, + params: match._strictParams, + search: match._strictSearch, + }) + const key = remountDeps ? JSON.stringify(remountDeps) : undefined + return { + key, routeId, - matchIndex, match: pick(match, ['id', 'status', 'error']), } }, structuralSharing: true as any, }) - const route = router.routesById[routeId]! + const route = router.routesById[routeId] as AnyRoute const out = React.useMemo(() => { const Comp = route.options.component ?? router.options.defaultComponent - return Comp ? : - }, [route.options.component, router.options.defaultComponent]) + if (Comp) { + return + } + return + }, [key, route.options.component, router.options.defaultComponent]) // function useChangedDiff(value: any) { // const ref = React.useRef(value) @@ -184,7 +199,8 @@ export const MatchInner = React.memo(function MatchInnerImpl({ if (router.isServer) { return ( context: TAllContext search: TFullSearchSchema + _strictSearch: TFullSearchSchema fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 654a71c8fe..5fafbb5e21 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -259,6 +259,8 @@ export type { BeforeLoadContextParameter, ResolveAllContext, ResolveAllParamsFromParent, + MakeRemountDepsOptionsUnion, + RemountDepsOptions, } from './route' export type { diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 7f8b22b4d2..4cfac92908 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -59,7 +59,7 @@ import type { RouteMatch, } from './Matches' import type { NavigateOptions, ToMaskOptions } from './link' -import type { RouteById, RouteIds, RoutePaths } from './routeInfo' +import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo' import type { AnyRouter, RegisteredRouter, Router } from './router' import type { BuildLocationFn, NavigateFn } from './RouterProvider' import type { NotFoundError } from './not-found' @@ -154,6 +154,7 @@ export type FileBaseRouteOptions< TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, + TRemountDepsFn = AnyContext, > = ParamsOptions & { validateSearch?: Constrain @@ -204,6 +205,18 @@ export type FileBaseRouteOptions< opts: FullSearchSchemaOption, ) => TLoaderDeps + remountDeps?: Constrain< + TRemountDepsFn, + ( + opt: RemountDepsOptions< + TId, + FullSearchSchemaOption, + Expand>, + TLoaderDeps + >, + ) => any + > + loader?: Constrain< TLoaderFn, ( @@ -275,6 +288,30 @@ export interface RouteContextOptions< context: Expand> } +export interface RemountDepsOptions< + in out TRouteId, + in out TFullSearchSchema, + in out TAllParams, + in out TLoaderDeps, +> { + routeId: TRouteId + search: TFullSearchSchema + params: TAllParams + loaderDeps: TLoaderDeps +} + +export type MakeRemountDepsOptionsUnion< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRoute extends AnyRoute = ParseRoute, +> = TRoute extends any + ? RemountDepsOptions< + TRoute['id'], + TRoute['types']['fullSearchSchema'], + TRoute['types']['allParams'], + TRoute['types']['loaderDeps'] + > + : never + export interface BeforeLoadContextOptions< in out TParentRoute extends AnyRoute, in out TSearchValidator, diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index e955c85edf..bb53a4fae3 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -61,6 +61,7 @@ import type { BeforeLoadContextOptions, ErrorRouteComponent, LoaderFnContext, + MakeRemountDepsOptionsUnion, NotFoundRouteComponent, RootRoute, RouteComponent, @@ -451,6 +452,8 @@ export interface RouterOptions< pathParamsAllowedCharacters?: Array< ';' | ':' | '@' | '&' | '=' | '+' | '$' | ',' > + + defaultRemountDeps?: (opts: MakeRemountDepsOptionsUnion) => any } export interface RouterErrorSerializer { @@ -1151,19 +1154,26 @@ export class Router< const parentMatch = matches[index - 1] - const [preMatchSearch, searchError]: [Record, any] = (() => { + const [preMatchSearch, strictMatchSearch, searchError]: [ + Record, + Record, + any, + ] = (() => { // Validate the search params and stabilize them const parentSearch = parentMatch?.search ?? next.search + const parentStrictSearch = parentMatch?._strictSearch ?? {} try { - const search = - validateSearch(route.options.validateSearch, parentSearch) ?? {} + const strictSearch = + validateSearch(route.options.validateSearch, { ...parentSearch }) ?? + {} return [ { ...parentSearch, - ...search, + ...strictSearch, }, + { ...parentStrictSearch, ...strictSearch }, undefined, ] } catch (err: any) { @@ -1178,7 +1188,7 @@ export class Router< throw searchParamError } - return [parentSearch, searchParamError] + return [parentSearch, {}, searchParamError] } })() @@ -1194,7 +1204,7 @@ export class Router< const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' - const interpolatedPath = interpolatePath({ + const { usedParams, interpolatedPath } = interpolatePath({ path: route.fullPath, params: routeParams, decodeCharMap: this.pathParamsDecodeCharMap, @@ -1206,7 +1216,7 @@ export class Router< params: routeParams, leaveWildcards: true, decodeCharMap: this.pathParamsDecodeCharMap, - }) + loaderDepsHash + }).interpolatedPath + loaderDepsHash // Waste not, want not. If we already have a match for this route, // reuse it. This is important for layout routes, which might stick @@ -1231,9 +1241,11 @@ export class Router< params: previousMatch ? replaceEqualDeep(previousMatch.params, routeParams) : routeParams, + _strictParams: usedParams, search: previousMatch ? replaceEqualDeep(previousMatch.search, preMatchSearch) : replaceEqualDeep(existingMatch.search, preMatchSearch), + _strictSearch: strictMatchSearch, } } else { const status = @@ -1251,11 +1263,13 @@ export class Router< params: previousMatch ? replaceEqualDeep(previousMatch.params, routeParams) : routeParams, + _strictParams: usedParams, pathname: joinPaths([this.basepath, interpolatedPath]), updatedAt: Date.now(), search: previousMatch ? replaceEqualDeep(previousMatch.search, preMatchSearch) : preMatchSearch, + _strictSearch: strictMatchSearch, searchError: undefined, status, isFetching: false, @@ -1463,7 +1477,7 @@ export class Router< path: route.fullPath, params: matchedRoutesResult?.routeParams ?? {}, decodeCharMap: this.pathParamsDecodeCharMap, - }) + }).interpolatedPath const pathname = joinPaths([this.basepath, interpolatedPath]) return pathname === fromPath })?.id as keyof this['routesById'] @@ -1503,7 +1517,7 @@ export class Router< leaveWildcards: false, leaveParams: opts.leaveParams, decodeCharMap: this.pathParamsDecodeCharMap, - }) + }).interpolatedPath let search = fromSearch if (opts._includeValidateSearch && this.options.search?.strict) { @@ -2173,6 +2187,10 @@ export class Router< this._handleNotFound(matches, err, { updateMatch, }) + this.serverSsr?.onMatchSettled({ + router: this, + match: this.getMatch(match.id)!, + }) throw err } } @@ -2637,6 +2655,7 @@ export class Router< if (isNotFound(err) && !allPreload) { await triggerOnReady() } + throw err } } @@ -2835,8 +2854,10 @@ export class Router< _fromLocation: next, }) } - // Preload errors are not fatal, but we should still log them - console.error(err) + if (!isNotFound(err)) { + // Preload errors are not fatal, but we should still log them + console.error(err) + } return undefined } } diff --git a/packages/react-router/tests/link.bench.tsx b/packages/react-router/tests/link.bench.tsx index 4854c908c5..a04149ed61 100644 --- a/packages/react-router/tests/link.bench.tsx +++ b/packages/react-router/tests/link.bench.tsx @@ -38,7 +38,7 @@ const InterpolatePathLink = ({ params, children, }: React.PropsWithChildren) => { - const href = interpolatePath({ path: to, params }) + const href = interpolatePath({ path: to, params }).interpolatedPath return {children} } diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 7f6a10e052..a176f432a3 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -23,6 +23,7 @@ import type { StandardSchemaValidator } from '@tanstack/router-core' import type { AnyRoute, AnyRouter, + MakeRemountDepsOptionsUnion, RouterOptions, ValidatorFn, ValidatorObj, @@ -61,6 +62,18 @@ function createTestRouter(options?: RouterOptions) { }, }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + }) + const userRoute = createRoute({ + getParentRoute: () => usersRoute, + path: '/$userId', + }) + const userFilesRoute = createRoute({ + getParentRoute: () => userRoute, + path: '/files/$fileId', + }) const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts', @@ -224,8 +237,21 @@ function createTestRouter(options?: RouterOptions) { }, }) + const nestedSearchRoute = createRoute({ + getParentRoute: () => rootRoute, + validateSearch: z.object({ foo: z.string() }), + path: 'nested-search', + }) + + const nestedSearchChildRoute = createRoute({ + getParentRoute: () => nestedSearchRoute, + validateSearch: z.object({ bar: z.string() }), + path: 'child', + }) + const routeTree = rootRoute.addChildren([ indexRoute, + usersRoute.addChildren([userRoute.addChildren([userFilesRoute])]), postsRoute.addChildren([postIdRoute]), pathSegmentEAccentRoute, pathSegmentRocketEmojiRoute, @@ -252,6 +278,7 @@ function createTestRouter(options?: RouterOptions) { searchWithDefaultIndexRoute, searchWithDefaultCheckRoute, ]), + nestedSearchRoute.addChildren([nestedSearchChildRoute]), ]) const router = createRouter({ routeTree, ...options }) @@ -681,8 +708,18 @@ describe('router emits events during rendering', () => { }) describe('router rendering stability', () => { - it('should not remount the page component when navigating to the same route', async () => { - const callerMock = vi.fn() + type RemountDepsFn = (opts: MakeRemountDepsOptionsUnion) => any + async function setup(opts?: { + remountDeps: { + default?: RemountDepsFn + fooId?: RemountDepsFn + barId?: RemountDepsFn + } + }) { + const mountMocks = { + fooId: vi.fn(), + barId: vi.fn(), + } const rootRoute = createRootRoute({ component: () => { @@ -690,12 +727,34 @@ describe('router rendering stability', () => {

Root

- + Foo1 - + Foo2 + + Foo3-Bar1 + + + Foo3-Bar2 +
@@ -711,43 +770,127 @@ describe('router rendering stability', () => { }) const fooIdRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/foo/$id', + path: '/foo/$fooId', component: FooIdRouteComponent, + remountDeps: opts?.remountDeps.fooId, }) + function FooIdRouteComponent() { - const id = fooIdRoute.useParams({ select: (s) => s.id }) + const fooId = fooIdRoute.useParams({ select: (s) => s.fooId }) + useEffect(() => { + mountMocks.fooId() + }, []) + + return ( +
+ Foo page {fooId} +
+ ) + } + + const barIdRoute = createRoute({ + getParentRoute: () => fooIdRoute, + path: '/bar/$barId', + component: BarIdRouteComponent, + remountDeps: opts?.remountDeps.barId, + }) + + function BarIdRouteComponent() { + const barId = fooIdRoute.useParams({ select: (s) => s.barId }) useEffect(() => { - callerMock() + mountMocks.barId() }, []) - return
Foo page {id}
+ return ( +
+ Bar page {barId} +
+ ) } - const routeTree = rootRoute.addChildren([fooIdRoute, indexRoute]) - const router = createRouter({ routeTree }) + const routeTree = rootRoute.addChildren([ + fooIdRoute.addChildren([barIdRoute]), + indexRoute, + ]) + const router = createRouter({ + routeTree, + defaultRemountDeps: opts?.remountDeps.default, + }) + + await act(() => render()) - render() + const foo1 = await screen.findByTestId('link-foo-1') + const foo2 = await screen.findByTestId('link-foo-2') + + const foo3bar1 = await screen.findByTestId('link-foo-3-bar-1') + const foo3bar2 = await screen.findByTestId('link-foo-3-bar-2') + + expect(foo1).toBeInTheDocument() + expect(foo2).toBeInTheDocument() + expect(foo3bar1).toBeInTheDocument() + expect(foo3bar2).toBeInTheDocument() + + return { router, mountMocks, links: { foo1, foo2, foo3bar1, foo3bar2 } } + } + + async function check( + page: 'fooId' | 'barId', + expected: { value: string; mountCount: number }, + mountMocks: Record<'fooId' | 'barId', () => void>, + ) { + const p = await screen.findByTestId(`${page}-page`) + expect(p).toBeInTheDocument() + const value = await screen.findByTestId(`${page}-value`) + expect(value).toBeInTheDocument() + expect(value).toHaveTextContent(expected.value) + + expect(mountMocks[page]).toBeCalledTimes(expected.mountCount) + } - const foo1Link = await screen.findByRole('link', { name: 'Foo1' }) - const foo2Link = await screen.findByRole('link', { name: 'Foo2' }) + it('should not remount the page component when navigating to the same route but different path param if no remount deps are configured', async () => { + const { mountMocks, links } = await setup() - expect(foo1Link).toBeInTheDocument() - expect(foo2Link).toBeInTheDocument() + await act(() => fireEvent.click(links.foo1)) + await check('fooId', { value: '1', mountCount: 1 }, mountMocks) + expect(mountMocks.barId).not.toHaveBeenCalled() - fireEvent.click(foo1Link) + await act(() => fireEvent.click(links.foo2)) + await check('fooId', { value: '2', mountCount: 1 }, mountMocks) + expect(mountMocks.barId).not.toHaveBeenCalled() - const fooPage1 = await screen.findByText('Foo page 1') - expect(fooPage1).toBeInTheDocument() + await act(() => fireEvent.click(links.foo3bar1)) + await check('fooId', { value: '3', mountCount: 1 }, mountMocks) + await check('barId', { value: '1', mountCount: 1 }, mountMocks), + await act(() => fireEvent.click(links.foo3bar2)) + await check('fooId', { value: '3', mountCount: 1 }, mountMocks) + await check('barId', { value: '2', mountCount: 1 }, mountMocks) + }) + + it('should remount the fooId and barId page component when navigating to the same route but different path param if defaultRemountDeps with params is used', async () => { + const defaultRemountDeps: RemountDepsFn = (opts) => { + return opts.params + } + const { mountMocks, links } = await setup({ + remountDeps: { default: defaultRemountDeps }, + }) + + await act(() => fireEvent.click(links.foo1)) + await check('fooId', { value: '1', mountCount: 1 }, mountMocks) + expect(mountMocks.barId).not.toHaveBeenCalled() - expect(callerMock).toBeCalledTimes(1) + await act(() => fireEvent.click(links.foo2)) - fireEvent.click(foo2Link) + await check('fooId', { value: '2', mountCount: 2 }, mountMocks) + expect(mountMocks.barId).not.toHaveBeenCalled() - const fooPage2 = await screen.findByText('Foo page 2') - expect(fooPage2).toBeInTheDocument() + await act(() => fireEvent.click(links.foo3bar1)) + await check('fooId', { value: '3', mountCount: 3 }, mountMocks) + await check('barId', { value: '1', mountCount: 1 }, mountMocks) - expect(callerMock).toBeCalledTimes(1) + await act(() => fireEvent.click(links.foo3bar2)) + await check('fooId', { value: '3', mountCount: 3 }, mountMocks) + await check('barId', { value: '2', mountCount: 2 }, mountMocks) }) }) @@ -792,21 +935,87 @@ describe('router matches URLs to route definitions', () => { ]) }) - it('layout splat route matches without splat', async () => { + it('nested path params', async () => { const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/layout-splat'] }), + history: createMemoryHistory({ + initialEntries: ['/users/5678/files/123'], + }), }) await act(() => router.load()) expect(router.state.matches.map((d) => d.routeId)).toEqual([ '__root__', - '/layout-splat', - '/layout-splat/', + '/users', + '/users/$userId', + '/users/$userId/files/$fileId', ]) }) }) +describe('matches', () => { + describe('params', () => { + it('/users/$userId/files/$fileId', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: ['/users/5678/files/123'], + }), + }) + + await act(() => router.load()) + + const expectedStrictParams: Record = { + __root__: {}, + '/users': {}, + '/users/$userId': { userId: '5678' }, + '/users/$userId/files/$fileId': { userId: '5678', fileId: '123' }, + } + + expect(router.state.matches.length).toEqual( + Object.entries(expectedStrictParams).length, + ) + router.state.matches.forEach((match) => { + expect(match.params).toEqual( + expectedStrictParams['/users/$userId/files/$fileId'], + ) + }) + router.state.matches.forEach((match) => { + expect(match._strictParams).toEqual(expectedStrictParams[match.routeId]) + }) + }) + }) + + describe('search', () => { + it('/nested-search/child?foo=hello&bar=world', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: ['/nested-search/child?foo=hello&bar=world'], + }), + }) + + await act(() => router.load()) + + const expectedStrictSearch: Record = { + __root__: {}, + '/nested-search': { foo: 'hello' }, + '/nested-search/child': { foo: 'hello', bar: 'world' }, + } + + expect(router.state.matches.length).toEqual( + Object.entries(expectedStrictSearch).length, + ) + router.state.matches.forEach((match) => { + expect(match.search).toEqual( + expectedStrictSearch['/nested-search/child'], + ) + }) + router.state.matches.forEach((match) => { + expect(match._strictSearch).toEqual(expectedStrictSearch[match.routeId]) + }) + }) + }) +}) + describe('invalidate', () => { it('after router.invalid(), routes should be `valid` again after loading', async () => { const { router } = createTestRouter({ diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index cf4b99b9c6..5866e14bd1 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -208,48 +208,55 @@ interface InterpolatePathOptions { decodeCharMap?: Map } +type InterPolatePathResult = { + interpolatedPath: string + usedParams: Record +} export function interpolatePath({ path, params, leaveWildcards, leaveParams, decodeCharMap, -}: InterpolatePathOptions) { +}: InterpolatePathOptions): InterPolatePathResult { const interpolatedPathSegments = parsePathname(path) - const encodedParams: any = {} - for (const [key, value] of Object.entries(params)) { + function encodeParam(key: string): any { + const value = params[key] const isValueString = typeof value === 'string' if (['*', '_splat'].includes(key)) { // the splat/catch-all routes shouldn't have the '/' encoded out - encodedParams[key] = isValueString ? encodeURI(value) : value + return isValueString ? encodeURI(value) : value } else { - encodedParams[key] = isValueString - ? encodePathParam(value, decodeCharMap) - : value + return isValueString ? encodePathParam(value, decodeCharMap) : value } } - return joinPaths( + const usedParams: Record = {} + const interpolatedPath = joinPaths( interpolatedPathSegments.map((segment) => { if (segment.type === 'wildcard') { - const value = encodedParams._splat + usedParams._splat = params._splat + const value = encodeParam('_splat') if (leaveWildcards) return `${segment.value}${value ?? ''}` return value } if (segment.type === 'param') { + const key = segment.value.substring(1) + usedParams[key] = params[key] if (leaveParams) { - const value = encodedParams[segment.value] + const value = encodeParam(segment.value) return `${segment.value}${value ?? ''}` } - return encodedParams![segment.value.substring(1)] ?? 'undefined' + return encodeParam(key) ?? 'undefined' } return segment.value }), ) + return { usedParams, interpolatedPath } } function encodePathParam(value: string, decodeCharMap?: Map) { diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 78fa6d347d..8c86975b1d 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -330,7 +330,7 @@ describe('interpolatePath', () => { path: exp.path, params: exp.params, decodeCharMap: exp.decodeCharMap, - }) + }).interpolatedPath expect(result).toBe(exp.result) }) })