diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index 42e0784d8ac..d40829ada7c 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -290,6 +290,16 @@ const link = ( ) ``` +> ⚠️ When directly navigating to a URL with a hash fragment, the fragment is only available on the client; the browser does not send the fragment to the server as part of the request URL. +> +> This means that if you are using a server-side rendering approach, the hash fragment will not be available on the server-side, and hydration mismatches can occur when using the hash for rendering markup. +> +> Examples of this would be: +> +> - returning the hash value in the markup, +> - conditional rendering based on the hash value, or +> - setting the Link as active based on the hash value. + ### Navigating with Optional Parameters Optional path parameters provide flexible navigation patterns where you can include or omit parameters as needed. Optional parameters use the `{-$paramName}` syntax and offer fine-grained control over URL structure. diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 1477ed0bac3..b5ecc4a50ff 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-coo 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 SpecialCharsHashRouteImport } from './routes/specialChars/hash' 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' @@ -193,6 +194,11 @@ const SpecialCharsSearchRoute = SpecialCharsSearchRouteImport.update({ path: '/search', getParentRoute: () => SpecialCharsRouteRoute, } as any) +const SpecialCharsHashRoute = SpecialCharsHashRouteImport.update({ + id: '/hash', + path: '/hash', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) const SpecialCharsParamRoute = SpecialCharsParamRouteImport.update({ id: '/$param', path: '/$param', @@ -394,6 +400,7 @@ export interface FileRoutesByFullPath { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -445,6 +452,7 @@ export interface FileRoutesByTo { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -504,6 +512,7 @@ export interface FileRoutesById { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -563,6 +572,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -614,6 +624,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -672,6 +683,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -901,6 +913,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SpecialCharsSearchRouteImport parentRoute: typeof SpecialCharsRouteRoute } + '/specialChars/hash': { + id: '/specialChars/hash' + path: '/hash' + fullPath: '/specialChars/hash' + preLoaderRoute: typeof SpecialCharsHashRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/specialChars/$param': { id: '/specialChars/$param' path: '/$param' @@ -1178,6 +1197,7 @@ const SpecialCharsMalformedRouteRouteWithChildren = interface SpecialCharsRouteRouteChildren { SpecialCharsMalformedRouteRoute: typeof SpecialCharsMalformedRouteRouteWithChildren SpecialCharsParamRoute: typeof SpecialCharsParamRoute + SpecialCharsHashRoute: typeof SpecialCharsHashRoute SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route } @@ -1185,6 +1205,7 @@ interface SpecialCharsRouteRouteChildren { const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { SpecialCharsMalformedRouteRoute: SpecialCharsMalformedRouteRouteWithChildren, SpecialCharsParamRoute: SpecialCharsParamRoute, + SpecialCharsHashRoute: SpecialCharsHashRoute, SpecialCharsSearchRoute: SpecialCharsSearchRoute, SpecialCharsChar45824Char54620Char48124Char44397Route: SpecialCharsChar45824Char54620Char48124Char44397Route, diff --git a/e2e/react-start/basic/src/routes/specialChars/hash.tsx b/e2e/react-start/basic/src/routes/specialChars/hash.tsx new file mode 100644 index 00000000000..1f1227e0207 --- /dev/null +++ b/e2e/react-start/basic/src/routes/specialChars/hash.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, useLocation } from '@tanstack/react-router' + +export const Route = createFileRoute('/specialChars/hash')({ + component: RouteComponent, +}) + +function RouteComponent() { + const l = useLocation() + return ( +
+ Hello "/specialChars/hash"! + {l.hash} +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/specialChars/route.tsx b/e2e/react-start/basic/src/routes/specialChars/route.tsx index a6069cff481..4e75790ddd2 100644 --- a/e2e/react-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/react-start/basic/src/routes/specialChars/route.tsx @@ -37,6 +37,19 @@ function RouteComponent() { > Unicode search param {' '} + + Unicode Hash + {' '} { }) }) + test.describe('Special characters in url hash', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await expect(page.getByTestId('special-hash-link')).not.toHaveClass( + 'font-bold', + ) + await page.goto('/specialChars/hash#대|') + + await page.waitForURL(`${baseURL}/specialChars/hash#%EB%8C%80|`) + await page.waitForLoadState('load') + + await expect(page.getByTestId('special-hash-heading')).toBeInViewport() + + const hashValue = await page.getByTestId('special-hash').textContent() + + await expect(page.getByTestId('special-hash-link')).toHaveClass( + 'font-bold', + ) + expect(hashValue).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + await expect(page.getByTestId('special-hash-link')).not.toHaveClass( + 'font-bold', + ) + const link = page.getByTestId('special-hash-link') + + await link.click() + + await page.waitForURL(`${baseURL}/specialChars/hash#%EB%8C%80|`) + await page.waitForLoadState('load') + + await expect(page.getByTestId('special-hash-heading')).toBeInViewport() + + const hashValue = await page.getByTestId('special-hash').textContent() + + await expect(page.getByTestId('special-hash-link')).toHaveClass( + 'font-bold', + ) + expect(hashValue).toBe('대|') + }) + }) + test.describe('malformed paths', () => { test.use({ whitelistErrors: [ diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts index f0a0fe6a1b5..d196ac95385 100644 --- a/e2e/react-start/basic/vite.config.ts +++ b/e2e/react-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-beforeLoad', '/not-found/via-loader', '/specialChars/search', + '/specialChars/hash', '/specialChars/malformed', '/users', ].some((p) => page.path.includes(p)), diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 70ae25782e3..ad5f5431487 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-coo 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 SpecialCharsHashRouteImport } from './routes/specialChars/hash' 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' @@ -181,6 +182,11 @@ const SpecialCharsSearchRoute = SpecialCharsSearchRouteImport.update({ path: '/search', getParentRoute: () => SpecialCharsRouteRoute, } as any) +const SpecialCharsHashRoute = SpecialCharsHashRouteImport.update({ + id: '/hash', + path: '/hash', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) const SpecialCharsParamRoute = SpecialCharsParamRouteImport.update({ id: '/$param', path: '/$param', @@ -382,6 +388,7 @@ export interface FileRoutesByFullPath { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -431,6 +438,7 @@ export interface FileRoutesByTo { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -489,6 +497,7 @@ export interface FileRoutesById { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -546,6 +555,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -595,6 +605,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -652,6 +663,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -866,6 +878,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof SpecialCharsSearchRouteImport parentRoute: typeof SpecialCharsRouteRoute } + '/specialChars/hash': { + id: '/specialChars/hash' + path: '/hash' + fullPath: '/specialChars/hash' + preLoaderRoute: typeof SpecialCharsHashRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/specialChars/$param': { id: '/specialChars/$param' path: '/$param' @@ -1143,6 +1162,7 @@ const SpecialCharsMalformedRouteRouteWithChildren = interface SpecialCharsRouteRouteChildren { SpecialCharsMalformedRouteRoute: typeof SpecialCharsMalformedRouteRouteWithChildren SpecialCharsParamRoute: typeof SpecialCharsParamRoute + SpecialCharsHashRoute: typeof SpecialCharsHashRoute SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route } @@ -1150,6 +1170,7 @@ interface SpecialCharsRouteRouteChildren { const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { SpecialCharsMalformedRouteRoute: SpecialCharsMalformedRouteRouteWithChildren, SpecialCharsParamRoute: SpecialCharsParamRoute, + SpecialCharsHashRoute: SpecialCharsHashRoute, SpecialCharsSearchRoute: SpecialCharsSearchRoute, SpecialCharsChar45824Char54620Char48124Char44397Route: SpecialCharsChar45824Char54620Char48124Char44397Route, diff --git a/e2e/solid-start/basic/src/routes/specialChars/hash.tsx b/e2e/solid-start/basic/src/routes/specialChars/hash.tsx new file mode 100644 index 00000000000..43d24ada29a --- /dev/null +++ b/e2e/solid-start/basic/src/routes/specialChars/hash.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, useLocation } from '@tanstack/solid-router' +import { createEffect, createSignal } from 'solid-js' + +export const Route = createFileRoute('/specialChars/hash')({ + component: RouteComponent, +}) + +function RouteComponent() { + const location = useLocation() + const [getHash, setHash] = createSignal('') + + createEffect(() => { + setHash(location().hash) + }) + + return ( +
+ Hello "/specialChars/hash"! + {getHash()} +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/specialChars/route.tsx b/e2e/solid-start/basic/src/routes/specialChars/route.tsx index 0bfad91cd77..863cb795068 100644 --- a/e2e/solid-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/solid-start/basic/src/routes/specialChars/route.tsx @@ -37,6 +37,19 @@ function RouteComponent() { > Unicode search param {' '} + + Unicode Hash + {' '} { }) }) + test.describe('Special characters in url hash', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await expect(page.getByTestId('special-hash-link')).not.toHaveClass( + 'font-bold', + ) + await page.goto('/specialChars/hash#대|') + + await page.waitForURL(`${baseURL}/specialChars/hash#%EB%8C%80|`) + await page.waitForLoadState('load') + + await expect(page.getByTestId('special-hash-heading')).toBeInViewport() + + const hashValue = await page.getByTestId('special-hash').textContent() + + const el = await page + .getByTestId('special-hash-link') + .evaluate((e) => e.classList.value) + + expect(el).toContain('font-bold') + + expect(hashValue).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + await expect(page.getByTestId('special-hash-link')).not.toHaveClass( + 'font-bold', + ) + const link = page.getByTestId('special-hash-link') + + await link.click() + + await page.waitForURL(`${baseURL}/specialChars/hash#%EB%8C%80|`) + await page.waitForLoadState('load') + + await expect(page.getByTestId('special-hash-heading')).toBeInViewport() + + const hashValue = await page.getByTestId('special-hash').textContent() + + await expect(page.getByTestId('special-hash-link')).toHaveClass( + 'font-bold', + ) + expect(hashValue).toBe('대|') + }) + }) + test.describe('malformed paths', () => { test.use({ whitelistErrors: [ diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 1301ae91393..8db88f34903 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-beforeLoad', '/not-found/via-loader', '/specialChars/search', + '/specialChars/hash', '/specialChars/malformed', '/search-params/default', '/transition', diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index def40f2e94e..77a84e6ecaa 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-start/basic/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-coo 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 SpecialCharsHashRouteImport } from './routes/specialChars/hash' 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' @@ -179,6 +180,11 @@ const SpecialCharsSearchRoute = SpecialCharsSearchRouteImport.update({ path: '/search', getParentRoute: () => SpecialCharsRouteRoute, } as any) +const SpecialCharsHashRoute = SpecialCharsHashRouteImport.update({ + id: '/hash', + path: '/hash', + getParentRoute: () => SpecialCharsRouteRoute, +} as any) const SpecialCharsParamRoute = SpecialCharsParamRouteImport.update({ id: '/$param', path: '/$param', @@ -368,6 +374,7 @@ export interface FileRoutesByFullPath { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -415,6 +422,7 @@ export interface FileRoutesByTo { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -471,6 +479,7 @@ export interface FileRoutesById { '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/specialChars/$param': typeof SpecialCharsParamRoute + '/specialChars/hash': typeof SpecialCharsHashRoute '/specialChars/search': typeof SpecialCharsSearchRoute '/specialChars/대한민국': typeof SpecialCharsChar45824Char54620Char48124Char44397Route '/users/$userId': typeof UsersUserIdRoute @@ -526,6 +535,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -573,6 +583,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -628,6 +639,7 @@ export interface FileRouteTypes { | '/search-params/default' | '/search-params/loader-throws-redirect' | '/specialChars/$param' + | '/specialChars/hash' | '/specialChars/search' | '/specialChars/대한민국' | '/users/$userId' @@ -838,6 +850,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof SpecialCharsSearchRouteImport parentRoute: typeof SpecialCharsRouteRoute } + '/specialChars/hash': { + id: '/specialChars/hash' + path: '/hash' + fullPath: '/specialChars/hash' + preLoaderRoute: typeof SpecialCharsHashRouteImport + parentRoute: typeof SpecialCharsRouteRoute + } '/specialChars/$param': { id: '/specialChars/$param' path: '/$param' @@ -1101,6 +1120,7 @@ const SpecialCharsMalformedRouteRouteWithChildren = interface SpecialCharsRouteRouteChildren { SpecialCharsMalformedRouteRoute: typeof SpecialCharsMalformedRouteRouteWithChildren SpecialCharsParamRoute: typeof SpecialCharsParamRoute + SpecialCharsHashRoute: typeof SpecialCharsHashRoute SpecialCharsSearchRoute: typeof SpecialCharsSearchRoute SpecialCharsChar45824Char54620Char48124Char44397Route: typeof SpecialCharsChar45824Char54620Char48124Char44397Route } @@ -1108,6 +1128,7 @@ interface SpecialCharsRouteRouteChildren { const SpecialCharsRouteRouteChildren: SpecialCharsRouteRouteChildren = { SpecialCharsMalformedRouteRoute: SpecialCharsMalformedRouteRouteWithChildren, SpecialCharsParamRoute: SpecialCharsParamRoute, + SpecialCharsHashRoute: SpecialCharsHashRoute, SpecialCharsSearchRoute: SpecialCharsSearchRoute, SpecialCharsChar45824Char54620Char48124Char44397Route: SpecialCharsChar45824Char54620Char48124Char44397Route, diff --git a/e2e/vue-start/basic/src/routes/specialChars/hash.tsx b/e2e/vue-start/basic/src/routes/specialChars/hash.tsx new file mode 100644 index 00000000000..1f76f5c7bba --- /dev/null +++ b/e2e/vue-start/basic/src/routes/specialChars/hash.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, useLocation } from '@tanstack/vue-router' + +export const Route = createFileRoute('/specialChars/hash')({ + component: RouteComponent, +}) + +function RouteComponent() { + const l = useLocation() + return ( +
+ Hello "/specialChars/hash"! + {l.value.hash} +
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/specialChars/route.tsx b/e2e/vue-start/basic/src/routes/specialChars/route.tsx index a34cf5ee64a..b0366b65f69 100644 --- a/e2e/vue-start/basic/src/routes/specialChars/route.tsx +++ b/e2e/vue-start/basic/src/routes/specialChars/route.tsx @@ -37,6 +37,19 @@ function RouteComponent() { > Unicode search param {' '} + + Unicode Hash + {' '} { }) }) + test.describe('Special characters in url hash', () => { + test('should render route correctly on direct navigation', async ({ + page, + baseURL, + }) => { + await expect(page.getByTestId('special-hash-link')).not.toHaveClass( + 'font-bold', + ) + await page.goto('/specialChars/hash#대|') + + await page.waitForURL(`${baseURL}/specialChars/hash#%EB%8C%80|`) + await page.waitForLoadState('load') + + await expect(page.getByTestId('special-hash-heading')).toBeInViewport() + + const hashValue = await page.getByTestId('special-hash').textContent() + + await expect(page.getByTestId('special-hash-link')).toHaveClass( + 'font-bold', + ) + expect(hashValue).toBe('대|') + }) + + test('should render route correctly on router navigation', async ({ + page, + baseURL, + }) => { + await expect(page.getByTestId('special-hash-link')).not.toHaveClass( + 'font-bold', + ) + const link = page.getByTestId('special-hash-link') + + await link.click() + + await page.waitForURL(`${baseURL}/specialChars/hash#%EB%8C%80|`) + await page.waitForLoadState('load') + + await expect(page.getByTestId('special-hash-heading')).toBeInViewport() + + const hashValue = await page.getByTestId('special-hash').textContent() + + await expect(page.getByTestId('special-hash-link')).toHaveClass( + 'font-bold', + ) + expect(hashValue).toBe('대|') + }) + }) + test.describe('malformed paths', () => { test.use({ whitelistErrors: [ diff --git a/e2e/vue-start/basic/vite.config.ts b/e2e/vue-start/basic/vite.config.ts index 222fce19b32..7fa09b226cd 100644 --- a/e2e/vue-start/basic/vite.config.ts +++ b/e2e/vue-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-beforeLoad', '/not-found/via-loader', '/specialChars/search', + '/specialChars/hash', '/specialChars/malformed', '/search-params', // search-param routes have dynamic content based on query params '/transition', diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 57a28df667f..e35fa7d1f31 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1199,7 +1199,7 @@ export class RouterCore< pathname: decodePath(url.pathname), searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, - hash: url.hash.split('#').reverse()[0] ?? '', + hash: decodePath(url.hash.split('#').reverse()[0] ?? ''), state: replaceEqualDeep(previousLocation?.state, state), } } diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 04ddc655960..fb1ec6e3c27 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -230,7 +230,8 @@ export function useLinkProps< } } - if (local.activeOptions?.includeHash) { + // url hash is not available on server, so do not evaluate this here when on server + if (local.activeOptions?.includeHash && !router.isServer) { return s.location.hash === next().hash } return true