diff --git a/e2e/react-router/basic-file-based/src/main.tsx b/e2e/react-router/basic-file-based/src/main.tsx
index 3dc73ddd51..b9a3f3ef55 100644
--- a/e2e/react-router/basic-file-based/src/main.tsx
+++ b/e2e/react-router/basic-file-based/src/main.tsx
@@ -10,6 +10,16 @@ const router = createRouter({
defaultPreload: 'intent',
defaultStaleTime: 5000,
scrollRestoration: true,
+ routeMasks: [
+ {
+ routeTree: null as any,
+ from: '/masks/admin/$userId',
+ to: '/masks/public/$username',
+ params: (prev: any) => ({
+ username: `user-${prev.userId}`,
+ }),
+ },
+ ],
})
// Register things for typesafety
diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts
index 588b2acb7b..83270c2e99 100644
--- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts
+++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as RemountDepsRouteImport } from './routes/remountDeps'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as NotRemountDepsRouteImport } from './routes/notRemountDeps'
+import { Route as MasksRouteImport } from './routes/masks'
import { Route as HoverPreloadHashRouteImport } from './routes/hover-preload-hash'
import { Route as EditingBRouteImport } from './routes/editing-b'
import { Route as EditingARouteImport } from './routes/editing-a'
@@ -67,6 +68,8 @@ import { Route as ParamsPsWildcardPrefixAtChar45824Char123Char125RouteImport } f
import { Route as ParamsPsWildcardSplatRouteImport } from './routes/params-ps/wildcard/$'
import { Route as ParamsPsNamedChar123fooChar125suffixRouteImport } from './routes/params-ps/named/{$foo}suffix'
import { Route as ParamsPsNamedPrefixChar123fooChar125RouteImport } from './routes/params-ps/named/prefix{$foo}'
+import { Route as MasksPublicUsernameRouteImport } from './routes/masks.public.$username'
+import { Route as MasksAdminUserIdRouteImport } from './routes/masks.admin.$userId'
import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b'
import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a'
import { Route as groupSubfolderInsideRouteImport } from './routes/(group)/subfolder/inside'
@@ -126,6 +129,11 @@ const NotRemountDepsRoute = NotRemountDepsRouteImport.update({
path: '/notRemountDeps',
getParentRoute: () => rootRouteImport,
} as any)
+const MasksRoute = MasksRouteImport.update({
+ id: '/masks',
+ path: '/masks',
+ getParentRoute: () => rootRouteImport,
+} as any)
const HoverPreloadHashRoute = HoverPreloadHashRouteImport.update({
id: '/hover-preload-hash',
path: '/hover-preload-hash',
@@ -417,6 +425,16 @@ const ParamsPsNamedPrefixChar123fooChar125Route =
path: '/params-ps/named/prefix{$foo}',
getParentRoute: () => rootRouteImport,
} as any)
+const MasksPublicUsernameRoute = MasksPublicUsernameRouteImport.update({
+ id: '/public/$username',
+ path: '/public/$username',
+ getParentRoute: () => MasksRoute,
+} as any)
+const MasksAdminUserIdRoute = MasksAdminUserIdRouteImport.update({
+ id: '/admin/$userId',
+ path: '/admin/$userId',
+ getParentRoute: () => MasksRoute,
+} as any)
const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({
id: '/layout-b',
path: '/layout-b',
@@ -666,6 +684,7 @@ export interface FileRoutesByFullPath {
'/editing-a': typeof EditingARoute
'/editing-b': typeof EditingBRoute
'/hover-preload-hash': typeof HoverPreloadHashRoute
+ '/masks': typeof MasksRouteWithChildren
'/notRemountDeps': typeof NotRemountDepsRoute
'/posts': typeof PostsRouteWithChildren
'/remountDeps': typeof RemountDepsRoute
@@ -700,6 +719,8 @@ export interface FileRoutesByFullPath {
'/subfolder/inside': typeof groupSubfolderInsideRoute
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
+ '/masks/admin/$userId': typeof MasksAdminUserIdRoute
+ '/masks/public/$username': typeof MasksPublicUsernameRoute
'/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route
'/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute
'/params-ps/wildcard/$': typeof ParamsPsWildcardSplatRoute
@@ -765,6 +786,7 @@ export interface FileRoutesByTo {
'/editing-a': typeof EditingARoute
'/editing-b': typeof EditingBRoute
'/hover-preload-hash': typeof HoverPreloadHashRoute
+ '/masks': typeof MasksRouteWithChildren
'/notRemountDeps': typeof NotRemountDepsRoute
'/remountDeps': typeof RemountDepsRoute
'/non-nested/deep': typeof NonNestedDeepRouteRouteWithChildren
@@ -792,6 +814,8 @@ export interface FileRoutesByTo {
'/subfolder/inside': typeof groupSubfolderInsideRoute
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
+ '/masks/admin/$userId': typeof MasksAdminUserIdRoute
+ '/masks/public/$username': typeof MasksPublicUsernameRoute
'/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route
'/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute
'/params-ps/wildcard/$': typeof ParamsPsWildcardSplatRoute
@@ -858,6 +882,7 @@ export interface FileRoutesById {
'/editing-a': typeof EditingARoute
'/editing-b': typeof EditingBRoute
'/hover-preload-hash': typeof HoverPreloadHashRoute
+ '/masks': typeof MasksRouteWithChildren
'/notRemountDeps': typeof NotRemountDepsRoute
'/posts': typeof PostsRouteWithChildren
'/remountDeps': typeof RemountDepsRoute
@@ -894,6 +919,8 @@ export interface FileRoutesById {
'/(group)/subfolder/inside': typeof groupSubfolderInsideRoute
'/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
+ '/masks/admin/$userId': typeof MasksAdminUserIdRoute
+ '/masks/public/$username': typeof MasksPublicUsernameRoute
'/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route
'/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute
'/params-ps/wildcard/$': typeof ParamsPsWildcardSplatRoute
@@ -962,6 +989,7 @@ export interface FileRouteTypes {
| '/editing-a'
| '/editing-b'
| '/hover-preload-hash'
+ | '/masks'
| '/notRemountDeps'
| '/posts'
| '/remountDeps'
@@ -996,6 +1024,8 @@ export interface FileRouteTypes {
| '/subfolder/inside'
| '/layout-a'
| '/layout-b'
+ | '/masks/admin/$userId'
+ | '/masks/public/$username'
| '/params-ps/named/prefix{$foo}'
| '/params-ps/named/{$foo}suffix'
| '/params-ps/wildcard/$'
@@ -1061,6 +1091,7 @@ export interface FileRouteTypes {
| '/editing-a'
| '/editing-b'
| '/hover-preload-hash'
+ | '/masks'
| '/notRemountDeps'
| '/remountDeps'
| '/non-nested/deep'
@@ -1088,6 +1119,8 @@ export interface FileRouteTypes {
| '/subfolder/inside'
| '/layout-a'
| '/layout-b'
+ | '/masks/admin/$userId'
+ | '/masks/public/$username'
| '/params-ps/named/prefix{$foo}'
| '/params-ps/named/{$foo}suffix'
| '/params-ps/wildcard/$'
@@ -1153,6 +1186,7 @@ export interface FileRouteTypes {
| '/editing-a'
| '/editing-b'
| '/hover-preload-hash'
+ | '/masks'
| '/notRemountDeps'
| '/posts'
| '/remountDeps'
@@ -1189,6 +1223,8 @@ export interface FileRouteTypes {
| '/(group)/subfolder/inside'
| '/_layout/_layout-2/layout-a'
| '/_layout/_layout-2/layout-b'
+ | '/masks/admin/$userId'
+ | '/masks/public/$username'
| '/params-ps/named/prefix{$foo}'
| '/params-ps/named/{$foo}suffix'
| '/params-ps/wildcard/$'
@@ -1257,6 +1293,7 @@ export interface RootRouteChildren {
EditingARoute: typeof EditingARoute
EditingBRoute: typeof EditingBRoute
HoverPreloadHashRoute: typeof HoverPreloadHashRoute
+ MasksRoute: typeof MasksRouteWithChildren
NotRemountDepsRoute: typeof NotRemountDepsRoute
PostsRoute: typeof PostsRouteWithChildren
RemountDepsRoute: typeof RemountDepsRoute
@@ -1313,6 +1350,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NotRemountDepsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/masks': {
+ id: '/masks'
+ path: '/masks'
+ fullPath: '/masks'
+ preLoaderRoute: typeof MasksRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/hover-preload-hash': {
id: '/hover-preload-hash'
path: '/hover-preload-hash'
@@ -1698,6 +1742,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ParamsPsNamedPrefixChar123fooChar125RouteImport
parentRoute: typeof rootRouteImport
}
+ '/masks/public/$username': {
+ id: '/masks/public/$username'
+ path: '/public/$username'
+ fullPath: '/masks/public/$username'
+ preLoaderRoute: typeof MasksPublicUsernameRouteImport
+ parentRoute: typeof MasksRoute
+ }
+ '/masks/admin/$userId': {
+ id: '/masks/admin/$userId'
+ path: '/admin/$userId'
+ fullPath: '/masks/admin/$userId'
+ preLoaderRoute: typeof MasksAdminUserIdRouteImport
+ parentRoute: typeof MasksRoute
+ }
'/_layout/_layout-2/layout-b': {
id: '/_layout/_layout-2/layout-b'
path: '/layout-b'
@@ -2262,6 +2320,18 @@ const LayoutRouteChildren: LayoutRouteChildren = {
const LayoutRouteWithChildren =
LayoutRoute._addFileChildren(LayoutRouteChildren)
+interface MasksRouteChildren {
+ MasksAdminUserIdRoute: typeof MasksAdminUserIdRoute
+ MasksPublicUsernameRoute: typeof MasksPublicUsernameRoute
+}
+
+const MasksRouteChildren: MasksRouteChildren = {
+ MasksAdminUserIdRoute: MasksAdminUserIdRoute,
+ MasksPublicUsernameRoute: MasksPublicUsernameRoute,
+}
+
+const MasksRouteWithChildren = MasksRoute._addFileChildren(MasksRouteChildren)
+
interface PostsRouteChildren {
PostsPostIdRoute: typeof PostsPostIdRoute
PostsIndexRoute: typeof PostsIndexRoute
@@ -2423,6 +2493,7 @@ const rootRouteChildren: RootRouteChildren = {
EditingARoute: EditingARoute,
EditingBRoute: EditingBRoute,
HoverPreloadHashRoute: HoverPreloadHashRoute,
+ MasksRoute: MasksRouteWithChildren,
NotRemountDepsRoute: NotRemountDepsRoute,
PostsRoute: PostsRouteWithChildren,
RemountDepsRoute: RemountDepsRoute,
diff --git a/e2e/react-router/basic-file-based/src/routes/__root.tsx b/e2e/react-router/basic-file-based/src/routes/__root.tsx
index 26cd6a60f8..a389870c3f 100644
--- a/e2e/react-router/basic-file-based/src/routes/__root.tsx
+++ b/e2e/react-router/basic-file-based/src/routes/__root.tsx
@@ -139,13 +139,21 @@ function RootComponent() {
unicode path
{' '}
This Route Does Not Exist
+ {' '}
+
+ Masks
diff --git a/e2e/react-router/basic-file-based/src/routes/masks.admin.$userId.tsx b/e2e/react-router/basic-file-based/src/routes/masks.admin.$userId.tsx
new file mode 100644
index 0000000000..5590a14062
--- /dev/null
+++ b/e2e/react-router/basic-file-based/src/routes/masks.admin.$userId.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/masks/admin/$userId')({
+ component: AdminUserRoute,
+})
+
+function AdminUserRoute() {
+ const params = Route.useParams()
+
+ return (
+
+ )
+}
diff --git a/e2e/react-router/basic-file-based/src/routes/masks.public.$username.tsx b/e2e/react-router/basic-file-based/src/routes/masks.public.$username.tsx
new file mode 100644
index 0000000000..200f7a3b6b
--- /dev/null
+++ b/e2e/react-router/basic-file-based/src/routes/masks.public.$username.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/masks/public/$username')({
+ component: PublicUserRoute,
+})
+
+function PublicUserRoute() {
+ const params = Route.useParams()
+
+ return (
+
+ )
+}
diff --git a/e2e/react-router/basic-file-based/src/routes/masks.tsx b/e2e/react-router/basic-file-based/src/routes/masks.tsx
new file mode 100644
index 0000000000..bbe2342833
--- /dev/null
+++ b/e2e/react-router/basic-file-based/src/routes/masks.tsx
@@ -0,0 +1,38 @@
+import {
+ Link,
+ Outlet,
+ createFileRoute,
+ useRouterState,
+} from '@tanstack/react-router'
+
+export const Route = createFileRoute('/masks')({
+ component: MasksLayout,
+})
+
+function MasksLayout() {
+ const location = useRouterState({
+ select: (state) => state.location,
+ })
+
+ return (
+
+
Route Masks
+
+
+
{location.pathname}
+
+ {location.maskedLocation?.pathname ?? ''}
+
+
+
+
+ )
+}
diff --git a/e2e/react-router/basic-file-based/tests/mask.spec.ts b/e2e/react-router/basic-file-based/tests/mask.spec.ts
new file mode 100644
index 0000000000..1b77760de6
--- /dev/null
+++ b/e2e/react-router/basic-file-based/tests/mask.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from '@playwright/test'
+
+test('route masks transform params and expose masked pathname in the browser (react)', async ({
+ page,
+}) => {
+ await page.goto('/')
+
+ await page.getByTestId('link-to-masks').click()
+ await expect(page.getByText('Route Masks')).toBeVisible()
+
+ const link = page.getByTestId('link-to-admin-mask')
+ await link.click()
+
+ await page.waitForURL('/masks/public/user-42')
+
+ await expect(page.getByTestId('admin-user-component')).toBeInViewport()
+ await expect(page.getByTestId('admin-user-id')).toHaveText('42')
+
+ await expect(page.getByTestId('router-pathname')).toHaveText(
+ '/masks/admin/42',
+ )
+
+ await expect(page.getByTestId('router-masked-pathname')).toHaveText(
+ '/masks/public/user-42',
+ )
+})
diff --git a/e2e/solid-router/basic-file-based/src/main.tsx b/e2e/solid-router/basic-file-based/src/main.tsx
index fc12c76570..2c02abe044 100644
--- a/e2e/solid-router/basic-file-based/src/main.tsx
+++ b/e2e/solid-router/basic-file-based/src/main.tsx
@@ -9,6 +9,16 @@ const router = createRouter({
defaultPreload: 'intent',
defaultStaleTime: 5000,
scrollRestoration: true,
+ routeMasks: [
+ {
+ routeTree: null as any,
+ from: '/masks/admin/$userId',
+ to: '/masks/public/$username',
+ params: (prev: any) => ({
+ username: `user-${prev.userId}`,
+ }),
+ },
+ ],
})
// Register things for typesafety
diff --git a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts
index bf5f4523c7..0b0315d349 100644
--- a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts
+++ b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as RemountDepsRouteImport } from './routes/remountDeps'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as NotRemountDepsRouteImport } from './routes/notRemountDeps'
+import { Route as MasksRouteImport } from './routes/masks'
import { Route as HoverPreloadHashRouteImport } from './routes/hover-preload-hash'
import { Route as EditingBRouteImport } from './routes/editing-b'
import { Route as EditingARouteImport } from './routes/editing-a'
@@ -68,6 +69,8 @@ import { Route as ParamsPsWildcardPrefixAtChar45824Char123Char125RouteImport } f
import { Route as ParamsPsWildcardSplatRouteImport } from './routes/params-ps/wildcard/$'
import { Route as ParamsPsNamedChar123fooChar125suffixRouteImport } from './routes/params-ps/named/{$foo}suffix'
import { Route as ParamsPsNamedPrefixChar123fooChar125RouteImport } from './routes/params-ps/named/prefix{$foo}'
+import { Route as MasksPublicUsernameRouteImport } from './routes/masks.public.$username'
+import { Route as MasksAdminUserIdRouteImport } from './routes/masks.admin.$userId'
import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b'
import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a'
import { Route as groupSubfolderInsideRouteImport } from './routes/(group)/subfolder/inside'
@@ -127,6 +130,11 @@ const NotRemountDepsRoute = NotRemountDepsRouteImport.update({
path: '/notRemountDeps',
getParentRoute: () => rootRouteImport,
} as any)
+const MasksRoute = MasksRouteImport.update({
+ id: '/masks',
+ path: '/masks',
+ getParentRoute: () => rootRouteImport,
+} as any)
const HoverPreloadHashRoute = HoverPreloadHashRouteImport.update({
id: '/hover-preload-hash',
path: '/hover-preload-hash',
@@ -424,6 +432,16 @@ const ParamsPsNamedPrefixChar123fooChar125Route =
path: '/params-ps/named/prefix{$foo}',
getParentRoute: () => rootRouteImport,
} as any)
+const MasksPublicUsernameRoute = MasksPublicUsernameRouteImport.update({
+ id: '/public/$username',
+ path: '/public/$username',
+ getParentRoute: () => MasksRoute,
+} as any)
+const MasksAdminUserIdRoute = MasksAdminUserIdRouteImport.update({
+ id: '/admin/$userId',
+ path: '/admin/$userId',
+ getParentRoute: () => MasksRoute,
+} as any)
const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({
id: '/layout-b',
path: '/layout-b',
@@ -673,6 +691,7 @@ export interface FileRoutesByFullPath {
'/editing-a': typeof EditingARoute
'/editing-b': typeof EditingBRoute
'/hover-preload-hash': typeof HoverPreloadHashRoute
+ '/masks': typeof MasksRouteWithChildren
'/notRemountDeps': typeof NotRemountDepsRoute
'/posts': typeof PostsRouteWithChildren
'/remountDeps': typeof RemountDepsRoute
@@ -706,6 +725,8 @@ export interface FileRoutesByFullPath {
'/subfolder/inside': typeof groupSubfolderInsideRoute
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
+ '/masks/admin/$userId': typeof MasksAdminUserIdRoute
+ '/masks/public/$username': typeof MasksPublicUsernameRoute
'/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route
'/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute
'/params-ps/wildcard/$': typeof ParamsPsWildcardSplatRoute
@@ -773,6 +794,7 @@ export interface FileRoutesByTo {
'/editing-a': typeof EditingARoute
'/editing-b': typeof EditingBRoute
'/hover-preload-hash': typeof HoverPreloadHashRoute
+ '/masks': typeof MasksRouteWithChildren
'/notRemountDeps': typeof NotRemountDepsRoute
'/remountDeps': typeof RemountDepsRoute
'/non-nested/deep': typeof NonNestedDeepRouteRouteWithChildren
@@ -799,6 +821,8 @@ export interface FileRoutesByTo {
'/subfolder/inside': typeof groupSubfolderInsideRoute
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
+ '/masks/admin/$userId': typeof MasksAdminUserIdRoute
+ '/masks/public/$username': typeof MasksPublicUsernameRoute
'/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route
'/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute
'/params-ps/wildcard/$': typeof ParamsPsWildcardSplatRoute
@@ -867,6 +891,7 @@ export interface FileRoutesById {
'/editing-a': typeof EditingARoute
'/editing-b': typeof EditingBRoute
'/hover-preload-hash': typeof HoverPreloadHashRoute
+ '/masks': typeof MasksRouteWithChildren
'/notRemountDeps': typeof NotRemountDepsRoute
'/posts': typeof PostsRouteWithChildren
'/remountDeps': typeof RemountDepsRoute
@@ -902,6 +927,8 @@ export interface FileRoutesById {
'/(group)/subfolder/inside': typeof groupSubfolderInsideRoute
'/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
+ '/masks/admin/$userId': typeof MasksAdminUserIdRoute
+ '/masks/public/$username': typeof MasksPublicUsernameRoute
'/params-ps/named/prefix{$foo}': typeof ParamsPsNamedPrefixChar123fooChar125Route
'/params-ps/named/{$foo}suffix': typeof ParamsPsNamedChar123fooChar125suffixRoute
'/params-ps/wildcard/$': typeof ParamsPsWildcardSplatRoute
@@ -972,6 +999,7 @@ export interface FileRouteTypes {
| '/editing-a'
| '/editing-b'
| '/hover-preload-hash'
+ | '/masks'
| '/notRemountDeps'
| '/posts'
| '/remountDeps'
@@ -1005,6 +1033,8 @@ export interface FileRouteTypes {
| '/subfolder/inside'
| '/layout-a'
| '/layout-b'
+ | '/masks/admin/$userId'
+ | '/masks/public/$username'
| '/params-ps/named/prefix{$foo}'
| '/params-ps/named/{$foo}suffix'
| '/params-ps/wildcard/$'
@@ -1072,6 +1102,7 @@ export interface FileRouteTypes {
| '/editing-a'
| '/editing-b'
| '/hover-preload-hash'
+ | '/masks'
| '/notRemountDeps'
| '/remountDeps'
| '/non-nested/deep'
@@ -1098,6 +1129,8 @@ export interface FileRouteTypes {
| '/subfolder/inside'
| '/layout-a'
| '/layout-b'
+ | '/masks/admin/$userId'
+ | '/masks/public/$username'
| '/params-ps/named/prefix{$foo}'
| '/params-ps/named/{$foo}suffix'
| '/params-ps/wildcard/$'
@@ -1165,6 +1198,7 @@ export interface FileRouteTypes {
| '/editing-a'
| '/editing-b'
| '/hover-preload-hash'
+ | '/masks'
| '/notRemountDeps'
| '/posts'
| '/remountDeps'
@@ -1200,6 +1234,8 @@ export interface FileRouteTypes {
| '/(group)/subfolder/inside'
| '/_layout/_layout-2/layout-a'
| '/_layout/_layout-2/layout-b'
+ | '/masks/admin/$userId'
+ | '/masks/public/$username'
| '/params-ps/named/prefix{$foo}'
| '/params-ps/named/{$foo}suffix'
| '/params-ps/wildcard/$'
@@ -1270,6 +1306,7 @@ export interface RootRouteChildren {
EditingARoute: typeof EditingARoute
EditingBRoute: typeof EditingBRoute
HoverPreloadHashRoute: typeof HoverPreloadHashRoute
+ MasksRoute: typeof MasksRouteWithChildren
NotRemountDepsRoute: typeof NotRemountDepsRoute
PostsRoute: typeof PostsRouteWithChildren
RemountDepsRoute: typeof RemountDepsRoute
@@ -1327,6 +1364,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof NotRemountDepsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/masks': {
+ id: '/masks'
+ path: '/masks'
+ fullPath: '/masks'
+ preLoaderRoute: typeof MasksRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/hover-preload-hash': {
id: '/hover-preload-hash'
path: '/hover-preload-hash'
@@ -1719,6 +1763,20 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof ParamsPsNamedPrefixChar123fooChar125RouteImport
parentRoute: typeof rootRouteImport
}
+ '/masks/public/$username': {
+ id: '/masks/public/$username'
+ path: '/public/$username'
+ fullPath: '/masks/public/$username'
+ preLoaderRoute: typeof MasksPublicUsernameRouteImport
+ parentRoute: typeof MasksRoute
+ }
+ '/masks/admin/$userId': {
+ id: '/masks/admin/$userId'
+ path: '/admin/$userId'
+ fullPath: '/masks/admin/$userId'
+ preLoaderRoute: typeof MasksAdminUserIdRouteImport
+ parentRoute: typeof MasksRoute
+ }
'/_layout/_layout-2/layout-b': {
id: '/_layout/_layout-2/layout-b'
path: '/layout-b'
@@ -2283,6 +2341,18 @@ const LayoutRouteChildren: LayoutRouteChildren = {
const LayoutRouteWithChildren =
LayoutRoute._addFileChildren(LayoutRouteChildren)
+interface MasksRouteChildren {
+ MasksAdminUserIdRoute: typeof MasksAdminUserIdRoute
+ MasksPublicUsernameRoute: typeof MasksPublicUsernameRoute
+}
+
+const MasksRouteChildren: MasksRouteChildren = {
+ MasksAdminUserIdRoute: MasksAdminUserIdRoute,
+ MasksPublicUsernameRoute: MasksPublicUsernameRoute,
+}
+
+const MasksRouteWithChildren = MasksRoute._addFileChildren(MasksRouteChildren)
+
interface PostsRouteChildren {
PostsPostIdRoute: typeof PostsPostIdRoute
PostsIndexRoute: typeof PostsIndexRoute
@@ -2444,6 +2514,7 @@ const rootRouteChildren: RootRouteChildren = {
EditingARoute: EditingARoute,
EditingBRoute: EditingBRoute,
HoverPreloadHashRoute: HoverPreloadHashRoute,
+ MasksRoute: MasksRouteWithChildren,
NotRemountDepsRoute: NotRemountDepsRoute,
PostsRoute: PostsRouteWithChildren,
RemountDepsRoute: RemountDepsRoute,
diff --git a/e2e/solid-router/basic-file-based/src/routes/__root.tsx b/e2e/solid-router/basic-file-based/src/routes/__root.tsx
index 883fbd3406..6acb7a3638 100644
--- a/e2e/solid-router/basic-file-based/src/routes/__root.tsx
+++ b/e2e/solid-router/basic-file-based/src/routes/__root.tsx
@@ -146,6 +146,15 @@ function RootComponent() {
}}
>
This Route Does Not Exist
+ {' '}
+
+ Masks
diff --git a/e2e/solid-router/basic-file-based/src/routes/masks.admin.$userId.tsx b/e2e/solid-router/basic-file-based/src/routes/masks.admin.$userId.tsx
new file mode 100644
index 0000000000..c808349245
--- /dev/null
+++ b/e2e/solid-router/basic-file-based/src/routes/masks.admin.$userId.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/masks/admin/$userId')({
+ component: AdminUserRoute,
+})
+
+function AdminUserRoute() {
+ const params = Route.useParams()
+
+ return (
+
+ )
+}
diff --git a/e2e/solid-router/basic-file-based/src/routes/masks.public.$username.tsx b/e2e/solid-router/basic-file-based/src/routes/masks.public.$username.tsx
new file mode 100644
index 0000000000..ea73ade15a
--- /dev/null
+++ b/e2e/solid-router/basic-file-based/src/routes/masks.public.$username.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/masks/public/$username')({
+ component: PublicUserRoute,
+})
+
+function PublicUserRoute() {
+ const params = Route.useParams()
+
+ return (
+
+ )
+}
diff --git a/e2e/solid-router/basic-file-based/src/routes/masks.tsx b/e2e/solid-router/basic-file-based/src/routes/masks.tsx
new file mode 100644
index 0000000000..ade84f501d
--- /dev/null
+++ b/e2e/solid-router/basic-file-based/src/routes/masks.tsx
@@ -0,0 +1,38 @@
+import {
+ Link,
+ Outlet,
+ createFileRoute,
+ useRouterState,
+} from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/masks')({
+ component: MasksLayout,
+})
+
+function MasksLayout() {
+ const location = useRouterState({
+ select: (state) => state.location,
+ })
+
+ return (
+
+
Route Masks
+
+
+
{location().pathname}
+
+ {location().maskedLocation?.pathname ?? ''}
+
+
+
+
+ )
+}
diff --git a/e2e/solid-router/basic-file-based/tests/mask.spec.ts b/e2e/solid-router/basic-file-based/tests/mask.spec.ts
new file mode 100644
index 0000000000..f6ec42c604
--- /dev/null
+++ b/e2e/solid-router/basic-file-based/tests/mask.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from '@playwright/test'
+
+test('route masks transform params and expose masked pathname in the browser (solid)', async ({
+ page,
+}) => {
+ await page.goto('/')
+
+ await page.getByTestId('link-to-masks').click()
+ await expect(page.getByText('Route Masks')).toBeVisible()
+
+ const link = page.getByTestId('link-to-admin-mask')
+ await link.click()
+
+ await page.waitForURL('/masks/public/user-42')
+
+ await expect(page.getByTestId('admin-user-component')).toBeInViewport()
+ await expect(page.getByTestId('admin-user-id')).toHaveText('42')
+
+ await expect(page.getByTestId('router-pathname')).toHaveText(
+ '/masks/admin/42',
+ )
+
+ await expect(page.getByTestId('router-masked-pathname')).toHaveText(
+ '/masks/public/user-42',
+ )
+})
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 4efaa61b7b..19b7753129 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1793,11 +1793,25 @@ export class RouterCore<
)
if (match) {
Object.assign(params, match.params) // Copy params, because they're cached
- const { from: _from, ...maskProps } = match.route
+ const {
+ from: _from,
+ params: maskParams,
+ ...maskProps
+ } = match.route
+
+ // If mask has a params function, call it with the matched params as context
+ // Otherwise, use the matched params or the provided params value
+ const nextParams =
+ maskParams === false || maskParams === null
+ ? {}
+ : (maskParams ?? true) === true
+ ? params
+ : Object.assign(params, functionalUpdate(maskParams, params))
+
maskedDest = {
from: opts.from,
...maskProps,
- params,
+ params: nextParams,
}
maskedNext = build(maskedDest)
}
diff --git a/packages/router-core/tests/mask.test.ts b/packages/router-core/tests/mask.test.ts
new file mode 100644
index 0000000000..6b885d05f5
--- /dev/null
+++ b/packages/router-core/tests/mask.test.ts
@@ -0,0 +1,623 @@
+import { describe, expect, test } from 'vitest'
+import { createMemoryHistory } from '@tanstack/history'
+import { BaseRootRoute, BaseRoute, RouterCore } from '../src'
+import type { RouteMask } from '../src'
+
+describe('buildLocation - route masks', () => {
+ const setup = (routeMasks?: Array>) => {
+ const rootRoute = new BaseRootRoute({})
+ const photoRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/photos/$photoId',
+ })
+
+ const modalRoute = new BaseRoute({
+ getParentRoute: () => photoRoute,
+ path: '/modal',
+ })
+
+ const postsRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+
+ const postRoute = new BaseRoute({
+ getParentRoute: () => postsRoute,
+ path: '/$postId',
+ })
+
+ const infoRoute = new BaseRoute({
+ getParentRoute: () => postRoute,
+ path: '/info',
+ })
+
+ const routeTree = rootRoute.addChildren([
+ photoRoute.addChildren([modalRoute]),
+ postsRoute.addChildren([postRoute.addChildren([infoRoute])]),
+ ])
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory(),
+ routeMasks,
+ })
+
+ return router
+ }
+
+ test('should not create maskedLocation when no mask matches', () => {
+ const router = setup()
+
+ const location = router.buildLocation({
+ to: '/photos/$photoId/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeUndefined()
+ expect(location.pathname).toBe('/photos/123/modal')
+ })
+
+ test('should not create maskedLocation when routeMasks is empty', () => {
+ const router = setup([])
+
+ const location = router.buildLocation({
+ to: '/photos/$photoId/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeUndefined()
+ expect(location.pathname).toBe('/photos/123/modal')
+ })
+
+ test('should find and apply mask when pathname matches', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ params: true,
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/photos/123')
+ expect(location.pathname).toBe('/photos/123/modal')
+ })
+
+ test('should set params to {} when maskParams is false', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/posts',
+ params: false,
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/posts')
+ // The masked location should have no params since maskParams is false
+ expect(location.maskedLocation!.href).toBe('/posts')
+ })
+
+ test('should set params to {} when maskParams is null', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/posts',
+ params: null,
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/posts')
+ expect(location.maskedLocation!.href).toBe('/posts')
+ })
+
+ test('should use matched params when maskParams is true', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ params: true,
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/photos/123')
+ // The photoId param should be preserved from the matched params
+ expect(location.maskedLocation!.href).toBe('/photos/123')
+ })
+
+ test('should use matched params when maskParams is undefined', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ // params is undefined, which should default to true behavior
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/photos/123')
+ expect(location.maskedLocation!.href).toBe('/photos/123')
+ })
+
+ test('should call function when maskParams is a function', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/posts/$postId',
+ params: (prev: any) => ({
+ postId: prev.photoId,
+ }),
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/posts/123')
+ // The function should have transformed photoId to postId
+ expect(location.maskedLocation!.href).toBe('/posts/123')
+ })
+
+ test('should merge object params when maskParams is an object', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ params: {
+ photoId: '456', // Override the matched param
+ },
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ // The object params should override the matched params
+ expect(location.maskedLocation!.pathname).toBe('/photos/456')
+ expect(location.maskedLocation!.href).toBe('/photos/456')
+ })
+
+ test('should merge object params with matched params', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/posts/$postId/info',
+ to: '/posts/$postId',
+ params: true, // Use matched params directly
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/posts/123/info',
+ params: { postId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/posts/123')
+ expect(location.maskedLocation!.href).toBe('/posts/123')
+ })
+
+ test('should use first matching mask when multiple masks exist', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ params: true,
+ },
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/posts',
+ params: false,
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ // Should use the first matching mask
+ expect(location.maskedLocation!.pathname).toBe('/photos/123')
+ })
+
+ test('should pass through other mask properties (search, hash, state)', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ params: true,
+ search: { filter: 'recent' },
+ hash: 'section1',
+ state: { modal: true },
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/photos/123')
+ expect(location.maskedLocation!.search).toEqual({ filter: 'recent' })
+ // Hash property stores the value without #, but href includes it
+ expect(location.maskedLocation!.hash).toBe('section1')
+ expect(location.maskedLocation!.href).toContain('#section1')
+ expect(location.maskedLocation!.state).toEqual({ modal: true })
+ })
+
+ test('should handle mask with function params that receives matched params', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/posts/$postId/info',
+ to: '/posts/$postId',
+ params: (prev: any) => {
+ // Function receives the matched params from the pathname
+ expect(prev.postId).toBe('123')
+ return {
+ postId: prev.postId,
+ }
+ },
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/posts/123/info',
+ params: { postId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/posts/123')
+ })
+
+ test('should not match mask when pathname does not match mask from pattern', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/photos/$photoId',
+ params: true,
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/posts/123/info',
+ params: { postId: '123' },
+ })
+
+ // Should not match the mask since pathname doesn't match
+ expect(location.maskedLocation).toBeUndefined()
+ expect(location.pathname).toBe('/posts/123/info')
+ })
+
+ test('should handle mask with complex param transformation', () => {
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$photoId/modal',
+ to: '/posts/$postId',
+ params: (prev: any) => ({
+ postId: `photo-${prev.photoId}`,
+ }),
+ },
+ ]
+
+ const router = setup(routeMasks)
+
+ const location = router.buildLocation({
+ to: '/photos/123/modal',
+ params: { photoId: '123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/posts/photo-123')
+ })
+
+ test('should transform params when original and masked routes have different param names', () => {
+ const rootRoute = new BaseRootRoute({})
+ const photoPrivateRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/photos/$privateId',
+ })
+ const detailsRoute = new BaseRoute({
+ getParentRoute: () => photoPrivateRoute,
+ path: '/details',
+ })
+ const photoPublicRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/photos/$publicId',
+ })
+ const routeTree = rootRoute.addChildren([
+ photoPrivateRoute.addChildren([detailsRoute]),
+ photoPublicRoute,
+ ])
+
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$privateId/details',
+ to: '/photos/$publicId',
+ params: (prev: any) => ({
+ publicId: prev.privateId, // Transform privateId to publicId
+ }),
+ },
+ ]
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory(),
+ routeMasks,
+ })
+
+ const location = router.buildLocation({
+ to: '/photos/abc123/details',
+ params: { privateId: 'abc123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/photos/abc123')
+ expect(location.pathname).toBe('/photos/abc123/details')
+ })
+
+ test('should handle param name transformation with object params', () => {
+ const rootRoute = new BaseRootRoute({})
+ const photoPrivateRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/photos/$privateId',
+ })
+ const detailsRoute = new BaseRoute({
+ getParentRoute: () => photoPrivateRoute,
+ path: '/details',
+ })
+ const photoPublicRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/photos/$publicId',
+ })
+ const routeTree = rootRoute.addChildren([
+ photoPrivateRoute.addChildren([detailsRoute]),
+ photoPublicRoute,
+ ])
+
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/photos/$privateId/details',
+ to: '/photos/$publicId',
+ // Use a function to transform params (objects with function values aren't supported)
+ params: (prev: any) => ({
+ publicId: prev.privateId, // Transform privateId to publicId
+ }),
+ },
+ ]
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory(),
+ routeMasks,
+ })
+
+ const location = router.buildLocation({
+ to: '/photos/secret123/details',
+ params: { privateId: 'secret123' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/photos/secret123')
+ })
+
+ test('should handle multiple params with different names in masked route', () => {
+ const rootRoute = new BaseRootRoute({})
+ const userRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/users/$userId',
+ })
+ const postRoute = new BaseRoute({
+ getParentRoute: () => userRoute,
+ path: '/posts/$postSlug',
+ })
+ const profileRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/profiles/$profileId',
+ })
+ const articleRoute = new BaseRoute({
+ getParentRoute: () => profileRoute,
+ path: '/articles/$articleId',
+ })
+ const routeTree = rootRoute.addChildren([
+ userRoute.addChildren([postRoute]),
+ profileRoute.addChildren([articleRoute]),
+ ])
+
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/users/$userId/posts/$postSlug',
+ to: '/profiles/$profileId/articles/$articleId',
+ params: (prev: any) => ({
+ profileId: prev.userId,
+ articleId: prev.postSlug,
+ }),
+ },
+ ]
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory(),
+ routeMasks,
+ })
+
+ const location = router.buildLocation({
+ to: '/users/john/posts/my-first-post',
+ params: { userId: 'john', postSlug: 'my-first-post' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe(
+ '/profiles/john/articles/my-first-post',
+ )
+ expect(location.pathname).toBe('/users/john/posts/my-first-post')
+ })
+
+ test('should handle param transformation when masked route requires different param', () => {
+ const rootRoute = new BaseRootRoute({})
+ const adminUsersRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/admin/users/$userId',
+ })
+ const publicUsersRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/users/$username',
+ })
+ const routeTree = rootRoute.addChildren([adminUsersRoute, publicUsersRoute])
+
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/admin/users/$userId',
+ to: '/users/$username',
+ params: (prev: any) => {
+ // Simulate looking up username from userId
+ return {
+ username: `user-${prev.userId}`, // Transform userId to username format
+ }
+ },
+ },
+ ]
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory(),
+ routeMasks,
+ })
+
+ const location = router.buildLocation({
+ to: '/admin/users/42',
+ params: { userId: '42' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe('/users/user-42')
+ expect(location.pathname).toBe('/admin/users/42')
+ })
+
+ test('should handle partial param transformation when some params are kept', () => {
+ const rootRoute = new BaseRootRoute({})
+ const postRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts/$postId',
+ })
+ const commentRoute = new BaseRoute({
+ getParentRoute: () => postRoute,
+ path: '/comments/$commentId',
+ })
+ const articleRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/articles/$articleId',
+ })
+ const replyRoute = new BaseRoute({
+ getParentRoute: () => articleRoute,
+ path: '/replies/$replyId',
+ })
+ const routeTree = rootRoute.addChildren([
+ postRoute.addChildren([commentRoute]),
+ articleRoute.addChildren([replyRoute]),
+ ])
+
+ const routeMasks: Array> = [
+ {
+ routeTree: null as any,
+ from: '/posts/$postId/comments/$commentId',
+ to: '/articles/$articleId/replies/$replyId',
+ params: (prev: any) => ({
+ articleId: `article-${prev.postId}`,
+ replyId: prev.commentId, // Keep commentId as replyId
+ }),
+ },
+ ]
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory(),
+ routeMasks,
+ })
+
+ const location = router.buildLocation({
+ to: '/posts/5/comments/10',
+ params: { postId: '5', commentId: '10' },
+ })
+
+ expect(location.maskedLocation).toBeDefined()
+ expect(location.maskedLocation!.pathname).toBe(
+ '/articles/article-5/replies/10',
+ )
+ })
+})