diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 770a526607..8a4df4fa02 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1787,11 +1787,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) } @@ -2096,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 () => { diff --git a/packages/router-core/tests/mask.test.ts b/packages/router-core/tests/mask.test.ts new file mode 100644 index 0000000000..5e1002ff94 --- /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') + }) +})