From 329e7642292cdc2e5fb85915181923c61f1d464e Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Wed, 1 Oct 2025 18:45:42 +0200 Subject: [PATCH] fix: correctly handle client-side vs server-side redirects with rewrites fixes #5324 --- .../custom-basepath/src/routeTree.gen.ts | 42 +++++++++++++++++++ .../src/routes/posts.$postId.tsx | 2 +- .../src/routes/redirect/index.tsx | 15 +++++++ .../src/routes/redirect/throw-it.tsx | 10 +++++ .../custom-basepath/tests/navigation.spec.ts | 25 +++++++++++ packages/router-core/src/router.ts | 9 ++-- 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 e2e/react-start/custom-basepath/src/routes/redirect/index.tsx create mode 100644 e2e/react-start/custom-basepath/src/routes/redirect/throw-it.tsx diff --git a/e2e/react-start/custom-basepath/src/routeTree.gen.ts b/e2e/react-start/custom-basepath/src/routeTree.gen.ts index 8113afd16dc..0e57e8bf39e 100644 --- a/e2e/react-start/custom-basepath/src/routeTree.gen.ts +++ b/e2e/react-start/custom-basepath/src/routeTree.gen.ts @@ -15,8 +15,10 @@ import { Route as LogoutRouteImport } from './routes/logout' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as IndexRouteImport } from './routes/index' import { Route as UsersIndexRouteImport } from './routes/users.index' +import { Route as RedirectIndexRouteImport } from './routes/redirect/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as RedirectThrowItRouteImport } from './routes/redirect/throw-it' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as ApiUsersRouteImport } from './routes/api.users' import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' @@ -52,6 +54,11 @@ const UsersIndexRoute = UsersIndexRouteImport.update({ path: '/', getParentRoute: () => UsersRoute, } as any) +const RedirectIndexRoute = RedirectIndexRouteImport.update({ + id: '/redirect/', + path: '/redirect/', + getParentRoute: () => rootRouteImport, +} as any) const PostsIndexRoute = PostsIndexRouteImport.update({ id: '/', path: '/', @@ -62,6 +69,11 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({ path: '/$userId', getParentRoute: () => UsersRoute, } as any) +const RedirectThrowItRoute = RedirectThrowItRouteImport.update({ + id: '/redirect/throw-it', + path: '/redirect/throw-it', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -91,8 +103,10 @@ export interface FileRoutesByFullPath { '/users': typeof UsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute + '/redirect/throw-it': typeof RedirectThrowItRoute '/users/$userId': typeof UsersUserIdRoute '/posts/': typeof PostsIndexRoute + '/redirect': typeof RedirectIndexRoute '/users/': typeof UsersIndexRoute '/api/users/$id': typeof ApiUsersIdRoute '/posts/$postId/deep': typeof PostsPostIdDeepRoute @@ -103,8 +117,10 @@ export interface FileRoutesByTo { '/logout': typeof LogoutRoute '/api/users': typeof ApiUsersRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute + '/redirect/throw-it': typeof RedirectThrowItRoute '/users/$userId': typeof UsersUserIdRoute '/posts': typeof PostsIndexRoute + '/redirect': typeof RedirectIndexRoute '/users': typeof UsersIndexRoute '/api/users/$id': typeof ApiUsersIdRoute '/posts/$postId/deep': typeof PostsPostIdDeepRoute @@ -118,8 +134,10 @@ export interface FileRoutesById { '/users': typeof UsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute + '/redirect/throw-it': typeof RedirectThrowItRoute '/users/$userId': typeof UsersUserIdRoute '/posts/': typeof PostsIndexRoute + '/redirect/': typeof RedirectIndexRoute '/users/': typeof UsersIndexRoute '/api/users/$id': typeof ApiUsersIdRoute '/posts_/$postId/deep': typeof PostsPostIdDeepRoute @@ -134,8 +152,10 @@ export interface FileRouteTypes { | '/users' | '/api/users' | '/posts/$postId' + | '/redirect/throw-it' | '/users/$userId' | '/posts/' + | '/redirect' | '/users/' | '/api/users/$id' | '/posts/$postId/deep' @@ -146,8 +166,10 @@ export interface FileRouteTypes { | '/logout' | '/api/users' | '/posts/$postId' + | '/redirect/throw-it' | '/users/$userId' | '/posts' + | '/redirect' | '/users' | '/api/users/$id' | '/posts/$postId/deep' @@ -160,8 +182,10 @@ export interface FileRouteTypes { | '/users' | '/api/users' | '/posts/$postId' + | '/redirect/throw-it' | '/users/$userId' | '/posts/' + | '/redirect/' | '/users/' | '/api/users/$id' | '/posts_/$postId/deep' @@ -174,6 +198,8 @@ export interface RootRouteChildren { PostsRoute: typeof PostsRouteWithChildren UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren + RedirectThrowItRoute: typeof RedirectThrowItRoute + RedirectIndexRoute: typeof RedirectIndexRoute PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute } @@ -221,6 +247,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UsersIndexRouteImport parentRoute: typeof UsersRoute } + '/redirect/': { + id: '/redirect/' + path: '/redirect' + fullPath: '/redirect' + preLoaderRoute: typeof RedirectIndexRouteImport + parentRoute: typeof rootRouteImport + } '/posts/': { id: '/posts/' path: '/' @@ -235,6 +268,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UsersUserIdRouteImport parentRoute: typeof UsersRoute } + '/redirect/throw-it': { + id: '/redirect/throw-it' + path: '/redirect/throw-it' + fullPath: '/redirect/throw-it' + preLoaderRoute: typeof RedirectThrowItRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -309,6 +349,8 @@ const rootRouteChildren: RootRouteChildren = { PostsRoute: PostsRouteWithChildren, UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, + RedirectThrowItRoute: RedirectThrowItRoute, + RedirectIndexRoute: RedirectIndexRoute, PostsPostIdDeepRoute: PostsPostIdDeepRoute, } export const routeTree = rootRouteImport diff --git a/e2e/react-start/custom-basepath/src/routes/posts.$postId.tsx b/e2e/react-start/custom-basepath/src/routes/posts.$postId.tsx index ec014911474..5ea6fb33859 100644 --- a/e2e/react-start/custom-basepath/src/routes/posts.$postId.tsx +++ b/e2e/react-start/custom-basepath/src/routes/posts.$postId.tsx @@ -21,7 +21,7 @@ function PostComponent() { const post = Route.useLoaderData() return ( -
+

