From fe49c438944d6ef874d367cf9a3caaa059df7222 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Tue, 23 Dec 2025 10:40:08 +0100 Subject: [PATCH 1/4] add tests --- e2e/react-start/i18n-paraglide/.gitignore | 10 + .../i18n-paraglide/.prettierignore | 4 + .../i18n-paraglide/.vscode/extensions.json | 3 + .../i18n-paraglide/.vscode/settings.json | 11 + .../i18n-paraglide/messages/de.json | 8 + .../i18n-paraglide/messages/en.json | 8 + e2e/react-start/i18n-paraglide/package.json | 34 +++ .../i18n-paraglide/playwright.config.ts | 35 +++ .../i18n-paraglide/project.inlang/.gitignore | 1 + .../i18n-paraglide/project.inlang/project_id | 1 + .../project.inlang/settings.json | 12 + .../i18n-paraglide/public/favicon.ico | Bin 0 -> 3870 bytes .../i18n-paraglide/public/logo192.png | Bin 0 -> 5347 bytes .../i18n-paraglide/public/logo512.png | Bin 0 -> 9664 bytes .../i18n-paraglide/public/manifest.json | 25 ++ .../i18n-paraglide/public/robots.txt | 3 + e2e/react-start/i18n-paraglide/src/logo.svg | 12 + .../i18n-paraglide/src/routeTree.gen.ts | 86 +++++++ e2e/react-start/i18n-paraglide/src/router.tsx | 18 ++ .../i18n-paraglide/src/routes/__root.tsx | 85 ++++++ .../i18n-paraglide/src/routes/about.tsx | 10 + .../i18n-paraglide/src/routes/index.tsx | 30 +++ e2e/react-start/i18n-paraglide/src/server.ts | 8 + e2e/react-start/i18n-paraglide/src/styles.css | 1 + .../i18n-paraglide/src/utils/prerender.ts | 8 + .../i18n-paraglide/src/utils/seo.ts | 33 +++ .../src/utils/translated-pathnames.ts | 56 ++++ .../i18n-paraglide/tests/navigation.spec.ts | 101 ++++++++ e2e/react-start/i18n-paraglide/tsconfig.json | 29 +++ e2e/react-start/i18n-paraglide/vite.config.ts | 47 ++++ packages/react-router/tests/router.test.tsx | 241 ++++++++++++++++++ pnpm-lock.yaml | 55 ++++ 32 files changed, 975 insertions(+) create mode 100644 e2e/react-start/i18n-paraglide/.gitignore create mode 100644 e2e/react-start/i18n-paraglide/.prettierignore create mode 100644 e2e/react-start/i18n-paraglide/.vscode/extensions.json create mode 100644 e2e/react-start/i18n-paraglide/.vscode/settings.json create mode 100644 e2e/react-start/i18n-paraglide/messages/de.json create mode 100644 e2e/react-start/i18n-paraglide/messages/en.json create mode 100644 e2e/react-start/i18n-paraglide/package.json create mode 100644 e2e/react-start/i18n-paraglide/playwright.config.ts create mode 100644 e2e/react-start/i18n-paraglide/project.inlang/.gitignore create mode 100644 e2e/react-start/i18n-paraglide/project.inlang/project_id create mode 100644 e2e/react-start/i18n-paraglide/project.inlang/settings.json create mode 100644 e2e/react-start/i18n-paraglide/public/favicon.ico create mode 100644 e2e/react-start/i18n-paraglide/public/logo192.png create mode 100644 e2e/react-start/i18n-paraglide/public/logo512.png create mode 100644 e2e/react-start/i18n-paraglide/public/manifest.json create mode 100644 e2e/react-start/i18n-paraglide/public/robots.txt create mode 100644 e2e/react-start/i18n-paraglide/src/logo.svg create mode 100644 e2e/react-start/i18n-paraglide/src/routeTree.gen.ts create mode 100644 e2e/react-start/i18n-paraglide/src/router.tsx create mode 100644 e2e/react-start/i18n-paraglide/src/routes/__root.tsx create mode 100644 e2e/react-start/i18n-paraglide/src/routes/about.tsx create mode 100644 e2e/react-start/i18n-paraglide/src/routes/index.tsx create mode 100644 e2e/react-start/i18n-paraglide/src/server.ts create mode 100644 e2e/react-start/i18n-paraglide/src/styles.css create mode 100644 e2e/react-start/i18n-paraglide/src/utils/prerender.ts create mode 100644 e2e/react-start/i18n-paraglide/src/utils/seo.ts create mode 100644 e2e/react-start/i18n-paraglide/src/utils/translated-pathnames.ts create mode 100644 e2e/react-start/i18n-paraglide/tests/navigation.spec.ts create mode 100644 e2e/react-start/i18n-paraglide/tsconfig.json create mode 100644 e2e/react-start/i18n-paraglide/vite.config.ts diff --git a/e2e/react-start/i18n-paraglide/.gitignore b/e2e/react-start/i18n-paraglide/.gitignore new file mode 100644 index 00000000000..eb0412b5f5b --- /dev/null +++ b/e2e/react-start/i18n-paraglide/.gitignore @@ -0,0 +1,10 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +count.txt +.env +.nitro +.tanstack +.output diff --git a/e2e/react-start/i18n-paraglide/.prettierignore b/e2e/react-start/i18n-paraglide/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/react-start/i18n-paraglide/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/e2e/react-start/i18n-paraglide/.vscode/extensions.json b/e2e/react-start/i18n-paraglide/.vscode/extensions.json new file mode 100644 index 00000000000..8cf06c2f61a --- /dev/null +++ b/e2e/react-start/i18n-paraglide/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["inlang.vs-code-extension"] +} diff --git a/e2e/react-start/i18n-paraglide/.vscode/settings.json b/e2e/react-start/i18n-paraglide/.vscode/settings.json new file mode 100644 index 00000000000..00b5278e580 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/e2e/react-start/i18n-paraglide/messages/de.json b/e2e/react-start/i18n-paraglide/messages/de.json new file mode 100644 index 00000000000..2c2167c3ca0 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/messages/de.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "example_message": "Guten Tag {username}", + "server_message": "Server Nachricht {emoji}", + "about_message": "Über uns", + "home_page": "Startseite", + "about_page": "Über uns" +} diff --git a/e2e/react-start/i18n-paraglide/messages/en.json b/e2e/react-start/i18n-paraglide/messages/en.json new file mode 100644 index 00000000000..204789de75c --- /dev/null +++ b/e2e/react-start/i18n-paraglide/messages/en.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "example_message": "Hello world {username}", + "server_message": "Server message {emoji}", + "about_message": "About message", + "home_page": "Home page", + "about_page": "About page" +} diff --git a/e2e/react-start/i18n-paraglide/package.json b/e2e/react-start/i18n-paraglide/package.json new file mode 100644 index 00000000000..8124319abc2 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-react-start-e2e-i18n-paraglide", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@inlang/paraglide-js": "^2.4.0", + "@playwright/test": "^1.50.1", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/i18n-paraglide/playwright.config.ts b/e2e/react-start/i18n-paraglide/playwright.config.ts new file mode 100644 index 00000000000..38690f09d8b --- /dev/null +++ b/e2e/react-start/i18n-paraglide/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm build && VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/i18n-paraglide/project.inlang/.gitignore b/e2e/react-start/i18n-paraglide/project.inlang/.gitignore new file mode 100644 index 00000000000..5e465967597 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/e2e/react-start/i18n-paraglide/project.inlang/project_id b/e2e/react-start/i18n-paraglide/project.inlang/project_id new file mode 100644 index 00000000000..a956b223fac --- /dev/null +++ b/e2e/react-start/i18n-paraglide/project.inlang/project_id @@ -0,0 +1 @@ +UoZ15Q8qSGIbImRS3Y \ No newline at end of file diff --git a/e2e/react-start/i18n-paraglide/project.inlang/settings.json b/e2e/react-start/i18n-paraglide/project.inlang/settings.json new file mode 100644 index 00000000000..9bdce4c8cc9 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "de"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/e2e/react-start/i18n-paraglide/public/favicon.ico b/e2e/react-start/i18n-paraglide/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/e2e/react-start/i18n-paraglide/public/logo192.png b/e2e/react-start/i18n-paraglide/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/e2e/react-start/i18n-paraglide/public/manifest.json b/e2e/react-start/i18n-paraglide/public/manifest.json new file mode 100644 index 00000000000..078ef501162 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "TanStack App", + "name": "Create TanStack App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/e2e/react-start/i18n-paraglide/public/robots.txt b/e2e/react-start/i18n-paraglide/public/robots.txt new file mode 100644 index 00000000000..e9e57dc4d41 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/e2e/react-start/i18n-paraglide/src/logo.svg b/e2e/react-start/i18n-paraglide/src/logo.svg new file mode 100644 index 00000000000..fe53fe8d0d2 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/logo.svg @@ -0,0 +1,12 @@ + + + logo + + \ No newline at end of file diff --git a/e2e/react-start/i18n-paraglide/src/routeTree.gen.ts b/e2e/react-start/i18n-paraglide/src/routeTree.gen.ts new file mode 100644 index 00000000000..421daf2790a --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AboutRouteImport } from './routes/about' +import { Route as IndexRouteImport } from './routes/index' + +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/about': typeof AboutRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/about' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/about' + id: '__root__' | '/' | '/about' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/i18n-paraglide/src/router.tsx b/e2e/react-start/i18n-paraglide/src/router.tsx new file mode 100644 index 00000000000..3a8413a9645 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/router.tsx @@ -0,0 +1,18 @@ +import { createRouter } from '@tanstack/react-router' + +// Import the generated route tree +import { routeTree } from './routeTree.gen' +import { deLocalizeUrl, localizeUrl } from './paraglide/runtime' + +// Create a new router instance +export const getRouter = () => { + return createRouter({ + routeTree, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + rewrite: { + input: ({ url }) => deLocalizeUrl(url), + output: ({ url }) => localizeUrl(url), + }, + }) +} diff --git a/e2e/react-start/i18n-paraglide/src/routes/__root.tsx b/e2e/react-start/i18n-paraglide/src/routes/__root.tsx new file mode 100644 index 00000000000..c5c84d708a2 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/routes/__root.tsx @@ -0,0 +1,85 @@ +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import styles from '../styles.css?url' +import { getLocale, locales, setLocale } from '@/paraglide/runtime' +import { m } from '@/paraglide/messages' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'TanStack Start Starter', + }, + ], + links: [{ rel: 'stylesheet', href: styles }], + }), + + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + +
+
+ + {m.home_page()} + + + + {m.about_page()} + +
+ +
+ {locales.map((locale) => ( + + ))} +
+
+ +
+ +
+ +
+ + + + + ) +} diff --git a/e2e/react-start/i18n-paraglide/src/routes/about.tsx b/e2e/react-start/i18n-paraglide/src/routes/about.tsx new file mode 100644 index 00000000000..0ee95a577a2 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/routes/about.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { m } from '@/paraglide/messages' + +export const Route = createFileRoute('/about')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{m.about_message()}
+} diff --git a/e2e/react-start/i18n-paraglide/src/routes/index.tsx b/e2e/react-start/i18n-paraglide/src/routes/index.tsx new file mode 100644 index 00000000000..d86af585cb4 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/routes/index.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router' +import { m } from '@/paraglide/messages.js' +import { createServerFn } from '@tanstack/react-start' + +const getServerMessage = createServerFn() + .inputValidator((emoji: string) => emoji) + .handler((ctx) => { + return m.server_message({ emoji: ctx.data }) + }) + +export const Route = createFileRoute('/')({ + component: Home, + loader: async () => { + return { + messageFromLoader: m.example_message({ username: 'John Doe' }), + serverFunctionMessage: await getServerMessage({ data: '📩' }), + } + }, +}) + +function Home() { + const { serverFunctionMessage, messageFromLoader } = Route.useLoaderData() + return ( +
+

