From c7417d4006907579b193445bac937ffac82b0109 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 18:12:25 +0200 Subject: [PATCH 01/18] fix encoding and update tests rework fix and tests --- e2e/react-start/basic/package.json | 11 +- e2e/react-start/basic/playwright.config.ts | 2 +- e2e/react-start/basic/server.js | 29 +++-- e2e/react-start/basic/src/routeTree.gen.ts | 118 ++++++++++++++---- .../basic/src/routes/specialChars/$param.tsx | 15 +++ .../basic/src/routes/specialChars/route.tsx | 45 +++++++ .../basic/src/routes/specialChars/search.tsx | 20 +++ ...0\355\225\234\353\257\274\352\265\255.tsx" | 9 ++ ...0\355\225\234\353\257\274\352\265\255.tsx" | 9 -- e2e/react-start/basic/tests/params.spec.ts | 22 ---- .../basic/tests/special-characters.spec.ts | 105 ++++++++++++++++ e2e/react-start/basic/vite.config.ts | 1 + e2e/react-start/virtual-routes/routes.ts | 1 + .../virtual-routes/src/routeTree.gen.ts | 21 ++++ .../virtual-routes/src/routes/pipe.tsx | 11 ++ .../virtual-routes/src/routes/root.tsx | 9 ++ .../tests/special-characters.spec.ts | 43 +++++++ e2e/solid-start/basic/package.json | 3 +- e2e/solid-start/basic/playwright.config.ts | 2 +- e2e/solid-start/basic/server.js | 29 +++-- e2e/solid-start/basic/src/routeTree.gen.ts | 118 ++++++++++++++---- .../basic/src/routes/specialChars/$param.tsx | 15 +++ .../basic/src/routes/specialChars/route.tsx | 45 +++++++ .../basic/src/routes/specialChars/search.tsx | 20 +++ ...0\355\225\234\353\257\274\352\265\255.tsx" | 9 ++ ...0\355\225\234\353\257\274\352\265\255.tsx" | 9 -- e2e/solid-start/basic/tests/params.spec.ts | 22 ---- .../basic/tests/special-characters.spec.ts | 105 ++++++++++++++++ e2e/solid-start/basic/vite.config.ts | 1 + e2e/solid-start/virtual-routes/routes.ts | 1 + .../virtual-routes/src/routeTree.gen.ts | 21 ++++ .../virtual-routes/src/routes/pipe.tsx | 11 ++ .../virtual-routes/src/routes/root.tsx | 9 ++ .../tests/special-characters.spec.ts | 43 +++++++ e2e/vue-start/basic/package.json | 3 +- e2e/vue-start/basic/playwright.config.ts | 2 +- e2e/vue-start/basic/server.js | 29 +++-- e2e/vue-start/basic/src/routeTree.gen.ts | 118 ++++++++++++++---- .../basic/src/routes/specialChars/$param.tsx | 15 +++ .../basic/src/routes/specialChars/route.tsx | 45 +++++++ .../basic/src/routes/specialChars/search.tsx | 22 ++++ ...0\355\225\234\353\257\274\352\265\255.tsx" | 5 +- e2e/vue-start/basic/tests/params.spec.ts | 22 ---- .../basic/tests/special-characters.spec.ts | 105 ++++++++++++++++ e2e/vue-start/basic/vite.config.ts | 1 + e2e/vue-start/virtual-routes/routes.ts | 1 + .../virtual-routes/src/routeTree.gen.ts | 21 ++++ .../virtual-routes/src/routes/pipe.tsx | 11 ++ .../virtual-routes/src/routes/root.tsx | 9 ++ .../tests/special-characters.spec.ts | 43 +++++++ packages/router-core/src/router.ts | 4 +- .../src/createStartHandler.ts | 32 ++--- 52 files changed, 1202 insertions(+), 220 deletions(-) create mode 100644 e2e/react-start/basic/src/routes/specialChars/$param.tsx create mode 100644 e2e/react-start/basic/src/routes/specialChars/route.tsx create mode 100644 e2e/react-start/basic/src/routes/specialChars/search.tsx create mode 100644 "e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" delete mode 100644 "e2e/react-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" delete mode 100644 e2e/react-start/basic/tests/params.spec.ts create mode 100644 e2e/react-start/basic/tests/special-characters.spec.ts create mode 100644 e2e/react-start/virtual-routes/src/routes/pipe.tsx create mode 100644 e2e/react-start/virtual-routes/tests/special-characters.spec.ts create mode 100644 e2e/solid-start/basic/src/routes/specialChars/$param.tsx create mode 100644 e2e/solid-start/basic/src/routes/specialChars/route.tsx create mode 100644 e2e/solid-start/basic/src/routes/specialChars/search.tsx create mode 100644 "e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" delete mode 100644 "e2e/solid-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" delete mode 100644 e2e/solid-start/basic/tests/params.spec.ts create mode 100644 e2e/solid-start/basic/tests/special-characters.spec.ts create mode 100644 e2e/solid-start/virtual-routes/src/routes/pipe.tsx create mode 100644 e2e/solid-start/virtual-routes/tests/special-characters.spec.ts create mode 100644 e2e/vue-start/basic/src/routes/specialChars/$param.tsx create mode 100644 e2e/vue-start/basic/src/routes/specialChars/route.tsx create mode 100644 e2e/vue-start/basic/src/routes/specialChars/search.tsx rename "e2e/vue-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" => "e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" (58%) delete mode 100644 e2e/vue-start/basic/tests/params.spec.ts create mode 100644 e2e/vue-start/basic/tests/special-characters.spec.ts create mode 100644 e2e/vue-start/virtual-routes/src/routes/pipe.tsx create mode 100644 e2e/vue-start/virtual-routes/tests/special-characters.spec.ts diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index a32278fd863..839e4d55564 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -10,14 +10,13 @@ "build:spa": "MODE=spa vite build && tsc --noEmit", "build:prerender": "MODE=prerender vite build && tsc --noEmit", "preview": "vite preview", - "start": "pnpx srvx --prod -s ../client dist/server/server.js", - "start:spa": "node server.js", + "start": "node server.js", "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:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", - "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", - "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", - "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test --project=chromium", + "test:e2e:spaMode": "rm -rf dist; rm -rf port*.txt; MODE=spa playwright test --project=chromium", + "test:e2e:ssrMode": "rm -rf dist; rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:prerender": "rm -rf dist; rm -rf port*.txt; MODE=prerender playwright test --project=chromium", + "test:e2e:preview": "rm -rf dist; rm -rf port*.txt; MODE=preview playwright test --project=chromium", "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:preview" }, "dependencies": { diff --git a/e2e/react-start/basic/playwright.config.ts b/e2e/react-start/basic/playwright.config.ts index aa29067f463..86c58bc1ce3 100644 --- a/e2e/react-start/basic/playwright.config.ts +++ b/e2e/react-start/basic/playwright.config.ts @@ -16,7 +16,7 @@ const START_PORT = await getTestServerPort( ) const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}` -const spaModeCommand = `pnpm build:spa && pnpm start:spa` +const spaModeCommand = `pnpm build:spa && pnpm start` const ssrModeCommand = `pnpm build && pnpm start` const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` const previewModeCommand = `pnpm build && pnpm preview --port ${PORT}` diff --git a/e2e/react-start/basic/server.js b/e2e/react-start/basic/server.js index d618ab4bce3..6d92dff8024 100644 --- a/e2e/react-start/basic/server.js +++ b/e2e/react-start/basic/server.js @@ -2,6 +2,7 @@ import { toNodeHandler } from 'srvx/node' import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' +import { isSpaMode } from './tests/utils/isSpaMode.ts' const port = process.env.PORT || 3000 @@ -54,14 +55,22 @@ export async function createSpaServer() { return { app } } -createSpaServer().then(async ({ app }) => - app.listen(port, () => { - console.info(`Client Server: http://localhost:${port}`) - }), -) +if (isSpaMode) { + createSpaServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Client Server: http://localhost:${port}`) + }), + ) -createStartServer().then(async ({ app }) => - app.listen(startPort, () => { - console.info(`Start Server: http://localhost:${startPort}`) - }), -) + createStartServer().then(async ({ app }) => + app.listen(startPort, () => { + console.info(`Start Server: http://localhost:${startPort}`) + }), + ) +} else { + createStartServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Start Server: http://localhost:${port}`) + }), + ) +} diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 27d5e911452..d1b81b1f246 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -9,7 +9,6 @@ // 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 Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' import { Route as UsersRouteImport } from './routes/users' import { Route as TypeOnlyReexportRouteImport } from './routes/type-only-reexport' import { Route as StreamRouteImport } from './routes/stream' @@ -21,6 +20,7 @@ import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as ClientOnlyRouteImport } from './routes/client-only' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as IndexRouteImport } from './routes/index' @@ -32,6 +32,9 @@ import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index' import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as SpecialCharsChar45824Char54620Char48124Char44397RouteImport } from './routes/specialChars/대한민국' +import { Route as SpecialCharsSearchRouteImport } from './routes/specialChars/search' +import { Route as SpecialCharsParamRouteImport } from './routes/specialChars/$param' import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' @@ -61,12 +64,6 @@ import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './route import { Route as FooBarQuxHereRouteImport } from './routes/foo/$bar/$qux/_here' import { Route as FooBarQuxHereIndexRouteImport } from './routes/foo/$bar/$qux/_here/index' -const Char45824Char54620Char48124Char44397Route = - Char45824Char54620Char48124Char44397RouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) const UsersRoute = UsersRouteImport.update({ id: '/users', path: '/users', @@ -121,6 +118,11 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SpecialCharsRouteRoute = SpecialCharsRouteRouteImport.update({ + id: '/specialChars', + path: '/specialChars', + getParentRoute: () => rootRouteImport, +} as any) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -177,6 +179,22 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({ path: '/$userId', getParentRoute: () => UsersRoute, } as any) +const SpecialCharsChar45824Char54620Char48124Char44397Route = + SpecialCharsChar45824Char54620Char48124Char44397RouteImport.update({ + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => SpecialCharsRouteRoute, + } as any) +const SpecialCharsSearchRoute = SpecialCharsSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) +const SpecialCharsParamRoute = SpecialCharsParamRouteImport.update({ + id: '/$param', + path: '/$param', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) const SearchParamsLoaderThrowsRedirectRoute = SearchParamsLoaderThrowsRedirectRouteImport.update({ id: '/loader-throws-redirect', @@ -328,6 +346,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -338,7 +357,6 @@ export interface FileRoutesByFullPath { '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -353,6 +371,9 @@ export interface FileRoutesByFullPath { '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute @@ -377,6 +398,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -384,7 +406,6 @@ export interface FileRoutesByTo { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -398,6 +419,9 @@ export interface FileRoutesByTo { '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found': typeof NotFoundIndexRoute @@ -424,6 +448,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute @@ -435,7 +460,6 @@ export interface FileRoutesById { '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -451,6 +475,9 @@ export interface FileRoutesById { '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute @@ -479,6 +506,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/specialChars' | '/client-only' | '/deferred' | '/inline-scripts' @@ -489,7 +517,6 @@ export interface FileRouteTypes { | '/stream' | '/type-only-reexport' | '/users' - | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -504,6 +531,9 @@ export interface FileRouteTypes { | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found/' @@ -528,6 +558,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/specialChars' | '/client-only' | '/deferred' | '/inline-scripts' @@ -535,7 +566,6 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/type-only-reexport' - | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -549,6 +579,9 @@ export interface FileRouteTypes { | '/raw-stream/ssr-text-hint' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found' @@ -574,6 +607,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/specialChars' | '/_layout' | '/client-only' | '/deferred' @@ -585,7 +619,6 @@ export interface FileRouteTypes { | '/stream' | '/type-only-reexport' | '/users' - | '/대한민국' | '/_layout/_layout-2' | '/api/users' | '/multi-cookie-redirect/target' @@ -601,6 +634,9 @@ export interface FileRouteTypes { | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect/' | '/not-found/' @@ -628,6 +664,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren + SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren ClientOnlyRoute: typeof ClientOnlyRoute DeferredRoute: typeof DeferredRoute @@ -639,7 +676,6 @@ export interface RootRouteChildren { StreamRoute: typeof StreamRoute TypeOnlyReexportRoute: typeof TypeOnlyReexportRoute UsersRoute: typeof UsersRouteWithChildren - Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren @@ -651,13 +687,6 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteImport - parentRoute: typeof rootRouteImport - } '/users': { id: '/users' path: '/users' @@ -735,6 +764,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/specialChars': { + id: '/specialChars' + path: '/specialChars' + fullPath: '/specialChars' + preLoaderRoute: typeof SpecialCharsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/search-params': { id: '/search-params' path: '/search-params' @@ -812,6 +848,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UsersUserIdRouteImport parentRoute: typeof UsersRoute } + '/specialChars/대한민국': { + id: '/specialChars/대한민국' + path: '/대한민국' + fullPath: '/specialChars/대한민국' + preLoaderRoute: typeof SpecialCharsChar45824Char54620Char48124Char44397RouteImport + parentRoute: typeof SpecialCharsRouteRoute + } + '/specialChars/search': { + id: '/specialChars/search' + path: '/search' + fullPath: '/specialChars/search' + preLoaderRoute: typeof SpecialCharsSearchRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } + '/specialChars/$param': { + id: '/specialChars/$param' + path: '/$param' + fullPath: '/specialChars/$param' + preLoaderRoute: typeof SpecialCharsParamRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/search-params/loader-throws-redirect': { id: '/search-params/loader-throws-redirect' path: '/loader-throws-redirect' @@ -1042,6 +1099,22 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface SpecialCharsRouteRouteChildren { + SpecialCharsParamRoute: typeof SpecialCharsParamRoute + SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute + SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route +} + +const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { + SpecialCharsParamRoute: SpecialCharsParamRoute, + SpecialCharsSearchRoute: SpecialCharsSearchRoute, + SpecialCharsChar45824Char54620Char48124Char44397Route: + SpecialCharsChar45824Char54620Char48124Char44397Route, +} + +const SpecialCharsRouteRouteWithChildren = + SpecialCharsRouteRoute._addFileChildren(SpecialCharsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -1169,6 +1242,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NotFoundRouteRoute: NotFoundRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, + SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, ClientOnlyRoute: ClientOnlyRoute, DeferredRoute: DeferredRoute, @@ -1180,8 +1254,6 @@ const rootRouteChildren: RootRouteChildren = { StreamRoute: StreamRoute, TypeOnlyReexportRoute: TypeOnlyReexportRoute, UsersRoute: UsersRouteWithChildren, - Char45824Char54620Char48124Char44397Route: - Char45824Char54620Char48124Char44397Route, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, diff --git a/e2e/react-start/basic/src/routes/specialChars/$param.tsx b/e2e/react-start/basic/src/routes/specialChars/$param.tsx new file mode 100644 index 00000000000..43e742d5127 --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/$param.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/specialChars/$param')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { param } = Route.useParams() + return ( +
+ Hello "/specialChars/$param":{' '} + {param} +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/specialChars/route.tsx b/e2e/react-start/basic/src/routes/specialChars/route.tsx new file mode 100644 index 00000000000..470aa21ef7c --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/route.tsx @@ -0,0 +1,45 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import * as React from 'react' + +export const Route = createFileRoute('/specialChars')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
Hello "/specialChars"!
+ + Unicode + {' '} + + Unicode param + {' '} + + Unicode search param + {' '} +
+ +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/specialChars/search.tsx b/e2e/react-start/basic/src/routes/specialChars/search.tsx new file mode 100644 index 00000000000..152f39f527b --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/search.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' +import z from 'zod' + +export const Route = createFileRoute('/specialChars/search')({ + validateSearch: z.object({ + searchParam: z.string(), + }), + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + + return ( +
+ Hello "/specialChars/search"! + {search.searchParam} +
+ ) +} diff --git "a/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" new file mode 100644 index 00000000000..23228b69b3c --- /dev/null +++ "b/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/specialChars/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/대한민국"!
+} diff --git "a/e2e/react-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/react-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" deleted file mode 100644 index c70cb5096a9..00000000000 --- "a/e2e/react-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/대한민국')({ - component: RouteComponent, -}) - -function RouteComponent() { - return
Hello "/대한민국"!
-} diff --git a/e2e/react-start/basic/tests/params.spec.ts b/e2e/react-start/basic/tests/params.spec.ts deleted file mode 100644 index 505e63ef433..00000000000 --- a/e2e/react-start/basic/tests/params.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect } from '@playwright/test' - -import { test } from '@tanstack/router-e2e-utils' - -test.beforeEach(async ({ page }) => { - await page.goto('/') -}) - -test.use({ - whitelistErrors: [ - 'Failed to load resource: the server responded with a status of 404', - ], -}) -test.describe('Unicode route rendering', () => { - test('should render non-latin route correctly', async ({ page, baseURL }) => { - await page.goto('/대한민국') - - await expect(page.locator('body')).toContainText('Hello "/대한민국"!') - - expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) - }) -}) diff --git a/e2e/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts new file mode 100644 index 00000000000..7f93c275479 --- /dev/null +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -0,0 +1,105 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 404/, + ], +}) +test.describe('Unicode route rendering', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/specialChars') + }) + + test('should render non-latin route correctly with direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/대한민국') + await page.waitForURL( + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD${isPrerender ? '/' : ''}`, + ) + + await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() + }) + + test('should render non-latin route correctly during router navigation', async ({ + page, + baseURL, + }) => { + const nonLatinLink = page.getByTestId('special-non-latin-link') + + await nonLatinLink.click() + await page.waitForURL( + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`, + ) + + await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() + }) + + test.describe('Special characters in path params', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/대|') + await page.waitForURL(`${baseURL}/specialChars/%EB%8C%80%7C`) + + const param = await page.getByTestId('special-param').textContent() + + expect(param).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + const link = page.getByTestId('special-param-link') + + await link.click() + await page.waitForURL(`${baseURL}/specialChars/%EB%8C%80%7C`) + + const param = await page.getByTestId('special-param').textContent() + + expect(param).toBe('대|') + }) + }) + + test.describe('Special characters in search params', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/search?searchParam=대|') + + await page.waitForURL( + `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + ) + + const searchParam = await page + .getByTestId('special-search-param') + .textContent() + + expect(searchParam).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + const link = page.getByTestId('special-searchParam-link') + + await link.click() + await page.waitForURL( + `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + ) + + const searchParam = await page + .getByTestId('special-search-param') + .textContent() + + expect(searchParam).toBe('대|') + }) + }) +}) diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts index 55c716bdb82..88f1d7690f6 100644 --- a/e2e/react-start/basic/vite.config.ts +++ b/e2e/react-start/basic/vite.config.ts @@ -22,6 +22,7 @@ const prerenderConfiguration = { '/i-do-not-exist', '/not-found/via-beforeLoad', '/not-found/via-loader', + '/specialChars/search', '/users', ].some((p) => page.path.includes(p)), maxRedirects: 100, diff --git a/e2e/react-start/virtual-routes/routes.ts b/e2e/react-start/virtual-routes/routes.ts index ab17b8f58b4..37573a05671 100644 --- a/e2e/react-start/virtual-routes/routes.ts +++ b/e2e/react-start/virtual-routes/routes.ts @@ -21,4 +21,5 @@ export const routes = rootRoute('root.tsx', [ ]), ]), physical('/classic', 'file-based-subtree'), + route('/special|pipe', 'pipe.tsx'), ]) diff --git a/e2e/react-start/virtual-routes/src/routeTree.gen.ts b/e2e/react-start/virtual-routes/src/routeTree.gen.ts index 540b02d9481..642eb62495e 100644 --- a/e2e/react-start/virtual-routes/src/routeTree.gen.ts +++ b/e2e/react-start/virtual-routes/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // 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 pipeRouteImport } from './routes/pipe' import { Route as postsPostsRouteImport } from './routes/posts/posts' import { Route as layoutFirstLayoutRouteImport } from './routes/layout/first-layout' import { Route as homeRouteImport } from './routes/home' @@ -22,6 +23,11 @@ import { Route as ClassicHelloUniverseRouteImport } from './routes/file-based-su import { Route as bRouteImport } from './routes/b' import { Route as aRouteImport } from './routes/a' +const pipeRoute = pipeRouteImport.update({ + id: '/special|pipe', + path: '/special|pipe', + getParentRoute: () => rootRouteImport, +} as any) const postsPostsRoute = postsPostsRouteImport.update({ id: '/posts', path: '/posts', @@ -84,6 +90,7 @@ const aRoute = aRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof homeRoute '/posts': typeof postsPostsRouteWithChildren + '/special|pipe': typeof pipeRoute '/classic/hello': typeof ClassicHelloRouteRouteWithChildren '/posts/': typeof postsPostsHomeRoute '/posts/$postId': typeof postsPostsDetailRoute @@ -95,6 +102,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof homeRoute + '/special|pipe': typeof pipeRoute '/posts': typeof postsPostsHomeRoute '/posts/$postId': typeof postsPostsDetailRoute '/classic/hello/universe': typeof ClassicHelloUniverseRoute @@ -108,6 +116,7 @@ export interface FileRoutesById { '/': typeof homeRoute '/_first': typeof layoutFirstLayoutRouteWithChildren '/posts': typeof postsPostsRouteWithChildren + '/special|pipe': typeof pipeRoute '/classic/hello': typeof ClassicHelloRouteRouteWithChildren '/posts/': typeof postsPostsHomeRoute '/_first/_second-layout': typeof layoutSecondLayoutRouteWithChildren @@ -123,6 +132,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/posts' + | '/special|pipe' | '/classic/hello' | '/posts/' | '/posts/$postId' @@ -134,6 +144,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/special|pipe' | '/posts' | '/posts/$postId' | '/classic/hello/universe' @@ -146,6 +157,7 @@ export interface FileRouteTypes { | '/' | '/_first' | '/posts' + | '/special|pipe' | '/classic/hello' | '/posts/' | '/_first/_second-layout' @@ -161,11 +173,19 @@ export interface RootRouteChildren { homeRoute: typeof homeRoute layoutFirstLayoutRoute: typeof layoutFirstLayoutRouteWithChildren postsPostsRoute: typeof postsPostsRouteWithChildren + pipeRoute: typeof pipeRoute ClassicHelloRouteRoute: typeof ClassicHelloRouteRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/special|pipe': { + id: '/special|pipe' + path: '/special|pipe' + fullPath: '/special|pipe' + preLoaderRoute: typeof pipeRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -310,6 +330,7 @@ const rootRouteChildren: RootRouteChildren = { homeRoute: homeRoute, layoutFirstLayoutRoute: layoutFirstLayoutRouteWithChildren, postsPostsRoute: postsPostsRouteWithChildren, + pipeRoute: pipeRoute, ClassicHelloRouteRoute: ClassicHelloRouteRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/e2e/react-start/virtual-routes/src/routes/pipe.tsx b/e2e/react-start/virtual-routes/src/routes/pipe.tsx new file mode 100644 index 00000000000..e4077f0db85 --- /dev/null +++ b/e2e/react-start/virtual-routes/src/routes/pipe.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/special|pipe')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
Hello "/special|pipe"!
+ ) +} diff --git a/e2e/react-start/virtual-routes/src/routes/root.tsx b/e2e/react-start/virtual-routes/src/routes/root.tsx index 19f23011b7e..c0035a41108 100644 --- a/e2e/react-start/virtual-routes/src/routes/root.tsx +++ b/e2e/react-start/virtual-routes/src/routes/root.tsx @@ -76,6 +76,15 @@ function RootDocument({ children }: { children: React.ReactNode }) { > Subtree {' '} + + Pipe + {' '} { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + }) + + test.describe('Special characters in route paths', () => { + test('should render route with pipe character in path on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/special|pipe') + await page.waitForURL(`${baseURL}/special%7Cpipe`) + + await expect( + page.getByTestId('special-pipe-route-heading'), + ).toBeInViewport() + }) + + test('should render route with pipe character in path on router navigation', async ({ + page, + baseURL, + }) => { + const pipeLink = page.getByTestId('special-pipe-link') + + await pipeLink.click() + await page.waitForURL(`${baseURL}/special%7Cpipe`) + + await expect( + page.getByTestId('special-pipe-route-heading'), + ).toBeInViewport() + }) + }) +}) diff --git a/e2e/solid-start/basic/package.json b/e2e/solid-start/basic/package.json index 483839b1238..4b9899fb607 100644 --- a/e2e/solid-start/basic/package.json +++ b/e2e/solid-start/basic/package.json @@ -10,8 +10,7 @@ "build:spa": "MODE=spa vite build && tsc --noEmit", "build:prerender": "MODE=prerender vite build && tsc --noEmit", "preview": "vite preview", - "start": "pnpx srvx --prod -s ../client dist/server/server.js", - "start:spa": "node server.js", + "start": "node server.js", "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:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", diff --git a/e2e/solid-start/basic/playwright.config.ts b/e2e/solid-start/basic/playwright.config.ts index aa29067f463..86c58bc1ce3 100644 --- a/e2e/solid-start/basic/playwright.config.ts +++ b/e2e/solid-start/basic/playwright.config.ts @@ -16,7 +16,7 @@ const START_PORT = await getTestServerPort( ) const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}` -const spaModeCommand = `pnpm build:spa && pnpm start:spa` +const spaModeCommand = `pnpm build:spa && pnpm start` const ssrModeCommand = `pnpm build && pnpm start` const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` const previewModeCommand = `pnpm build && pnpm preview --port ${PORT}` diff --git a/e2e/solid-start/basic/server.js b/e2e/solid-start/basic/server.js index d618ab4bce3..6d92dff8024 100644 --- a/e2e/solid-start/basic/server.js +++ b/e2e/solid-start/basic/server.js @@ -2,6 +2,7 @@ import { toNodeHandler } from 'srvx/node' import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' +import { isSpaMode } from './tests/utils/isSpaMode.ts' const port = process.env.PORT || 3000 @@ -54,14 +55,22 @@ export async function createSpaServer() { return { app } } -createSpaServer().then(async ({ app }) => - app.listen(port, () => { - console.info(`Client Server: http://localhost:${port}`) - }), -) +if (isSpaMode) { + createSpaServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Client Server: http://localhost:${port}`) + }), + ) -createStartServer().then(async ({ app }) => - app.listen(startPort, () => { - console.info(`Start Server: http://localhost:${startPort}`) - }), -) + createStartServer().then(async ({ app }) => + app.listen(startPort, () => { + console.info(`Start Server: http://localhost:${startPort}`) + }), + ) +} else { + createStartServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Start Server: http://localhost:${port}`) + }), + ) +} diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 238008b2049..8785c63ae4c 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -9,7 +9,6 @@ // 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 Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' import { Route as UsersRouteImport } from './routes/users' import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' @@ -19,6 +18,7 @@ import { Route as LinksRouteImport } from './routes/links' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as IndexRouteImport } from './routes/index' @@ -30,6 +30,9 @@ import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index' import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as SpecialCharsChar45824Char54620Char48124Char44397RouteImport } from './routes/specialChars/대한민국' +import { Route as SpecialCharsSearchRouteImport } from './routes/specialChars/search' +import { Route as SpecialCharsParamRouteImport } from './routes/specialChars/$param' import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' @@ -59,12 +62,6 @@ import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './rout import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' -const Char45824Char54620Char48124Char44397Route = - Char45824Char54620Char48124Char44397RouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) const UsersRoute = UsersRouteImport.update({ id: '/users', path: '/users', @@ -109,6 +106,11 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SpecialCharsRouteRoute = SpecialCharsRouteRouteImport.update({ + id: '/specialChars', + path: '/specialChars', + getParentRoute: () => rootRouteImport, +} as any) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -165,6 +167,22 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({ path: '/$userId', getParentRoute: () => UsersRoute, } as any) +const SpecialCharsChar45824Char54620Char48124Char44397Route = + SpecialCharsChar45824Char54620Char48124Char44397RouteImport.update({ + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => SpecialCharsRouteRoute, + } as any) +const SpecialCharsSearchRoute = SpecialCharsSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) +const SpecialCharsParamRoute = SpecialCharsParamRouteImport.update({ + id: '/$param', + path: '/$param', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) const SearchParamsLoaderThrowsRedirectRoute = SearchParamsLoaderThrowsRedirectRouteImport.update({ id: '/loader-throws-redirect', @@ -318,6 +336,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute @@ -326,7 +345,6 @@ export interface FileRoutesByFullPath { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -341,6 +359,9 @@ export interface FileRoutesByFullPath { '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute @@ -365,12 +386,12 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -384,6 +405,9 @@ export interface FileRoutesByTo { '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found': typeof NotFoundIndexRoute @@ -411,6 +435,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -420,7 +445,6 @@ export interface FileRoutesById { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -436,6 +460,9 @@ export interface FileRoutesById { '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute @@ -464,6 +491,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/specialChars' | '/deferred' | '/inline-scripts' | '/links' @@ -472,7 +500,6 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' - | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -487,6 +514,9 @@ export interface FileRouteTypes { | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found/' @@ -511,12 +541,12 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/specialChars' | '/deferred' | '/inline-scripts' | '/links' | '/scripts' | '/stream' - | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -530,6 +560,9 @@ export interface FileRouteTypes { | '/raw-stream/ssr-text-hint' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found' @@ -556,6 +589,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/specialChars' | '/_layout' | '/deferred' | '/inline-scripts' @@ -565,7 +599,6 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' - | '/대한민국' | '/_layout/_layout-2' | '/api/users' | '/multi-cookie-redirect/target' @@ -581,6 +614,9 @@ export interface FileRouteTypes { | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect/' | '/not-found/' @@ -608,6 +644,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren + SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute InlineScriptsRoute: typeof InlineScriptsRoute @@ -617,7 +654,6 @@ export interface RootRouteChildren { ScriptsRoute: typeof ScriptsRoute StreamRoute: typeof StreamRoute UsersRoute: typeof UsersRouteWithChildren - Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren @@ -630,13 +666,6 @@ export interface RootRouteChildren { declare module '@tanstack/solid-router' { interface FileRoutesByPath { - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteImport - parentRoute: typeof rootRouteImport - } '/users': { id: '/users' path: '/users' @@ -700,6 +729,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/specialChars': { + id: '/specialChars' + path: '/specialChars' + fullPath: '/specialChars' + preLoaderRoute: typeof SpecialCharsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/search-params': { id: '/search-params' path: '/search-params' @@ -777,6 +813,27 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof UsersUserIdRouteImport parentRoute: typeof UsersRoute } + '/specialChars/대한민국': { + id: '/specialChars/대한민국' + path: '/대한민국' + fullPath: '/specialChars/대한민국' + preLoaderRoute: typeof SpecialCharsChar45824Char54620Char48124Char44397RouteImport + parentRoute: typeof SpecialCharsRouteRoute + } + '/specialChars/search': { + id: '/specialChars/search' + path: '/search' + fullPath: '/specialChars/search' + preLoaderRoute: typeof SpecialCharsSearchRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } + '/specialChars/$param': { + id: '/specialChars/$param' + path: '/$param' + fullPath: '/specialChars/$param' + preLoaderRoute: typeof SpecialCharsParamRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/search-params/loader-throws-redirect': { id: '/search-params/loader-throws-redirect' path: '/loader-throws-redirect' @@ -1007,6 +1064,22 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface SpecialCharsRouteRouteChildren { + SpecialCharsParamRoute: typeof SpecialCharsParamRoute + SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute + SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route +} + +const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { + SpecialCharsParamRoute: SpecialCharsParamRoute, + SpecialCharsSearchRoute: SpecialCharsSearchRoute, + SpecialCharsChar45824Char54620Char48124Char44397Route: + SpecialCharsChar45824Char54620Char48124Char44397Route, +} + +const SpecialCharsRouteRouteWithChildren = + SpecialCharsRouteRoute._addFileChildren(SpecialCharsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -1122,6 +1195,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NotFoundRouteRoute: NotFoundRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, + SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, InlineScriptsRoute: InlineScriptsRoute, @@ -1131,8 +1205,6 @@ const rootRouteChildren: RootRouteChildren = { ScriptsRoute: ScriptsRoute, StreamRoute: StreamRoute, UsersRoute: UsersRouteWithChildren, - Char45824Char54620Char48124Char44397Route: - Char45824Char54620Char48124Char44397Route, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, diff --git a/e2e/solid-start/basic/src/routes/specialChars/$param.tsx b/e2e/solid-start/basic/src/routes/specialChars/$param.tsx new file mode 100644 index 00000000000..179965e2c0c --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/$param.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/specialChars/$param')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + return ( +
+ Hello "/specialChars/$param":{' '} + {params().param} +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/specialChars/route.tsx b/e2e/solid-start/basic/src/routes/specialChars/route.tsx new file mode 100644 index 00000000000..de8eb9e29be --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/route.tsx @@ -0,0 +1,45 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' +import * as React from 'react' + +export const Route = createFileRoute('/specialChars')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
Hello "/specialChars"!
+ + Unicode + {' '} + + Unicode param + {' '} + + Unicode search param + {' '} +
+ +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/specialChars/search.tsx b/e2e/solid-start/basic/src/routes/specialChars/search.tsx new file mode 100644 index 00000000000..9ffc8f026f0 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/search.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/solid-router' +import z from 'zod' + +export const Route = createFileRoute('/specialChars/search')({ + validateSearch: z.object({ + searchParam: z.string(), + }), + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + + return ( +
+ Hello "/specialChars/search"! + {search().searchParam} +
+ ) +} diff --git "a/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" new file mode 100644 index 00000000000..460d90ba263 --- /dev/null +++ "b/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/specialChars/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/대한민국"!
+} diff --git "a/e2e/solid-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/solid-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" deleted file mode 100644 index 897c0576cc4..00000000000 --- "a/e2e/solid-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/solid-router' - -export const Route = createFileRoute('/대한민국')({ - component: RouteComponent, -}) - -function RouteComponent() { - return
Hello "/대한민국"!
-} diff --git a/e2e/solid-start/basic/tests/params.spec.ts b/e2e/solid-start/basic/tests/params.spec.ts deleted file mode 100644 index 505e63ef433..00000000000 --- a/e2e/solid-start/basic/tests/params.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect } from '@playwright/test' - -import { test } from '@tanstack/router-e2e-utils' - -test.beforeEach(async ({ page }) => { - await page.goto('/') -}) - -test.use({ - whitelistErrors: [ - 'Failed to load resource: the server responded with a status of 404', - ], -}) -test.describe('Unicode route rendering', () => { - test('should render non-latin route correctly', async ({ page, baseURL }) => { - await page.goto('/대한민국') - - await expect(page.locator('body')).toContainText('Hello "/대한민국"!') - - expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) - }) -}) diff --git a/e2e/solid-start/basic/tests/special-characters.spec.ts b/e2e/solid-start/basic/tests/special-characters.spec.ts new file mode 100644 index 00000000000..7f93c275479 --- /dev/null +++ b/e2e/solid-start/basic/tests/special-characters.spec.ts @@ -0,0 +1,105 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 404/, + ], +}) +test.describe('Unicode route rendering', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/specialChars') + }) + + test('should render non-latin route correctly with direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/대한민국') + await page.waitForURL( + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD${isPrerender ? '/' : ''}`, + ) + + await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() + }) + + test('should render non-latin route correctly during router navigation', async ({ + page, + baseURL, + }) => { + const nonLatinLink = page.getByTestId('special-non-latin-link') + + await nonLatinLink.click() + await page.waitForURL( + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`, + ) + + await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() + }) + + test.describe('Special characters in path params', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/대|') + await page.waitForURL(`${baseURL}/specialChars/%EB%8C%80%7C`) + + const param = await page.getByTestId('special-param').textContent() + + expect(param).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + const link = page.getByTestId('special-param-link') + + await link.click() + await page.waitForURL(`${baseURL}/specialChars/%EB%8C%80%7C`) + + const param = await page.getByTestId('special-param').textContent() + + expect(param).toBe('대|') + }) + }) + + test.describe('Special characters in search params', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/search?searchParam=대|') + + await page.waitForURL( + `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + ) + + const searchParam = await page + .getByTestId('special-search-param') + .textContent() + + expect(searchParam).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + const link = page.getByTestId('special-searchParam-link') + + await link.click() + await page.waitForURL( + `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + ) + + const searchParam = await page + .getByTestId('special-search-param') + .textContent() + + expect(searchParam).toBe('대|') + }) + }) +}) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 37a52a0ea3c..3d310300aef 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -22,6 +22,7 @@ const prerenderConfiguration = { '/i-do-not-exist', '/not-found/via-beforeLoad', '/not-found/via-loader', + '/specialChars/search', '/search-params/default', '/transition', '/users', diff --git a/e2e/solid-start/virtual-routes/routes.ts b/e2e/solid-start/virtual-routes/routes.ts index ab17b8f58b4..37573a05671 100644 --- a/e2e/solid-start/virtual-routes/routes.ts +++ b/e2e/solid-start/virtual-routes/routes.ts @@ -21,4 +21,5 @@ export const routes = rootRoute('root.tsx', [ ]), ]), physical('/classic', 'file-based-subtree'), + route('/special|pipe', 'pipe.tsx'), ]) diff --git a/e2e/solid-start/virtual-routes/src/routeTree.gen.ts b/e2e/solid-start/virtual-routes/src/routeTree.gen.ts index 6b10324a1ff..cb662d8a5d6 100644 --- a/e2e/solid-start/virtual-routes/src/routeTree.gen.ts +++ b/e2e/solid-start/virtual-routes/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // 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 pipeRouteImport } from './routes/pipe' import { Route as postsPostsRouteImport } from './routes/posts/posts' import { Route as layoutFirstLayoutRouteImport } from './routes/layout/first-layout' import { Route as homeRouteImport } from './routes/home' @@ -22,6 +23,11 @@ import { Route as ClassicHelloUniverseRouteImport } from './routes/file-based-su import { Route as bRouteImport } from './routes/b' import { Route as aRouteImport } from './routes/a' +const pipeRoute = pipeRouteImport.update({ + id: '/special|pipe', + path: '/special|pipe', + getParentRoute: () => rootRouteImport, +} as any) const postsPostsRoute = postsPostsRouteImport.update({ id: '/posts', path: '/posts', @@ -84,6 +90,7 @@ const aRoute = aRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof homeRoute '/posts': typeof postsPostsRouteWithChildren + '/special|pipe': typeof pipeRoute '/classic/hello': typeof ClassicHelloRouteRouteWithChildren '/posts/': typeof postsPostsHomeRoute '/posts/$postId': typeof postsPostsDetailRoute @@ -95,6 +102,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof homeRoute + '/special|pipe': typeof pipeRoute '/posts': typeof postsPostsHomeRoute '/posts/$postId': typeof postsPostsDetailRoute '/classic/hello/universe': typeof ClassicHelloUniverseRoute @@ -108,6 +116,7 @@ export interface FileRoutesById { '/': typeof homeRoute '/_first': typeof layoutFirstLayoutRouteWithChildren '/posts': typeof postsPostsRouteWithChildren + '/special|pipe': typeof pipeRoute '/classic/hello': typeof ClassicHelloRouteRouteWithChildren '/posts/': typeof postsPostsHomeRoute '/_first/_second-layout': typeof layoutSecondLayoutRouteWithChildren @@ -123,6 +132,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/posts' + | '/special|pipe' | '/classic/hello' | '/posts/' | '/posts/$postId' @@ -134,6 +144,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/special|pipe' | '/posts' | '/posts/$postId' | '/classic/hello/universe' @@ -146,6 +157,7 @@ export interface FileRouteTypes { | '/' | '/_first' | '/posts' + | '/special|pipe' | '/classic/hello' | '/posts/' | '/_first/_second-layout' @@ -161,11 +173,19 @@ export interface RootRouteChildren { homeRoute: typeof homeRoute layoutFirstLayoutRoute: typeof layoutFirstLayoutRouteWithChildren postsPostsRoute: typeof postsPostsRouteWithChildren + pipeRoute: typeof pipeRoute ClassicHelloRouteRoute: typeof ClassicHelloRouteRouteWithChildren } declare module '@tanstack/solid-router' { interface FileRoutesByPath { + '/special|pipe': { + id: '/special|pipe' + path: '/special|pipe' + fullPath: '/special|pipe' + preLoaderRoute: typeof pipeRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -310,6 +330,7 @@ const rootRouteChildren: RootRouteChildren = { homeRoute: homeRoute, layoutFirstLayoutRoute: layoutFirstLayoutRouteWithChildren, postsPostsRoute: postsPostsRouteWithChildren, + pipeRoute: pipeRoute, ClassicHelloRouteRoute: ClassicHelloRouteRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/e2e/solid-start/virtual-routes/src/routes/pipe.tsx b/e2e/solid-start/virtual-routes/src/routes/pipe.tsx new file mode 100644 index 00000000000..009b116c162 --- /dev/null +++ b/e2e/solid-start/virtual-routes/src/routes/pipe.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/special|pipe')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
Hello "/special|pipe"!
+ ) +} diff --git a/e2e/solid-start/virtual-routes/src/routes/root.tsx b/e2e/solid-start/virtual-routes/src/routes/root.tsx index ef7b0745f16..bd4ae2d4dd9 100644 --- a/e2e/solid-start/virtual-routes/src/routes/root.tsx +++ b/e2e/solid-start/virtual-routes/src/routes/root.tsx @@ -76,6 +76,15 @@ function RootDocument({ children }: { children: JSX.Element }) { > Subtree {' '} + + Pipe + {' '} { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + }) + + test.describe('Special characters in route paths', () => { + test('should render route with pipe character in path on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/special|pipe') + await page.waitForURL(`${baseURL}/special%7Cpipe`) + + await expect( + page.getByTestId('special-pipe-route-heading'), + ).toBeInViewport() + }) + + test('should render route with pipe character in path on router navigation', async ({ + page, + baseURL, + }) => { + const pipeLink = page.getByTestId('special-pipe-link') + + await pipeLink.click() + await page.waitForURL(`${baseURL}/special%7Cpipe`) + + await expect( + page.getByTestId('special-pipe-route-heading'), + ).toBeInViewport() + }) + }) +}) diff --git a/e2e/vue-start/basic/package.json b/e2e/vue-start/basic/package.json index ace768f1e40..fb27b73fca2 100644 --- a/e2e/vue-start/basic/package.json +++ b/e2e/vue-start/basic/package.json @@ -10,8 +10,7 @@ "build:spa": "MODE=spa vite build && tsc --noEmit", "build:prerender": "MODE=prerender vite build && tsc --noEmit", "preview": "vite preview", - "start": "pnpx srvx --prod -s ../client dist/server/server.js", - "start:spa": "node server.js", + "start": "node server.js", "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:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", diff --git a/e2e/vue-start/basic/playwright.config.ts b/e2e/vue-start/basic/playwright.config.ts index aa29067f463..86c58bc1ce3 100644 --- a/e2e/vue-start/basic/playwright.config.ts +++ b/e2e/vue-start/basic/playwright.config.ts @@ -16,7 +16,7 @@ const START_PORT = await getTestServerPort( ) const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}` -const spaModeCommand = `pnpm build:spa && pnpm start:spa` +const spaModeCommand = `pnpm build:spa && pnpm start` const ssrModeCommand = `pnpm build && pnpm start` const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` const previewModeCommand = `pnpm build && pnpm preview --port ${PORT}` diff --git a/e2e/vue-start/basic/server.js b/e2e/vue-start/basic/server.js index d618ab4bce3..6d92dff8024 100644 --- a/e2e/vue-start/basic/server.js +++ b/e2e/vue-start/basic/server.js @@ -2,6 +2,7 @@ import { toNodeHandler } from 'srvx/node' import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' +import { isSpaMode } from './tests/utils/isSpaMode.ts' const port = process.env.PORT || 3000 @@ -54,14 +55,22 @@ export async function createSpaServer() { return { app } } -createSpaServer().then(async ({ app }) => - app.listen(port, () => { - console.info(`Client Server: http://localhost:${port}`) - }), -) +if (isSpaMode) { + createSpaServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Client Server: http://localhost:${port}`) + }), + ) -createStartServer().then(async ({ app }) => - app.listen(startPort, () => { - console.info(`Start Server: http://localhost:${startPort}`) - }), -) + createStartServer().then(async ({ app }) => + app.listen(startPort, () => { + console.info(`Start Server: http://localhost:${startPort}`) + }), + ) +} else { + createStartServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Start Server: http://localhost:${port}`) + }), + ) +} diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index 422ba41bb43..b8a1f2b5493 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-start/basic/src/routeTree.gen.ts @@ -9,7 +9,6 @@ // 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 Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' import { Route as UsersRouteImport } from './routes/users' import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' @@ -19,6 +18,7 @@ import { Route as LinksRouteImport } from './routes/links' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as IndexRouteImport } from './routes/index' @@ -30,6 +30,9 @@ import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index' import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as SpecialCharsChar45824Char54620Char48124Char44397RouteImport } from './routes/specialChars/대한민국' +import { Route as SpecialCharsSearchRouteImport } from './routes/specialChars/search' +import { Route as SpecialCharsParamRouteImport } from './routes/specialChars/$param' import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' @@ -57,12 +60,6 @@ import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './rout import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' -const Char45824Char54620Char48124Char44397Route = - Char45824Char54620Char48124Char44397RouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) const UsersRoute = UsersRouteImport.update({ id: '/users', path: '/users', @@ -107,6 +104,11 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SpecialCharsRouteRoute = SpecialCharsRouteRouteImport.update({ + id: '/specialChars', + path: '/specialChars', + getParentRoute: () => rootRouteImport, +} as any) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -163,6 +165,22 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({ path: '/$userId', getParentRoute: () => UsersRoute, } as any) +const SpecialCharsChar45824Char54620Char48124Char44397Route = + SpecialCharsChar45824Char54620Char48124Char44397RouteImport.update({ + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => SpecialCharsRouteRoute, + } as any) +const SpecialCharsSearchRoute = SpecialCharsSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) +const SpecialCharsParamRoute = SpecialCharsParamRouteImport.update({ + id: '/$param', + path: '/$param', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) const SearchParamsLoaderThrowsRedirectRoute = SearchParamsLoaderThrowsRedirectRouteImport.update({ id: '/loader-throws-redirect', @@ -304,6 +322,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute @@ -312,7 +331,6 @@ export interface FileRoutesByFullPath { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -327,6 +345,9 @@ export interface FileRoutesByFullPath { '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute @@ -349,12 +370,12 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -368,6 +389,9 @@ export interface FileRoutesByTo { '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found': typeof NotFoundIndexRoute @@ -393,6 +417,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -402,7 +427,6 @@ export interface FileRoutesById { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -418,6 +442,9 @@ export interface FileRoutesById { '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/search': typeof SpecialCharsSearchRoute + '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute @@ -444,6 +471,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/specialChars' | '/deferred' | '/inline-scripts' | '/links' @@ -452,7 +480,6 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' - | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -467,6 +494,9 @@ export interface FileRouteTypes { | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found/' @@ -489,12 +519,12 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/specialChars' | '/deferred' | '/inline-scripts' | '/links' | '/scripts' | '/stream' - | '/대한민국' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -508,6 +538,9 @@ export interface FileRouteTypes { | '/raw-stream/ssr-text-hint' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found' @@ -532,6 +565,7 @@ export interface FileRouteTypes { | '/' | '/not-found' | '/search-params' + | '/specialChars' | '/_layout' | '/deferred' | '/inline-scripts' @@ -541,7 +575,6 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' - | '/대한민국' | '/_layout/_layout-2' | '/api/users' | '/multi-cookie-redirect/target' @@ -557,6 +590,9 @@ export interface FileRouteTypes { | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' + | '/specialChars/$param' + | '/specialChars/search' + | '/specialChars/대한민국' | '/users/$userId' | '/multi-cookie-redirect/' | '/not-found/' @@ -582,6 +618,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren + SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute InlineScriptsRoute: typeof InlineScriptsRoute @@ -591,7 +628,6 @@ export interface RootRouteChildren { ScriptsRoute: typeof ScriptsRoute StreamRoute: typeof StreamRoute UsersRoute: typeof UsersRouteWithChildren - Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren @@ -602,13 +638,6 @@ export interface RootRouteChildren { declare module '@tanstack/vue-router' { interface FileRoutesByPath { - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteImport - parentRoute: typeof rootRouteImport - } '/users': { id: '/users' path: '/users' @@ -672,6 +701,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/specialChars': { + id: '/specialChars' + path: '/specialChars' + fullPath: '/specialChars' + preLoaderRoute: typeof SpecialCharsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/search-params': { id: '/search-params' path: '/search-params' @@ -749,6 +785,27 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof UsersUserIdRouteImport parentRoute: typeof UsersRoute } + '/specialChars/대한민국': { + id: '/specialChars/대한민국' + path: '/대한민국' + fullPath: '/specialChars/대한민국' + preLoaderRoute: typeof SpecialCharsChar45824Char54620Char48124Char44397RouteImport + parentRoute: typeof SpecialCharsRouteRoute + } + '/specialChars/search': { + id: '/specialChars/search' + path: '/search' + fullPath: '/specialChars/search' + preLoaderRoute: typeof SpecialCharsSearchRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } + '/specialChars/$param': { + id: '/specialChars/$param' + path: '/$param' + fullPath: '/specialChars/$param' + preLoaderRoute: typeof SpecialCharsParamRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/search-params/loader-throws-redirect': { id: '/search-params/loader-throws-redirect' path: '/loader-throws-redirect' @@ -965,6 +1022,22 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface SpecialCharsRouteRouteChildren { + SpecialCharsParamRoute: typeof SpecialCharsParamRoute + SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute + SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route +} + +const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { + SpecialCharsParamRoute: SpecialCharsParamRoute, + SpecialCharsSearchRoute: SpecialCharsSearchRoute, + SpecialCharsChar45824Char54620Char48124Char44397Route: + SpecialCharsChar45824Char54620Char48124Char44397Route, +} + +const SpecialCharsRouteRouteWithChildren = + SpecialCharsRouteRoute._addFileChildren(SpecialCharsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -1080,6 +1153,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NotFoundRouteRoute: NotFoundRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, + SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, InlineScriptsRoute: InlineScriptsRoute, @@ -1089,8 +1163,6 @@ const rootRouteChildren: RootRouteChildren = { ScriptsRoute: ScriptsRoute, StreamRoute: StreamRoute, UsersRoute: UsersRouteWithChildren, - Char45824Char54620Char48124Char44397Route: - Char45824Char54620Char48124Char44397Route, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, diff --git a/e2e/vue-start/basic/src/routes/specialChars/$param.tsx b/e2e/vue-start/basic/src/routes/specialChars/$param.tsx new file mode 100644 index 00000000000..de3cba0a97e --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/$param.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/specialChars/$param')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + return ( +
+ Hello "/specialChars/$param":{' '} + {params.value.param} +
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/specialChars/route.tsx b/e2e/vue-start/basic/src/routes/specialChars/route.tsx new file mode 100644 index 00000000000..c8e37b183ff --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/route.tsx @@ -0,0 +1,45 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' +import * as React from 'react' + +export const Route = createFileRoute('/specialChars')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
Hello "/specialChars"!
+ + Unicode + {' '} + + Unicode param + {' '} + + Unicode search param + {' '} +
+ +
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/specialChars/search.tsx b/e2e/vue-start/basic/src/routes/specialChars/search.tsx new file mode 100644 index 00000000000..5ba858e7470 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/search.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from '@tanstack/vue-router' +import z from 'zod' + +export const Route = createFileRoute('/specialChars/search')({ + validateSearch: z.object({ + searchParam: z.string(), + }), + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + + return ( +
+ Hello "/specialChars/search"! + + {search.value.searchParam} + +
+ ) +} diff --git "a/e2e/vue-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" similarity index 58% rename from "e2e/vue-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" rename to "e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" index 16196a6bda7..3afd48bae7b 100644 --- "a/e2e/vue-start/basic/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ "b/e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -1,13 +1,14 @@ import { createFileRoute } from '@tanstack/vue-router' -export const Route = createFileRoute('/대한민국')({ +export const Route = createFileRoute('/specialChars/대한민국')({ component: KoreaComponent, }) function KoreaComponent() { return (
-

대한민국

+ Test +

Hello /대한민국

This is a route with a non-ASCII path.

) diff --git a/e2e/vue-start/basic/tests/params.spec.ts b/e2e/vue-start/basic/tests/params.spec.ts deleted file mode 100644 index 46ed630994c..00000000000 --- a/e2e/vue-start/basic/tests/params.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect } from '@playwright/test' - -import { test } from '@tanstack/router-e2e-utils' - -test.beforeEach(async ({ page }) => { - await page.goto('/') -}) - -test.use({ - whitelistErrors: [ - 'Failed to load resource: the server responded with a status of 404', - ], -}) -test.describe('Unicode route rendering', () => { - test('should render non-latin route correctly', async ({ page, baseURL }) => { - await page.goto('/대한민국') - - await expect(page.locator('body')).toContainText('대한민국') - - expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) - }) -}) diff --git a/e2e/vue-start/basic/tests/special-characters.spec.ts b/e2e/vue-start/basic/tests/special-characters.spec.ts new file mode 100644 index 00000000000..7f93c275479 --- /dev/null +++ b/e2e/vue-start/basic/tests/special-characters.spec.ts @@ -0,0 +1,105 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 404/, + ], +}) +test.describe('Unicode route rendering', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/specialChars') + }) + + test('should render non-latin route correctly with direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/대한민국') + await page.waitForURL( + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD${isPrerender ? '/' : ''}`, + ) + + await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() + }) + + test('should render non-latin route correctly during router navigation', async ({ + page, + baseURL, + }) => { + const nonLatinLink = page.getByTestId('special-non-latin-link') + + await nonLatinLink.click() + await page.waitForURL( + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`, + ) + + await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() + }) + + test.describe('Special characters in path params', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/대|') + await page.waitForURL(`${baseURL}/specialChars/%EB%8C%80%7C`) + + const param = await page.getByTestId('special-param').textContent() + + expect(param).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + const link = page.getByTestId('special-param-link') + + await link.click() + await page.waitForURL(`${baseURL}/specialChars/%EB%8C%80%7C`) + + const param = await page.getByTestId('special-param').textContent() + + expect(param).toBe('대|') + }) + }) + + test.describe('Special characters in search params', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/search?searchParam=대|') + + await page.waitForURL( + `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + ) + + const searchParam = await page + .getByTestId('special-search-param') + .textContent() + + expect(searchParam).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + const link = page.getByTestId('special-searchParam-link') + + await link.click() + await page.waitForURL( + `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + ) + + const searchParam = await page + .getByTestId('special-search-param') + .textContent() + + expect(searchParam).toBe('대|') + }) + }) +}) diff --git a/e2e/vue-start/basic/vite.config.ts b/e2e/vue-start/basic/vite.config.ts index 96e38ba6ca8..58f6baaab6a 100644 --- a/e2e/vue-start/basic/vite.config.ts +++ b/e2e/vue-start/basic/vite.config.ts @@ -22,6 +22,7 @@ const prerenderConfiguration = { '/i-do-not-exist', '/not-found/via-beforeLoad', '/not-found/via-loader', + '/specialChars/search', '/search-params', // search-param routes have dynamic content based on query params '/transition', '/users', diff --git a/e2e/vue-start/virtual-routes/routes.ts b/e2e/vue-start/virtual-routes/routes.ts index ab17b8f58b4..37573a05671 100644 --- a/e2e/vue-start/virtual-routes/routes.ts +++ b/e2e/vue-start/virtual-routes/routes.ts @@ -21,4 +21,5 @@ export const routes = rootRoute('root.tsx', [ ]), ]), physical('/classic', 'file-based-subtree'), + route('/special|pipe', 'pipe.tsx'), ]) diff --git a/e2e/vue-start/virtual-routes/src/routeTree.gen.ts b/e2e/vue-start/virtual-routes/src/routeTree.gen.ts index 1ec4fba15f1..addf953a25d 100644 --- a/e2e/vue-start/virtual-routes/src/routeTree.gen.ts +++ b/e2e/vue-start/virtual-routes/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // 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 pipeRouteImport } from './routes/pipe' import { Route as postsPostsRouteImport } from './routes/posts/posts' import { Route as layoutFirstLayoutRouteImport } from './routes/layout/first-layout' import { Route as homeRouteImport } from './routes/home' @@ -22,6 +23,11 @@ import { Route as ClassicHelloUniverseRouteImport } from './routes/file-based-su import { Route as bRouteImport } from './routes/b' import { Route as aRouteImport } from './routes/a' +const pipeRoute = pipeRouteImport.update({ + id: '/special|pipe', + path: '/special|pipe', + getParentRoute: () => rootRouteImport, +} as any) const postsPostsRoute = postsPostsRouteImport.update({ id: '/posts', path: '/posts', @@ -84,6 +90,7 @@ const aRoute = aRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof homeRoute '/posts': typeof postsPostsRouteWithChildren + '/special|pipe': typeof pipeRoute '/classic/hello': typeof ClassicHelloRouteRouteWithChildren '/posts/': typeof postsPostsHomeRoute '/posts/$postId': typeof postsPostsDetailRoute @@ -95,6 +102,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof homeRoute + '/special|pipe': typeof pipeRoute '/posts': typeof postsPostsHomeRoute '/posts/$postId': typeof postsPostsDetailRoute '/classic/hello/universe': typeof ClassicHelloUniverseRoute @@ -108,6 +116,7 @@ export interface FileRoutesById { '/': typeof homeRoute '/_first': typeof layoutFirstLayoutRouteWithChildren '/posts': typeof postsPostsRouteWithChildren + '/special|pipe': typeof pipeRoute '/classic/hello': typeof ClassicHelloRouteRouteWithChildren '/posts/': typeof postsPostsHomeRoute '/_first/_second-layout': typeof layoutSecondLayoutRouteWithChildren @@ -123,6 +132,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/posts' + | '/special|pipe' | '/classic/hello' | '/posts/' | '/posts/$postId' @@ -134,6 +144,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/special|pipe' | '/posts' | '/posts/$postId' | '/classic/hello/universe' @@ -146,6 +157,7 @@ export interface FileRouteTypes { | '/' | '/_first' | '/posts' + | '/special|pipe' | '/classic/hello' | '/posts/' | '/_first/_second-layout' @@ -161,11 +173,19 @@ export interface RootRouteChildren { homeRoute: typeof homeRoute layoutFirstLayoutRoute: typeof layoutFirstLayoutRouteWithChildren postsPostsRoute: typeof postsPostsRouteWithChildren + pipeRoute: typeof pipeRoute ClassicHelloRouteRoute: typeof ClassicHelloRouteRouteWithChildren } declare module '@tanstack/vue-router' { interface FileRoutesByPath { + '/special|pipe': { + id: '/special|pipe' + path: '/special|pipe' + fullPath: '/special|pipe' + preLoaderRoute: typeof pipeRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -310,6 +330,7 @@ const rootRouteChildren: RootRouteChildren = { homeRoute: homeRoute, layoutFirstLayoutRoute: layoutFirstLayoutRouteWithChildren, postsPostsRoute: postsPostsRouteWithChildren, + pipeRoute: pipeRoute, ClassicHelloRouteRoute: ClassicHelloRouteRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/e2e/vue-start/virtual-routes/src/routes/pipe.tsx b/e2e/vue-start/virtual-routes/src/routes/pipe.tsx new file mode 100644 index 00000000000..bd7dd29a64a --- /dev/null +++ b/e2e/vue-start/virtual-routes/src/routes/pipe.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/special|pipe')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
Hello "/special|pipe"!
+ ) +} diff --git a/e2e/vue-start/virtual-routes/src/routes/root.tsx b/e2e/vue-start/virtual-routes/src/routes/root.tsx index 2a3a8b98cb8..8193fcea71a 100644 --- a/e2e/vue-start/virtual-routes/src/routes/root.tsx +++ b/e2e/vue-start/virtual-routes/src/routes/root.tsx @@ -69,6 +69,15 @@ function RootComponent() { > Subtree {' '} + + Pipe + {' '} { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + }) + + test.describe('Special characters in route paths', () => { + test('should render route with pipe character in path on direct navigation', async ({ + page, + baseURL, + }) => { + await page.goto('/special|pipe') + await page.waitForURL(`${baseURL}/special%7Cpipe`) + + await expect( + page.getByTestId('special-pipe-route-heading'), + ).toBeInViewport() + }) + + test('should render route with pipe character in path on router navigation', async ({ + page, + baseURL, + }) => { + const pipeLink = page.getByTestId('special-pipe-link') + + await pipeLink.click() + await page.waitForURL(`${baseURL}/special%7Cpipe`) + + await expect( + page.getByTestId('special-pipe-route-heading'), + ).toBeInViewport() + }) + }) +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 67ae9524cfc..a05f226ba3c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1779,7 +1779,7 @@ export class RouterCore< const fullPath = `${nextPathname}${searchStr}${hashStr}` // Create the new href with full origin - const url = new URL(fullPath, this.origin) + const url = new URL(decodeURIComponent(fullPath), this.origin) // If a rewrite function is provided, use it to rewrite the URL const rewrittenUrl = executeRewriteOutput(this.rewrite, url) @@ -1787,7 +1787,7 @@ export class RouterCore< return { publicHref: rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash, - href: fullPath, + href: url.href.replace(url.origin, ''), url: rewrittenUrl, pathname: nextPathname, search: nextSearch, diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index c35a24f3092..2464edff560 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -82,12 +82,7 @@ function getEntries() { return entriesPromise } -function getManifest(matchedRoutes?: ReadonlyArray) { - // In dev mode, always get fresh manifest (no caching) to include route-specific dev styles - if (process.env.TSS_DEV_SERVER === 'true') { - return getStartManifest(matchedRoutes) - } - // In prod, cache the manifest +function getManifest() { if (!manifestPromise) { manifestPromise = getStartManifest() } @@ -216,8 +211,13 @@ export function createStartHandler( let cbWillCleanup = false as boolean try { - const url = new URL(request.url) + // server and browser can decode/encode characters differently. + // Server generally strictly follows the WHATWG URL Standard, while browsers differ for legacy reasons. + // for example, "|" is not encoded on the server but is encoded on chromium while "대" is encoded on both sides. + // normalizing the pathname here for server, so we always deal with the same format during SSR. + const url = new URL(decodeURI(request.url)) const href = url.href.replace(url.origin, '') + const origin = getOrigin(request) const entries = await getEntries() @@ -318,10 +318,7 @@ export function createStartHandler( } // Router execution function - const executeRouter = async ( - serverContext: TODO, - matchedRoutes?: ReadonlyArray, - ): Promise => { + const executeRouter = async (serverContext: TODO): Promise => { const acceptHeader = request.headers.get('Accept') || '*/*' const acceptParts = acceptHeader.split(',') const supportedMimeTypes = ['*/*', 'text/html'] @@ -337,7 +334,7 @@ export function createStartHandler( ) } - const manifest = await getManifest(matchedRoutes) + const manifest = await getManifest() const routerInstance = await getRouter() attachRouterServerSsrUtils({ @@ -485,10 +482,7 @@ async function handleServerRoutes({ getRouter: () => Promise request: Request url: URL - executeRouter: ( - serverContext: any, - matchedRoutes?: ReadonlyArray, - ) => Promise + executeRouter: (serverContext: any) => Promise context: any executedRequestMiddlewares: Set }): Promise { @@ -552,10 +546,8 @@ async function handleServerRoutes({ } } - // Final middleware: execute router with matched routes for dev styles - routeMiddlewares.push((ctx: TODO) => - executeRouter(ctx.context, matchedRoutes), - ) + // Final middleware: execute router + routeMiddlewares.push((ctx: TODO) => executeRouter(ctx.context)) const ctx = await executeMiddleware(routeMiddlewares, { request, From d58cd38eedfbb6525f027939edb1dab45270a58e Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 22:22:55 +0200 Subject: [PATCH 02/18] refine solution --- .../basic/tests/special-characters.spec.ts | 2 +- .../basic/tests/special-characters.spec.ts | 2 +- .../basic/tests/special-characters.spec.ts | 2 +- packages/router-core/src/router.ts | 2 +- .../src/createStartHandler.ts | 19 +++++++++++++++---- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/e2e/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts index 7f93c275479..dc6d510d9d8 100644 --- a/e2e/react-start/basic/tests/special-characters.spec.ts +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -92,7 +92,7 @@ test.describe('Unicode route rendering', () => { await link.click() await page.waitForURL( - `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + `${baseURL}/specialChars/search?searchParam=%EB%8C%80%7C`, ) const searchParam = await page diff --git a/e2e/solid-start/basic/tests/special-characters.spec.ts b/e2e/solid-start/basic/tests/special-characters.spec.ts index 7f93c275479..dc6d510d9d8 100644 --- a/e2e/solid-start/basic/tests/special-characters.spec.ts +++ b/e2e/solid-start/basic/tests/special-characters.spec.ts @@ -92,7 +92,7 @@ test.describe('Unicode route rendering', () => { await link.click() await page.waitForURL( - `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + `${baseURL}/specialChars/search?searchParam=%EB%8C%80%7C`, ) const searchParam = await page diff --git a/e2e/vue-start/basic/tests/special-characters.spec.ts b/e2e/vue-start/basic/tests/special-characters.spec.ts index 7f93c275479..dc6d510d9d8 100644 --- a/e2e/vue-start/basic/tests/special-characters.spec.ts +++ b/e2e/vue-start/basic/tests/special-characters.spec.ts @@ -92,7 +92,7 @@ test.describe('Unicode route rendering', () => { await link.click() await page.waitForURL( - `${baseURL}/specialChars/search?searchParam=%EB%8C%80|`, + `${baseURL}/specialChars/search?searchParam=%EB%8C%80%7C`, ) const searchParam = await page diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index a05f226ba3c..9d8518782e1 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1779,7 +1779,7 @@ export class RouterCore< const fullPath = `${nextPathname}${searchStr}${hashStr}` // Create the new href with full origin - const url = new URL(decodeURIComponent(fullPath), this.origin) + const url = new URL(fullPath, this.origin) // If a rewrite function is provided, use it to rewrite the URL const rewrittenUrl = executeRewriteOutput(this.rewrite, url) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 2464edff560..188622f630f 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -6,6 +6,7 @@ import { safeObjectMerge, } from '@tanstack/start-client-core' import { + decodePath, executeRewriteInput, isRedirect, isResolvedRedirect, @@ -211,15 +212,25 @@ export function createStartHandler( let cbWillCleanup = false as boolean try { + const origin = getOrigin(request) // server and browser can decode/encode characters differently. // Server generally strictly follows the WHATWG URL Standard, while browsers differ for legacy reasons. // for example, "|" is not encoded on the server but is encoded on chromium while "대" is encoded on both sides. - // normalizing the pathname here for server, so we always deal with the same format during SSR. - const url = new URL(decodeURI(request.url)) + // Another anomaly is that new URLSearchParams and new URL also decode/encode characters differently. + // we are encoding search params later on using the new URLSearchParams. This encodes "|" in turn while new URL does not. + // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. + const decodedPath = decodePath(request.url.replace(origin, '')) + const decodedURL = new URL(decodedPath, origin) + const searchParams = new URLSearchParams(decodedURL.search) + const normalizedHref = + decodedURL.pathname + + (searchParams.size > 0 ? '?' : '') + + searchParams.toString() + + decodedURL.hash + + const url = new URL(normalizedHref, decodedURL.origin) const href = url.href.replace(url.origin, '') - const origin = getOrigin(request) - const entries = await getEntries() const startOptions: AnyStartInstanceOptions = (await entries.startEntry.startInstance?.getOptions()) || From 24e67855a347302dcaba98c93cea975fcb6587b6 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 22:23:52 +0200 Subject: [PATCH 03/18] revert unnecessary change --- packages/router-core/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 9d8518782e1..67ae9524cfc 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1787,7 +1787,7 @@ export class RouterCore< return { publicHref: rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash, - href: url.href.replace(url.origin, ''), + href: fullPath, url: rewrittenUrl, pathname: nextPathname, search: nextSearch, From 8d0dd14113dd3de359712fff3290f912516df37c Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 22:56:13 +0200 Subject: [PATCH 04/18] fix tests --- e2e/react-start/basic/tests/prerendering.spec.ts | 4 +++- e2e/solid-start/basic/tests/prerendering.spec.ts | 4 +++- e2e/vue-start/basic/tests/prerendering.spec.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 7718fe86ef0..93580977c19 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -17,7 +17,9 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) - expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true) + expect( + existsSync(join(distDir, '/specialChars대한민국/index.html')), + ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) expect(existsSync(join(distDir, '_layout', 'index.html'))).toBe(false) // /_layout diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index 7718fe86ef0..e5ffbd3752f 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -17,7 +17,9 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) - expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true) + expect( + existsSync(join(distDir, '/specialChars/대한민국/index.html')), + ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) expect(existsSync(join(distDir, '_layout', 'index.html'))).toBe(false) // /_layout diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index 7718fe86ef0..93580977c19 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -17,7 +17,9 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) - expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true) + expect( + existsSync(join(distDir, '/specialChars대한민국/index.html')), + ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) expect(existsSync(join(distDir, '_layout', 'index.html'))).toBe(false) // /_layout From fe26b5c38ccf50438044911ca5b78d79765511dd Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 23:37:04 +0200 Subject: [PATCH 05/18] resolve pre-render test issues --- e2e/react-start/basic/server.js | 5 ++++- e2e/react-start/basic/tests/prerendering.spec.ts | 2 +- e2e/react-start/basic/tests/special-characters.spec.ts | 2 +- e2e/solid-start/basic/server.js | 5 ++++- e2e/solid-start/basic/tests/special-characters.spec.ts | 2 +- e2e/vue-start/basic/server.js | 5 ++++- e2e/vue-start/basic/tests/prerendering.spec.ts | 2 +- e2e/vue-start/basic/tests/special-characters.spec.ts | 2 +- 8 files changed, 17 insertions(+), 8 deletions(-) diff --git a/e2e/react-start/basic/server.js b/e2e/react-start/basic/server.js index 6d92dff8024..b69b91baf54 100644 --- a/e2e/react-start/basic/server.js +++ b/e2e/react-start/basic/server.js @@ -3,6 +3,7 @@ import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' import { isSpaMode } from './tests/utils/isSpaMode.ts' +import { isPrerender } from './tests/utils/isPrerender.ts' const port = process.env.PORT || 3000 @@ -14,7 +15,9 @@ export async function createStartServer() { const app = express() - app.use(express.static('./dist/client')) + // to keep testing uniform stop express from redirecting /posts to /posts/ + // when serving pre-rendered pages + app.use(express.static('./dist/client', { redirect: !isPrerender })) app.use(async (req, res, next) => { try { diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 93580977c19..e5ffbd3752f 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -18,7 +18,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) expect( - existsSync(join(distDir, '/specialChars대한민국/index.html')), + existsSync(join(distDir, '/specialChars/대한민국/index.html')), ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) diff --git a/e2e/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts index dc6d510d9d8..62eb4d75d27 100644 --- a/e2e/react-start/basic/tests/special-characters.spec.ts +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -18,7 +18,7 @@ test.describe('Unicode route rendering', () => { }) => { await page.goto('/specialChars/대한민국') await page.waitForURL( - `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD${isPrerender ? '/' : ''}`, + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`, ) await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() diff --git a/e2e/solid-start/basic/server.js b/e2e/solid-start/basic/server.js index 6d92dff8024..b69b91baf54 100644 --- a/e2e/solid-start/basic/server.js +++ b/e2e/solid-start/basic/server.js @@ -3,6 +3,7 @@ import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' import { isSpaMode } from './tests/utils/isSpaMode.ts' +import { isPrerender } from './tests/utils/isPrerender.ts' const port = process.env.PORT || 3000 @@ -14,7 +15,9 @@ export async function createStartServer() { const app = express() - app.use(express.static('./dist/client')) + // to keep testing uniform stop express from redirecting /posts to /posts/ + // when serving pre-rendered pages + app.use(express.static('./dist/client', { redirect: !isPrerender })) app.use(async (req, res, next) => { try { diff --git a/e2e/solid-start/basic/tests/special-characters.spec.ts b/e2e/solid-start/basic/tests/special-characters.spec.ts index dc6d510d9d8..62eb4d75d27 100644 --- a/e2e/solid-start/basic/tests/special-characters.spec.ts +++ b/e2e/solid-start/basic/tests/special-characters.spec.ts @@ -18,7 +18,7 @@ test.describe('Unicode route rendering', () => { }) => { await page.goto('/specialChars/대한민국') await page.waitForURL( - `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD${isPrerender ? '/' : ''}`, + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`, ) await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() diff --git a/e2e/vue-start/basic/server.js b/e2e/vue-start/basic/server.js index 6d92dff8024..b69b91baf54 100644 --- a/e2e/vue-start/basic/server.js +++ b/e2e/vue-start/basic/server.js @@ -3,6 +3,7 @@ import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' import { isSpaMode } from './tests/utils/isSpaMode.ts' +import { isPrerender } from './tests/utils/isPrerender.ts' const port = process.env.PORT || 3000 @@ -14,7 +15,9 @@ export async function createStartServer() { const app = express() - app.use(express.static('./dist/client')) + // to keep testing uniform stop express from redirecting /posts to /posts/ + // when serving pre-rendered pages + app.use(express.static('./dist/client', { redirect: !isPrerender })) app.use(async (req, res, next) => { try { diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index 93580977c19..e5ffbd3752f 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -18,7 +18,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) expect( - existsSync(join(distDir, '/specialChars대한민국/index.html')), + existsSync(join(distDir, '/specialChars/대한민국/index.html')), ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) diff --git a/e2e/vue-start/basic/tests/special-characters.spec.ts b/e2e/vue-start/basic/tests/special-characters.spec.ts index dc6d510d9d8..62eb4d75d27 100644 --- a/e2e/vue-start/basic/tests/special-characters.spec.ts +++ b/e2e/vue-start/basic/tests/special-characters.spec.ts @@ -18,7 +18,7 @@ test.describe('Unicode route rendering', () => { }) => { await page.goto('/specialChars/대한민국') await page.waitForURL( - `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD${isPrerender ? '/' : ''}`, + `${baseURL}/specialChars/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`, ) await expect(page.getByTestId('special-non-latin-heading')).toBeInViewport() From a33b4d9b2708163c37203444c66fd34db3afd0a4 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 23:53:41 +0200 Subject: [PATCH 06/18] refine comment on normalization requirement --- packages/start-server-core/src/createStartHandler.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 188622f630f..364adea87e4 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -213,12 +213,13 @@ export function createStartHandler( try { const origin = getOrigin(request) - // server and browser can decode/encode characters differently. - // Server generally strictly follows the WHATWG URL Standard, while browsers differ for legacy reasons. - // for example, "|" is not encoded on the server but is encoded on chromium while "대" is encoded on both sides. - // Another anomaly is that new URLSearchParams and new URL also decode/encode characters differently. - // we are encoding search params later on using the new URLSearchParams. This encodes "|" in turn while new URL does not. // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. + // server and browser can decode/encode characters differently in paths and search params. + // Server generally strictly follows the WHATWG URL Standard, while browsers may differ for legacy reasons. + // for example, in paths "|" is not encoded on the server but is encoded on chromium (and not on firefox) while "대" is encoded on both sides. + // Another anomaly is that in Node new URLSearchParams and new URL also decode/encode characters differently. + // new URLSearchParams() encodes "|" while new URL() does not, and in this instance + // chromium treats search params differently than paths, i.e. "|" is not encoded in search params. const decodedPath = decodePath(request.url.replace(origin, '')) const decodedURL = new URL(decodedPath, origin) const searchParams = new URLSearchParams(decodedURL.search) From e707a1b1709e375add9d318f620656474a4bf1f7 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Thu, 15 Jan 2026 23:59:27 +0200 Subject: [PATCH 07/18] cleanup: remove unnecessary imports --- e2e/react-start/basic/tests/special-characters.spec.ts | 1 - e2e/solid-start/basic/tests/special-characters.spec.ts | 1 - e2e/vue-start/basic/tests/special-characters.spec.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/e2e/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts index 62eb4d75d27..b583e28fdd8 100644 --- a/e2e/react-start/basic/tests/special-characters.spec.ts +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -1,6 +1,5 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' -import { isPrerender } from './utils/isPrerender' test.use({ whitelistErrors: [ diff --git a/e2e/solid-start/basic/tests/special-characters.spec.ts b/e2e/solid-start/basic/tests/special-characters.spec.ts index 62eb4d75d27..b583e28fdd8 100644 --- a/e2e/solid-start/basic/tests/special-characters.spec.ts +++ b/e2e/solid-start/basic/tests/special-characters.spec.ts @@ -1,6 +1,5 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' -import { isPrerender } from './utils/isPrerender' test.use({ whitelistErrors: [ diff --git a/e2e/vue-start/basic/tests/special-characters.spec.ts b/e2e/vue-start/basic/tests/special-characters.spec.ts index 62eb4d75d27..b583e28fdd8 100644 --- a/e2e/vue-start/basic/tests/special-characters.spec.ts +++ b/e2e/vue-start/basic/tests/special-characters.spec.ts @@ -1,6 +1,5 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' -import { isPrerender } from './utils/isPrerender' test.use({ whitelistErrors: [ From 9ec616a3ce16265bbbc7d0e0b0a30c390d942e46 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Fri, 16 Jan 2026 00:38:17 +0200 Subject: [PATCH 08/18] code rabbit suggestions --- e2e/react-start/basic/server.js | 5 +++-- e2e/react-start/basic/src/routes/specialChars/route.tsx | 1 - .../\353\214\200\355\225\234\353\257\274\352\265\255.tsx" | 6 +++++- e2e/react-start/basic/tests/prerendering.spec.ts | 2 +- e2e/solid-start/basic/server.js | 5 +++-- e2e/solid-start/basic/src/routes/specialChars/route.tsx | 1 - .../\353\214\200\355\225\234\353\257\274\352\265\255.tsx" | 6 +++++- e2e/solid-start/basic/tests/prerendering.spec.ts | 2 +- e2e/vue-start/basic/server.js | 5 +++-- e2e/vue-start/basic/src/routes/specialChars/route.tsx | 1 - .../\353\214\200\355\225\234\353\257\274\352\265\255.tsx" | 4 +++- e2e/vue-start/basic/tests/prerendering.spec.ts | 2 +- 12 files changed, 25 insertions(+), 15 deletions(-) diff --git a/e2e/react-start/basic/server.js b/e2e/react-start/basic/server.js index b69b91baf54..83f5ff0079c 100644 --- a/e2e/react-start/basic/server.js +++ b/e2e/react-start/basic/server.js @@ -2,13 +2,14 @@ import { toNodeHandler } from 'srvx/node' import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' -import { isSpaMode } from './tests/utils/isSpaMode.ts' -import { isPrerender } from './tests/utils/isPrerender.ts' const port = process.env.PORT || 3000 const startPort = process.env.START_PORT || 3001 +const isSpaMode = process.env.MODE === 'spa' +const isPrerender = process.env.MODE === 'prerender' + export async function createStartServer() { const server = (await import('./dist/server/server.js')).default const nodeHandler = toNodeHandler(server.fetch) diff --git a/e2e/react-start/basic/src/routes/specialChars/route.tsx b/e2e/react-start/basic/src/routes/specialChars/route.tsx index 470aa21ef7c..9811378459e 100644 --- a/e2e/react-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/react-start/basic/src/routes/specialChars/route.tsx @@ -1,5 +1,4 @@ import { Link, Outlet, createFileRoute } from '@tanstack/react-router' -import * as React from 'react' export const Route = createFileRoute('/specialChars')({ component: RouteComponent, diff --git "a/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" index 23228b69b3c..555a8518908 100644 --- "a/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ "b/e2e/react-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -5,5 +5,9 @@ export const Route = createFileRoute('/specialChars/대한민국')({ }) function RouteComponent() { - return
Hello "/대한민국"!
+ return ( +
+ Hello "/specialChars/대한민국"! +
+ ) } diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index e5ffbd3752f..8506ff9b061 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -18,7 +18,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) expect( - existsSync(join(distDir, '/specialChars/대한민국/index.html')), + existsSync(join(distDir, 'specialChars/대한민국/index.html')), ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) diff --git a/e2e/solid-start/basic/server.js b/e2e/solid-start/basic/server.js index b69b91baf54..83f5ff0079c 100644 --- a/e2e/solid-start/basic/server.js +++ b/e2e/solid-start/basic/server.js @@ -2,13 +2,14 @@ import { toNodeHandler } from 'srvx/node' import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' -import { isSpaMode } from './tests/utils/isSpaMode.ts' -import { isPrerender } from './tests/utils/isPrerender.ts' const port = process.env.PORT || 3000 const startPort = process.env.START_PORT || 3001 +const isSpaMode = process.env.MODE === 'spa' +const isPrerender = process.env.MODE === 'prerender' + export async function createStartServer() { const server = (await import('./dist/server/server.js')).default const nodeHandler = toNodeHandler(server.fetch) diff --git a/e2e/solid-start/basic/src/routes/specialChars/route.tsx b/e2e/solid-start/basic/src/routes/specialChars/route.tsx index de8eb9e29be..e57876041bb 100644 --- a/e2e/solid-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/solid-start/basic/src/routes/specialChars/route.tsx @@ -1,5 +1,4 @@ import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' -import * as React from 'react' export const Route = createFileRoute('/specialChars')({ component: RouteComponent, diff --git "a/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" index 460d90ba263..13257e1fa89 100644 --- "a/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ "b/e2e/solid-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -5,5 +5,9 @@ export const Route = createFileRoute('/specialChars/대한민국')({ }) function RouteComponent() { - return
Hello "/대한민국"!
+ return ( +
+ Hello "/specialChars/대한민국"! +
+ ) } diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index e5ffbd3752f..8506ff9b061 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -18,7 +18,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) expect( - existsSync(join(distDir, '/specialChars/대한민국/index.html')), + existsSync(join(distDir, 'specialChars/대한민국/index.html')), ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) diff --git a/e2e/vue-start/basic/server.js b/e2e/vue-start/basic/server.js index b69b91baf54..83f5ff0079c 100644 --- a/e2e/vue-start/basic/server.js +++ b/e2e/vue-start/basic/server.js @@ -2,13 +2,14 @@ import { toNodeHandler } from 'srvx/node' import path from 'node:path' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' -import { isSpaMode } from './tests/utils/isSpaMode.ts' -import { isPrerender } from './tests/utils/isPrerender.ts' const port = process.env.PORT || 3000 const startPort = process.env.START_PORT || 3001 +const isSpaMode = process.env.MODE === 'spa' +const isPrerender = process.env.MODE === 'prerender' + export async function createStartServer() { const server = (await import('./dist/server/server.js')).default const nodeHandler = toNodeHandler(server.fetch) diff --git a/e2e/vue-start/basic/src/routes/specialChars/route.tsx b/e2e/vue-start/basic/src/routes/specialChars/route.tsx index c8e37b183ff..7e31bd63fd6 100644 --- a/e2e/vue-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/vue-start/basic/src/routes/specialChars/route.tsx @@ -1,5 +1,4 @@ import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' -import * as React from 'react' export const Route = createFileRoute('/specialChars')({ component: RouteComponent, diff --git "a/e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" index 3afd48bae7b..90bd3120569 100644 --- "a/e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ "b/e2e/vue-start/basic/src/routes/specialChars/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -8,7 +8,9 @@ function KoreaComponent() { return (
Test -

Hello /대한민국

+

+ Hello /specialChars/대한민국 +

This is a route with a non-ASCII path.

) diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index e5ffbd3752f..8506ff9b061 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -18,7 +18,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) expect( - existsSync(join(distDir, '/specialChars/대한민국/index.html')), + existsSync(join(distDir, 'specialChars/대한민국/index.html')), ).toBe(true) // Pathless layouts should NOT be prerendered (they start with _) From ffb6d24edb119a6e1bdb955a2da4df83a096dd89 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Fri, 16 Jan 2026 02:22:43 +0200 Subject: [PATCH 09/18] revert merge issues in createStartHandler.ts --- .../src/createStartHandler.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 364adea87e4..e5c9ba0c7a4 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -83,7 +83,12 @@ function getEntries() { return entriesPromise } -function getManifest() { +function getManifest(matchedRoutes?: ReadonlyArray) { + // In dev mode, always get fresh manifest (no caching) to include route-specific dev styles + if (process.env.TSS_DEV_SERVER === 'true') { + return getStartManifest(matchedRoutes) + } + // In prod, cache the manifest if (!manifestPromise) { manifestPromise = getStartManifest() } @@ -330,7 +335,10 @@ export function createStartHandler( } // Router execution function - const executeRouter = async (serverContext: TODO): Promise => { + const executeRouter = async ( + serverContext: TODO, + matchedRoutes?: ReadonlyArray, + ): Promise => { const acceptHeader = request.headers.get('Accept') || '*/*' const acceptParts = acceptHeader.split(',') const supportedMimeTypes = ['*/*', 'text/html'] @@ -346,7 +354,7 @@ export function createStartHandler( ) } - const manifest = await getManifest() + const manifest = await getManifest(matchedRoutes) const routerInstance = await getRouter() attachRouterServerSsrUtils({ @@ -494,7 +502,10 @@ async function handleServerRoutes({ getRouter: () => Promise request: Request url: URL - executeRouter: (serverContext: any) => Promise + executeRouter: ( + serverContext: any, + matchedRoutes?: ReadonlyArray, + ) => Promise context: any executedRequestMiddlewares: Set }): Promise { @@ -558,8 +569,10 @@ async function handleServerRoutes({ } } - // Final middleware: execute router - routeMiddlewares.push((ctx: TODO) => executeRouter(ctx.context)) + // Final middleware: execute router with matched routes for dev styles + routeMiddlewares.push((ctx: TODO) => + executeRouter(ctx.context, matchedRoutes), + ) const ctx = await executeMiddleware(routeMiddlewares, { request, From 453dfefd6c0db9b08f2da674d3d427856249dee2 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Fri, 16 Jan 2026 02:47:37 +0200 Subject: [PATCH 10/18] Avoid decoding query/hash --- packages/start-server-core/src/createStartHandler.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index e5c9ba0c7a4..ed748ffe6d0 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -225,16 +225,16 @@ export function createStartHandler( // Another anomaly is that in Node new URLSearchParams and new URL also decode/encode characters differently. // new URLSearchParams() encodes "|" while new URL() does not, and in this instance // chromium treats search params differently than paths, i.e. "|" is not encoded in search params. - const decodedPath = decodePath(request.url.replace(origin, '')) - const decodedURL = new URL(decodedPath, origin) - const searchParams = new URLSearchParams(decodedURL.search) + const rawUrl = new URL(request.url, origin) + const decodedPathname = decodePath(rawUrl.pathname) + const searchParams = new URLSearchParams(rawUrl.search) const normalizedHref = - decodedURL.pathname + + decodedPathname + (searchParams.size > 0 ? '?' : '') + searchParams.toString() + - decodedURL.hash + rawUrl.hash - const url = new URL(normalizedHref, decodedURL.origin) + const url = new URL(normalizedHref, rawUrl.origin) const href = url.href.replace(url.origin, '') const entries = await getEntries() From 4658e19a0758793c660ce340f84ac030e5052322 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sat, 17 Jan 2026 03:08:20 +0200 Subject: [PATCH 11/18] apply solution to router-ssr also --- .../src/ssr/createRequestHandler.ts | 9 ++++++-- packages/router-core/src/ssr/server.ts | 6 +++++- packages/router-core/src/ssr/ssr-server.ts | 20 ++++++++++++++++++ .../src/createStartHandler.ts | 21 +++---------------- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 29b7c1e25d9..53e3a94e7c4 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -1,6 +1,10 @@ import { createMemoryHistory } from '@tanstack/history' import { mergeHeaders } from './headers' -import { attachRouterServerSsrUtils, getOrigin } from './ssr-server' +import { + attachRouterServerSsrUtils, + getNormalizedURL, + getOrigin, +} from './ssr-server' import type { HandlerCallback } from './handlerCallback' import type { AnyRouter } from '../router' import type { Manifest } from '../manifest' @@ -29,7 +33,8 @@ export function createRequestHandler({ manifest: await getRouterManifest?.(), }) - const url = new URL(request.url, 'http://localhost') + // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. + const url = getNormalizedURL(request.url, 'http://localhost') const origin = getOrigin(request) const href = url.href.replace(url.origin, '') diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index de39fb81367..89b6059e6de 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -7,4 +7,8 @@ export { transformStreamWithRouter, transformReadableStreamWithRouter, } from './transformStreamWithRouter' -export { attachRouterServerSsrUtils, getOrigin } from './ssr-server' +export { + attachRouterServerSsrUtils, + getNormalizedURL, + getOrigin, +} from './ssr-server' diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 254d2a0f1e1..81af3898f6c 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,5 +1,6 @@ import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' import invariant from 'tiny-invariant' +import { decodePath } from '../utils' import minifiedTsrBootStrapScript from './tsrScript?script-string' import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants' import { defaultSerovalPlugins } from './serializer/seroval-plugins' @@ -348,3 +349,22 @@ export function getOrigin(request: Request) { } catch {} return 'http://localhost' } + +// server and browser can decode/encode characters differently in paths and search params. +// Server generally strictly follows the WHATWG URL Standard, while browsers may differ for legacy reasons. +// for example, in paths "|" is not encoded on the server but is encoded on chromium (and not on firefox) while "대" is encoded on both sides. +// Another anomaly is that in Node new URLSearchParams and new URL also decode/encode characters differently. +// new URLSearchParams() encodes "|" while new URL() does not, and in this instance +// chromium treats search params differently than paths, i.e. "|" is not encoded in search params. +export function getNormalizedURL(url: string | URL, base?: string | URL) { + const rawUrl = new URL(url, base) + const decodedPathname = decodePath(rawUrl.pathname) + const searchParams = new URLSearchParams(rawUrl.search) + const normalizedHref = + decodedPathname + + (searchParams.size > 0 ? '?' : '') + + searchParams.toString() + + rawUrl.hash + + return new URL(normalizedHref, rawUrl.origin) +} diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index ed748ffe6d0..9944267d18f 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -6,13 +6,13 @@ import { safeObjectMerge, } from '@tanstack/start-client-core' import { - decodePath, executeRewriteInput, isRedirect, isResolvedRedirect, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, + getNormalizedURL, getOrigin, } from '@tanstack/router-core/ssr/server' import { runWithStartContext } from '@tanstack/start-storage-context' @@ -217,25 +217,10 @@ export function createStartHandler( let cbWillCleanup = false as boolean try { - const origin = getOrigin(request) // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. - // server and browser can decode/encode characters differently in paths and search params. - // Server generally strictly follows the WHATWG URL Standard, while browsers may differ for legacy reasons. - // for example, in paths "|" is not encoded on the server but is encoded on chromium (and not on firefox) while "대" is encoded on both sides. - // Another anomaly is that in Node new URLSearchParams and new URL also decode/encode characters differently. - // new URLSearchParams() encodes "|" while new URL() does not, and in this instance - // chromium treats search params differently than paths, i.e. "|" is not encoded in search params. - const rawUrl = new URL(request.url, origin) - const decodedPathname = decodePath(rawUrl.pathname) - const searchParams = new URLSearchParams(rawUrl.search) - const normalizedHref = - decodedPathname + - (searchParams.size > 0 ? '?' : '') + - searchParams.toString() + - rawUrl.hash - - const url = new URL(normalizedHref, rawUrl.origin) + const url = getNormalizedURL(request.url) const href = url.href.replace(url.origin, '') + const origin = getOrigin(request) const entries = await getEntries() const startOptions: AnyStartInstanceOptions = From 18205c9f8fc6a9fe30d3ecb94a0b48571bb41bdf Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sat, 17 Jan 2026 03:48:35 +0200 Subject: [PATCH 12/18] add test for getNormalizedURL to handle URLs correctly --- .../tests/getNormalizedURL.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/router-core/tests/getNormalizedURL.test.ts diff --git a/packages/router-core/tests/getNormalizedURL.test.ts b/packages/router-core/tests/getNormalizedURL.test.ts new file mode 100644 index 00000000000..5a831527e6c --- /dev/null +++ b/packages/router-core/tests/getNormalizedURL.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest' +import { getNormalizedURL } from '../src/ssr/ssr-server' + +describe('getNormalizedURL', () => { + test('should return URL that is in standardized format', () => { + const url1 = 'https://example.com/%EB%8C%80%7C/path?query=%EB%8C%80|#hash' + const url2 = 'https://example.com/%EB%8C%80|/path?query=%EB%8C%80%7C#hash' + + const normalizedUrl1 = getNormalizedURL(url1) + const normalizedUrl2 = getNormalizedURL(url2) + + expect(normalizedUrl1.pathname).toBe('/%EB%8C%80|/path') + expect(normalizedUrl1.pathname).toBe(normalizedUrl2.pathname) + expect(new URL(url1).pathname).not.toBe(new URL(url2).pathname) + + expect(normalizedUrl1.search).toBe(`?query=%EB%8C%80%7C`) + expect(normalizedUrl1.search).toBe(normalizedUrl2.search) + expect(new URL(url1).search).not.toBe(new URL(url2).search) + }) + + test('should treat encoded URL specific characters correctly', () => { + const url = 'https://example.com/ab%3F|%23abc/path?query=%EB%8C%80|#hash' + const normalizedUrl = getNormalizedURL(url) + expect(normalizedUrl.pathname).toBe('/ab%3F|%23abc/path') + }) +}) From 2e64a15d942aa659718564aaa69641aef374bddb Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sat, 17 Jan 2026 04:08:07 +0200 Subject: [PATCH 13/18] enhance getNormalizedURL tests --- .../tests/getNormalizedURL.test.ts | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/router-core/tests/getNormalizedURL.test.ts b/packages/router-core/tests/getNormalizedURL.test.ts index 5a831527e6c..24fb6bd7bca 100644 --- a/packages/router-core/tests/getNormalizedURL.test.ts +++ b/packages/router-core/tests/getNormalizedURL.test.ts @@ -18,9 +18,51 @@ describe('getNormalizedURL', () => { expect(new URL(url1).search).not.toBe(new URL(url2).search) }) - test('should treat encoded URL specific characters correctly', () => { - const url = 'https://example.com/ab%3F|%23abc/path?query=%EB%8C%80|#hash' - const normalizedUrl = getNormalizedURL(url) - expect(normalizedUrl.pathname).toBe('/ab%3F|%23abc/path') - }) + const testCases = [ + { + url: 'https://example.com/%3Fstart?query=value', + expectedPathName: '/%3Fstart', + expectedSearchParams: '?query=value', + expectedHash: '', + }, + { + url: 'https://example.com/end%3F?query=value', + expectedPathName: '/end%3F', + expectedSearchParams: '?query=value', + expectedHash: '', + }, + { + url: 'https://example.com/%23?query=value', + expectedPathName: '/%23', + expectedSearchParams: '?query=value', + expectedHash: '', + }, + { + url: 'https://example.com/a%3Fb%3Fc%23d?query=value', + expectedPathName: '/a%3Fb%3Fc%23d', + expectedSearchParams: '?query=value', + expectedHash: '', + }, + { + url: 'https://example.com/path?query=value#section%3Fpart', + expectedPathName: '/path', + expectedSearchParams: '?query=value', + expectedHash: '#section%3Fpart', + }, + { + url: 'https://example.com/start%3Fmiddle%23end?key=value%23part&other=%3Fdata#section%3Fpart', + expectedPathName: '/start%3Fmiddle%23end', + expectedSearchParams: '?key=value%23part&other=%3Fdata', + expectedHash: '#section%3Fpart', + }, + ] + test.each(testCases)( + 'should treat encoded URL specific characters correctly', + ({ url, expectedPathName, expectedHash, expectedSearchParams }) => { + const normalizedUrl = getNormalizedURL(url) + expect(normalizedUrl.pathname).toBe(expectedPathName) + expect(normalizedUrl.search).toBe(expectedSearchParams) + expect(normalizedUrl.hash).toBe(expectedHash) + }, + ) }) From 6fe0137dc923219d6365a493be3ef222d2058432 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 18 Jan 2026 18:26:44 +0200 Subject: [PATCH 14/18] handle malformed paths --- e2e/react-start/basic/src/routeTree.gen.ts | 78 ++++++++++++++ .../routes/specialChars/malformed/$param.tsx | 15 +++ .../routes/specialChars/malformed/route.tsx | 29 +++++ .../routes/specialChars/malformed/search.tsx | 22 ++++ .../basic/src/routes/specialChars/route.tsx | 9 ++ .../basic/tests/special-characters.spec.ts | 68 ++++++++++++ e2e/react-start/basic/vite.config.ts | 1 + e2e/solid-start/basic/src/routeTree.gen.ts | 100 ++++++++++++++++-- .../routes/specialChars/malformed/$param.tsx | 15 +++ .../routes/specialChars/malformed/route.tsx | 29 +++++ .../routes/specialChars/malformed/search.tsx | 22 ++++ .../basic/src/routes/specialChars/route.tsx | 9 ++ .../basic/tests/special-characters.spec.ts | 67 ++++++++++++ e2e/solid-start/basic/vite.config.ts | 1 + e2e/vue-start/basic/src/routeTree.gen.ts | 100 ++++++++++++++++-- .../routes/specialChars/malformed/$param.tsx | 15 +++ .../routes/specialChars/malformed/route.tsx | 29 +++++ .../routes/specialChars/malformed/search.tsx | 22 ++++ .../basic/src/routes/specialChars/route.tsx | 9 ++ .../basic/tests/special-characters.spec.ts | 67 ++++++++++++ e2e/vue-start/basic/vite.config.ts | 1 + .../router-core/src/new-process-route-tree.ts | 17 ++- .../tests/getNormalizedURL.test.ts | 12 +++ 23 files changed, 710 insertions(+), 27 deletions(-) create mode 100644 e2e/react-start/basic/src/routes/specialChars/malformed/$param.tsx create mode 100644 e2e/react-start/basic/src/routes/specialChars/malformed/route.tsx create mode 100644 e2e/react-start/basic/src/routes/specialChars/malformed/search.tsx create mode 100644 e2e/solid-start/basic/src/routes/specialChars/malformed/$param.tsx create mode 100644 e2e/solid-start/basic/src/routes/specialChars/malformed/route.tsx create mode 100644 e2e/solid-start/basic/src/routes/specialChars/malformed/search.tsx create mode 100644 e2e/vue-start/basic/src/routes/specialChars/malformed/$param.tsx create mode 100644 e2e/vue-start/basic/src/routes/specialChars/malformed/route.tsx create mode 100644 e2e/vue-start/basic/src/routes/specialChars/malformed/search.tsx diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 45217f53d69..1477ed0bac3 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -50,7 +50,10 @@ import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/vi import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target' import { Route as ApiUsersRouteImport } from './routes/api.users' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' +import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route' import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' +import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search' +import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param' import { Route as RedirectTargetViaLoaderRouteImport } from './routes/redirect/$target/via-loader' import { Route as RedirectTargetViaBeforeLoadRouteImport } from './routes/redirect/$target/via-beforeLoad' import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' @@ -271,11 +274,29 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({ id: '/_layout-2', getParentRoute: () => LayoutRoute, } as any) +const SpecialCharsMalformedRouteRoute = + SpecialCharsMalformedRouteRouteImport.update({ + id: '/malformed', + path: '/malformed', + getParentRoute: () => SpecialCharsRouteRoute, + } as any) const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => RedirectTargetRoute, } as any) +const SpecialCharsMalformedSearchRoute = + SpecialCharsMalformedSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => SpecialCharsMalformedRouteRoute, + } as any) +const SpecialCharsMalformedParamRoute = + SpecialCharsMalformedParamRouteImport.update({ + id: '/$param', + path: '/$param', + getParentRoute: () => SpecialCharsMalformedRouteRoute, + } as any) const RedirectTargetViaLoaderRoute = RedirectTargetViaLoaderRouteImport.update({ id: '/via-loader', path: '/via-loader', @@ -357,6 +378,7 @@ export interface FileRoutesByFullPath { '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -388,6 +410,8 @@ export interface FileRoutesByFullPath { '/posts/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/foo/$bar/$qux': typeof FooBarQuxHereRouteWithChildren '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute @@ -406,6 +430,7 @@ export interface FileRoutesByTo { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -436,6 +461,8 @@ export interface FileRoutesByTo { '/posts/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target': typeof RedirectTargetIndexRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -460,6 +487,7 @@ export interface FileRoutesById { '/stream': typeof StreamRoute '/type-only-reexport': typeof TypeOnlyReexportRoute '/users': typeof UsersRouteWithChildren + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -492,6 +520,8 @@ export interface FileRoutesById { '/posts_/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/foo/$bar/$qux/_here': typeof FooBarQuxHereRouteWithChildren '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute @@ -517,6 +547,7 @@ export interface FileRouteTypes { | '/stream' | '/type-only-reexport' | '/users' + | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -548,6 +579,8 @@ export interface FileRouteTypes { | '/posts/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/redirect/$target/' | '/foo/$bar/$qux' | '/redirect/$target/serverFn/via-beforeLoad' @@ -566,6 +599,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/type-only-reexport' + | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -596,6 +630,8 @@ export interface FileRouteTypes { | '/posts/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/redirect/$target' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -619,6 +655,7 @@ export interface FileRouteTypes { | '/stream' | '/type-only-reexport' | '/users' + | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' | '/multi-cookie-redirect/target' @@ -651,6 +688,8 @@ export interface FileRouteTypes { | '/posts_/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/redirect/$target/' | '/foo/$bar/$qux/_here' | '/redirect/$target/serverFn/via-beforeLoad' @@ -974,6 +1013,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutLayout2RouteImport parentRoute: typeof LayoutRoute } + '/specialChars/malformed': { + id: '/specialChars/malformed' + path: '/malformed' + fullPath: '/specialChars/malformed' + preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/redirect/$target/': { id: '/redirect/$target/' path: '/' @@ -981,6 +1027,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RedirectTargetIndexRouteImport parentRoute: typeof RedirectTargetRoute } + '/specialChars/malformed/search': { + id: '/specialChars/malformed/search' + path: '/search' + fullPath: '/specialChars/malformed/search' + preLoaderRoute: typeof SpecialCharsMalformedSearchRouteImport + parentRoute: typeof SpecialCharsMalformedRouteRoute + } + '/specialChars/malformed/$param': { + id: '/specialChars/malformed/$param' + path: '/$param' + fullPath: '/specialChars/malformed/$param' + preLoaderRoute: typeof SpecialCharsMalformedParamRouteImport + parentRoute: typeof SpecialCharsMalformedRouteRoute + } '/redirect/$target/via-loader': { id: '/redirect/$target/via-loader' path: '/via-loader' @@ -1099,13 +1159,31 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface SpecialCharsMalformedRouteRouteChildren { + SpecialCharsMalformedParamRoute: typeof SpecialCharsMalformedParamRoute + SpecialCharsMalformedSearchRoute: typeof SpecialCharsMalformedSearchRoute +} + +const SpecialCharsMalformedRouteRouteChildren: SpecialCharsMalformedRouteRouteChildren = + { + SpecialCharsMalformedParamRoute: SpecialCharsMalformedParamRoute, + SpecialCharsMalformedSearchRoute: SpecialCharsMalformedSearchRoute, + } + +const SpecialCharsMalformedRouteRouteWithChildren = + SpecialCharsMalformedRouteRoute._addFileChildren( + SpecialCharsMalformedRouteRouteChildren, + ) + interface SpecialCharsRouteRouteChildren { + SpecialCharsMalformedRouteRoute: typeof SpecialCharsMalformedRouteRouteWithChildren SpecialCharsParamRoute: typeof SpecialCharsParamRoute SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route } const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { + SpecialCharsMalformedRouteRoute: SpecialCharsMalformedRouteRouteWithChildren, SpecialCharsParamRoute: SpecialCharsParamRoute, SpecialCharsSearchRoute: SpecialCharsSearchRoute, SpecialCharsChar45824Char54620Char48124Char44397Route: diff --git a/e2e/react-start/basic/src/routes/specialChars/malformed/$param.tsx b/e2e/react-start/basic/src/routes/specialChars/malformed/$param.tsx new file mode 100644 index 00000000000..4040b6b7d45 --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/malformed/$param.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/specialChars/malformed/$param')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { param } = Route.useParams() + return ( +
+ Hello "/specialChars/malformed/$param":{' '} + {param} +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/specialChars/malformed/route.tsx b/e2e/react-start/basic/src/routes/specialChars/malformed/route.tsx new file mode 100644 index 00000000000..91114692b2b --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/malformed/route.tsx @@ -0,0 +1,29 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/specialChars/malformed')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
Hello "/specialChars/malformed"!
+ + malformed path param + {' '} + + malformed search param + {' '} +
+ +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/specialChars/malformed/search.tsx b/e2e/react-start/basic/src/routes/specialChars/malformed/search.tsx new file mode 100644 index 00000000000..c5256de3372 --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/malformed/search.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from '@tanstack/react-router' +import z from 'zod' + +export const Route = createFileRoute('/specialChars/malformed/search')({ + validateSearch: z.object({ + searchParam: z.string(), + }), + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + + return ( +
+ Hello "/specialChars/malformed/search"! + + {search.searchParam} + +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/specialChars/route.tsx b/e2e/react-start/basic/src/routes/specialChars/route.tsx index 9811378459e..a6069cff481 100644 --- a/e2e/react-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/react-start/basic/src/routes/specialChars/route.tsx @@ -37,6 +37,15 @@ function RouteComponent() { > Unicode search param {' '} + + Malformed paths + {' '}
diff --git a/e2e/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts index b583e28fdd8..b09942ce5d5 100644 --- a/e2e/react-start/basic/tests/special-characters.spec.ts +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from './utils/isSpaMode' test.use({ whitelistErrors: [ @@ -101,4 +102,71 @@ test.describe('Unicode route rendering', () => { expect(searchParam).toBe('대|') }) }) + + test.describe('malformed paths', () => { + test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 404', + 'Failed to load resource: the server responded with a status of 400 (Bad Request)', + ], + }) + + test('un-matched malformed paths should return not found on direct navigation', async ({ + page, + }) => { + const res = await page.goto('/specialChars/malformed/%E0%A4') + + await page.waitForLoadState(`load`) + + // in spa mode this is caught and handled at server level + if (!isSpaMode) { + expect(res!.status()).toBe(404) + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + } else { + expect(res!.status()).toBe(400) + } + }) + + test('malformed path params should return not found on router link', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/malformed') + await page.waitForURL(`${baseURL}/specialChars/malformed`) + + const link = page.getByTestId('special-malformed-path-link') + + await link.click() + + await page.waitForLoadState('load') + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + }) + + test('un-matched malformed paths should return not found on direct navigation in search params', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/malformed/search?searchParam=%E0%A4') + + await page.waitForURL( + `${baseURL}/specialChars/malformed/search?searchParam=%E0%A4`, + ) + + await expect( + page.getByTestId('special-malformed-search-param'), + ).toBeInViewport() + + const searchParam = await page + .getByTestId('special-malformed-search-param') + .textContent() + + expect(searchParam).toBe('�') + }) + }) }) diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts index 88f1d7690f6..f0a0fe6a1b5 100644 --- a/e2e/react-start/basic/vite.config.ts +++ b/e2e/react-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-beforeLoad', '/not-found/via-loader', '/specialChars/search', + '/specialChars/malformed', '/users', ].some((p) => page.path.includes(p)), maxRedirects: 100, diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 8785c63ae4c..70ae25782e3 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -48,9 +48,12 @@ import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/vi import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target' import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' +import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route' import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as TransitionTypingCreateResourceRouteImport } from './routes/transition/typing/create-resource' import { Route as TransitionCountCreateResourceRouteImport } from './routes/transition/count/create-resource' +import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search' +import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param' import { Route as RedirectTargetViaLoaderRouteImport } from './routes/redirect/$target/via-loader' import { Route as RedirectTargetViaBeforeLoadRouteImport } from './routes/redirect/$target/via-beforeLoad' import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' @@ -259,6 +262,12 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({ id: '/_layout-2', getParentRoute: () => LayoutRoute, } as any) +const SpecialCharsMalformedRouteRoute = + SpecialCharsMalformedRouteRouteImport.update({ + id: '/malformed', + path: '/malformed', + getParentRoute: () => SpecialCharsRouteRoute, + } as any) const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ id: '/', path: '/', @@ -276,6 +285,18 @@ const TransitionCountCreateResourceRoute = path: '/transition/count/create-resource', getParentRoute: () => rootRouteImport, } as any) +const SpecialCharsMalformedSearchRoute = + SpecialCharsMalformedSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => SpecialCharsMalformedRouteRoute, + } as any) +const SpecialCharsMalformedParamRoute = + SpecialCharsMalformedParamRouteImport.update({ + id: '/$param', + path: '/$param', + getParentRoute: () => SpecialCharsMalformedRouteRoute, + } as any) const RedirectTargetViaLoaderRoute = RedirectTargetViaLoaderRouteImport.update({ id: '/via-loader', path: '/via-loader', @@ -345,6 +366,7 @@ export interface FileRoutesByFullPath { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -363,11 +385,11 @@ export interface FileRoutesByFullPath { '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute - '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute + '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute '/raw-stream/': typeof RawStreamIndexRoute - '/redirect': typeof RedirectIndexRoute + '/redirect/': typeof RedirectIndexRoute '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute @@ -376,13 +398,15 @@ export interface FileRoutesByFullPath { '/posts/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute - '/redirect/$target/serverFn': typeof RedirectTargetServerFnIndexRoute + '/redirect/$target/serverFn/': typeof RedirectTargetServerFnIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -392,6 +416,7 @@ export interface FileRoutesByTo { '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -422,6 +447,8 @@ export interface FileRoutesByTo { '/posts/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target': typeof RedirectTargetIndexRoute @@ -445,6 +472,7 @@ export interface FileRoutesById { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -477,6 +505,8 @@ export interface FileRoutesById { '/posts_/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -500,6 +530,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -518,11 +549,11 @@ export interface FileRouteTypes { | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' - | '/multi-cookie-redirect' + | '/multi-cookie-redirect/' | '/not-found/' | '/posts/' | '/raw-stream/' - | '/redirect' + | '/redirect/' | '/search-params/' | '/users/' | '/layout-a' @@ -531,13 +562,15 @@ export interface FileRouteTypes { | '/posts/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target/' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' - | '/redirect/$target/serverFn' + | '/redirect/$target/serverFn/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -547,6 +580,7 @@ export interface FileRouteTypes { | '/links' | '/scripts' | '/stream' + | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -577,6 +611,8 @@ export interface FileRouteTypes { | '/posts/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target' @@ -599,6 +635,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' | '/multi-cookie-redirect/target' @@ -631,6 +668,8 @@ export interface FileRouteTypes { | '/posts_/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target/' @@ -725,7 +764,7 @@ declare module '@tanstack/solid-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -774,7 +813,7 @@ declare module '@tanstack/solid-router' { '/redirect/': { id: '/redirect/' path: '/redirect' - fullPath: '/redirect' + fullPath: '/redirect/' preLoaderRoute: typeof RedirectIndexRouteImport parentRoute: typeof rootRouteImport } @@ -802,7 +841,7 @@ declare module '@tanstack/solid-router' { '/multi-cookie-redirect/': { id: '/multi-cookie-redirect/' path: '/multi-cookie-redirect' - fullPath: '/multi-cookie-redirect' + fullPath: '/multi-cookie-redirect/' preLoaderRoute: typeof MultiCookieRedirectIndexRouteImport parentRoute: typeof rootRouteImport } @@ -935,10 +974,17 @@ declare module '@tanstack/solid-router' { '/_layout/_layout-2': { id: '/_layout/_layout-2' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutLayout2RouteImport parentRoute: typeof LayoutRoute } + '/specialChars/malformed': { + id: '/specialChars/malformed' + path: '/malformed' + fullPath: '/specialChars/malformed' + preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/redirect/$target/': { id: '/redirect/$target/' path: '/' @@ -960,6 +1006,20 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof TransitionCountCreateResourceRouteImport parentRoute: typeof rootRouteImport } + '/specialChars/malformed/search': { + id: '/specialChars/malformed/search' + path: '/search' + fullPath: '/specialChars/malformed/search' + preLoaderRoute: typeof SpecialCharsMalformedSearchRouteImport + parentRoute: typeof SpecialCharsMalformedRouteRoute + } + '/specialChars/malformed/$param': { + id: '/specialChars/malformed/$param' + path: '/$param' + fullPath: '/specialChars/malformed/$param' + preLoaderRoute: typeof SpecialCharsMalformedParamRouteImport + parentRoute: typeof SpecialCharsMalformedRouteRoute + } '/redirect/$target/via-loader': { id: '/redirect/$target/via-loader' path: '/via-loader' @@ -1005,7 +1065,7 @@ declare module '@tanstack/solid-router' { '/redirect/$target/serverFn/': { id: '/redirect/$target/serverFn/' path: '/serverFn' - fullPath: '/redirect/$target/serverFn' + fullPath: '/redirect/$target/serverFn/' preLoaderRoute: typeof RedirectTargetServerFnIndexRouteImport parentRoute: typeof RedirectTargetRoute } @@ -1064,13 +1124,31 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface SpecialCharsMalformedRouteRouteChildren { + SpecialCharsMalformedParamRoute: typeof SpecialCharsMalformedParamRoute + SpecialCharsMalformedSearchRoute: typeof SpecialCharsMalformedSearchRoute +} + +const SpecialCharsMalformedRouteRouteChildren: SpecialCharsMalformedRouteRouteChildren = + { + SpecialCharsMalformedParamRoute: SpecialCharsMalformedParamRoute, + SpecialCharsMalformedSearchRoute: SpecialCharsMalformedSearchRoute, + } + +const SpecialCharsMalformedRouteRouteWithChildren = + SpecialCharsMalformedRouteRoute._addFileChildren( + SpecialCharsMalformedRouteRouteChildren, + ) + interface SpecialCharsRouteRouteChildren { + SpecialCharsMalformedRouteRoute: typeof SpecialCharsMalformedRouteRouteWithChildren SpecialCharsParamRoute: typeof SpecialCharsParamRoute SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route } const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { + SpecialCharsMalformedRouteRoute: SpecialCharsMalformedRouteRouteWithChildren, SpecialCharsParamRoute: SpecialCharsParamRoute, SpecialCharsSearchRoute: SpecialCharsSearchRoute, SpecialCharsChar45824Char54620Char48124Char44397Route: diff --git a/e2e/solid-start/basic/src/routes/specialChars/malformed/$param.tsx b/e2e/solid-start/basic/src/routes/specialChars/malformed/$param.tsx new file mode 100644 index 00000000000..132d7f72bf2 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/malformed/$param.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/specialChars/malformed/$param')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + return ( +
+ Hello "/specialChars/malformed/$param":{' '} + {params().param} +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/specialChars/malformed/route.tsx b/e2e/solid-start/basic/src/routes/specialChars/malformed/route.tsx new file mode 100644 index 00000000000..1b32e5ff710 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/malformed/route.tsx @@ -0,0 +1,29 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/specialChars/malformed')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
Hello "/specialChars/malformed"!
+ + malformed path param + {' '} + + malformed search param + {' '} +
+ +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/specialChars/malformed/search.tsx b/e2e/solid-start/basic/src/routes/specialChars/malformed/search.tsx new file mode 100644 index 00000000000..a9aea491370 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/malformed/search.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from '@tanstack/solid-router' +import z from 'zod' + +export const Route = createFileRoute('/specialChars/malformed/search')({ + validateSearch: z.object({ + searchParam: z.string(), + }), + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + + return ( +
+ Hello "/specialChars/malformed/search"! + + {search().searchParam} + +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/specialChars/route.tsx b/e2e/solid-start/basic/src/routes/specialChars/route.tsx index e57876041bb..0bfad91cd77 100644 --- a/e2e/solid-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/solid-start/basic/src/routes/specialChars/route.tsx @@ -37,6 +37,15 @@ function RouteComponent() { > Unicode search param {' '} + + Malformed paths + {' '}
diff --git a/e2e/solid-start/basic/tests/special-characters.spec.ts b/e2e/solid-start/basic/tests/special-characters.spec.ts index b583e28fdd8..8ff7e7762e6 100644 --- a/e2e/solid-start/basic/tests/special-characters.spec.ts +++ b/e2e/solid-start/basic/tests/special-characters.spec.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from './utils/isSpaMode' test.use({ whitelistErrors: [ @@ -101,4 +102,70 @@ test.describe('Unicode route rendering', () => { expect(searchParam).toBe('대|') }) }) + + test.describe('malformed paths', () => { + test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 404', + 'Failed to load resource: the server responded with a status of 400 (Bad Request)', + ], + }) + + test('un-matched malformed paths should return not found on direct navigation', async ({ + page, + }) => { + const res = await page.goto('/specialChars/malformed/%E0%A4') + + await page.waitForLoadState(`load`) + + // in spa mode this is caught and handled at server level + if (!isSpaMode) { + expect(res!.status()).toBe(404) + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + } else { + expect(res!.status()).toBe(400) + } + }) + + test('malformed path params should return not found on router link', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/malformed') + await page.waitForURL(`${baseURL}/specialChars/malformed`) + + const link = page.getByTestId('special-malformed-path-link') + + await link.click() + await page.waitForLoadState('load') + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + }) + + test('un-matched malformed paths should return not found on direct navigation in search params', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/malformed/search?searchParam=%E0%A4') + + await page.waitForURL( + `${baseURL}/specialChars/malformed/search?searchParam=%E0%A4`, + ) + + await expect( + page.getByTestId('special-malformed-search-param'), + ).toBeInViewport() + + const searchParam = await page + .getByTestId('special-malformed-search-param') + .textContent() + + expect(searchParam).toBe('�') + }) + }) }) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 3d310300aef..1301ae91393 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-beforeLoad', '/not-found/via-loader', '/specialChars/search', + '/specialChars/malformed', '/search-params/default', '/transition', '/users', diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index b8a1f2b5493..def40f2e94e 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-start/basic/src/routeTree.gen.ts @@ -48,7 +48,10 @@ import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/vi import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target' import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' +import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route' import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' +import { Route as SpecialCharsMalformedSearchRouteImport } from './routes/specialChars/malformed/search' +import { Route as SpecialCharsMalformedParamRouteImport } from './routes/specialChars/malformed/$param' import { Route as RedirectTargetViaLoaderRouteImport } from './routes/redirect/$target/via-loader' import { Route as RedirectTargetViaBeforeLoadRouteImport } from './routes/redirect/$target/via-beforeLoad' import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' @@ -257,11 +260,29 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({ id: '/_layout-2', getParentRoute: () => LayoutRoute, } as any) +const SpecialCharsMalformedRouteRoute = + SpecialCharsMalformedRouteRouteImport.update({ + id: '/malformed', + path: '/malformed', + getParentRoute: () => SpecialCharsRouteRoute, + } as any) const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => RedirectTargetRoute, } as any) +const SpecialCharsMalformedSearchRoute = + SpecialCharsMalformedSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => SpecialCharsMalformedRouteRoute, + } as any) +const SpecialCharsMalformedParamRoute = + SpecialCharsMalformedParamRouteImport.update({ + id: '/$param', + path: '/$param', + getParentRoute: () => SpecialCharsMalformedRouteRoute, + } as any) const RedirectTargetViaLoaderRoute = RedirectTargetViaLoaderRouteImport.update({ id: '/via-loader', path: '/via-loader', @@ -331,6 +352,7 @@ export interface FileRoutesByFullPath { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -349,11 +371,11 @@ export interface FileRoutesByFullPath { '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute - '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute + '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute '/raw-stream/': typeof RawStreamIndexRoute - '/redirect': typeof RedirectIndexRoute + '/redirect/': typeof RedirectIndexRoute '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute @@ -362,11 +384,13 @@ export interface FileRoutesByFullPath { '/posts/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute - '/redirect/$target/serverFn': typeof RedirectTargetServerFnIndexRoute + '/redirect/$target/serverFn/': typeof RedirectTargetServerFnIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -376,6 +400,7 @@ export interface FileRoutesByTo { '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute @@ -406,6 +431,8 @@ export interface FileRoutesByTo { '/posts/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target': typeof RedirectTargetIndexRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -427,6 +454,7 @@ export interface FileRoutesById { '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren + '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute @@ -459,6 +487,8 @@ export interface FileRoutesById { '/posts_/$postId/deep': typeof PostsPostIdDeepRoute '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute + '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -480,6 +510,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -498,11 +529,11 @@ export interface FileRouteTypes { | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' - | '/multi-cookie-redirect' + | '/multi-cookie-redirect/' | '/not-found/' | '/posts/' | '/raw-stream/' - | '/redirect' + | '/redirect/' | '/search-params/' | '/users/' | '/layout-a' @@ -511,11 +542,13 @@ export interface FileRouteTypes { | '/posts/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/redirect/$target/' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' - | '/redirect/$target/serverFn' + | '/redirect/$target/serverFn/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -525,6 +558,7 @@ export interface FileRouteTypes { | '/links' | '/scripts' | '/stream' + | '/specialChars/malformed' | '/api/users' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' @@ -555,6 +589,8 @@ export interface FileRouteTypes { | '/posts/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/redirect/$target' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -575,6 +611,7 @@ export interface FileRouteTypes { | '/scripts' | '/stream' | '/users' + | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' | '/multi-cookie-redirect/target' @@ -607,6 +644,8 @@ export interface FileRouteTypes { | '/posts_/$postId/deep' | '/redirect/$target/via-beforeLoad' | '/redirect/$target/via-loader' + | '/specialChars/malformed/$param' + | '/specialChars/malformed/search' | '/redirect/$target/' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -697,7 +736,7 @@ declare module '@tanstack/vue-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -746,7 +785,7 @@ declare module '@tanstack/vue-router' { '/redirect/': { id: '/redirect/' path: '/redirect' - fullPath: '/redirect' + fullPath: '/redirect/' preLoaderRoute: typeof RedirectIndexRouteImport parentRoute: typeof rootRouteImport } @@ -774,7 +813,7 @@ declare module '@tanstack/vue-router' { '/multi-cookie-redirect/': { id: '/multi-cookie-redirect/' path: '/multi-cookie-redirect' - fullPath: '/multi-cookie-redirect' + fullPath: '/multi-cookie-redirect/' preLoaderRoute: typeof MultiCookieRedirectIndexRouteImport parentRoute: typeof rootRouteImport } @@ -907,10 +946,17 @@ declare module '@tanstack/vue-router' { '/_layout/_layout-2': { id: '/_layout/_layout-2' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutLayout2RouteImport parentRoute: typeof LayoutRoute } + '/specialChars/malformed': { + id: '/specialChars/malformed' + path: '/malformed' + fullPath: '/specialChars/malformed' + preLoaderRoute: typeof SpecialCharsMalformedRouteRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/redirect/$target/': { id: '/redirect/$target/' path: '/' @@ -918,6 +964,20 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RedirectTargetIndexRouteImport parentRoute: typeof RedirectTargetRoute } + '/specialChars/malformed/search': { + id: '/specialChars/malformed/search' + path: '/search' + fullPath: '/specialChars/malformed/search' + preLoaderRoute: typeof SpecialCharsMalformedSearchRouteImport + parentRoute: typeof SpecialCharsMalformedRouteRoute + } + '/specialChars/malformed/$param': { + id: '/specialChars/malformed/$param' + path: '/$param' + fullPath: '/specialChars/malformed/$param' + preLoaderRoute: typeof SpecialCharsMalformedParamRouteImport + parentRoute: typeof SpecialCharsMalformedRouteRoute + } '/redirect/$target/via-loader': { id: '/redirect/$target/via-loader' path: '/via-loader' @@ -963,7 +1023,7 @@ declare module '@tanstack/vue-router' { '/redirect/$target/serverFn/': { id: '/redirect/$target/serverFn/' path: '/serverFn' - fullPath: '/redirect/$target/serverFn' + fullPath: '/redirect/$target/serverFn/' preLoaderRoute: typeof RedirectTargetServerFnIndexRouteImport parentRoute: typeof RedirectTargetRoute } @@ -1022,13 +1082,31 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface SpecialCharsMalformedRouteRouteChildren { + SpecialCharsMalformedParamRoute: typeof SpecialCharsMalformedParamRoute + SpecialCharsMalformedSearchRoute: typeof SpecialCharsMalformedSearchRoute +} + +const SpecialCharsMalformedRouteRouteChildren: SpecialCharsMalformedRouteRouteChildren = + { + SpecialCharsMalformedParamRoute: SpecialCharsMalformedParamRoute, + SpecialCharsMalformedSearchRoute: SpecialCharsMalformedSearchRoute, + } + +const SpecialCharsMalformedRouteRouteWithChildren = + SpecialCharsMalformedRouteRoute._addFileChildren( + SpecialCharsMalformedRouteRouteChildren, + ) + interface SpecialCharsRouteRouteChildren { + SpecialCharsMalformedRouteRoute: typeof SpecialCharsMalformedRouteRouteWithChildren SpecialCharsParamRoute: typeof SpecialCharsParamRoute SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route } const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { + SpecialCharsMalformedRouteRoute: SpecialCharsMalformedRouteRouteWithChildren, SpecialCharsParamRoute: SpecialCharsParamRoute, SpecialCharsSearchRoute: SpecialCharsSearchRoute, SpecialCharsChar45824Char54620Char48124Char44397Route: diff --git a/e2e/vue-start/basic/src/routes/specialChars/malformed/$param.tsx b/e2e/vue-start/basic/src/routes/specialChars/malformed/$param.tsx new file mode 100644 index 00000000000..71609645499 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/malformed/$param.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/specialChars/malformed/$param')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + return ( +
+ Hello "/specialChars/malformed/$param":{' '} + {params.value.param} +
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/specialChars/malformed/route.tsx b/e2e/vue-start/basic/src/routes/specialChars/malformed/route.tsx new file mode 100644 index 00000000000..1121505377b --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/malformed/route.tsx @@ -0,0 +1,29 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/specialChars/malformed')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
Hello "/specialChars/malformed"!
+ + malformed path param + {' '} + + malformed search param + {' '} +
+ +
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/specialChars/malformed/search.tsx b/e2e/vue-start/basic/src/routes/specialChars/malformed/search.tsx new file mode 100644 index 00000000000..24386049543 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/malformed/search.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from '@tanstack/vue-router' +import z from 'zod' + +export const Route = createFileRoute('/specialChars/malformed/search')({ + validateSearch: z.object({ + searchParam: z.string(), + }), + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + + return ( +
+ Hello "/specialChars/malformed/search"! + + {search.value.searchParam} + +
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/specialChars/route.tsx b/e2e/vue-start/basic/src/routes/specialChars/route.tsx index 7e31bd63fd6..a34cf5ee64a 100644 --- a/e2e/vue-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/vue-start/basic/src/routes/specialChars/route.tsx @@ -37,6 +37,15 @@ function RouteComponent() { > Unicode search param {' '} + + Malformed paths + {' '}
diff --git a/e2e/vue-start/basic/tests/special-characters.spec.ts b/e2e/vue-start/basic/tests/special-characters.spec.ts index b583e28fdd8..8ff7e7762e6 100644 --- a/e2e/vue-start/basic/tests/special-characters.spec.ts +++ b/e2e/vue-start/basic/tests/special-characters.spec.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from './utils/isSpaMode' test.use({ whitelistErrors: [ @@ -101,4 +102,70 @@ test.describe('Unicode route rendering', () => { expect(searchParam).toBe('대|') }) }) + + test.describe('malformed paths', () => { + test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 404', + 'Failed to load resource: the server responded with a status of 400 (Bad Request)', + ], + }) + + test('un-matched malformed paths should return not found on direct navigation', async ({ + page, + }) => { + const res = await page.goto('/specialChars/malformed/%E0%A4') + + await page.waitForLoadState(`load`) + + // in spa mode this is caught and handled at server level + if (!isSpaMode) { + expect(res!.status()).toBe(404) + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + } else { + expect(res!.status()).toBe(400) + } + }) + + test('malformed path params should return not found on router link', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/malformed') + await page.waitForURL(`${baseURL}/specialChars/malformed`) + + const link = page.getByTestId('special-malformed-path-link') + + await link.click() + await page.waitForLoadState('load') + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + }) + + test('un-matched malformed paths should return not found on direct navigation in search params', async ({ + page, + baseURL, + }) => { + await page.goto('/specialChars/malformed/search?searchParam=%E0%A4') + + await page.waitForURL( + `${baseURL}/specialChars/malformed/search?searchParam=%E0%A4`, + ) + + await expect( + page.getByTestId('special-malformed-search-param'), + ).toBeInViewport() + + const searchParam = await page + .getByTestId('special-malformed-search-param') + .textContent() + + expect(searchParam).toBe('�') + }) + }) }) diff --git a/e2e/vue-start/basic/vite.config.ts b/e2e/vue-start/basic/vite.config.ts index 58f6baaab6a..222fce19b32 100644 --- a/e2e/vue-start/basic/vite.config.ts +++ b/e2e/vue-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-beforeLoad', '/not-found/via-loader', '/specialChars/search', + '/specialChars/malformed', '/search-params', // search-param routes have dynamic content based on query params '/transition', '/users', diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 2eef6426404..29dcf2771e0 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -732,11 +732,18 @@ export function findRouteMatch< const cached = processedTree.matchCache.get(key) if (cached !== undefined) return cached path ||= '/' - const result = findMatch( - path, - processedTree.segmentTree, - fuzzy, - ) as RouteMatch | null + let result: RouteMatch | null + + try { + result = findMatch( + path, + processedTree.segmentTree, + fuzzy, + ) as RouteMatch | null + } catch { + result = null + } + if (result) result.branch = buildRouteBranch(result.route) processedTree.matchCache.set(key, result) return result diff --git a/packages/router-core/tests/getNormalizedURL.test.ts b/packages/router-core/tests/getNormalizedURL.test.ts index 24fb6bd7bca..173c2ee0b5d 100644 --- a/packages/router-core/tests/getNormalizedURL.test.ts +++ b/packages/router-core/tests/getNormalizedURL.test.ts @@ -55,6 +55,18 @@ describe('getNormalizedURL', () => { expectedSearchParams: '?key=value%23part&other=%3Fdata', expectedHash: '#section%3Fpart', }, + { + url: 'https://example.com/%E0%A4', + expectedPathName: '/%E0%A4', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/%ZZ', + expectedPathName: '/%ZZ', + expectedSearchParams: '', + expectedHash: '', + }, ] test.each(testCases)( 'should treat encoded URL specific characters correctly', From 8a099130f81d3e29178b8f0f0cbb28218d92884a Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 18 Jan 2026 18:28:36 +0200 Subject: [PATCH 15/18] build pathname using url parts --- packages/start-server-core/src/createStartHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 9944267d18f..7fa0e4a4843 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -219,7 +219,7 @@ export function createStartHandler( try { // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. const url = getNormalizedURL(request.url) - const href = url.href.replace(url.origin, '') + const href = url.pathname + url.search + url.hash const origin = getOrigin(request) const entries = await getEntries() From 02a26a78cb291e60d6b637eb11681a254fad63dc Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 18 Jan 2026 19:12:55 +0200 Subject: [PATCH 16/18] handle backslash encoding in URL normalization --- packages/router-core/src/ssr/ssr-server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 81af3898f6c..e8d05c4841b 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -357,6 +357,9 @@ export function getOrigin(request: Request) { // new URLSearchParams() encodes "|" while new URL() does not, and in this instance // chromium treats search params differently than paths, i.e. "|" is not encoded in search params. export function getNormalizedURL(url: string | URL, base?: string | URL) { + // ensure backslashes are encoded correctly in the URL + if (typeof url === 'string') url = url.replace('\\', '%5C') + const rawUrl = new URL(url, base) const decodedPathname = decodePath(rawUrl.pathname) const searchParams = new URLSearchParams(rawUrl.search) From 2e64c374d42a9d1b5675905c79f7867a2bd42a72 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 18 Jan 2026 19:13:07 +0200 Subject: [PATCH 17/18] additional tests --- .../tests/getNormalizedURL.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/router-core/tests/getNormalizedURL.test.ts b/packages/router-core/tests/getNormalizedURL.test.ts index 173c2ee0b5d..6af6b2736a6 100644 --- a/packages/router-core/tests/getNormalizedURL.test.ts +++ b/packages/router-core/tests/getNormalizedURL.test.ts @@ -67,6 +67,54 @@ describe('getNormalizedURL', () => { expectedSearchParams: '', expectedHash: '', }, + { + url: 'https://example.com/path?a=1&a=2', + expectedPathName: '/path', + expectedSearchParams: '?a=1&a=2', + expectedHash: '', + }, + { + url: 'https://example.com/path+a', + expectedPathName: '/path+a', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/path a', + expectedPathName: '/path%20a', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/path%20a', + expectedPathName: '/path%20a', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/path%25a', + expectedPathName: '/path%25a', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/path%25a', + expectedPathName: '/path%25a', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/path\\a', + expectedPathName: '/path%5Ca', + expectedSearchParams: '', + expectedHash: '', + }, + { + url: 'https://example.com/path%5Ca', + expectedPathName: '/path%5Ca', + expectedSearchParams: '', + expectedHash: '', + }, ] test.each(testCases)( 'should treat encoded URL specific characters correctly', From adb9a83f129efc8c979938a8eced66beb13b46fd Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 18 Jan 2026 19:35:15 +0200 Subject: [PATCH 18/18] refine match error handling --- packages/router-core/src/new-process-route-tree.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 29dcf2771e0..ef5387d2c99 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -740,8 +740,12 @@ export function findRouteMatch< processedTree.segmentTree, fuzzy, ) as RouteMatch | null - } catch { - result = null + } catch (err) { + if (err instanceof URIError) { + result = null + } else { + throw err + } } if (result) result.branch = buildRouteBranch(result.route)