Repository: TanStack/router (ts)

Issue:
No builtin mechanism for remounting on path param changes
### Which project does this relate to?

Router

### Describe the bug

This issue is a result from a discussion I had with @schiller-manuel in https://discord.com/channels/719702312431386674/1316448877670563931.

Currently local state is preserved when navigating between two instances of the same route with different path params, i.e. navigating from `/posts/1` to `/posts/2`. In our app we want to treat path param changes as completely new instances of the page with forced remounts, like if we were using `<PostPage key={params.postId} />` at the top level. There's currently no mechanism for opting into this behavior.

The way we mentally think about our routes is that path params take you to a new page, while search params configure the current page. So the mechanism has to allow for search params to change without remounting as well. We were surprised to find that navigating `/posts/1 => /posts/2` we ended up in a different state than when navigating `/posts/1 => / => /posts/2`.

### Your Example Website or App

https://stackblitz.com/edit/tanstack-router-kgrqvrxv?file=src%2Froutes%2Fposts.%24postId.tsx

### Steps to Reproduce the Bug or Issue

asd

### Expected behavior

sad

### Screenshots or Videos

_No response_

### Platform

- OS: [e.g. macOS, Windows, Linux]
- Browser: [e.g. Chrome, Safari, Firefox]
- Version: [e.g. 91.1]asd


### Additional context

_No response_

Pull request:
feat(react-router): add `remountDeps`
closes #2990

Patch:
diff --git a/docs/eslint/create-route-property-order.md b/docs/eslint/create-route-property-order.md
index 56f27821f..b0a664eb2 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 e486579df..a614261ee 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<string, any>
 - 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 5de8c3bda..eeafe9aa5 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 1bcd28257..dcead4334 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 5240072ad..aa46d1095 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 ? <Comp /> : <Outlet />
-  }, [route.options.component, router.options.defaultComponent])
+    if (Comp) {
+      return <Comp key={key} />
+    }
+    return <Outlet />
+  }, [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 (
         <RouteErrorComponent
-          error={match.error}
+          error={match.error as any}
+          reset={undefined as any}
           info={{
             componentStack: '',
           }}
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index 40ffc3951..4da374e6e 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -63,6 +63,7 @@ export interface RouteMatch<
   index: number
   pathname: string
   params: TAllParams
+  _strictParams: TAllParams
   status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound'
   isFetching: false | 'beforeLoad' | 'loader'
   error: unknown
@@ -77,6 +78,7 @@ export interface RouteMatch<
   __beforeLoadContext: Record<string, unknown>
   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 654a71c8f..5fafbb5e2 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 7f8b22b4d..4cfac9290 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<TPath, TParams> & {
   validateSearch?: Constrain<TSearchValidator, AnyValidator, DefaultValidator>
 
@@ -204,6 +205,18 @@ export type FileBaseRouteOptions<
     opts: FullSearchSchemaOption<TParentRoute, TSearchValidator>,
   ) => TLoaderDeps
 
+  remountDeps?: Constrain<
+    TRemountDepsFn,
+    (
+      opt: RemountDepsOptions<
+        TId,
+        FullSearchSchemaOption<TParentRoute, TSearchValidator>,
+        Expand<ResolveAllParamsFromParent<TParentRoute, TParams>>,
+        TLoaderDeps
+      >,
+    ) => any
+  >
+
   loader?: Constrain<
     TLoaderFn,
     (
@@ -275,6 +288,30 @@ export interface RouteContextOptions<
   context: Expand<RouteContextParameter<TParentRoute, TRouterContext>>
 }
 
+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<TRouteTree>,
+> = 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 e955c85ed..bb53a4fae 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<TRouteTree>) => any
 }
 
 export interface RouterErrorSerializer<TSerializedError> {
@@ -1151,19 +1154,26 @@ export class Router<
 
       const parentMatch = matches[index - 1]
 
-      const [preMatchSearch, searchError]: [Record<string, any>, any] = (() => {
+      const [preMatchSearch, strictMatchSearch, searchError]: [
+        Record<string, any>,
+        Record<string, any>,
+        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/router-core/src/path.ts b/packages/router-core/src/path.ts
index cf4b99b9c..5866e14bd 100644
--- a/packages/router-core/src/path.ts
+++ b/packages/router-core/src/path.ts
@@ -208,48 +208,55 @@ interface InterpolatePathOptions {
   decodeCharMap?: Map<string, string>
 }
 
+type InterPolatePathResult = {
+  interpolatedPath: string
+  usedParams: Record<string, unknown>
+}
 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<string, unknown> = {}
+  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<string, string>) {


Test patch:
diff --git a/packages/react-router/tests/link.bench.tsx b/packages/react-router/tests/link.bench.tsx
index 4854c908c..a04149ed6 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<LinkProps>) => {
-  const href = interpolatePath({ path: to, params })
+  const href = interpolatePath({ path: to, params }).interpolatedPath
   return <a href={href}>{children}</a>
 }
 
diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index 7f6a10e05..a176f432a 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<AnyRoute, 'never'>) {
     },
   })
   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<AnyRoute, 'never'>) {
     },
   })
 
+  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<AnyRoute, 'never'>) {
       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', () => {
           <div>
             <p>Root</p>
             <div>
-              <Link to="/foo/$id" params={{ id: '1' }}>
+              <Link
+                data-testid="link-foo-1"
+                to="/foo/$fooId"
+                params={{ fooId: '1' }}
+              >
                 Foo1
               </Link>
-              <Link to="/foo/$id" params={{ id: '2' }}>
+              <Link
+                data-testid="link-foo-2"
+                to="/foo/$fooId"
+                params={{ fooId: '2' }}
+              >
                 Foo2
               </Link>
+              <Link
+                data-testid="link-foo-3-bar-1"
+                to="/foo/$fooId/bar/$barId"
+                params={{ fooId: '3', barId: '1' }}
+              >
+                Foo3-Bar1
+              </Link>
+              <Link
+                data-testid="link-foo-3-bar-2"
+                to="/foo/$fooId/bar/$barId"
+                params={{ fooId: '3', barId: '2' }}
+              >
+                Foo3-Bar2
+              </Link>
             </div>
             <Outlet />
           </div>
@@ -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 (
+        <div data-testid="fooId-page">
+          Foo page <span data-testid="fooId-value">{fooId}</span> <Outlet />
+        </div>
+      )
+    }
+
+    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 <div>Foo page {id}</div>
+      return (
+        <div data-testid="barId-page">
+          Bar page <span data-testid="barId-value">{barId}</span> <Outlet />
+        </div>
+      )
     }
 
-    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(<RouterProvider router={router} />))
 
-    render(<RouterProvider router={router} />)
+    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<string, unknown> = {
+        __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<string, unknown> = {
+        __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/tests/path.test.ts b/packages/router-core/tests/path.test.ts
index 78fa6d347..8c86975b1 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)
     })
   })