{post.title}

{post.body}
+ +
Throw It
+ +
+ ) +} diff --git a/e2e/react-start/custom-basepath/src/routes/redirect/throw-it.tsx b/e2e/react-start/custom-basepath/src/routes/redirect/throw-it.tsx new file mode 100644 index 00000000000..efb7c3cd33f --- /dev/null +++ b/e2e/react-start/custom-basepath/src/routes/redirect/throw-it.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect/throw-it')({ + beforeLoad: () => { + throw redirect({ + to: '/posts/$postId', + params: { postId: '1' }, + }) + }, +}) diff --git a/e2e/react-start/custom-basepath/tests/navigation.spec.ts b/e2e/react-start/custom-basepath/tests/navigation.spec.ts index bd7c31339cd..765609c47de 100644 --- a/e2e/react-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/react-start/custom-basepath/tests/navigation.spec.ts @@ -45,3 +45,28 @@ test('Server function URLs correctly include app basepath', async ({ '/custom/basepath/_serverFn/src_routes_logout_tsx--logoutFn_createServerFn_handler', ) }) + +test('client-side redirect', async ({ page, baseURL }) => { + await page.goto('/redirect') + await page.getByTestId('link-to-throw-it').click() + await page.waitForLoadState('networkidle') + + expect(await page.getByTestId('post-view').isVisible()).toBe(true) + expect(page.url()).toBe(`${baseURL}/posts/1`) +}) + +test('server-side redirect', async ({ page, baseURL }) => { + await page.goto('/redirect/throw-it') + await page.waitForLoadState('networkidle') + + expect(await page.getByTestId('post-view').isVisible()).toBe(true) + expect(page.url()).toBe(`${baseURL}/posts/1`) + + // do not follow redirects since we want to test the Location header + await page.request + .get('/redirect/throw-it', { maxRedirects: 0 }) + .then((res) => { + const headers = new Headers(res.headers()) + expect(headers.get('location')).toBe('/custom/basepath/posts/1') + }) +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8c644db78b2..7a1727d2140 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2230,18 +2230,17 @@ export class RouterCore< resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { if (!redirect.options.href) { - let href = this.buildLocation(redirect.options).url + const location = this.buildLocation(redirect.options) + let href = location.url if (this.origin && href.startsWith(this.origin)) { href = href.replace(this.origin, '') || '/' } - redirect.options.href = href - redirect.headers.set('Location', redirect.options.href) + redirect.options.href = location.href + redirect.headers.set('Location', href) } - if (!redirect.headers.get('Location')) { redirect.headers.set('Location', redirect.options.href) } - return redirect }