From 20e3b3b7f73bc3fd2357c49d04f6c8c77e337c79 Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Tue, 4 Nov 2025 11:17:33 -0800 Subject: [PATCH 1/6] Use params from declarative masks --- packages/router-core/src/router.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index bd4d22860d3..869e5f82c81 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1795,11 +1795,21 @@ export class RouterCore< }) if (foundMask) { - const { from: _from, ...maskProps } = foundMask + const { from: _from, params: maskParams, ...maskProps } = foundMask + + // 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) } From b4854d340297b3a05eaf19f149f72593a3bcf17f Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Tue, 11 Nov 2025 15:22:20 -0800 Subject: [PATCH 2/6] Add tests for masks --- packages/router-core/tests/mask.test.ts | 729 ++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 packages/router-core/tests/mask.test.ts diff --git a/packages/router-core/tests/mask.test.ts b/packages/router-core/tests/mask.test.ts new file mode 100644 index 00000000000..5e1002ff94f --- /dev/null +++ b/packages/router-core/tests/mask.test.ts @@ -0,0 +1,729 @@ +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 preserve opts.from when building masked destination', () => { + 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', + from: '/photos/456', + params: { photoId: '123' }, + }) + + expect(location.maskedLocation).toBeDefined() + // The from option should be preserved in the masked destination + expect(location.maskedLocation!.pathname).toBe('/photos/123') + }) + + test('should handle unmaskOnReload property in mask', () => { + const routeMasks: Array> = [ + { + routeTree: null as any, + from: '/photos/$photoId/modal', + to: '/photos/$photoId', + params: true, + unmaskOnReload: true, + }, + ] + + const router = setup(routeMasks) + + const location = router.buildLocation({ + to: '/photos/123/modal', + params: { photoId: '123' }, + }) + + expect(location.maskedLocation).toBeDefined() + expect(location.maskedLocation!.unmaskOnReload).toBe(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') + // Ensure no undefined values + expect(location.maskedLocation!.pathname).not.toContain('undefined') + }) + + 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') + expect(location.maskedLocation!.pathname).not.toContain('undefined') + }) + + 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') + expect(location.maskedLocation!.pathname).not.toContain('undefined') + }) + + 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') + expect(location.maskedLocation!.pathname).not.toContain('undefined') + }) + + test('should not have undefined params in masked location when param names differ', () => { + // This is the critical test - ensure we don't get undefined values + const rootRoute = new BaseRootRoute({}) + const internalRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/internal/$internalId', + }) + const detailsRoute = new BaseRoute({ + getParentRoute: () => internalRoute, + path: '/details', + }) + const publicRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/public/$publicId', + }) + const routeTree = rootRoute.addChildren([ + internalRoute.addChildren([detailsRoute]), + publicRoute, + ]) + + const routeMasks: Array> = [ + { + routeTree: null as any, + from: '/internal/$internalId/details', + to: '/public/$publicId', + params: (prev: any) => { + // Ensure we're transforming the param, not leaving it undefined + expect(prev.internalId).toBeDefined() + expect(prev.internalId).toBe('internal-123') + return { + publicId: prev.internalId.replace('internal-', 'public-'), + } + }, + }, + ] + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + routeMasks, + }) + + const location = router.buildLocation({ + to: '/internal/internal-123/details', + params: { internalId: 'internal-123' }, + }) + + expect(location.maskedLocation).toBeDefined() + expect(location.maskedLocation!.pathname).toBe('/public/public-123') + // Ensure the pathname doesn't contain 'undefined' + expect(location.maskedLocation!.pathname).not.toContain('undefined') + expect(location.maskedLocation!.href).not.toContain('undefined') + }) + + 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', + ) + // Verify no undefined values + expect(location.maskedLocation!.pathname).not.toContain('undefined') + }) +}) From 9c2a094955b3816ae4dd7e20a01a58f9816994b8 Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Mon, 17 Nov 2025 17:13:23 -0800 Subject: [PATCH 3/6] Include the necessary changes --- packages/router-core/src/router.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 3fdc837df30..8a4df4fa024 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1787,16 +1787,29 @@ 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) } } - } } if (maskedNext) { @@ -2097,7 +2110,6 @@ export class RouterCore< updateMatch: this.updateMatch, // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { - // eslint-disable-next-line @typescript-eslint/require-await // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { this.startViewTransition(async () => { From 0bcb3116bed05709d6393536742ae119f255bd7d Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Mon, 1 Dec 2025 14:22:27 +0100 Subject: [PATCH 4/6] Update mask tests --- packages/router-core/tests/mask.test.ts | 106 ------------------------ 1 file changed, 106 deletions(-) diff --git a/packages/router-core/tests/mask.test.ts b/packages/router-core/tests/mask.test.ts index 5e1002ff94f..6b885d05f5b 100644 --- a/packages/router-core/tests/mask.test.ts +++ b/packages/router-core/tests/mask.test.ts @@ -310,51 +310,6 @@ describe('buildLocation - route masks', () => { expect(location.maskedLocation!.state).toEqual({ modal: true }) }) - test('should preserve opts.from when building masked destination', () => { - 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', - from: '/photos/456', - params: { photoId: '123' }, - }) - - expect(location.maskedLocation).toBeDefined() - // The from option should be preserved in the masked destination - expect(location.maskedLocation!.pathname).toBe('/photos/123') - }) - - test('should handle unmaskOnReload property in mask', () => { - const routeMasks: Array> = [ - { - routeTree: null as any, - from: '/photos/$photoId/modal', - to: '/photos/$photoId', - params: true, - unmaskOnReload: true, - }, - ] - - const router = setup(routeMasks) - - const location = router.buildLocation({ - to: '/photos/123/modal', - params: { photoId: '123' }, - }) - - expect(location.maskedLocation).toBeDefined() - expect(location.maskedLocation!.unmaskOnReload).toBe(true) - }) - test('should handle mask with function params that receives matched params', () => { const routeMasks: Array> = [ { @@ -471,8 +426,6 @@ describe('buildLocation - route masks', () => { expect(location.maskedLocation).toBeDefined() expect(location.maskedLocation!.pathname).toBe('/photos/abc123') expect(location.pathname).toBe('/photos/abc123/details') - // Ensure no undefined values - expect(location.maskedLocation!.pathname).not.toContain('undefined') }) test('should handle param name transformation with object params', () => { @@ -519,7 +472,6 @@ describe('buildLocation - route masks', () => { expect(location.maskedLocation).toBeDefined() expect(location.maskedLocation!.pathname).toBe('/photos/secret123') - expect(location.maskedLocation!.pathname).not.toContain('undefined') }) test('should handle multiple params with different names in masked route', () => { @@ -573,7 +525,6 @@ describe('buildLocation - route masks', () => { '/profiles/john/articles/my-first-post', ) expect(location.pathname).toBe('/users/john/posts/my-first-post') - expect(location.maskedLocation!.pathname).not.toContain('undefined') }) test('should handle param transformation when masked route requires different param', () => { @@ -616,61 +567,6 @@ describe('buildLocation - route masks', () => { expect(location.maskedLocation).toBeDefined() expect(location.maskedLocation!.pathname).toBe('/users/user-42') expect(location.pathname).toBe('/admin/users/42') - expect(location.maskedLocation!.pathname).not.toContain('undefined') - }) - - test('should not have undefined params in masked location when param names differ', () => { - // This is the critical test - ensure we don't get undefined values - const rootRoute = new BaseRootRoute({}) - const internalRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/internal/$internalId', - }) - const detailsRoute = new BaseRoute({ - getParentRoute: () => internalRoute, - path: '/details', - }) - const publicRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/public/$publicId', - }) - const routeTree = rootRoute.addChildren([ - internalRoute.addChildren([detailsRoute]), - publicRoute, - ]) - - const routeMasks: Array> = [ - { - routeTree: null as any, - from: '/internal/$internalId/details', - to: '/public/$publicId', - params: (prev: any) => { - // Ensure we're transforming the param, not leaving it undefined - expect(prev.internalId).toBeDefined() - expect(prev.internalId).toBe('internal-123') - return { - publicId: prev.internalId.replace('internal-', 'public-'), - } - }, - }, - ] - - const router = new RouterCore({ - routeTree, - history: createMemoryHistory(), - routeMasks, - }) - - const location = router.buildLocation({ - to: '/internal/internal-123/details', - params: { internalId: 'internal-123' }, - }) - - expect(location.maskedLocation).toBeDefined() - expect(location.maskedLocation!.pathname).toBe('/public/public-123') - // Ensure the pathname doesn't contain 'undefined' - expect(location.maskedLocation!.pathname).not.toContain('undefined') - expect(location.maskedLocation!.href).not.toContain('undefined') }) test('should handle partial param transformation when some params are kept', () => { @@ -723,7 +619,5 @@ describe('buildLocation - route masks', () => { expect(location.maskedLocation!.pathname).toBe( '/articles/article-5/replies/10', ) - // Verify no undefined values - expect(location.maskedLocation!.pathname).not.toContain('undefined') }) }) From 5fd21d12018020cd9cddfc544bda42556396218e Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Tue, 2 Dec 2025 02:40:18 +0100 Subject: [PATCH 5/6] Add e2e tests --- .../basic-file-based/src/main.tsx | 10 +++ .../basic-file-based/src/routeTree.gen.ts | 71 +++++++++++++++++++ .../basic-file-based/src/routes/__root.tsx | 10 ++- .../src/routes/masks.admin.$userId.tsx | 15 ++++ .../src/routes/masks.public.$username.tsx | 15 ++++ .../basic-file-based/src/routes/masks.tsx | 38 ++++++++++ .../basic-file-based/tests/mask.spec.ts | 26 +++++++ .../basic-file-based/src/main.tsx | 10 +++ .../basic-file-based/src/routeTree.gen.ts | 71 +++++++++++++++++++ .../basic-file-based/src/routes/__root.tsx | 9 +++ .../src/routes/masks.admin.$userId.tsx | 15 ++++ .../src/routes/masks.public.$username.tsx | 15 ++++ .../basic-file-based/src/routes/masks.tsx | 38 ++++++++++ .../basic-file-based/tests/mask.spec.ts | 26 +++++++ 14 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 e2e/react-router/basic-file-based/src/routes/masks.admin.$userId.tsx create mode 100644 e2e/react-router/basic-file-based/src/routes/masks.public.$username.tsx create mode 100644 e2e/react-router/basic-file-based/src/routes/masks.tsx create mode 100644 e2e/react-router/basic-file-based/tests/mask.spec.ts create mode 100644 e2e/solid-router/basic-file-based/src/routes/masks.admin.$userId.tsx create mode 100644 e2e/solid-router/basic-file-based/src/routes/masks.public.$username.tsx create mode 100644 e2e/solid-router/basic-file-based/src/routes/masks.tsx create mode 100644 e2e/solid-router/basic-file-based/tests/mask.spec.ts diff --git a/e2e/react-router/basic-file-based/src/main.tsx b/e2e/react-router/basic-file-based/src/main.tsx index 3dc73ddd511..b9a3f3ef55b 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 588b2acb7b5..83270c2e991 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 26cd6a60f85..a389870c3fa 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 00000000000..5590a14062b --- /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 00000000000..200f7a3b6b6 --- /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 00000000000..bbe23428331 --- /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 00000000000..1b77760de69 --- /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 fc12c765703..2c02abe044b 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 bf5f4523c77..0b0315d3498 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 883fbd3406a..6acb7a3638b 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 00000000000..c808349245d --- /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 00000000000..ea73ade15a9 --- /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 00000000000..ade84f501d5 --- /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 00000000000..f6ec42c604e --- /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', + ) +}) From 913be4e21df3d52f2ef34e9fc1976e14b740a059 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 2 Dec 2025 18:22:50 +0200 Subject: [PATCH 6/6] minor nitpicks --- .../basic-file-based/src/main.tsx | 26 +++++++++++-------- .../basic-file-based/src/routes/__root.tsx | 1 + .../basic-file-based/src/main.tsx | 26 +++++++++++-------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/e2e/react-router/basic-file-based/src/main.tsx b/e2e/react-router/basic-file-based/src/main.tsx index b9a3f3ef55b..12e202dae41 100644 --- a/e2e/react-router/basic-file-based/src/main.tsx +++ b/e2e/react-router/basic-file-based/src/main.tsx @@ -1,25 +1,29 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { RouterProvider, createRouter } from '@tanstack/react-router' +import { + RouterProvider, + createRouteMask, + createRouter, +} from '@tanstack/react-router' import { routeTree } from './routeTree.gen' import './styles.css' +const mask = createRouteMask({ + routeTree, + from: '/masks/admin/$userId', + to: '/masks/public/$username', + params: (prev) => ({ + username: `user-${prev.userId}`, + }), +}) + // Set up a Router instance const router = createRouter({ routeTree, 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}`, - }), - }, - ], + routeMasks: [mask], }) // Register things for typesafety 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 a389870c3fa..da39e29e4e7 100644 --- a/e2e/react-router/basic-file-based/src/routes/__root.tsx +++ b/e2e/react-router/basic-file-based/src/routes/__root.tsx @@ -139,6 +139,7 @@ function RootComponent() { unicode path {' '} ({ + username: `user-${prev.userId}`, + }), +}) + // Set up a Router instance const router = createRouter({ routeTree, 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}`, - }), - }, - ], + routeMasks: [mask], }) // Register things for typesafety