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 ( +
+
{params.userId}
+
+ ) +} 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 ( +
+
{params.username}
+
+ ) +} 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 ( +
+
{params().userId}
+
+ ) +} 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 ( +
+
{params().username}
+
+ ) +} 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', + ) + }) +})