diff --git a/.changeset/fix-ssr-bare-basepath-redirect.md b/.changeset/fix-ssr-bare-basepath-redirect.md new file mode 100644 index 0000000000..b7e44e1df1 --- /dev/null +++ b/.changeset/fix-ssr-bare-basepath-redirect.md @@ -0,0 +1,7 @@ +--- +'@tanstack/router-core': patch +--- + +fix(router-core): apply the `trailingSlash` option to the rewritten basepath `publicHref` before the SSR redirect comparison. + +Fixes [#7291](https://github.com/TanStack/router/issues/7291): requesting a bare basepath URL (for example `/preview` with `basepath: '/preview'`) on the server always answered with a `308` redirect to `/preview/`, even with `trailingSlash: 'never'` (the default). `rewriteBasepath.output` joins the basepath and the internal pathname, which yields a trailing slash for the root route, and `buildLocation` never reconciled that with `trailingSlash` before comparing it to the incoming request URL. The rewritten pathname is now trimmed under `'never'`, given a trailing slash under `'always'` and left as is under `'preserve'`. This also applies to custom `rewrite.output` implementations: under `'never'` a trailing slash they emit is trimmed from `publicHref`. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 18de1cf47f..d3fd3fca67 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2139,7 +2139,19 @@ export class RouterCore< } else { // A same-origin rewrite can produce a pathname like "//evil.example". // Normalize it to "/evil.example" so the link stays on this origin. - publicHref = normalizeProtocolRelative(getUrlPath(rewrittenUrl)) + const trailingSlashOpt = this.options.trailingSlash ?? 'never' + let rewrittenPathname = rewrittenUrl.pathname + if (trailingSlashOpt === 'never') { + rewrittenPathname = trimPathRight(rewrittenPathname) + } else if ( + trailingSlashOpt === 'always' && + !rewrittenPathname.endsWith('/') + ) { + rewrittenPathname += '/' + } + publicHref = normalizeProtocolRelative( + rewrittenPathname + rewrittenUrl.search + rewrittenUrl.hash, + ) } } else { // Fast path: no rewrite, skip URL construction entirely diff --git a/packages/router-core/tests/rewrite.test.ts b/packages/router-core/tests/rewrite.test.ts index 2494f050e5..5e2b33ff38 100644 --- a/packages/router-core/tests/rewrite.test.ts +++ b/packages/router-core/tests/rewrite.test.ts @@ -237,6 +237,30 @@ describe('router rewrites', () => { }, ) + test.each([ + { trailingSlash: 'never' as const, root: '/app', nested: '/app/posts' }, + { trailingSlash: 'always' as const, root: '/app/', nested: '/app/posts/' }, + { trailingSlash: 'preserve' as const, root: '/app/', nested: '/app/posts' }, + ])( + 'applies trailingSlash=$trailingSlash to the rewritten basepath publicHref', + ({ trailingSlash, root, nested }) => { + const router = createTestRouter({ + routeTree: new BaseRootRoute({}), + history: createMemoryHistory({ initialEntries: ['/app'] }), + basepath: '/app', + trailingSlash, + }) + + // The bare basepath is where the SSR redirect check used to see + // '/app' !== '/app/' and answer with a spurious 308. + expect(router.buildLocation({ to: '/' }).publicHref).toBe(root) + expect(router.buildLocation({ to: '/posts' }).publicHref).toBe(nested) + expect( + router.buildLocation({ to: '/posts', search: { page: 2 } }).publicHref, + ).toBe(`${nested}?page=2`) + }, + ) + test('rebuilds rewrites and stored locations when basepath or custom rewrite changes', () => { const router = createTestRouter({ routeTree: new BaseRootRoute({}),