diff --git a/e2e/react-router/basepath-file-based/src/routes/index.tsx b/e2e/react-router/basepath-file-based/src/routes/index.tsx index 7ae0eca2bc3..551724d3376 100644 --- a/e2e/react-router/basepath-file-based/src/routes/index.tsx +++ b/e2e/react-router/basepath-file-based/src/routes/index.tsx @@ -39,6 +39,27 @@ function App() { } > Navigate to /redirectReload + {' '} + {' '} + ) diff --git a/e2e/react-router/basepath-file-based/tests/reload-document.test.ts b/e2e/react-router/basepath-file-based/tests/reload-document.test.ts index f35ddd5670d..ce07699aed8 100644 --- a/e2e/react-router/basepath-file-based/tests/reload-document.test.ts +++ b/e2e/react-router/basepath-file-based/tests/reload-document.test.ts @@ -38,3 +38,27 @@ test('redirect respects basepath with reloadDocument = true on redirect', async await page.waitForURL('/app/about') await expect(page.getByTestId(`about-component`)).toBeInViewport() }) + +test('navigate() with href containing basepath', async ({ page }) => { + await page.goto(`/app/`) + await expect(page.getByTestId(`home-component`)).toBeInViewport() + + const aboutBtn = page.getByTestId(`to-about-href-with-basepath-btn`) + await aboutBtn.click() + // Should navigate to /app/about, NOT /app/app/about + await page.waitForURL('/app/about') + await expect(page.getByTestId(`about-component`)).toBeInViewport() +}) + +test('navigate() with href containing basepath and reloadDocument=true', async ({ + page, +}) => { + await page.goto(`/app/`) + await expect(page.getByTestId(`home-component`)).toBeInViewport() + + const aboutBtn = page.getByTestId(`to-about-href-with-basepath-reload-btn`) + await aboutBtn.click() + // Should navigate to /app/about, NOT stay on current page + await page.waitForURL('/app/about') + await expect(page.getByTestId(`about-component`)).toBeInViewport() +}) diff --git a/e2e/react-start/custom-basepath/src/routeTree.gen.ts b/e2e/react-start/custom-basepath/src/routeTree.gen.ts index 0e57e8bf39e..986f31bbbbd 100644 --- a/e2e/react-start/custom-basepath/src/routeTree.gen.ts +++ b/e2e/react-start/custom-basepath/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UsersRouteImport } from './routes/users' import { Route as PostsRouteImport } from './routes/posts' +import { Route as NavigateTestRouteImport } from './routes/navigate-test' import { Route as LogoutRouteImport } from './routes/logout' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as IndexRouteImport } from './routes/index' @@ -34,6 +35,11 @@ const PostsRoute = PostsRouteImport.update({ path: '/posts', getParentRoute: () => rootRouteImport, } as any) +const NavigateTestRoute = NavigateTestRouteImport.update({ + id: '/navigate-test', + path: '/navigate-test', + getParentRoute: () => rootRouteImport, +} as any) const LogoutRoute = LogoutRouteImport.update({ id: '/logout', path: '/logout', @@ -99,6 +105,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/deferred': typeof DeferredRoute '/logout': typeof LogoutRoute + '/navigate-test': typeof NavigateTestRoute '/posts': typeof PostsRouteWithChildren '/users': typeof UsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -115,6 +122,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/deferred': typeof DeferredRoute '/logout': typeof LogoutRoute + '/navigate-test': typeof NavigateTestRoute '/api/users': typeof ApiUsersRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/redirect/throw-it': typeof RedirectThrowItRoute @@ -130,6 +138,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/deferred': typeof DeferredRoute '/logout': typeof LogoutRoute + '/navigate-test': typeof NavigateTestRoute '/posts': typeof PostsRouteWithChildren '/users': typeof UsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -148,6 +157,7 @@ export interface FileRouteTypes { | '/' | '/deferred' | '/logout' + | '/navigate-test' | '/posts' | '/users' | '/api/users' @@ -164,6 +174,7 @@ export interface FileRouteTypes { | '/' | '/deferred' | '/logout' + | '/navigate-test' | '/api/users' | '/posts/$postId' | '/redirect/throw-it' @@ -178,6 +189,7 @@ export interface FileRouteTypes { | '/' | '/deferred' | '/logout' + | '/navigate-test' | '/posts' | '/users' | '/api/users' @@ -195,6 +207,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute DeferredRoute: typeof DeferredRoute LogoutRoute: typeof LogoutRoute + NavigateTestRoute: typeof NavigateTestRoute PostsRoute: typeof PostsRouteWithChildren UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren @@ -219,6 +232,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PostsRouteImport parentRoute: typeof rootRouteImport } + '/navigate-test': { + id: '/navigate-test' + path: '/navigate-test' + fullPath: '/navigate-test' + preLoaderRoute: typeof NavigateTestRouteImport + parentRoute: typeof rootRouteImport + } '/logout': { id: '/logout' path: '/logout' @@ -346,6 +366,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DeferredRoute: DeferredRoute, LogoutRoute: LogoutRoute, + NavigateTestRoute: NavigateTestRoute, PostsRoute: PostsRouteWithChildren, UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, diff --git a/e2e/react-start/custom-basepath/src/routes/navigate-test.tsx b/e2e/react-start/custom-basepath/src/routes/navigate-test.tsx new file mode 100644 index 00000000000..db81aba5f6c --- /dev/null +++ b/e2e/react-start/custom-basepath/src/routes/navigate-test.tsx @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/navigate-test')({ + component: NavigateTest, +}) + +function NavigateTest() { + const navigate = Route.useNavigate() + + return ( +
+

Navigate Test

+ {' '} + +
+ ) +} diff --git a/e2e/react-start/custom-basepath/src/routes/posts.tsx b/e2e/react-start/custom-basepath/src/routes/posts.tsx index 0f69c183419..0c309c23c11 100644 --- a/e2e/react-start/custom-basepath/src/routes/posts.tsx +++ b/e2e/react-start/custom-basepath/src/routes/posts.tsx @@ -18,7 +18,7 @@ function PostsComponent() { const posts = Route.useLoaderData() return ( -
+
) diff --git a/e2e/solid-router/basepath-file-based/tests/reload-document.test.ts b/e2e/solid-router/basepath-file-based/tests/reload-document.test.ts index 3e60f3bedcb..a50d133115b 100644 --- a/e2e/solid-router/basepath-file-based/tests/reload-document.test.ts +++ b/e2e/solid-router/basepath-file-based/tests/reload-document.test.ts @@ -16,3 +16,27 @@ test('navigate() respects basepath for when reloadDocument=true', async ({ await page.waitForURL('/app/') await expect(page.getByTestId(`home-component`)).toBeInViewport() }) + +test('navigate() with href containing basepath', async ({ page }) => { + await page.goto(`/app/`) + await expect(page.getByTestId(`home-component`)).toBeInViewport() + + const aboutBtn = page.getByTestId(`to-about-href-with-basepath-btn`) + await aboutBtn.click() + // Should navigate to /app/about, NOT /app/app/about + await page.waitForURL('/app/about') + await expect(page.getByTestId(`about-component`)).toBeInViewport() +}) + +test('navigate() with href containing basepath and reloadDocument=true', async ({ + page, +}) => { + await page.goto(`/app/`) + await expect(page.getByTestId(`home-component`)).toBeInViewport() + + const aboutBtn = page.getByTestId(`to-about-href-with-basepath-reload-btn`) + await aboutBtn.click() + // Should navigate to /app/about, NOT stay on current page + await page.waitForURL('/app/about') + await expect(page.getByTestId(`about-component`)).toBeInViewport() +}) diff --git a/e2e/solid-start/custom-basepath/src/routeTree.gen.ts b/e2e/solid-start/custom-basepath/src/routeTree.gen.ts index 1a94469caaf..8f6c31e98a3 100644 --- a/e2e/solid-start/custom-basepath/src/routeTree.gen.ts +++ b/e2e/solid-start/custom-basepath/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UsersRouteImport } from './routes/users' import { Route as PostsRouteImport } from './routes/posts' +import { Route as NavigateTestRouteImport } from './routes/navigate-test' import { Route as LogoutRouteImport } from './routes/logout' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as IndexRouteImport } from './routes/index' @@ -34,6 +35,11 @@ const PostsRoute = PostsRouteImport.update({ path: '/posts', getParentRoute: () => rootRouteImport, } as any) +const NavigateTestRoute = NavigateTestRouteImport.update({ + id: '/navigate-test', + path: '/navigate-test', + getParentRoute: () => rootRouteImport, +} as any) const LogoutRoute = LogoutRouteImport.update({ id: '/logout', path: '/logout', @@ -99,6 +105,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/deferred': typeof DeferredRoute '/logout': typeof LogoutRoute + '/navigate-test': typeof NavigateTestRoute '/posts': typeof PostsRouteWithChildren '/users': typeof UsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -115,6 +122,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/deferred': typeof DeferredRoute '/logout': typeof LogoutRoute + '/navigate-test': typeof NavigateTestRoute '/api/users': typeof ApiUsersRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/redirect/throw-it': typeof RedirectThrowItRoute @@ -130,6 +138,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/deferred': typeof DeferredRoute '/logout': typeof LogoutRoute + '/navigate-test': typeof NavigateTestRoute '/posts': typeof PostsRouteWithChildren '/users': typeof UsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -148,6 +157,7 @@ export interface FileRouteTypes { | '/' | '/deferred' | '/logout' + | '/navigate-test' | '/posts' | '/users' | '/api/users' @@ -164,6 +174,7 @@ export interface FileRouteTypes { | '/' | '/deferred' | '/logout' + | '/navigate-test' | '/api/users' | '/posts/$postId' | '/redirect/throw-it' @@ -178,6 +189,7 @@ export interface FileRouteTypes { | '/' | '/deferred' | '/logout' + | '/navigate-test' | '/posts' | '/users' | '/api/users' @@ -195,6 +207,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute DeferredRoute: typeof DeferredRoute LogoutRoute: typeof LogoutRoute + NavigateTestRoute: typeof NavigateTestRoute PostsRoute: typeof PostsRouteWithChildren UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren @@ -219,6 +232,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof PostsRouteImport parentRoute: typeof rootRouteImport } + '/navigate-test': { + id: '/navigate-test' + path: '/navigate-test' + fullPath: '/navigate-test' + preLoaderRoute: typeof NavigateTestRouteImport + parentRoute: typeof rootRouteImport + } '/logout': { id: '/logout' path: '/logout' @@ -346,6 +366,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DeferredRoute: DeferredRoute, LogoutRoute: LogoutRoute, + NavigateTestRoute: NavigateTestRoute, PostsRoute: PostsRouteWithChildren, UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, diff --git a/e2e/solid-start/custom-basepath/src/routes/navigate-test.tsx b/e2e/solid-start/custom-basepath/src/routes/navigate-test.tsx new file mode 100644 index 00000000000..cd1cd1e1744 --- /dev/null +++ b/e2e/solid-start/custom-basepath/src/routes/navigate-test.tsx @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/navigate-test')({ + component: NavigateTest, +}) + +function NavigateTest() { + const navigate = Route.useNavigate() + + return ( +
+

Navigate Test

+ {' '} + +
+ ) +} diff --git a/e2e/solid-start/custom-basepath/src/routes/posts.tsx b/e2e/solid-start/custom-basepath/src/routes/posts.tsx index 0e94cd4d2cf..22187bf863b 100644 --- a/e2e/solid-start/custom-basepath/src/routes/posts.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/posts.tsx @@ -19,7 +19,7 @@ function PostsComponent() { const posts = Route.useLoaderData() return ( -
+
    {(post) => { diff --git a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts index 3e6a5bd1bea..49454e1b8e6 100644 --- a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts @@ -75,3 +75,28 @@ test('server-side redirect', async ({ page, baseURL }) => { expect(headers.get('location')).toBe('/custom/basepath/posts/1') }) }) + +test('navigate() with href containing basepath', async ({ page, baseURL }) => { + await page.goto('/navigate-test') + await expect(page.getByTestId('navigate-test-component')).toBeVisible() + + const btn = page.getByTestId('to-posts-href-with-basepath-btn') + await btn.click() + // Should navigate to /custom/basepath/posts, NOT /custom/basepath/custom/basepath/posts + await page.waitForURL(`${baseURL}/posts`) + await expect(page.getByTestId('posts-component')).toBeVisible() +}) + +test('navigate() with href containing basepath and reloadDocument=true', async ({ + page, + baseURL, +}) => { + await page.goto('/navigate-test') + await expect(page.getByTestId('navigate-test-component')).toBeVisible() + + const btn = page.getByTestId('to-posts-href-with-basepath-reload-btn') + await btn.click() + // Should navigate to /custom/basepath/posts, NOT stay on current page + await page.waitForURL(`${baseURL}/posts`) + await expect(page.getByTestId('posts-component')).toBeVisible() +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 1e837bd4261..cf86c48bd46 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1977,7 +1977,14 @@ export class RouterCore< const parsed = parseHref(href, { __TSR_index: replace ? currentIndex : currentIndex + 1, }) - rest.to = parsed.pathname + + // If the href contains the basepath, we need to strip it before setting `to` + // because `buildLocation` will add the basepath back when creating the final URL. + // Without this, hrefs like '/app/about' would become '/app/app/about'. + const hrefUrl = new URL(parsed.pathname, this.origin) + const rewrittenUrl = executeRewriteInput(this.rewrite, hrefUrl) + + rest.to = rewrittenUrl.pathname rest.search = this.options.parseSearch(parsed.search) // remove the leading `#` from the hash rest.hash = parsed.hash.slice(1) @@ -2040,12 +2047,18 @@ export class RouterCore< } if (reloadDocument) { - if (!href || (!publicHref && !hrefIsUrl)) { + // When to is provided, always build a location to get the proper publicHref + // (this handles redirects where href might be an internal path from resolveRedirect) + // When only href is provided (no to), use it directly as it should already + // be a complete path (possibly with basepath) + if (to !== undefined || !href) { const location = this.buildLocation({ to, ...rest } as any) href = href ?? location.url.href publicHref = publicHref ?? location.url.href } + // Use publicHref when available and href is not a full URL, + // otherwise use href directly (which may already include basepath) const reloadHref = !hrefIsUrl && publicHref ? publicHref : href // Check blockers for external URLs unless ignoreBlocker is true