Message from loader: {messageFromLoader}

+

Server function message: {serverFunctionMessage}:

+

{m.example_message({ username: 'John Doe' })}

+
+ ) +} diff --git a/e2e/react-start/i18n-paraglide/src/server.ts b/e2e/react-start/i18n-paraglide/src/server.ts new file mode 100644 index 00000000000..9542b01d4ac --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/server.ts @@ -0,0 +1,8 @@ +import { paraglideMiddleware } from './paraglide/server.js' +import handler from '@tanstack/react-start/server-entry' + +export default { + fetch(req: Request): Promise { + return paraglideMiddleware(req, ({ request }) => handler.fetch(request)) + }, +} diff --git a/e2e/react-start/i18n-paraglide/src/styles.css b/e2e/react-start/i18n-paraglide/src/styles.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/styles.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-start/i18n-paraglide/src/utils/prerender.ts b/e2e/react-start/i18n-paraglide/src/utils/prerender.ts new file mode 100644 index 00000000000..0cb1630595f --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/utils/prerender.ts @@ -0,0 +1,8 @@ +import { localizeHref } from '../paraglide/runtime' + +export const prerenderRoutes = ['/', '/about'].map((path) => ({ + path: localizeHref(path), + prerender: { + enabled: true, + }, +})) diff --git a/e2e/react-start/i18n-paraglide/src/utils/seo.ts b/e2e/react-start/i18n-paraglide/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/react-start/i18n-paraglide/src/utils/translated-pathnames.ts b/e2e/react-start/i18n-paraglide/src/utils/translated-pathnames.ts new file mode 100644 index 00000000000..bb8649ff4e0 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/src/utils/translated-pathnames.ts @@ -0,0 +1,56 @@ +import { Locale } from '@/paraglide/runtime' +import { FileRoutesByTo } from '../routeTree.gen' + +type RoutePath = keyof FileRoutesByTo + +const excludedPaths = ['admin', 'docs', 'api'] as const + +type PublicRoutePath = Exclude< + RoutePath, + `${string}${(typeof excludedPaths)[number]}${string}` +> + +type TranslatedPathname = { + pattern: string + localized: Array<[Locale, string]> +} + +function toUrlPattern(path: string) { + return ( + path + // catch-all + .replace(/\/\$$/, '/:path(.*)?') + // optional parameters: {-$param} + .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ':$1?') + // named parameters: $param + .replace(/\$([a-zA-Z0-9_]+)/g, ':$1') + // remove trailing slash + .replace(/\/+$/, '') + ) +} + +function createTranslatedPathnames( + input: Record>, +): TranslatedPathname[] { + return Object.entries(input).map(([pattern, locales]) => ({ + pattern: toUrlPattern(pattern), + localized: Object.entries(locales).map( + ([locale, path]) => + [locale as Locale, `/${locale}${toUrlPattern(path)}`] satisfies [ + Locale, + string, + ], + ), + })) +} + +export const translatedPathnames = createTranslatedPathnames({ + '/': { + en: '/', + de: '/', + }, + '/about': { + en: '/about', + de: '/ueber', + }, +}) diff --git a/e2e/react-start/i18n-paraglide/tests/navigation.spec.ts b/e2e/react-start/i18n-paraglide/tests/navigation.spec.ts new file mode 100644 index 00000000000..ebb18900499 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/tests/navigation.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test' + +test('should not cause redirect loops when accessing the home page', async ({ + page, +}) => { + // This test verifies that accessing the root URL does not cause a redirect loop + // The issue is that with i18n rewrites, the server may keep redirecting between + // / and /en (or similar locale prefixed paths) causing "too many redirects" + + // Navigate to the root - should eventually land on a locale-prefixed page + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Verify the page loaded successfully and shows content + expect(await page.getByText('Hello world').first().isVisible()).toBe(true) +}) + +test('should not cause redirect loops when accessing locale-prefixed home page', async ({ + page, +}) => { + // Navigate to the English home page + await page.goto('/en') + await page.waitForLoadState('networkidle') + + // Verify the page loaded successfully + expect(await page.getByText('Hello world').first().isVisible()).toBe(true) +}) + +test('should not cause redirect loops when accessing German home page', async ({ + page, +}) => { + // Navigate to the German home page + await page.goto('/de') + await page.waitForLoadState('networkidle') + + // Verify the page loaded successfully - German message + expect(await page.getByText('Guten Tag').first().isVisible()).toBe(true) +}) + +test('should navigate between locales without redirect loops', async ({ + page, +}) => { + // Start at English page + await page.goto('/en') + await page.waitForLoadState('networkidle') + + // Click the German locale button + await page.getByRole('button', { name: 'de' }).click() + await page.waitForLoadState('networkidle') + + // Verify we're now on the German page + expect(page.url()).toContain('/de') + expect(await page.getByText('Guten Tag').first().isVisible()).toBe(true) +}) + +test('server-side navigation to about page should not cause redirect loops', async ({ + page, +}) => { + // Navigate directly to English about page + await page.goto('/en/about') + await page.waitForLoadState('networkidle') + + // Verify the page loaded successfully + expect(await page.getByText('About message').isVisible()).toBe(true) +}) + +test('server-side navigation to German about page should not cause redirect loops', async ({ + page, +}) => { + // Navigate directly to German about page (translated path) + await page.goto('/de/ueber') + await page.waitForLoadState('networkidle') + + // Verify the page loaded successfully by checking the URL + expect(page.url()).toContain('/de/ueber') + + // Verify the page content loaded - check for the German nav link which is always present + // The about page also contains "Über uns" as the content + await expect(page.locator('a[href="/de/ueber"]')).toBeVisible() +}) + +test('check redirect behavior does not loop', async ({ page }) => { + // Test that requesting the root without locale prefix works with a single redirect + const response = await page.request.get('/', { maxRedirects: 0 }) + + // We expect either a 200 (if no redirect needed) or a redirect to a locale-prefixed path + const status = response.status() + if (status >= 300 && status < 400) { + const location = response.headers()['location'] + // The redirect should be to a locale-prefixed path + expect(location).toMatch(/^\/(en|de)/) + + // Following the redirect should not cause another redirect loop + const followUp = await page.request.get(location!, { maxRedirects: 0 }) + // The follow-up should be a 200 or another valid response, not another redirect to the same location + expect(followUp.status()).toBeLessThan(400) + } else { + // If no redirect, the page should load successfully + expect(status).toBe(200) + } +}) diff --git a/e2e/react-start/i18n-paraglide/tsconfig.json b/e2e/react-start/i18n-paraglide/tsconfig.json new file mode 100644 index 00000000000..3e42c72626a --- /dev/null +++ b/e2e/react-start/i18n-paraglide/tsconfig.json @@ -0,0 +1,29 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/e2e/react-start/i18n-paraglide/vite.config.ts b/e2e/react-start/i18n-paraglide/vite.config.ts new file mode 100644 index 00000000000..a0d8a6f77e1 --- /dev/null +++ b/e2e/react-start/i18n-paraglide/vite.config.ts @@ -0,0 +1,47 @@ +import { paraglideVitePlugin } from '@inlang/paraglide-js' +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import tailwindcss from '@tailwindcss/vite' + +const config = defineConfig({ + plugins: [ + paraglideVitePlugin({ + project: './project.inlang', + outdir: './src/paraglide', + outputStructure: 'message-modules', + cookieName: 'PARAGLIDE_LOCALE', + strategy: ['url', 'cookie', 'preferredLanguage', 'baseLocale'], + urlPatterns: [ + { + pattern: '/', + localized: [ + ['en', '/en'], + ['de', '/de'], + ], + }, + { + pattern: '/about', + localized: [ + ['en', '/en/about'], + ['de', '/de/ueber'], + ], + }, + { + pattern: '/:path(.*)?', + localized: [ + ['en', '/en/:path(.*)?'], + ['de', '/de/:path(.*)?'], + ], + }, + ], + }), + viteTsConfigPaths(), + tanstackStart(), + viteReact(), + tailwindcss(), + ], +}) + +export default config diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 1228f837118..4708552d869 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -3124,6 +3124,247 @@ describe('Router rewrite functionality', () => { expect(history.location.pathname).toBe('/user') }) + + it('should not cause redirect loops with i18n locale prefix rewriting', async () => { + // This test simulates an i18n middleware that: + // - Input: strips locale prefix (e.g., /en/home -> /home) + // - Output: adds locale prefix back (e.g., /home -> /en/home) + + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/home', + component: () =>
Home
, + }) + + const routeTree = rootRoute.addChildren([homeRoute]) + + // The history starts at the public-facing locale-prefixed URL. + // The input rewrite strips the locale prefix for internal routing. + const history = createMemoryHistory({ initialEntries: ['/en/home'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: { + input: ({ url }) => { + // Strip locale prefix: /en/home -> /home + if (url.pathname.startsWith('/en')) { + url.pathname = url.pathname.replace(/^\/en/, '') + } + return url + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeInTheDocument() + }) + + // The internal pathname should be /home (after input rewrite strips /en) + expect(router.state.location.pathname).toBe('/home') + + // The publicHref should include the locale prefix (via output rewrite) + // Since we only have input rewrite here, publicHref equals the internal href + expect(router.state.location.publicHref).toBe('/home') + }) + + it('should handle i18n rewriting with navigation between localized routes', async () => { + // Tests navigation between routes with i18n locale prefix rewriting + + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ Home + + About + +
+ ), + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + + // Start at the public-facing locale-prefixed URL + const history = createMemoryHistory({ initialEntries: ['/en'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: { + input: ({ url }) => { + // Strip locale prefix + if (url.pathname.startsWith('/en')) { + url.pathname = url.pathname.replace(/^\/en/, '') || '/' + return url + } + return url + }, + output: ({ url }) => { + // Add locale prefix + if (!url.pathname.startsWith('/en')) { + url.pathname = `/en${url.pathname === '/' ? '' : url.pathname}` + return url + } + return url + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeInTheDocument() + }) + + // Click the about link + const aboutLink = screen.getByTestId('about-link') + fireEvent.click(aboutLink) + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + // Internal pathname should be /about + expect(router.state.location.pathname).toBe('/about') + + // Public href should be /en/about + expect(router.state.location.publicHref).toBe('/en/about') + + // History should show the public-facing path + expect(history.location.pathname).toBe('/en/about') + }) + + it('should handle i18n rewriting with direct navigation to localized URL', async () => { + // Tests that navigating directly to a locale-prefixed URL works correctly + + const rootRoute = createRootRoute({ + component: () => , + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([aboutRoute]) + + // Start at German locale-prefixed URL + const history = createMemoryHistory({ initialEntries: ['/de/about'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: { + input: ({ url }) => { + // Strip any locale prefix + const match = url.pathname.match(/^\/(en|de)(.*)$/) + if (match) { + url.pathname = match[2] || '/' + return url + } + return url + }, + output: ({ url }) => { + // Default to German locale + if (!url.pathname.match(/^\/(en|de)/)) { + url.pathname = `/de${url.pathname === '/' ? '' : url.pathname}` + return url + } + return url + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + // Internal pathname should be /about (de-localized) + expect(router.state.location.pathname).toBe('/about') + + // Public href should include locale + expect(router.state.location.publicHref).toBe('/de/about') + }) + + it('should maintain consistent publicHref between parseLocation and buildLocation', async () => { + // This test specifically verifies the fix for the redirect loop bug: + // parseLocation and buildLocation must compute the same publicHref + // for the same logical location. + + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const routeTree = rootRoute.addChildren([homeRoute]) + + // Start at the locale-prefixed URL + const history = createMemoryHistory({ initialEntries: ['/fr'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: { + input: ({ url }) => { + // De-localize: /fr -> / + if (url.pathname.startsWith('/fr')) { + url.pathname = url.pathname.replace(/^\/fr/, '') || '/' + } + return url + }, + output: ({ url }) => { + // Re-localize: / -> /fr + if (!url.pathname.startsWith('/fr')) { + url.pathname = `/fr${url.pathname === '/' ? '' : url.pathname}` + } + return url + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeInTheDocument() + }) + + // Get the current location's publicHref (computed by parseLocation) + const parsedPublicHref = router.state.location.publicHref + + // Build a location to the same path and check its publicHref + const builtLocation = router.buildLocation({ to: '/' }) + + // These must match - if they don't, the router will think it needs + // to redirect, causing an infinite loop + expect(parsedPublicHref).toBe(builtLocation.publicHref) + expect(parsedPublicHref).toBe('/fr') + }) }) describe('basepath', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec29f2343b2..50d1f88dd46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1538,6 +1538,61 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/i18n-paraglide: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@inlang/paraglide-js': + specifier: ^2.4.0 + version: 2.4.0(babel-plugin-macros@3.1.0) + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/query-integration: dependencies: '@tanstack/react-query': From b6e14a8d98a8409d01e2d59b68fdf1bde6b68e96 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Tue, 23 Dec 2025 10:54:38 +0100 Subject: [PATCH 2/4] add SPA test --- e2e/react-router/i18n-paraglide/.gitignore | 7 + e2e/react-router/i18n-paraglide/index.html | 18 ++ .../i18n-paraglide/messages/de.json | 7 + .../i18n-paraglide/messages/en.json | 7 + e2e/react-router/i18n-paraglide/package.json | 32 +++ .../i18n-paraglide/playwright.config.ts | 35 +++ .../i18n-paraglide/project.inlang/.gitignore | 1 + .../i18n-paraglide/project.inlang/project_id | 1 + .../project.inlang/settings.json | 12 + e2e/react-router/i18n-paraglide/src/main.tsx | 40 +++ .../i18n-paraglide/src/routes/__root.tsx | 68 +++++ .../i18n-paraglide/src/routes/about.tsx | 10 + .../i18n-paraglide/src/routes/index.tsx | 18 ++ .../i18n-paraglide/src/styles.css | 1 + .../i18n-paraglide/tests/navigation.spec.ts | 246 ++++++++++++++++++ e2e/react-router/i18n-paraglide/tsconfig.json | 19 ++ .../i18n-paraglide/vite.config.ts | 49 ++++ pnpm-lock.yaml | 73 +++++- 18 files changed, 632 insertions(+), 12 deletions(-) create mode 100644 e2e/react-router/i18n-paraglide/.gitignore create mode 100644 e2e/react-router/i18n-paraglide/index.html create mode 100644 e2e/react-router/i18n-paraglide/messages/de.json create mode 100644 e2e/react-router/i18n-paraglide/messages/en.json create mode 100644 e2e/react-router/i18n-paraglide/package.json create mode 100644 e2e/react-router/i18n-paraglide/playwright.config.ts create mode 100644 e2e/react-router/i18n-paraglide/project.inlang/.gitignore create mode 100644 e2e/react-router/i18n-paraglide/project.inlang/project_id create mode 100644 e2e/react-router/i18n-paraglide/project.inlang/settings.json create mode 100644 e2e/react-router/i18n-paraglide/src/main.tsx create mode 100644 e2e/react-router/i18n-paraglide/src/routes/__root.tsx create mode 100644 e2e/react-router/i18n-paraglide/src/routes/about.tsx create mode 100644 e2e/react-router/i18n-paraglide/src/routes/index.tsx create mode 100644 e2e/react-router/i18n-paraglide/src/styles.css create mode 100644 e2e/react-router/i18n-paraglide/tests/navigation.spec.ts create mode 100644 e2e/react-router/i18n-paraglide/tsconfig.json create mode 100644 e2e/react-router/i18n-paraglide/vite.config.ts diff --git a/e2e/react-router/i18n-paraglide/.gitignore b/e2e/react-router/i18n-paraglide/.gitignore new file mode 100644 index 00000000000..633517e72cc --- /dev/null +++ b/e2e/react-router/i18n-paraglide/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +src/routeTree.gen.ts +src/paraglide +*.local +port*.txt +test-results diff --git a/e2e/react-router/i18n-paraglide/index.html b/e2e/react-router/i18n-paraglide/index.html new file mode 100644 index 00000000000..3292330d0bf --- /dev/null +++ b/e2e/react-router/i18n-paraglide/index.html @@ -0,0 +1,18 @@ + + + + + + + + + TanStack Router i18n-paraglide e2e + + +
+ + + diff --git a/e2e/react-router/i18n-paraglide/messages/de.json b/e2e/react-router/i18n-paraglide/messages/de.json new file mode 100644 index 00000000000..11498bd8b60 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/messages/de.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "example_message": "Guten Tag {username}", + "hello_about": "Hallo /ueber!", + "home_page": "Startseite", + "about_page": "Über uns" +} diff --git a/e2e/react-router/i18n-paraglide/messages/en.json b/e2e/react-router/i18n-paraglide/messages/en.json new file mode 100644 index 00000000000..8a85ed03fc9 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/messages/en.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "example_message": "Hello world {username}", + "hello_about": "Hello /about!", + "home_page": "Home page", + "about_page": "About page" +} diff --git a/e2e/react-router/i18n-paraglide/package.json b/e2e/react-router/i18n-paraglide/package.json new file mode 100644 index 00000000000..58dc5a1689f --- /dev/null +++ b/e2e/react-router/i18n-paraglide/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-router-e2e-react-i18n-paraglide", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-router": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@inlang/paraglide-js": "^2.4.0", + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^7.1.7" + } +} diff --git a/e2e/react-router/i18n-paraglide/playwright.config.ts b/e2e/react-router/i18n-paraglide/playwright.config.ts new file mode 100644 index 00000000000..91ef8fbd9ff --- /dev/null +++ b/e2e/react-router/i18n-paraglide/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/i18n-paraglide/project.inlang/.gitignore b/e2e/react-router/i18n-paraglide/project.inlang/.gitignore new file mode 100644 index 00000000000..06cf65390f5 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/project.inlang/.gitignore @@ -0,0 +1 @@ +cache diff --git a/e2e/react-router/i18n-paraglide/project.inlang/project_id b/e2e/react-router/i18n-paraglide/project.inlang/project_id new file mode 100644 index 00000000000..203a95649e0 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/project.inlang/project_id @@ -0,0 +1 @@ +apLjNFHNH2yAHYTN5d \ No newline at end of file diff --git a/e2e/react-router/i18n-paraglide/project.inlang/settings.json b/e2e/react-router/i18n-paraglide/project.inlang/settings.json new file mode 100644 index 00000000000..9bdce4c8cc9 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "de"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/e2e/react-router/i18n-paraglide/src/main.tsx b/e2e/react-router/i18n-paraglide/src/main.tsx new file mode 100644 index 00000000000..b30558a9472 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/src/main.tsx @@ -0,0 +1,40 @@ +import { StrictMode } from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import './styles.css' +// Import the generated route tree +import { routeTree } from './routeTree.gen' +import { deLocalizeUrl, localizeUrl } from './paraglide/runtime.js' + +// Create a new router instance +const router = createRouter({ + routeTree, + context: {}, + defaultPreload: 'intent', + scrollRestoration: true, + defaultStructuralSharing: true, + defaultPreloadStaleTime: 0, + + rewrite: { + input: ({ url }) => deLocalizeUrl(url), + output: ({ url }) => localizeUrl(url), + }, +}) + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +// Render the app +const rootElement = document.getElementById('app') +if (rootElement && !rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render( + + + , + ) +} diff --git a/e2e/react-router/i18n-paraglide/src/routes/__root.tsx b/e2e/react-router/i18n-paraglide/src/routes/__root.tsx new file mode 100644 index 00000000000..6f50edef003 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/src/routes/__root.tsx @@ -0,0 +1,68 @@ +import { Link, Outlet, createRootRoute, redirect } from '@tanstack/react-router' +import { + getLocale, + locales, + setLocale, + shouldRedirect, +} from '@/paraglide/runtime' +import { m } from '@/paraglide/messages' + +export const Route = createRootRoute({ + beforeLoad: async () => { + document.documentElement.setAttribute('lang', getLocale()) + + const decision = await shouldRedirect({ url: window.location.href }) + + if (decision.redirectUrl) { + throw redirect({ href: decision.redirectUrl.href }) + } + }, + component: () => ( + <> +
+
+ + {m.home_page()} + + + + {m.about_page()} + +
+ +
+ {locales.map((locale) => ( + + ))} +
+
+ +
+ +
+ +
+ + ), +}) diff --git a/e2e/react-router/i18n-paraglide/src/routes/about.tsx b/e2e/react-router/i18n-paraglide/src/routes/about.tsx new file mode 100644 index 00000000000..93cfdb3bd70 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/src/routes/about.tsx @@ -0,0 +1,10 @@ +import { m } from '@/paraglide/messages' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{m.hello_about()}
+} diff --git a/e2e/react-router/i18n-paraglide/src/routes/index.tsx b/e2e/react-router/i18n-paraglide/src/routes/index.tsx new file mode 100644 index 00000000000..accd04a1a19 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/src/routes/index.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +import { m } from '@/paraglide/messages' + +export const Route = createFileRoute('/')({ + component: App, +}) + +function App() { + return ( +
+

+ {m.example_message({ + username: 'TanStack Router!', + })} +

+
+ ) +} diff --git a/e2e/react-router/i18n-paraglide/src/styles.css b/e2e/react-router/i18n-paraglide/src/styles.css new file mode 100644 index 00000000000..d4b5078586e --- /dev/null +++ b/e2e/react-router/i18n-paraglide/src/styles.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/e2e/react-router/i18n-paraglide/tests/navigation.spec.ts b/e2e/react-router/i18n-paraglide/tests/navigation.spec.ts new file mode 100644 index 00000000000..8ec0d822925 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/tests/navigation.spec.ts @@ -0,0 +1,246 @@ +import { expect, test } from '@playwright/test' + +// These tests verify that client-side i18n rewrites work correctly in a pure SPA +// (no server-side rendering). The router uses the rewrite API to: +// - input: de-localize URLs for route matching (e.g., /de/ueber -> /about) +// - output: localize URLs for display (e.g., /about -> /de/ueber) + +test.describe('Client-side i18n navigation', () => { + test('should load the home page without redirect loops', async ({ page }) => { + // Navigate to root - should work without any redirect loop issues + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Verify the page loaded successfully + await expect(page.getByTestId('home-content')).toBeVisible() + await expect(page.getByTestId('home-content')).toContainText('Hello world') + }) + + test('should load the German home page (/de)', async ({ page }) => { + await page.goto('/de') + await page.waitForLoadState('networkidle') + + // Verify we're on the German page + await expect(page.getByTestId('home-content')).toBeVisible() + await expect(page.getByTestId('home-content')).toContainText('Guten Tag') + + // URL should remain /de + expect(page.url()).toContain('/de') + }) + + test('should navigate to about page and update URL correctly', async ({ + page, + }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Click on about link + await page.getByTestId('about-link').click() + await page.waitForLoadState('networkidle') + + // Verify we're on the about page + await expect(page.getByTestId('about-content')).toBeVisible() + await expect(page.getByTestId('about-content')).toContainText( + 'Hello /about!', + ) + + // URL should be /about (English) + expect(page.url()).toContain('/about') + }) + + test('should navigate to German about page with translated URL', async ({ + page, + }) => { + await page.goto('/de') + await page.waitForLoadState('networkidle') + + // Click on about link (should navigate to /de/ueber) + await page.getByTestId('about-link').click() + await page.waitForLoadState('networkidle') + + // Verify we're on the about page with German content + await expect(page.getByTestId('about-content')).toBeVisible() + await expect(page.getByTestId('about-content')).toContainText( + 'Hallo /ueber!', + ) + + // URL should be /de/ueber (German translated path) + expect(page.url()).toContain('/de/ueber') + }) + + test('should directly access German about page (/de/ueber)', async ({ + page, + }) => { + // Direct navigation to translated URL + await page.goto('/de/ueber') + await page.waitForLoadState('networkidle') + + // Verify the page loaded correctly + await expect(page.getByTestId('about-content')).toBeVisible() + await expect(page.getByTestId('about-content')).toContainText( + 'Hallo /ueber!', + ) + + // URL should stay /de/ueber + expect(page.url()).toContain('/de/ueber') + }) + + test('should switch locale and update URLs accordingly', async ({ page }) => { + // Start at English home page + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Verify English content + await expect(page.getByTestId('home-content')).toContainText('Hello world') + + // Switch to German + await page.getByTestId('locale-de').click() + await page.waitForLoadState('networkidle') + + // Verify German content + await expect(page.getByTestId('home-content')).toContainText('Guten Tag') + + // URL should now include /de + expect(page.url()).toContain('/de') + }) + + test('should switch locale on about page and update translated URL', async ({ + page, + }) => { + // Start at English about page + await page.goto('/about') + await page.waitForLoadState('networkidle') + + // Verify English content + await expect(page.getByTestId('about-content')).toContainText( + 'Hello /about!', + ) + + // Switch to German + await page.getByTestId('locale-de').click() + await page.waitForLoadState('networkidle') + + // Verify German content + await expect(page.getByTestId('about-content')).toContainText( + 'Hallo /ueber!', + ) + + // URL should now be /de/ueber (translated path) + expect(page.url()).toContain('/de/ueber') + }) + + test('should maintain correct links after locale switch', async ({ + page, + }) => { + // Start at German home page + await page.goto('/de') + await page.waitForLoadState('networkidle') + + // Verify about link has German translated href + const aboutLink = page.getByTestId('about-link') + await expect(aboutLink).toHaveAttribute('href', '/de/ueber') + + // Switch to English + await page.getByTestId('locale-en').click() + await page.waitForLoadState('networkidle') + + // Verify about link now has English href + await expect(aboutLink).toHaveAttribute('href', '/about') + }) +}) + +test.describe('Client-side navigation without redirect loops', () => { + test('navigating back and forth should not cause issues', async ({ + page, + }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate to about + await page.getByTestId('about-link').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('about-content')).toBeVisible() + + // Navigate back to home + await page.getByTestId('home-link').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('home-content')).toBeVisible() + + // Navigate to about again + await page.getByTestId('about-link').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('about-content')).toBeVisible() + }) + + test('switching locales multiple times should work correctly', async ({ + page, + }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Switch to German + await page.getByTestId('locale-de').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('home-content')).toContainText('Guten Tag') + + // Switch back to English + await page.getByTestId('locale-en').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('home-content')).toContainText('Hello world') + + // Switch to German again + await page.getByTestId('locale-de').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('home-content')).toContainText('Guten Tag') + }) + + test('browser back/forward should work with locale changes', async ({ + page, + }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate to about + await page.getByTestId('about-link').click() + await page.waitForLoadState('networkidle') + + // Switch to German + await page.getByTestId('locale-de').click() + await page.waitForLoadState('networkidle') + expect(page.url()).toContain('/de/ueber') + + // Go back + await page.goBack() + await page.waitForLoadState('networkidle') + + // Should be on about page (English or previous state) + await expect(page.getByTestId('about-content')).toBeVisible() + }) +}) + +test.describe('URL rewrite consistency', () => { + test('internal navigation should use rewritten URLs in links', async ({ + page, + }) => { + await page.goto('/de') + await page.waitForLoadState('networkidle') + + // Check that links are properly localized + const homeLink = page.getByTestId('home-link') + const aboutLink = page.getByTestId('about-link') + + await expect(homeLink).toHaveAttribute('href', '/de') + await expect(aboutLink).toHaveAttribute('href', '/de/ueber') + }) + + test('English links should not have locale prefix', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + const homeLink = page.getByTestId('home-link') + const aboutLink = page.getByTestId('about-link') + + await expect(homeLink).toHaveAttribute('href', '/') + await expect(aboutLink).toHaveAttribute('href', '/about') + }) +}) diff --git a/e2e/react-router/i18n-paraglide/tsconfig.json b/e2e/react-router/i18n-paraglide/tsconfig.json new file mode 100644 index 00000000000..c478245689c --- /dev/null +++ b/e2e/react-router/i18n-paraglide/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": false, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "allowJs": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "tests"] +} diff --git a/e2e/react-router/i18n-paraglide/vite.config.ts b/e2e/react-router/i18n-paraglide/vite.config.ts new file mode 100644 index 00000000000..97e9dda7bc0 --- /dev/null +++ b/e2e/react-router/i18n-paraglide/vite.config.ts @@ -0,0 +1,49 @@ +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' +import { resolve } from 'node:path' +import { paraglideVitePlugin } from '@inlang/paraglide-js' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [ + tailwindcss(), + paraglideVitePlugin({ + project: './project.inlang', + outdir: './src/paraglide', + outputStructure: 'message-modules', + cookieName: 'PARAGLIDE_LOCALE', + strategy: ['url', 'cookie', 'preferredLanguage', 'baseLocale'], + urlPatterns: [ + { + pattern: '/', + localized: [ + ['en', '/'], + ['de', '/de'], + ], + }, + { + pattern: '/about', + localized: [ + ['en', '/about'], + ['de', '/de/ueber'], + ], + }, + { + pattern: '/:path(.*)?', + localized: [ + ['en', '/:path(.*)?'], + ['de', '/de/:path(.*)?'], + ], + }, + ], + }), + tanstackRouter({ autoCodeSplitting: true }), + viteReact(), + ], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50d1f88dd46..7f824556f1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,6 +726,55 @@ importers: specifier: ^7.1.7 version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-router/i18n-paraglide: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + devDependencies: + '@inlang/paraglide-js': + specifier: ^2.4.0 + version: 2.4.0(babel-plugin-macros@3.1.0) + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-router/js-only-file-based: dependencies: '@tailwindcss/vite': @@ -10222,7 +10271,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -26450,13 +26499,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -26524,12 +26573,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -26619,9 +26668,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -26649,9 +26698,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -26679,13 +26728,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -29786,7 +29835,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5) From 700efec35013e2af68da65fc6923ace9da028db7 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Tue, 23 Dec 2025 10:55:03 +0100 Subject: [PATCH 3/4] fix rewrite --- packages/react-router/src/Transitioner.tsx | 7 ++++-- packages/router-core/src/router.ts | 27 ++++++++++++++++++++-- packages/solid-router/src/Transitioner.tsx | 7 ++++-- packages/vue-router/src/Transitioner.tsx | 7 ++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index f547cdd5f58..83fb46cfe0a 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -52,9 +52,12 @@ export function Transitioner() { _includeValidateSearch: true, }) + // Check if the current URL matches the canonical form. + // Compare publicHref (browser-facing URL) for consistency with + // the server-side redirect check in router.beforeLoad. if ( - trimPathRight(router.latestLocation.href) !== - trimPathRight(nextLocation.href) + trimPathRight(router.latestLocation.publicHref) !== + trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b0748bc2c0c..eb846b0afa1 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1177,6 +1177,7 @@ export class RouterCore< // Before we do any processing, we need to allow rewrites to modify the URL // build up the full URL by combining the href from history with the router's origin const fullUrl = new URL(href, this.origin) + const url = executeRewriteInput(this.rewrite, fullUrl) const parsedSearch = this.options.parseSearch(url.search) @@ -1187,11 +1188,33 @@ export class RouterCore< const fullPath = url.href.replace(url.origin, '') + // Save the internal pathname for route matching (before output rewrite) + const internalPathname = url.pathname + + // Compute publicHref by applying the output rewrite. + // + // The publicHref represents the URL as it should appear in the browser. + // This must match what buildLocation computes for the same logical route, + // otherwise the server-side redirect check will see a mismatch and trigger + // an infinite redirect loop. + // + // We always apply the output rewrite (not conditionally) because the + // incoming URL may have already been transformed by external middleware + // before reaching the router. In that case, the input rewrite has nothing + // to do, but we still need the output rewrite to reconstruct the correct + // public-facing URL. + // + // Clone the URL to avoid mutating the one used for route matching. + const urlForOutput = new URL(url.href) + const rewrittenUrl = executeRewriteOutput(this.rewrite, urlForOutput) + const publicHref = + rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash + return { href: fullPath, - publicHref: href, + publicHref, url: url, - pathname: decodePath(url.pathname), + pathname: decodePath(internalPathname), searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, hash: url.hash.split('#').reverse()[0] ?? '', diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 1708f1bcb8c..c9df0b20537 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -55,9 +55,12 @@ export function Transitioner() { _includeValidateSearch: true, }) + // Check if the current URL matches the canonical form. + // Compare publicHref (browser-facing URL) for consistency with + // the server-side redirect check in router.beforeLoad. if ( - trimPathRight(router.latestLocation.href) !== - trimPathRight(nextLocation.href) + trimPathRight(router.latestLocation.publicHref) !== + trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) } diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index 743ac6f7b87..4b4c2316772 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -123,9 +123,12 @@ export function useTransitionerSetup() { _includeValidateSearch: true, }) + // Check if the current URL matches the canonical form. + // Compare publicHref (browser-facing URL) for consistency with + // the server-side redirect check in router.beforeLoad. if ( - trimPathRight(router.latestLocation.href) !== - trimPathRight(nextLocation.href) + trimPathRight(router.latestLocation.publicHref) !== + trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) } From 231c544c5a7b1a3b8c24a0baafe330d1b1dc110d Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Tue, 23 Dec 2025 11:07:54 +0100 Subject: [PATCH 4/4] fix e2e tests --- e2e/react-start/custom-basepath/tests/navigation.spec.ts | 7 ++++--- e2e/solid-start/custom-basepath/tests/navigation.spec.ts | 7 ++++--- e2e/vue-start/custom-basepath/tests/navigation.spec.ts | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/react-start/custom-basepath/tests/navigation.spec.ts b/e2e/react-start/custom-basepath/tests/navigation.spec.ts index e66f14be9e2..e238134d2ed 100644 --- a/e2e/react-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/react-start/custom-basepath/tests/navigation.spec.ts @@ -61,14 +61,15 @@ test('server-side redirect', async ({ page, baseURL }) => { 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 + // Both requests (with or without basepath) should redirect directly to the final destination. + // The router is smart enough to skip the intermediate "add basepath" redirect and go + // straight to where the route's beforeLoad redirect points to. await page.request .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') }) - // now go to the route WITH the base path, this will redirect to the final destination await page.request .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { diff --git a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts index e66f14be9e2..e238134d2ed 100644 --- a/e2e/solid-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/solid-start/custom-basepath/tests/navigation.spec.ts @@ -61,14 +61,15 @@ test('server-side redirect', async ({ page, baseURL }) => { 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 + // Both requests (with or without basepath) should redirect directly to the final destination. + // The router is smart enough to skip the intermediate "add basepath" redirect and go + // straight to where the route's beforeLoad redirect points to. await page.request .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') }) - // now go to the route WITH the base path, this will redirect to the final destination await page.request .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) .then((res) => { diff --git a/e2e/vue-start/custom-basepath/tests/navigation.spec.ts b/e2e/vue-start/custom-basepath/tests/navigation.spec.ts index e66f14be9e2..e238134d2ed 100644 --- a/e2e/vue-start/custom-basepath/tests/navigation.spec.ts +++ b/e2e/vue-start/custom-basepath/tests/navigation.spec.ts @@ -61,14 +61,15 @@ test('server-side redirect', async ({ page, baseURL }) => { 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 + // Both requests (with or without basepath) should redirect directly to the final destination. + // The router is smart enough to skip the intermediate "add basepath" redirect and go + // straight to where the route's beforeLoad redirect points to. await page.request .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') }) - // now go to the route WITH the base path, this will redirect to the final destination await page.request .get('/custom/basepath/redirect/throw-it', { maxRedirects: 0 }) .then((res) => {