From 829c7d0cda06a2284ea3c92d3d59eac849a26124 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 25 Dec 2025 12:05:50 +0200 Subject: [PATCH 01/10] fix --- packages/start-plugin-core/src/prerender.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index ea6368109e5..d55af441eea 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -2,6 +2,7 @@ import { promises as fsp } from 'node:fs' import os from 'node:os' import path from 'pathe' import { joinURL, withBase, withoutBase } from 'ufo' +import { removeTrailingSlash } from '@tanstack/router-generator' import { VITE_ENVIRONMENT_NAMES } from './constants' import { createLogger } from './utils' import { Queue } from './queue' @@ -270,6 +271,7 @@ async function startPreviewServer( try { return await vite.preview({ configFile: viteConfig.configFile, + base: removeTrailingSlash(viteConfig.base), preview: { port: 0, open: false, From b00df2209e45d592e41ae3a4e34b339af93d7c75 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 25 Dec 2025 12:06:04 +0200 Subject: [PATCH 02/10] update test --- e2e/react-start/custom-basepath/package.json | 9 ++++- .../custom-basepath/playwright.config.ts | 37 ++++++++++++++++++- .../custom-basepath/src/router.tsx | 3 +- .../src/routes/users.$userId.tsx | 8 ++-- .../custom-basepath/src/routes/users.tsx | 7 +++- .../custom-basepath/src/utils/basepath.ts | 1 - .../custom-basepath/tests/navigation.spec.ts | 8 +++- .../tests/utils/isPrerender.ts | 1 + .../custom-basepath/vite.config.ts | 19 +++++++++- 9 files changed, 81 insertions(+), 12 deletions(-) delete mode 100644 e2e/react-start/custom-basepath/src/utils/basepath.ts create mode 100644 e2e/react-start/custom-basepath/tests/utils/isPrerender.ts diff --git a/e2e/react-start/custom-basepath/package.json b/e2e/react-start/custom-basepath/package.json index cdb371b91f9..c5262d3673c 100644 --- a/e2e/react-start/custom-basepath/package.json +++ b/e2e/react-start/custom-basepath/package.json @@ -6,9 +6,16 @@ "scripts": { "dev": "cross-env NODE_ENV=development tsx express-server.ts", "build": "vite build && tsc --noEmit", + "build:prerender": "MODE=prerender vite build && tsc --noEmit", + "build:prerender:trailing": "MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", "preview": "vite preview", "start": "tsx express-server.ts", - "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", + "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", + "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", + "test:e2e:prerender:trailing": "rm -rf port*.txt; MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", + "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:prerender:trailing" }, "dependencies": { "@tanstack/react-router": "workspace:^", diff --git a/e2e/react-start/custom-basepath/playwright.config.ts b/e2e/react-start/custom-basepath/playwright.config.ts index 5095425f473..17c013aaa6c 100644 --- a/e2e/react-start/custom-basepath/playwright.config.ts +++ b/e2e/react-start/custom-basepath/playwright.config.ts @@ -4,11 +4,36 @@ import { getTestServerPort, } from '@tanstack/router-e2e-utils' import packageJson from './package.json' with { type: 'json' } +import { isPrerender } from './tests/utils/isPrerender' const PORT = await getTestServerPort(packageJson.name) +const START_PORT = await getTestServerPort(packageJson.name) const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}/custom/basepath` +const ssrModeCommand = `pnpm build && pnpm start` +const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const prerenderTrailingModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender:trailing && pnpm run test:e2e:stopDummyServer && pnpm start` +const isTrailingSlashPrerender = + process.env.TRAILING_SLASH?.toLowerCase() === 'true' + +const getCommand = () => { + if (isPrerender && isTrailingSlashPrerender) + return prerenderTrailingModeCommand + if (isPrerender) return prerenderModeCommand + return ssrModeCommand +} + +console.log( + 'running in prerender mode: ', + isPrerender.toString(), + isPrerender + ? isTrailingSlashPrerender + ? 'with trailing slash' + : 'without trailing slash' + : '', +) + /** * See https://playwright.dev/docs/test-configuration. */ @@ -27,10 +52,20 @@ export default defineConfig({ }, webServer: { - command: `VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`, + command: getCommand(), url: baseURL, reuseExistingServer: !process.env.CI, stdout: 'pipe', + env: { + MODE: process.env.MODE || '', + TRAILING_SLASH: isTrailingSlashPrerender.toString(), + VITE_TRAILING_SLASH: isTrailingSlashPrerender.toString(), + VITE_NODE_ENV: 'test', + VITE_EXTERNAL_PORT: String(EXTERNAL_PORT), + VITE_SERVER_PORT: String(PORT), + START_PORT: String(START_PORT), + PORT: String(PORT), + }, }, projects: [ diff --git a/e2e/react-start/custom-basepath/src/router.tsx b/e2e/react-start/custom-basepath/src/router.tsx index 81b4c31daa8..62422fc2dfc 100644 --- a/e2e/react-start/custom-basepath/src/router.tsx +++ b/e2e/react-start/custom-basepath/src/router.tsx @@ -2,7 +2,6 @@ import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' import { NotFound } from './components/NotFound' -import { basepath } from './utils/basepath' export function getRouter() { const router = createRouter({ @@ -11,7 +10,7 @@ export function getRouter() { defaultErrorComponent: DefaultCatchBoundary, defaultNotFoundComponent: () => , scrollRestoration: true, - basepath: basepath, + basepath: import.meta.env.BASE_URL, }) return router diff --git a/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx index f811910df42..104703bf303 100644 --- a/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx @@ -1,15 +1,17 @@ import { ErrorComponent, createFileRoute } from '@tanstack/react-router' import axios from 'redaxios' +import { getRouterInstance } from '@tanstack/react-start' import type { ErrorComponentProps } from '@tanstack/react-router' - import type { User } from '~/utils/users' import { NotFound } from '~/components/NotFound' -import { basepath } from '~/utils/basepath' export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { + const router = await getRouterInstance() return await axios - .get(basepath + '/api/users/' + userId) + .get('/api/users/' + userId, { + baseURL: router.options.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/react-start/custom-basepath/src/routes/users.tsx b/e2e/react-start/custom-basepath/src/routes/users.tsx index e78600349f7..a7b5a1d316a 100644 --- a/e2e/react-start/custom-basepath/src/routes/users.tsx +++ b/e2e/react-start/custom-basepath/src/routes/users.tsx @@ -1,12 +1,15 @@ import { Link, Outlet, createFileRoute } from '@tanstack/react-router' import axios from 'redaxios' +import { getRouterInstance } from '@tanstack/react-start' import type { User } from '~/utils/users' -import { basepath } from '~/utils/basepath' export const Route = createFileRoute('/users')({ loader: async () => { + const router = await getRouterInstance() return await axios - .get>(basepath + '/api/users') + .get>('/api/users', { + baseURL: router.options.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/react-start/custom-basepath/src/utils/basepath.ts b/e2e/react-start/custom-basepath/src/utils/basepath.ts deleted file mode 100644 index 6e719f196cf..00000000000 --- a/e2e/react-start/custom-basepath/src/utils/basepath.ts +++ /dev/null @@ -1 +0,0 @@ -export const basepath = '/custom/basepath' diff --git a/e2e/react-start/custom-basepath/tests/navigation.spec.ts b/e2e/react-start/custom-basepath/tests/navigation.spec.ts index e238134d2ed..fb1d2f1367e 100644 --- a/e2e/react-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/react-start/custom-basepath/tests/navigation.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { isPrerender } from './utils/isPrerender' test('Navigating to post', async ({ page }) => { await page.goto('/') @@ -58,7 +59,12 @@ test('server-side redirect', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle') expect(await page.getByTestId('post-view').isVisible()).toBe(true) - expect(page.url()).toBe(`${baseURL}/posts/1`) + + if (isPrerender) { + expect(page.url()).toBe(`${baseURL}/posts/1/`) + } else { + expect(page.url()).toBe(`${baseURL}/posts/1`) + } // do not follow redirects since we want to test the Location header // Both requests (with or without basepath) should redirect directly to the final destination. diff --git a/e2e/react-start/custom-basepath/tests/utils/isPrerender.ts b/e2e/react-start/custom-basepath/tests/utils/isPrerender.ts new file mode 100644 index 00000000000..d5d991d4545 --- /dev/null +++ b/e2e/react-start/custom-basepath/tests/utils/isPrerender.ts @@ -0,0 +1 @@ +export const isPrerender: boolean = process.env.MODE === 'prerender' diff --git a/e2e/react-start/custom-basepath/vite.config.ts b/e2e/react-start/custom-basepath/vite.config.ts index 75559f50a17..fe5c8910c93 100644 --- a/e2e/react-start/custom-basepath/vite.config.ts +++ b/e2e/react-start/custom-basepath/vite.config.ts @@ -3,9 +3,25 @@ import tsConfigPaths from 'vite-tsconfig-paths' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { isPrerender } from './tests/utils/isPrerender' + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => { + return ![ + '/i-do-not-exist', + '/this-route-does-not-exist', + '/redirect', + '/users', + ].some((p) => page.path.includes(p)) + }, + onSuccess: ({ page }: { page: { path: string } }) => { + console.log(`Rendered ${page.path}!`) + }, +} export default defineConfig({ - base: '/custom/basepath', + base: `/custom/basepath${process.env.TRAILING_SLASH?.toLowerCase() === 'true' ? '/' : ''}`, server: { port: 3000, }, @@ -16,6 +32,7 @@ export default defineConfig({ }), tanstackStart({ vite: { installDevServerMiddleware: true }, + prerender: isPrerender ? prerenderConfiguration : undefined, }), viteReact(), ], From 81573029a0e2baba6083111ed723969201ae8d47 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 25 Dec 2025 15:01:26 +0200 Subject: [PATCH 03/10] replicate tests for vue and solid --- e2e/solid-start/custom-basepath/package.json | 9 ++++- .../custom-basepath/playwright.config.ts | 37 ++++++++++++++++++- .../custom-basepath/src/router.tsx | 3 +- .../src/routes/users.$userId.tsx | 8 ++-- .../custom-basepath/src/routes/users.tsx | 7 +++- .../custom-basepath/src/utils/basepath.ts | 1 - .../custom-basepath/tests/navigation.spec.ts | 8 +++- .../tests/utils/isPrerender.ts | 1 + .../custom-basepath/vite.config.ts | 22 ++++++++++- e2e/vue-start/custom-basepath/package.json | 9 ++++- .../custom-basepath/playwright.config.ts | 37 ++++++++++++++++++- e2e/vue-start/custom-basepath/src/router.tsx | 3 +- .../src/routes/users.$userId.tsx | 8 ++-- .../custom-basepath/src/routes/users.tsx | 7 +++- .../custom-basepath/src/utils/basepath.ts | 1 - .../custom-basepath/tests/navigation.spec.ts | 8 +++- .../tests/utils/isPrerender.ts | 1 + e2e/vue-start/custom-basepath/vite.config.ts | 22 ++++++++++- 18 files changed, 166 insertions(+), 26 deletions(-) delete mode 100644 e2e/solid-start/custom-basepath/src/utils/basepath.ts create mode 100644 e2e/solid-start/custom-basepath/tests/utils/isPrerender.ts delete mode 100644 e2e/vue-start/custom-basepath/src/utils/basepath.ts create mode 100644 e2e/vue-start/custom-basepath/tests/utils/isPrerender.ts diff --git a/e2e/solid-start/custom-basepath/package.json b/e2e/solid-start/custom-basepath/package.json index e706b214533..cd19493acf8 100644 --- a/e2e/solid-start/custom-basepath/package.json +++ b/e2e/solid-start/custom-basepath/package.json @@ -6,9 +6,16 @@ "scripts": { "dev": "cross-env NODE_ENV=development tsx express-server.ts", "build": "vite build && tsc --noEmit", + "build:prerender": "MODE=prerender vite build && tsc --noEmit", + "build:prerender:trailing": "MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", "preview": "vite preview", "start": "tsx express-server.ts", - "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", + "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", + "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", + "test:e2e:prerender:trailing": "rm -rf port*.txt; MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", + "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:prerender:trailing" }, "dependencies": { "@tanstack/solid-router": "workspace:^", diff --git a/e2e/solid-start/custom-basepath/playwright.config.ts b/e2e/solid-start/custom-basepath/playwright.config.ts index 5095425f473..17c013aaa6c 100644 --- a/e2e/solid-start/custom-basepath/playwright.config.ts +++ b/e2e/solid-start/custom-basepath/playwright.config.ts @@ -4,11 +4,36 @@ import { getTestServerPort, } from '@tanstack/router-e2e-utils' import packageJson from './package.json' with { type: 'json' } +import { isPrerender } from './tests/utils/isPrerender' const PORT = await getTestServerPort(packageJson.name) +const START_PORT = await getTestServerPort(packageJson.name) const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}/custom/basepath` +const ssrModeCommand = `pnpm build && pnpm start` +const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const prerenderTrailingModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender:trailing && pnpm run test:e2e:stopDummyServer && pnpm start` +const isTrailingSlashPrerender = + process.env.TRAILING_SLASH?.toLowerCase() === 'true' + +const getCommand = () => { + if (isPrerender && isTrailingSlashPrerender) + return prerenderTrailingModeCommand + if (isPrerender) return prerenderModeCommand + return ssrModeCommand +} + +console.log( + 'running in prerender mode: ', + isPrerender.toString(), + isPrerender + ? isTrailingSlashPrerender + ? 'with trailing slash' + : 'without trailing slash' + : '', +) + /** * See https://playwright.dev/docs/test-configuration. */ @@ -27,10 +52,20 @@ export default defineConfig({ }, webServer: { - command: `VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`, + command: getCommand(), url: baseURL, reuseExistingServer: !process.env.CI, stdout: 'pipe', + env: { + MODE: process.env.MODE || '', + TRAILING_SLASH: isTrailingSlashPrerender.toString(), + VITE_TRAILING_SLASH: isTrailingSlashPrerender.toString(), + VITE_NODE_ENV: 'test', + VITE_EXTERNAL_PORT: String(EXTERNAL_PORT), + VITE_SERVER_PORT: String(PORT), + START_PORT: String(START_PORT), + PORT: String(PORT), + }, }, projects: [ diff --git a/e2e/solid-start/custom-basepath/src/router.tsx b/e2e/solid-start/custom-basepath/src/router.tsx index b940fffea80..71efa7e12a1 100644 --- a/e2e/solid-start/custom-basepath/src/router.tsx +++ b/e2e/solid-start/custom-basepath/src/router.tsx @@ -2,7 +2,6 @@ import { createRouter } from '@tanstack/solid-router' import { routeTree } from './routeTree.gen' import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' import { NotFound } from './components/NotFound' -import { basepath } from './utils/basepath' export function getRouter() { const router = createRouter({ @@ -11,7 +10,7 @@ export function getRouter() { defaultErrorComponent: DefaultCatchBoundary, defaultNotFoundComponent: () => , scrollRestoration: true, - basepath: basepath, + basepath: import.meta.env.BASE_URL, }) return router diff --git a/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx index db5614394af..dcb0e1d28fe 100644 --- a/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx @@ -1,15 +1,17 @@ import { createFileRoute } from '@tanstack/solid-router' import axios from 'redaxios' - +import { getRouterInstance } from '@tanstack/solid-start' import type { User } from '~/utils/users' import { NotFound } from '~/components/NotFound' import { UserErrorComponent } from '~/components/UserErrorComponent' -import { basepath } from '~/utils/basepath' export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { + const router = await getRouterInstance() return await axios - .get(basepath + '/api/users/' + userId) + .get('/api/users/' + userId, { + baseURL: router.options.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/solid-start/custom-basepath/src/routes/users.tsx b/e2e/solid-start/custom-basepath/src/routes/users.tsx index b686fa91cda..d1d312df3ee 100644 --- a/e2e/solid-start/custom-basepath/src/routes/users.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/users.tsx @@ -1,12 +1,15 @@ import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' import axios from 'redaxios' +import { getRouterInstance } from '@tanstack/solid-start' import type { User } from '~/utils/users' -import { basepath } from '~/utils/basepath' export const Route = createFileRoute('/users')({ loader: async () => { + const router = await getRouterInstance() return await axios - .get>(basepath + '/api/users') + .get>('/api/users', { + baseURL: router.options.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/solid-start/custom-basepath/src/utils/basepath.ts b/e2e/solid-start/custom-basepath/src/utils/basepath.ts deleted file mode 100644 index 6e719f196cf..00000000000 --- a/e2e/solid-start/custom-basepath/src/utils/basepath.ts +++ /dev/null @@ -1 +0,0 @@ -export const basepath = '/custom/basepath' diff --git a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts index e238134d2ed..fb1d2f1367e 100644 --- a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { isPrerender } from './utils/isPrerender' test('Navigating to post', async ({ page }) => { await page.goto('/') @@ -58,7 +59,12 @@ test('server-side redirect', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle') expect(await page.getByTestId('post-view').isVisible()).toBe(true) - expect(page.url()).toBe(`${baseURL}/posts/1`) + + if (isPrerender) { + expect(page.url()).toBe(`${baseURL}/posts/1/`) + } else { + expect(page.url()).toBe(`${baseURL}/posts/1`) + } // do not follow redirects since we want to test the Location header // Both requests (with or without basepath) should redirect directly to the final destination. diff --git a/e2e/solid-start/custom-basepath/tests/utils/isPrerender.ts b/e2e/solid-start/custom-basepath/tests/utils/isPrerender.ts new file mode 100644 index 00000000000..d5d991d4545 --- /dev/null +++ b/e2e/solid-start/custom-basepath/tests/utils/isPrerender.ts @@ -0,0 +1 @@ +export const isPrerender: boolean = process.env.MODE === 'prerender' diff --git a/e2e/solid-start/custom-basepath/vite.config.ts b/e2e/solid-start/custom-basepath/vite.config.ts index bbc8fea1019..ef8785cab39 100644 --- a/e2e/solid-start/custom-basepath/vite.config.ts +++ b/e2e/solid-start/custom-basepath/vite.config.ts @@ -3,9 +3,25 @@ import tsConfigPaths from 'vite-tsconfig-paths' import { tanstackStart } from '@tanstack/solid-start/plugin/vite' import viteSolid from 'vite-plugin-solid' import tailwindcss from '@tailwindcss/vite' +import { isPrerender } from './tests/utils/isPrerender' + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => { + return ![ + '/i-do-not-exist', + '/this-route-does-not-exist', + '/redirect', + '/users', + ].some((p) => page.path.includes(p)) + }, + onSuccess: ({ page }: { page: { path: string } }) => { + console.log(`Rendered ${page.path}!`) + }, +} export default defineConfig({ - base: '/custom/basepath', + base: `/custom/basepath${process.env.TRAILING_SLASH?.toLowerCase() === 'true' ? '/' : ''}`, server: { port: 3000, }, @@ -14,7 +30,9 @@ export default defineConfig({ tsConfigPaths({ projects: ['./tsconfig.json'], }), - tanstackStart(), + tanstackStart({ + prerender: isPrerender ? prerenderConfiguration : undefined, + }), viteSolid({ ssr: true }), ], }) diff --git a/e2e/vue-start/custom-basepath/package.json b/e2e/vue-start/custom-basepath/package.json index a4b30785ab3..3583de0de4d 100644 --- a/e2e/vue-start/custom-basepath/package.json +++ b/e2e/vue-start/custom-basepath/package.json @@ -6,9 +6,16 @@ "scripts": { "dev": "cross-env NODE_ENV=development tsx express-server.ts", "build": "vite build && tsc --noEmit", + "build:prerender": "MODE=prerender vite build && tsc --noEmit", + "build:prerender:trailing": "MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", "preview": "vite preview", "start": "tsx express-server.ts", - "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", + "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", + "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", + "test:e2e:prerender:trailing": "rm -rf port*.txt; MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", + "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:prerender:trailing" }, "dependencies": { "@tanstack/vue-router": "workspace:^", diff --git a/e2e/vue-start/custom-basepath/playwright.config.ts b/e2e/vue-start/custom-basepath/playwright.config.ts index 5095425f473..17c013aaa6c 100644 --- a/e2e/vue-start/custom-basepath/playwright.config.ts +++ b/e2e/vue-start/custom-basepath/playwright.config.ts @@ -4,11 +4,36 @@ import { getTestServerPort, } from '@tanstack/router-e2e-utils' import packageJson from './package.json' with { type: 'json' } +import { isPrerender } from './tests/utils/isPrerender' const PORT = await getTestServerPort(packageJson.name) +const START_PORT = await getTestServerPort(packageJson.name) const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}/custom/basepath` +const ssrModeCommand = `pnpm build && pnpm start` +const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const prerenderTrailingModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender:trailing && pnpm run test:e2e:stopDummyServer && pnpm start` +const isTrailingSlashPrerender = + process.env.TRAILING_SLASH?.toLowerCase() === 'true' + +const getCommand = () => { + if (isPrerender && isTrailingSlashPrerender) + return prerenderTrailingModeCommand + if (isPrerender) return prerenderModeCommand + return ssrModeCommand +} + +console.log( + 'running in prerender mode: ', + isPrerender.toString(), + isPrerender + ? isTrailingSlashPrerender + ? 'with trailing slash' + : 'without trailing slash' + : '', +) + /** * See https://playwright.dev/docs/test-configuration. */ @@ -27,10 +52,20 @@ export default defineConfig({ }, webServer: { - command: `VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`, + command: getCommand(), url: baseURL, reuseExistingServer: !process.env.CI, stdout: 'pipe', + env: { + MODE: process.env.MODE || '', + TRAILING_SLASH: isTrailingSlashPrerender.toString(), + VITE_TRAILING_SLASH: isTrailingSlashPrerender.toString(), + VITE_NODE_ENV: 'test', + VITE_EXTERNAL_PORT: String(EXTERNAL_PORT), + VITE_SERVER_PORT: String(PORT), + START_PORT: String(START_PORT), + PORT: String(PORT), + }, }, projects: [ diff --git a/e2e/vue-start/custom-basepath/src/router.tsx b/e2e/vue-start/custom-basepath/src/router.tsx index 81103bf0bd6..33ff448cb86 100644 --- a/e2e/vue-start/custom-basepath/src/router.tsx +++ b/e2e/vue-start/custom-basepath/src/router.tsx @@ -2,7 +2,6 @@ import { createRouter } from '@tanstack/vue-router' import { routeTree } from './routeTree.gen' import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' import { NotFound } from './components/NotFound' -import { basepath } from './utils/basepath' export function getRouter() { const router = createRouter({ @@ -11,7 +10,7 @@ export function getRouter() { defaultErrorComponent: DefaultCatchBoundary, defaultNotFoundComponent: () => , scrollRestoration: true, - basepath: basepath, + basepath: import.meta.env.BASE_URL, }) return router diff --git a/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx index fb7fce5f4c4..1fdbe55f02e 100644 --- a/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx @@ -1,15 +1,17 @@ import { createFileRoute } from '@tanstack/vue-router' +import { getRouterInstance } from '@tanstack/vue-start' import axios from 'redaxios' - import type { User } from '~/utils/users' import { NotFound } from '~/components/NotFound' import { UserErrorComponent } from '~/components/UserErrorComponent' -import { basepath } from '~/utils/basepath' export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { + const router = await getRouterInstance() return await axios - .get(basepath + '/api/users/' + userId) + .get('/api/users/' + userId, { + baseURL: router.options.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/vue-start/custom-basepath/src/routes/users.tsx b/e2e/vue-start/custom-basepath/src/routes/users.tsx index 1432e504516..33f01eaccbc 100644 --- a/e2e/vue-start/custom-basepath/src/routes/users.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/users.tsx @@ -1,12 +1,15 @@ import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' +import { getRouterInstance } from '@tanstack/vue-start' import axios from 'redaxios' import type { User } from '~/utils/users' -import { basepath } from '~/utils/basepath' export const Route = createFileRoute('/users')({ loader: async () => { + const router = await getRouterInstance() return await axios - .get>(basepath + '/api/users') + .get>('/api/users', { + baseURL: router.options.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/vue-start/custom-basepath/src/utils/basepath.ts b/e2e/vue-start/custom-basepath/src/utils/basepath.ts deleted file mode 100644 index 6e719f196cf..00000000000 --- a/e2e/vue-start/custom-basepath/src/utils/basepath.ts +++ /dev/null @@ -1 +0,0 @@ -export const basepath = '/custom/basepath' diff --git a/e2e/vue-start/custom-basepath/tests/navigation.spec.ts b/e2e/vue-start/custom-basepath/tests/navigation.spec.ts index e238134d2ed..fb1d2f1367e 100644 --- a/e2e/vue-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/vue-start/custom-basepath/tests/navigation.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { isPrerender } from './utils/isPrerender' test('Navigating to post', async ({ page }) => { await page.goto('/') @@ -58,7 +59,12 @@ test('server-side redirect', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle') expect(await page.getByTestId('post-view').isVisible()).toBe(true) - expect(page.url()).toBe(`${baseURL}/posts/1`) + + if (isPrerender) { + expect(page.url()).toBe(`${baseURL}/posts/1/`) + } else { + expect(page.url()).toBe(`${baseURL}/posts/1`) + } // do not follow redirects since we want to test the Location header // Both requests (with or without basepath) should redirect directly to the final destination. diff --git a/e2e/vue-start/custom-basepath/tests/utils/isPrerender.ts b/e2e/vue-start/custom-basepath/tests/utils/isPrerender.ts new file mode 100644 index 00000000000..d5d991d4545 --- /dev/null +++ b/e2e/vue-start/custom-basepath/tests/utils/isPrerender.ts @@ -0,0 +1 @@ +export const isPrerender: boolean = process.env.MODE === 'prerender' diff --git a/e2e/vue-start/custom-basepath/vite.config.ts b/e2e/vue-start/custom-basepath/vite.config.ts index 4d6e769fe88..6c926f13e76 100644 --- a/e2e/vue-start/custom-basepath/vite.config.ts +++ b/e2e/vue-start/custom-basepath/vite.config.ts @@ -4,9 +4,25 @@ import { tanstackStart } from '@tanstack/vue-start/plugin/vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' import tailwindcss from '@tailwindcss/vite' +import { isPrerender } from './tests/utils/isPrerender' + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => { + return ![ + '/i-do-not-exist', + '/this-route-does-not-exist', + '/redirect', + '/users', + ].some((p) => page.path.includes(p)) + }, + onSuccess: ({ page }: { page: { path: string } }) => { + console.log(`Rendered ${page.path}!`) + }, +} export default defineConfig({ - base: '/custom/basepath', + base: `/custom/basepath${process.env.TRAILING_SLASH?.toLowerCase() === 'true' ? '/' : ''}`, server: { port: 3000, }, @@ -15,7 +31,9 @@ export default defineConfig({ tsConfigPaths({ projects: ['./tsconfig.json'], }), - tanstackStart(), + tanstackStart({ + prerender: isPrerender ? prerenderConfiguration : undefined, + }), vue(), vueJsx(), ], From b4b41dd011ef461ff32001b9fb6597167eb5c3a4 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 25 Dec 2025 15:03:00 +0200 Subject: [PATCH 04/10] use cross-env --- e2e/react-start/custom-basepath/package.json | 8 ++++---- e2e/solid-start/custom-basepath/package.json | 8 ++++---- e2e/vue-start/custom-basepath/package.json | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/e2e/react-start/custom-basepath/package.json b/e2e/react-start/custom-basepath/package.json index c5262d3673c..ff1c29bf6e6 100644 --- a/e2e/react-start/custom-basepath/package.json +++ b/e2e/react-start/custom-basepath/package.json @@ -6,14 +6,14 @@ "scripts": { "dev": "cross-env NODE_ENV=development tsx express-server.ts", "build": "vite build && tsc --noEmit", - "build:prerender": "MODE=prerender vite build && tsc --noEmit", - "build:prerender:trailing": "MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", + "build:prerender": "cross-env MODE=prerender vite build && tsc --noEmit", + "build:prerender:trailing": "cross-env MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", "preview": "vite preview", "start": "tsx express-server.ts", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", - "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", - "test:e2e:prerender:trailing": "rm -rf port*.txt; MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", + "test:e2e:prerender": "rm -rf port*.txt; cross-env MODE=prerender playwright test --project=chromium", + "test:e2e:prerender:trailing": "rm -rf port*.txt; cross-env MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", "test:e2e": "pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:prerender:trailing" }, diff --git a/e2e/solid-start/custom-basepath/package.json b/e2e/solid-start/custom-basepath/package.json index cd19493acf8..eba00787c52 100644 --- a/e2e/solid-start/custom-basepath/package.json +++ b/e2e/solid-start/custom-basepath/package.json @@ -6,14 +6,14 @@ "scripts": { "dev": "cross-env NODE_ENV=development tsx express-server.ts", "build": "vite build && tsc --noEmit", - "build:prerender": "MODE=prerender vite build && tsc --noEmit", - "build:prerender:trailing": "MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", + "build:prerender": "cross-env MODE=prerender vite build && tsc --noEmit", + "build:prerender:trailing": "cross-env MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", "preview": "vite preview", "start": "tsx express-server.ts", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", - "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", - "test:e2e:prerender:trailing": "rm -rf port*.txt; MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", + "test:e2e:prerender": "rm -rf port*.txt; cross-env MODE=prerender playwright test --project=chromium", + "test:e2e:prerender:trailing": "rm -rf port*.txt; cross-env MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", "test:e2e": "pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:prerender:trailing" }, diff --git a/e2e/vue-start/custom-basepath/package.json b/e2e/vue-start/custom-basepath/package.json index 3583de0de4d..d93af7e9f1b 100644 --- a/e2e/vue-start/custom-basepath/package.json +++ b/e2e/vue-start/custom-basepath/package.json @@ -6,14 +6,14 @@ "scripts": { "dev": "cross-env NODE_ENV=development tsx express-server.ts", "build": "vite build && tsc --noEmit", - "build:prerender": "MODE=prerender vite build && tsc --noEmit", - "build:prerender:trailing": "MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", + "build:prerender": "cross-env MODE=prerender vite build && tsc --noEmit", + "build:prerender:trailing": "cross-env MODE=prerender TRAILING_SLASH=true vite build && tsc --noEmit", "preview": "vite preview", "start": "tsx express-server.ts", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", - "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", - "test:e2e:prerender:trailing": "rm -rf port*.txt; MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", + "test:e2e:prerender": "rm -rf port*.txt; cross-env MODE=prerender playwright test --project=chromium", + "test:e2e:prerender:trailing": "rm -rf port*.txt; cross-env MODE=prerender TRAILING_SLASH=true playwright test --project=chromium", "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", "test:e2e": "pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:prerender:trailing" }, From 723a69d9d66f5e98575394d95bc791af0fe66340 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Fri, 26 Dec 2025 13:24:45 +0200 Subject: [PATCH 05/10] exclude posts from prerender --- e2e/react-start/custom-basepath/vite.config.ts | 3 ++- e2e/solid-start/custom-basepath/vite.config.ts | 3 ++- e2e/vue-start/custom-basepath/vite.config.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/e2e/react-start/custom-basepath/vite.config.ts b/e2e/react-start/custom-basepath/vite.config.ts index fe5c8910c93..32a7643ca2b 100644 --- a/e2e/react-start/custom-basepath/vite.config.ts +++ b/e2e/react-start/custom-basepath/vite.config.ts @@ -10,8 +10,9 @@ const prerenderConfiguration = { filter: (page: { path: string }) => { return ![ '/i-do-not-exist', - '/this-route-does-not-exist', + '/posts', '/redirect', + '/this-route-does-not-exist', '/users', ].some((p) => page.path.includes(p)) }, diff --git a/e2e/solid-start/custom-basepath/vite.config.ts b/e2e/solid-start/custom-basepath/vite.config.ts index ef8785cab39..d35cd033419 100644 --- a/e2e/solid-start/custom-basepath/vite.config.ts +++ b/e2e/solid-start/custom-basepath/vite.config.ts @@ -10,8 +10,9 @@ const prerenderConfiguration = { filter: (page: { path: string }) => { return ![ '/i-do-not-exist', - '/this-route-does-not-exist', + '/posts', '/redirect', + '/this-route-does-not-exist', '/users', ].some((p) => page.path.includes(p)) }, diff --git a/e2e/vue-start/custom-basepath/vite.config.ts b/e2e/vue-start/custom-basepath/vite.config.ts index 6c926f13e76..5ed7688f7f3 100644 --- a/e2e/vue-start/custom-basepath/vite.config.ts +++ b/e2e/vue-start/custom-basepath/vite.config.ts @@ -11,8 +11,9 @@ const prerenderConfiguration = { filter: (page: { path: string }) => { return ![ '/i-do-not-exist', - '/this-route-does-not-exist', + '/posts', '/redirect', + '/this-route-does-not-exist', '/users', ].some((p) => page.path.includes(p)) }, From b08805ec46a4d8667fe8cc601f14fa2e08bae6b6 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 28 Dec 2025 04:34:51 +0200 Subject: [PATCH 06/10] resolve issues with vite preview --- packages/router-core/src/rewrite.ts | 23 ++++++++------- packages/router-core/src/router.ts | 9 +++++- .../src/createStartHandler.ts | 29 ++++++++++++++----- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/packages/router-core/src/rewrite.ts b/packages/router-core/src/rewrite.ts index 0da50c5b620..01581799c34 100644 --- a/packages/router-core/src/rewrite.ts +++ b/packages/router-core/src/rewrite.ts @@ -26,21 +26,19 @@ export function rewriteBasepath(opts: { basepath: string caseSensitive?: boolean }) { + const normalizePathCase = (path: string) => { + return opts.caseSensitive ? path : path.toLowerCase() + } + const trimmedBasepath = trimPath(opts.basepath) const normalizedBasepath = `/${trimmedBasepath}` const normalizedBasepathWithSlash = `${normalizedBasepath}/` - const checkBasepath = opts.caseSensitive - ? normalizedBasepath - : normalizedBasepath.toLowerCase() - const checkBasepathWithSlash = opts.caseSensitive - ? normalizedBasepathWithSlash - : normalizedBasepathWithSlash.toLowerCase() + const checkBasepath = normalizePathCase(normalizedBasepath) + const checkBasepathWithSlash = normalizePathCase(normalizedBasepathWithSlash) return { input: ({ url }) => { - const pathname = opts.caseSensitive - ? url.pathname - : url.pathname.toLowerCase() + const pathname = normalizePathCase(url.pathname) // Handle exact basepath match (e.g., /my-app -> /) if (pathname === checkBasepath) { @@ -52,7 +50,12 @@ export function rewriteBasepath(opts: { return url }, output: ({ url }) => { - url.pathname = joinPaths(['/', trimmedBasepath, url.pathname]) + const pathname = normalizePathCase(url.pathname) + const base = pathname.startsWith(checkBasepathWithSlash) + ? '' + : trimmedBasepath + + url.pathname = joinPaths(['/', base, url.pathname]) return url }, } satisfies LocationRewrite diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d9d808a3185..31128a03d13 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1187,10 +1187,17 @@ export class RouterCore< url.search = searchStr const fullPath = url.href.replace(url.origin, '') + let publicHref = href + + if (this.isServer) { + const urlForOutput = new URL(url.href) + const rewrittenUrl = executeRewriteOutput(this.rewrite, urlForOutput) + publicHref = rewrittenUrl.href.replace(rewrittenUrl.origin, '') + } return { href: fullPath, - publicHref: href, + publicHref: publicHref, url: url, pathname: decodePath(url.pathname), searchStr, diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index b0984b32751..0191675f6e2 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -2,6 +2,7 @@ import { createMemoryHistory } from '@tanstack/history' import { flattenMiddlewares, mergeHeaders } from '@tanstack/start-client-core' import { executeRewriteInput, + executeRewriteOutput, isRedirect, isResolvedRedirect, } from '@tanstack/router-core' @@ -138,19 +139,32 @@ export function createStartHandler( const requestHandlerMiddleware = handlerToMiddleware( async ({ context }) => { + const router = await getRouter() + + let req = request + let publicHref = href + + if (router.isServer) { + const rewrittenUrl = executeRewriteOutput(router.rewrite, url) + publicHref = rewrittenUrl.href.replace(rewrittenUrl.origin, '') + if (publicHref !== href) { + req = new Request(rewrittenUrl, request) + } + } + const response = await runWithStartContext( { getRouter, startOptions: requestStartOptions, contextAfterGlobalMiddlewares: context, - request, + request: req, }, async () => { try { // First, let's attempt to handle server functions - if (href.startsWith(process.env.TSS_SERVER_FN_BASE)) { + if (publicHref.startsWith(process.env.TSS_SERVER_FN_BASE)) { return await handleServerAction({ - request, + request: req, context: requestOpts?.context, }) } @@ -160,8 +174,7 @@ export function createStartHandler( }: { serverContext: any }) => { - const requestAcceptHeader = - request.headers.get('Accept') || '*/*' + const requestAcceptHeader = req.headers.get('Accept') || '*/*' const splitRequestAcceptHeader = requestAcceptHeader.split(',') @@ -188,7 +201,7 @@ export function createStartHandler( if (startRoutesManifest === null) { startRoutesManifest = await getStartManifest() } - const router = await getRouter() + attachRouterServerSsrUtils({ router, manifest: startRoutesManifest, @@ -208,7 +221,7 @@ export function createStartHandler( // Mark that the callback will handle cleanup cbWillCleanup = true const response = await cb({ - request, + request: req, router, responseHeaders, }) @@ -218,7 +231,7 @@ export function createStartHandler( const response = await handleServerRoutes({ getRouter, - request, + request: req, executeRouter, context, }) From 46c0efe78f6d058fcf8c0fffb961386397a78a05 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 28 Dec 2025 04:35:05 +0200 Subject: [PATCH 07/10] clean up tests --- .../custom-basepath/src/routes/users.$userId.tsx | 4 +--- e2e/react-start/custom-basepath/src/routes/users.tsx | 4 +--- .../custom-basepath/tests/navigation.spec.ts | 10 ++-------- .../custom-basepath/src/routes/users.$userId.tsx | 4 +--- e2e/solid-start/custom-basepath/src/routes/users.tsx | 4 +--- .../custom-basepath/tests/navigation.spec.ts | 10 ++-------- .../custom-basepath/src/routes/users.$userId.tsx | 4 +--- e2e/vue-start/custom-basepath/src/routes/users.tsx | 4 +--- e2e/vue-start/custom-basepath/tests/navigation.spec.ts | 10 ++-------- 9 files changed, 12 insertions(+), 42 deletions(-) diff --git a/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx index 104703bf303..d1b4977922d 100644 --- a/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx @@ -9,9 +9,7 @@ export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { const router = await getRouterInstance() return await axios - .get('/api/users/' + userId, { - baseURL: router.options.origin, - }) + .get(`/${router.options.basepath}/api/users/${userId}`) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/react-start/custom-basepath/src/routes/users.tsx b/e2e/react-start/custom-basepath/src/routes/users.tsx index a7b5a1d316a..03308b3a1be 100644 --- a/e2e/react-start/custom-basepath/src/routes/users.tsx +++ b/e2e/react-start/custom-basepath/src/routes/users.tsx @@ -7,9 +7,7 @@ export const Route = createFileRoute('/users')({ loader: async () => { const router = await getRouterInstance() return await axios - .get>('/api/users', { - baseURL: router.options.origin, - }) + .get>(`/${router.options.basepath}/api/users`) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/react-start/custom-basepath/tests/navigation.spec.ts b/e2e/react-start/custom-basepath/tests/navigation.spec.ts index 5962658134e..fba5af53848 100644 --- a/e2e/react-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/react-start/custom-basepath/tests/navigation.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from '@playwright/test' -import { isPrerender } from './utils/isPrerender' test('Navigating to post', async ({ page }) => { await page.goto('/') @@ -59,12 +58,7 @@ test('server-side redirect', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle') expect(await page.getByTestId('post-view').isVisible()).toBe(true) - - if (isPrerender) { - expect(page.url()).toBe(`${baseURL}/posts/1/`) - } else { - expect(page.url()).toBe(`${baseURL}/posts/1`) - } + expect(page.url()).toBe(`${baseURL}/posts/1`) // do not follow redirects since we want to test the Location header // first go to the route WITHOUT the base path, this will just add the base path @@ -72,7 +66,7 @@ test('server-side redirect', async ({ page, baseURL }) => { .get('/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { const headers = new Headers(res.headers()) - expect(headers.get('location')).toBe('/custom/basepath/redirect/throw-it') + expect(headers.get('location')).toBe('/custom/basepath/posts/1') }) await page.request .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) diff --git a/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx index dcb0e1d28fe..a59fc3b0f2a 100644 --- a/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx @@ -9,9 +9,7 @@ export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { const router = await getRouterInstance() return await axios - .get('/api/users/' + userId, { - baseURL: router.options.origin, - }) + .get(`/${router.options.basepath}/api/users/${userId}`) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/solid-start/custom-basepath/src/routes/users.tsx b/e2e/solid-start/custom-basepath/src/routes/users.tsx index d1d312df3ee..0666a0536ce 100644 --- a/e2e/solid-start/custom-basepath/src/routes/users.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/users.tsx @@ -7,9 +7,7 @@ export const Route = createFileRoute('/users')({ loader: async () => { const router = await getRouterInstance() return await axios - .get>('/api/users', { - baseURL: router.options.origin, - }) + .get>(`/${router.options.basepath}/api/users`) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts index 5962658134e..fba5af53848 100644 --- a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from '@playwright/test' -import { isPrerender } from './utils/isPrerender' test('Navigating to post', async ({ page }) => { await page.goto('/') @@ -59,12 +58,7 @@ test('server-side redirect', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle') expect(await page.getByTestId('post-view').isVisible()).toBe(true) - - if (isPrerender) { - expect(page.url()).toBe(`${baseURL}/posts/1/`) - } else { - expect(page.url()).toBe(`${baseURL}/posts/1`) - } + expect(page.url()).toBe(`${baseURL}/posts/1`) // do not follow redirects since we want to test the Location header // first go to the route WITHOUT the base path, this will just add the base path @@ -72,7 +66,7 @@ test('server-side redirect', async ({ page, baseURL }) => { .get('/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { const headers = new Headers(res.headers()) - expect(headers.get('location')).toBe('/custom/basepath/redirect/throw-it') + expect(headers.get('location')).toBe('/custom/basepath/posts/1') }) await page.request .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) diff --git a/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx index 1fdbe55f02e..1d663758e98 100644 --- a/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx @@ -9,9 +9,7 @@ export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { const router = await getRouterInstance() return await axios - .get('/api/users/' + userId, { - baseURL: router.options.origin, - }) + .get(`/${router.options.basepath}/api/users/${userId}`) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/vue-start/custom-basepath/src/routes/users.tsx b/e2e/vue-start/custom-basepath/src/routes/users.tsx index 33f01eaccbc..9306c894af2 100644 --- a/e2e/vue-start/custom-basepath/src/routes/users.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/users.tsx @@ -7,9 +7,7 @@ export const Route = createFileRoute('/users')({ loader: async () => { const router = await getRouterInstance() return await axios - .get>('/api/users', { - baseURL: router.options.origin, - }) + .get>(`/${router.options.basepath}/api/users`) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/vue-start/custom-basepath/tests/navigation.spec.ts b/e2e/vue-start/custom-basepath/tests/navigation.spec.ts index 5962658134e..fba5af53848 100644 --- a/e2e/vue-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/vue-start/custom-basepath/tests/navigation.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from '@playwright/test' -import { isPrerender } from './utils/isPrerender' test('Navigating to post', async ({ page }) => { await page.goto('/') @@ -59,12 +58,7 @@ test('server-side redirect', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle') expect(await page.getByTestId('post-view').isVisible()).toBe(true) - - if (isPrerender) { - expect(page.url()).toBe(`${baseURL}/posts/1/`) - } else { - expect(page.url()).toBe(`${baseURL}/posts/1`) - } + expect(page.url()).toBe(`${baseURL}/posts/1`) // do not follow redirects since we want to test the Location header // first go to the route WITHOUT the base path, this will just add the base path @@ -72,7 +66,7 @@ test('server-side redirect', async ({ page, baseURL }) => { .get('/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { const headers = new Headers(res.headers()) - expect(headers.get('location')).toBe('/custom/basepath/redirect/throw-it') + expect(headers.get('location')).toBe('/custom/basepath/posts/1') }) await page.request .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) From 1cbffe314b81a6cbaa67f28430ab23bc0805c6c3 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 28 Dec 2025 05:38:30 +0200 Subject: [PATCH 08/10] reapply fix with latest changes to createStartHandler --- packages/start-server-core/src/createStartHandler.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 1f2820c7f13..12fd4889351 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -9,6 +9,7 @@ import { executeRewriteInput, isRedirect, isResolvedRedirect, + joinPaths, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, @@ -210,6 +211,12 @@ export function createStartHandler( try { const url = new URL(request.url) + + if (!url.pathname.startsWith(joinPaths(['/', ROUTER_BASEPATH]))) { + url.pathname = joinPaths(['/', ROUTER_BASEPATH, url.pathname]) + request = new Request(url, request) + } + const href = url.href.replace(url.origin, '') const origin = getOrigin(request) From 22595a1480ed9396aaf74276bea9ab6887aec111 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 28 Dec 2025 06:48:08 +0200 Subject: [PATCH 09/10] cleanup --- .../custom-basepath/src/routes/users.$userId.tsx | 4 +++- e2e/react-start/custom-basepath/src/routes/users.tsx | 4 +++- .../custom-basepath/src/routes/users.$userId.tsx | 4 +++- e2e/solid-start/custom-basepath/src/routes/users.tsx | 4 +++- .../custom-basepath/src/routes/users.$userId.tsx | 4 +++- e2e/vue-start/custom-basepath/src/routes/users.tsx | 4 +++- packages/router-core/src/router.ts | 9 +-------- packages/start-server-core/src/createStartHandler.ts | 5 +++-- 8 files changed, 22 insertions(+), 16 deletions(-) diff --git a/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx index d1b4977922d..1741a80b939 100644 --- a/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/react-start/custom-basepath/src/routes/users.$userId.tsx @@ -9,7 +9,9 @@ export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { const router = await getRouterInstance() return await axios - .get(`/${router.options.basepath}/api/users/${userId}`) + .get(`/${router.options.basepath}/api/users/${userId}`, { + baseURL: router.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/react-start/custom-basepath/src/routes/users.tsx b/e2e/react-start/custom-basepath/src/routes/users.tsx index 03308b3a1be..28d1e7caff9 100644 --- a/e2e/react-start/custom-basepath/src/routes/users.tsx +++ b/e2e/react-start/custom-basepath/src/routes/users.tsx @@ -7,7 +7,9 @@ export const Route = createFileRoute('/users')({ loader: async () => { const router = await getRouterInstance() return await axios - .get>(`/${router.options.basepath}/api/users`) + .get>(`/${router.options.basepath}/api/users`, { + baseURL: router.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx index a59fc3b0f2a..29eeb3ad641 100644 --- a/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/users.$userId.tsx @@ -9,7 +9,9 @@ export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { const router = await getRouterInstance() return await axios - .get(`/${router.options.basepath}/api/users/${userId}`) + .get(`/${router.options.basepath}/api/users/${userId}`, { + baseURL: router.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/solid-start/custom-basepath/src/routes/users.tsx b/e2e/solid-start/custom-basepath/src/routes/users.tsx index 0666a0536ce..ee0fcda038f 100644 --- a/e2e/solid-start/custom-basepath/src/routes/users.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/users.tsx @@ -7,7 +7,9 @@ export const Route = createFileRoute('/users')({ loader: async () => { const router = await getRouterInstance() return await axios - .get>(`/${router.options.basepath}/api/users`) + .get>(`/${router.options.basepath}/api/users`, { + baseURL: router.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx b/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx index 1d663758e98..e3f84b79c9a 100644 --- a/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/users.$userId.tsx @@ -9,7 +9,9 @@ export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { const router = await getRouterInstance() return await axios - .get(`/${router.options.basepath}/api/users/${userId}`) + .get(`/${router.options.basepath}/api/users/${userId}`, { + baseURL: router.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/vue-start/custom-basepath/src/routes/users.tsx b/e2e/vue-start/custom-basepath/src/routes/users.tsx index 9306c894af2..09c11dad56e 100644 --- a/e2e/vue-start/custom-basepath/src/routes/users.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/users.tsx @@ -7,7 +7,9 @@ export const Route = createFileRoute('/users')({ loader: async () => { const router = await getRouterInstance() return await axios - .get>(`/${router.options.basepath}/api/users`) + .get>(`/${router.options.basepath}/api/users`, { + baseURL: router.origin, + }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 31128a03d13..d9d808a3185 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1187,17 +1187,10 @@ export class RouterCore< url.search = searchStr const fullPath = url.href.replace(url.origin, '') - let publicHref = href - - if (this.isServer) { - const urlForOutput = new URL(url.href) - const rewrittenUrl = executeRewriteOutput(this.rewrite, urlForOutput) - publicHref = rewrittenUrl.href.replace(rewrittenUrl.origin, '') - } return { href: fullPath, - publicHref: publicHref, + publicHref: href, url: url, pathname: decodePath(url.pathname), searchStr, diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 12fd4889351..4649d324dc2 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -211,9 +211,10 @@ export function createStartHandler( try { const url = new URL(request.url) + const basePath = joinPaths(['/', ROUTER_BASEPATH]) - if (!url.pathname.startsWith(joinPaths(['/', ROUTER_BASEPATH]))) { - url.pathname = joinPaths(['/', ROUTER_BASEPATH, url.pathname]) + if (!url.pathname.startsWith(basePath)) { + url.pathname = joinPaths([basePath, url.pathname]) request = new Request(url, request) } From 555faeeadf6f7324b80f0f41d4867c3f961a0aa1 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 28 Dec 2025 06:58:00 +0200 Subject: [PATCH 10/10] cleanup --- packages/router-core/src/rewrite.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/router-core/src/rewrite.ts b/packages/router-core/src/rewrite.ts index 01581799c34..0da50c5b620 100644 --- a/packages/router-core/src/rewrite.ts +++ b/packages/router-core/src/rewrite.ts @@ -26,19 +26,21 @@ export function rewriteBasepath(opts: { basepath: string caseSensitive?: boolean }) { - const normalizePathCase = (path: string) => { - return opts.caseSensitive ? path : path.toLowerCase() - } - const trimmedBasepath = trimPath(opts.basepath) const normalizedBasepath = `/${trimmedBasepath}` const normalizedBasepathWithSlash = `${normalizedBasepath}/` - const checkBasepath = normalizePathCase(normalizedBasepath) - const checkBasepathWithSlash = normalizePathCase(normalizedBasepathWithSlash) + const checkBasepath = opts.caseSensitive + ? normalizedBasepath + : normalizedBasepath.toLowerCase() + const checkBasepathWithSlash = opts.caseSensitive + ? normalizedBasepathWithSlash + : normalizedBasepathWithSlash.toLowerCase() return { input: ({ url }) => { - const pathname = normalizePathCase(url.pathname) + const pathname = opts.caseSensitive + ? url.pathname + : url.pathname.toLowerCase() // Handle exact basepath match (e.g., /my-app -> /) if (pathname === checkBasepath) { @@ -50,12 +52,7 @@ export function rewriteBasepath(opts: { return url }, output: ({ url }) => { - const pathname = normalizePathCase(url.pathname) - const base = pathname.startsWith(checkBasepathWithSlash) - ? '' - : trimmedBasepath - - url.pathname = joinPaths(['/', base, url.pathname]) + url.pathname = joinPaths(['/', trimmedBasepath, url.pathname]) return url }, } satisfies LocationRewrite