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..83f5ff0079c 100644 --- a/e2e/react-start/basic/server.js +++ b/e2e/react-start/basic/server.js @@ -7,13 +7,18 @@ 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) 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 { @@ -54,14 +59,22 @@ export async function createSpaServer() { return { app } } -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}`) - }), -) +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}`) + }), + ) +} 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..9811378459e --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/route.tsx @@ -0,0 +1,44 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +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..555a8518908 --- /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,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/specialChars/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ Hello "/specialChars/대한민국"! +
+ ) +} 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/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 7718fe86ef0..8506ff9b061 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/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts new file mode 100644 index 00000000000..b583e28fdd8 --- /dev/null +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -0,0 +1,104 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +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`, + ) + + 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%7C`, + ) + + 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..83f5ff0079c 100644 --- a/e2e/solid-start/basic/server.js +++ b/e2e/solid-start/basic/server.js @@ -7,13 +7,18 @@ 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) 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 { @@ -54,14 +59,22 @@ export async function createSpaServer() { return { app } } -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}`) - }), -) +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}`) + }), + ) +} 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..e57876041bb --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/route.tsx @@ -0,0 +1,44 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' + +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..13257e1fa89 --- /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,13 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/specialChars/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ Hello "/specialChars/대한민국"! +
+ ) +} 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/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index 7718fe86ef0..8506ff9b061 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/solid-start/basic/tests/special-characters.spec.ts b/e2e/solid-start/basic/tests/special-characters.spec.ts new file mode 100644 index 00000000000..b583e28fdd8 --- /dev/null +++ b/e2e/solid-start/basic/tests/special-characters.spec.ts @@ -0,0 +1,104 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +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`, + ) + + 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%7C`, + ) + + 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..83f5ff0079c 100644 --- a/e2e/vue-start/basic/server.js +++ b/e2e/vue-start/basic/server.js @@ -7,13 +7,18 @@ 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) 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 { @@ -54,14 +59,22 @@ export async function createSpaServer() { return { app } } -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}`) - }), -) +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}`) + }), + ) +} 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..7e31bd63fd6 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/route.tsx @@ -0,0 +1,44 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' + +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 54% 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..90bd3120569 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,16 @@ import { createFileRoute } from '@tanstack/vue-router' -export const Route = createFileRoute('/대한민국')({ +export const Route = createFileRoute('/specialChars/대한민국')({ component: KoreaComponent, }) function KoreaComponent() { return (
-

대한민국

+ Test +

+ Hello /specialChars/대한민국 +

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/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index 7718fe86ef0..8506ff9b061 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 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..b583e28fdd8 --- /dev/null +++ b/e2e/vue-start/basic/tests/special-characters.spec.ts @@ -0,0 +1,104 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +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`, + ) + + 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%7C`, + ) + + 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/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/router-core/tests/getNormalizedURL.test.ts b/packages/router-core/tests/getNormalizedURL.test.ts new file mode 100644 index 00000000000..24fb6bd7bca --- /dev/null +++ b/packages/router-core/tests/getNormalizedURL.test.ts @@ -0,0 +1,68 @@ +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) + }) + + 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) + }, + ) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index c35a24f3092..9944267d18f 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -12,6 +12,7 @@ import { } from '@tanstack/router-core' import { attachRouterServerSsrUtils, + getNormalizedURL, getOrigin, } from '@tanstack/router-core/ssr/server' import { runWithStartContext } from '@tanstack/start-storage-context' @@ -216,7 +217,8 @@ export function createStartHandler( let cbWillCleanup = false as boolean try { - const url = new URL(request.url) + // 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 origin = getOrigin(request)