From 2150ba40d7f94db8d17403ddf6ce90d78072cc53 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 15 Sep 2026 21:22:34 +0200
Subject: [PATCH 1/3] fix(vue-router): avoid hash-dependent Link hydration
mismatches
---
.changeset/sixty-rings-find.md | 5 +
packages/vue-router/src/link.tsx | 20 +-
.../tests/link-hash-hydration.test.tsx | 328 ++++++++++++++++++
3 files changed, 351 insertions(+), 2 deletions(-)
create mode 100644 .changeset/sixty-rings-find.md
create mode 100644 packages/vue-router/tests/link-hash-hydration.test.tsx
diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md
new file mode 100644
index 0000000000..44bb036553
--- /dev/null
+++ b/.changeset/sixty-rings-find.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/vue-router': patch
+---
+
+Fix Link hydration when the client URL has a fragment. Match the server's empty hash for initial active state and inherited or function-based hash hrefs, then update to the client hash after hydration. Client-only mounts continue to use the live hash immediately.
diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx
index 6d2fea690c..e65a032648 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -189,11 +189,27 @@ function useLinkPropsImpl(
})
}
+ // Vue assigns vnode.el before setup only when reusing server DOM. A fresh
+ // client mount must use the live hash immediately, even on an SSR router.
+ const hydrating = Vue.ref(Vue.getCurrentInstance()?.vnode.el != null)
+ Vue.onMounted(() => {
+ hydrating.value = false
+ })
+
+ const renderLocation = Vue.computed(() => {
+ const location = currentLocation.value
+ // Fragments are not sent to the server. Reproduce its empty hash for both
+ // active matching and inherited/function hash hrefs until hydration ends.
+ return hydrating.value && location.hash
+ ? { ...location, hash: '' }
+ : location
+ })
+
const next = Vue.computed(() => {
// Rebuild when inherited search/hash or the current route context changes.
const options = getOptions()
- const opts = { _fromLocation: currentLocation.value, ...options }
+ const opts = { _fromLocation: renderLocation.value, ...options }
return router.buildLocation(opts)
})
@@ -230,7 +246,7 @@ function useLinkPropsImpl(
return false
}
return getIsActive(
- currentLocation.value,
+ renderLocation.value,
next.value,
options.activeOptions,
router,
diff --git a/packages/vue-router/tests/link-hash-hydration.test.tsx b/packages/vue-router/tests/link-hash-hydration.test.tsx
new file mode 100644
index 0000000000..1622790e12
--- /dev/null
+++ b/packages/vue-router/tests/link-hash-hydration.test.tsx
@@ -0,0 +1,328 @@
+import * as Vue from 'vue'
+import { afterEach, expect, test, vi } from 'vitest'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import {
+ Link,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+import { createRequestHandler, renderRouterToString } from '../src/ssr/server'
+import type { AnyRoute, LinkOptions } from '../src'
+
+const cleanups: Array<() => void> = []
+
+afterEach(() => {
+ while (cleanups.length) {
+ cleanups.pop()!()
+ }
+ vi.restoreAllMocks()
+ delete window.$_TSR
+ document.body.innerHTML = ''
+})
+
+function makeRouter(
+ isServer: boolean,
+ url: string,
+ component: AnyRoute['options']['component'],
+) {
+ const root = createRootRoute()
+ const index = createRoute({
+ getParentRoute: () => root,
+ path: '/',
+ component,
+ })
+ const router = createRouter({
+ routeTree: root.addChildren([index]),
+ history: createMemoryHistory({ initialEntries: [url] }),
+ isServer,
+ defaultHashScrollIntoView: false,
+ })
+ cleanups.push(() => router.history.destroy())
+ return router
+}
+
+async function prepareHydration(
+ component: AnyRoute['options']['component'],
+ clientUrl = '/#details',
+) {
+ const response = await createRequestHandler({
+ request: new Request('http://localhost/'),
+ createRouter: () => makeRouter(true, '/', component),
+ })(({ router, responseHeaders }) =>
+ renderRouterToString({
+ router,
+ responseHeaders,
+ App: Vue.defineComponent({
+ inheritAttrs: false,
+ setup: () => () => (
+
+
+
+
+
+
+
+
+ ),
+ }),
+ }),
+ )
+ expect(response.status).toBe(200)
+ const serverDocument = new DOMParser().parseFromString(
+ await response.text(),
+ 'text/html',
+ )
+ const container = document.createElement('div')
+ container.innerHTML = serverDocument.getElementById('app')!.innerHTML
+ document.body.appendChild(container)
+ // Vitest's jsdom does not execute appended scripts. Evaluate only the public
+ // SSR handler's bootstrap, supplying the browser's currentScript for cleanup.
+ const currentScript = vi.spyOn(document, 'currentScript', 'get')
+ try {
+ for (const script of serverDocument.querySelectorAll('script')) {
+ currentScript.mockReturnValue(script)
+ new Function(script.textContent ?? '')()
+ }
+ } finally {
+ currentScript.mockRestore()
+ }
+ const router = makeRouter(false, clientUrl, component)
+ await hydrate(router)
+ window.$_TSR!.h()
+ const app = Vue.createSSRApp({
+ setup: () => () => ,
+ })
+ return {
+ container,
+ router,
+ mount() {
+ app.mount(container)
+ cleanups.push(() => app.unmount())
+ },
+ }
+}
+
+test('hydrates a hash-sensitive Link against the server HTML before applying the client hash', async () => {
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ {({ isActive }: { isActive: boolean }) => String(isActive)}
+
+ ),
+ })
+ const { container, mount } = await prepareHydration(page)
+ const anchor = container.querySelector('a')!
+ expect(anchor.getAttribute('href')).toBe('/#details')
+ expect(anchor.className).toBe('')
+ expect(anchor.getAttribute('aria-current')).toBeNull()
+ expect(anchor.textContent).toBe('false')
+
+ const warn = vi.spyOn(console, 'warn')
+ const error = vi.spyOn(console, 'error')
+ mount()
+ expect(warn).not.toHaveBeenCalled()
+ expect(error).not.toHaveBeenCalled()
+ expect(container.querySelector('a')).toBe(anchor)
+ expect(anchor.textContent).toBe('false')
+
+ await Vue.nextTick()
+ expect(anchor.className).toBe('active')
+ expect(anchor.getAttribute('aria-current')).toBe('page')
+ expect(anchor.textContent).toBe('true')
+ expect(container.querySelector('a')).toBe(anchor)
+})
+
+test.each(['/#details', '/'])(
+ 'hydrates hashes at %s and follows hash/activeOptions changes',
+ checkHashHydration,
+)
+
+async function checkHashHydration(clientUrl: string) {
+ const includeHash = Vue.ref(true)
+ const cases: Array<{
+ id: string
+ hash?: LinkOptions['hash']
+ insensitive?: boolean
+ server: [string, boolean]
+ details: [string, boolean]
+ other: [string, boolean]
+ }> = [
+ {
+ id: 'matching',
+ hash: 'details',
+ server: ['/#details', false],
+ details: ['/#details', true],
+ other: ['/#details', false],
+ },
+ {
+ id: 'nonmatching',
+ hash: 'other',
+ server: ['/#other', false],
+ details: ['/#other', false],
+ other: ['/#other', true],
+ },
+ {
+ id: 'empty',
+ hash: '',
+ server: ['/', true],
+ details: ['/', false],
+ other: ['/', false],
+ },
+ {
+ id: 'inherited',
+ hash: true,
+ server: ['/', true],
+ details: ['/#details', true],
+ other: ['/#other', true],
+ },
+ {
+ id: 'function',
+ hash: (previous = '') => `${previous}-child`,
+ server: ['/#-child', false],
+ details: ['/#details-child', false],
+ other: ['/#other-child', false],
+ },
+ {
+ id: 'inherited-insensitive',
+ hash: true,
+ insensitive: true,
+ server: ['/', true],
+ details: ['/#details', true],
+ other: ['/#other', true],
+ },
+ {
+ id: 'ordinary',
+ insensitive: true,
+ server: ['/', true],
+ details: ['/', true],
+ other: ['/', true],
+ },
+ ]
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ ),
+ })
+ const { container, mount, router } = await prepareHydration(page, clientUrl)
+ const anchors = Array.from(container.querySelectorAll('a'))
+ function check(phase: 'server' | 'details' | 'other', ignoreHash = false) {
+ for (const [index, entry] of cases.entries()) {
+ const anchor = container.querySelector(`#${entry.id}`)!
+ const [href, active] = entry[phase]
+ const isActive = ignoreHash || active
+ expect(anchor).toBe(anchors[index])
+ expect(anchor.getAttribute('href')).toBe(href)
+ expect(anchor.className).toBe(isActive ? 'active' : 'inactive')
+ expect(anchor.getAttribute('aria-current')).toBe(isActive ? 'page' : null)
+ expect(anchor.getAttribute('data-status')).toBe(
+ isActive ? 'active' : null,
+ )
+ expect(anchor.textContent).toBe(String(isActive))
+ }
+ }
+ check('server')
+ const warn = vi.spyOn(console, 'warn')
+ const error = vi.spyOn(console, 'error')
+ mount()
+ check('server')
+ await Vue.nextTick()
+ check(clientUrl === '/' ? 'server' : 'details')
+
+ await router.navigate({ to: '/', hash: 'other' })
+ await Vue.nextTick()
+ check('other')
+ includeHash.value = false
+ await Vue.nextTick()
+ check('other', true)
+ includeHash.value = true
+ await Vue.nextTick()
+ check('other')
+ expect(warn).not.toHaveBeenCalled()
+ expect(error).not.toHaveBeenCalled()
+}
+
+test('uses the live hash on the first render of a client-only mount', async () => {
+ const renders: Array = []
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ ),
+ })
+ const router = makeRouter(false, '/#details', page)
+ await router.load()
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const app = Vue.createApp({
+ setup: () => () => ,
+ })
+ app.mount(container)
+ cleanups.push(() => app.unmount())
+ expect(renders).toEqual([true, true])
+ for (const anchor of container.querySelectorAll('a')) {
+ expect(anchor.getAttribute('href')).toBe('/#details')
+ expect(anchor.className).toBe('active')
+ expect(anchor.getAttribute('aria-current')).toBe('page')
+ }
+ await Vue.nextTick()
+ expect(renders.every(Boolean)).toBe(true)
+})
+
+test('new Links mounted after hydration use the live hash immediately', async () => {
+ const show = Vue.ref(false)
+ const renders: Array = []
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ {show.value && (
+
+ {({ isActive }: { isActive: boolean }) => {
+ renders.push(isActive)
+ return String(isActive)
+ }}
+
+ )}
+
+ ),
+ })
+ const { container, mount } = await prepareHydration(page)
+ const warn = vi.spyOn(console, 'warn')
+ const error = vi.spyOn(console, 'error')
+ mount()
+ await Vue.nextTick()
+ show.value = true
+ await Vue.nextTick()
+ const anchor = container.querySelector('a')!
+ expect(renders[0]).toBe(true)
+ expect(anchor.getAttribute('href')).toBe('/#details')
+ expect(anchor.className).toBe('active')
+ expect(anchor.getAttribute('aria-current')).toBe('page')
+ expect(warn).not.toHaveBeenCalled()
+ expect(error).not.toHaveBeenCalled()
+})
From 9c89651958487b8372c5edf442413718c514d5d4 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 15 Sep 2026 23:30:51 +0200
Subject: [PATCH 2/3] fix(router): align hash-dependent Link hydration across
adapters
---
.changeset/sixty-rings-find.md | 4 +-
e2e/solid-start/basic/src/routeTree.gen.ts | 21 ++
.../basic/src/routes/link-hash-hydration.tsx | 115 ++++++
.../basic/tests/link-hash-hydration.spec.ts | 139 +++++++
packages/react-router/src/ClientOnly.tsx | 11 +-
packages/react-router/src/link.tsx | 34 +-
.../tests/link-hash-hydration.test.tsx | 349 ++++++++++++++++++
packages/solid-router/src/link.tsx | 30 +-
.../solid-router/tests/link-hash.test.tsx | 96 +++++
packages/vue-router/src/link.tsx | 36 +-
.../tests/link-hash-hydration.test.tsx | 232 ++++++++++--
11 files changed, 998 insertions(+), 69 deletions(-)
create mode 100644 e2e/solid-start/basic/src/routes/link-hash-hydration.tsx
create mode 100644 e2e/solid-start/basic/tests/link-hash-hydration.spec.ts
create mode 100644 packages/react-router/tests/link-hash-hydration.test.tsx
create mode 100644 packages/solid-router/tests/link-hash.test.tsx
diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md
index 44bb036553..11dfc0813f 100644
--- a/.changeset/sixty-rings-find.md
+++ b/.changeset/sixty-rings-find.md
@@ -1,5 +1,7 @@
---
+'@tanstack/react-router': patch
+'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---
-Fix Link hydration when the client URL has a fragment. Match the server's empty hash for initial active state and inherited or function-based hash hrefs, then update to the client hash after hydration. Client-only mounts continue to use the live hash immediately.
+Compute hash-sensitive Link active states and inherited or function-derived hash hrefs from the server's empty hash during hydration. Use the live hash after hydration and immediately for client-only mounts, without a hydration update for ordinary links.
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index 07ba58b219..3f01b45c13 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -15,6 +15,7 @@ import { Route as DeferredRouteImport } from './routes/deferred'
import { Route as DeferredWithoutSuspenseRouteImport } from './routes/deferred-without-suspense'
import { Route as ErrorNormalizationRouteImport } from './routes/error-normalization'
import { Route as InlineScriptsRouteImport } from './routes/inline-scripts'
+import { Route as LinkHashHydrationRouteImport } from './routes/link-hash-hydration'
import { Route as LinksRouteImport } from './routes/links'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
import { Route as PostsRouteImport } from './routes/posts'
@@ -100,6 +101,11 @@ const InlineScriptsRoute = InlineScriptsRouteImport.update({
path: '/inline-scripts',
getParentRoute: () => rootRouteImport,
} as any)
+const LinkHashHydrationRoute = LinkHashHydrationRouteImport.update({
+ id: '/link-hash-hydration',
+ path: '/link-hash-hydration',
+ getParentRoute: () => rootRouteImport,
+} as any)
const LinksRoute = LinksRouteImport.update({
id: '/links',
path: '/links',
@@ -401,6 +407,7 @@ export interface FileRoutesByFullPath {
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
+ '/link-hash-hydration': typeof LinkHashHydrationRoute
'/links': typeof LinksRoute
'/posts': typeof PostsRouteWithChildren
'/raw-stream': typeof RawStreamRouteWithChildren
@@ -460,6 +467,7 @@ export interface FileRoutesByTo {
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
+ '/link-hash-hydration': typeof LinkHashHydrationRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
@@ -519,6 +527,7 @@ export interface FileRoutesById {
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
+ '/link-hash-hydration': typeof LinkHashHydrationRoute
'/links': typeof LinksRoute
'/posts': typeof PostsRouteWithChildren
'/raw-stream': typeof RawStreamRouteWithChildren
@@ -583,6 +592,7 @@ export interface FileRouteTypes {
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
+ | '/link-hash-hydration'
| '/links'
| '/posts'
| '/raw-stream'
@@ -642,6 +652,7 @@ export interface FileRouteTypes {
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
+ | '/link-hash-hydration'
| '/links'
| '/scripts'
| '/stream'
@@ -700,6 +711,7 @@ export interface FileRouteTypes {
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
+ | '/link-hash-hydration'
| '/links'
| '/posts'
| '/raw-stream'
@@ -764,6 +776,7 @@ export interface RootRouteChildren {
DeferredWithoutSuspenseRoute: typeof DeferredWithoutSuspenseRoute
ErrorNormalizationRoute: typeof ErrorNormalizationRoute
InlineScriptsRoute: typeof InlineScriptsRoute
+ LinkHashHydrationRoute: typeof LinkHashHydrationRoute
LinksRoute: typeof LinksRoute
PostsRoute: typeof PostsRouteWithChildren
RawStreamRoute: typeof RawStreamRouteWithChildren
@@ -824,6 +837,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof InlineScriptsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/link-hash-hydration': {
+ id: '/link-hash-hydration'
+ path: '/link-hash-hydration'
+ fullPath: '/link-hash-hydration'
+ preLoaderRoute: typeof LinkHashHydrationRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/links': {
id: '/links'
path: '/links'
@@ -1420,6 +1440,7 @@ const rootRouteChildren: RootRouteChildren = {
DeferredWithoutSuspenseRoute: DeferredWithoutSuspenseRoute,
ErrorNormalizationRoute: ErrorNormalizationRoute,
InlineScriptsRoute: InlineScriptsRoute,
+ LinkHashHydrationRoute: LinkHashHydrationRoute,
LinksRoute: LinksRoute,
PostsRoute: PostsRouteWithChildren,
RawStreamRoute: RawStreamRouteWithChildren,
diff --git a/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx b/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx
new file mode 100644
index 0000000000..dc9ad90651
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx
@@ -0,0 +1,115 @@
+import { createSignal, onMount } from 'solid-js'
+import {
+ Link,
+ createFileRoute,
+ createLink,
+ useRouter,
+} from '@tanstack/solid-router'
+import type { ComponentProps } from 'solid-js'
+import type { LinkOptions } from '@tanstack/solid-router'
+
+declare global {
+ interface Window {
+ initialHashLinks: Record
+ }
+}
+
+const cases: Array<{
+ id: string
+ hash?: LinkOptions['hash']
+ insensitive?: boolean
+ sourceHash?: string
+ href?: string
+}> = [
+ { id: 'explicit-source', hash: true, sourceHash: 'preset' },
+ {
+ id: 'explicit-source-function',
+ hash: (hash = '') => `${hash}-child`,
+ sourceHash: 'preset',
+ },
+ {
+ id: 'explicit-href',
+ href: '/link-hash-hydration#fixed',
+ hash: () => {
+ throw new Error('href overrides hash')
+ },
+ },
+ { id: 'matching', hash: 'details' },
+ { id: 'nonmatching', hash: 'other' },
+ { id: 'empty', hash: '' },
+ { id: 'omitted' },
+ { id: 'inherited', hash: true },
+ { id: 'identity', hash: (hash = '') => hash },
+ { id: 'derived', hash: (hash) => `${hash}-child` },
+ { id: 'inherited-insensitive', hash: true, insensitive: true },
+ {
+ id: 'derived-insensitive',
+ hash: (hash) => `${hash}-child`,
+ insensitive: true,
+ },
+ { id: 'ordinary', insensitive: true },
+]
+
+// Capture the first client props, before onMount can conceal a mismatch.
+const ObservedLink = createLink((props: ComponentProps<'a'>) => {
+ if (typeof window !== 'undefined') {
+ window.initialHashLinks ??= {}
+ window.initialHashLinks[props.id!] ??= {
+ href: props.href,
+ active: props['aria-current'] === 'page',
+ }
+ }
+ return
+})
+
+export const Route = createFileRoute('/link-hash-hydration')({
+ component: Page,
+})
+
+function Page() {
+ const router = useRouter()
+ const [mounted, setMounted] = createSignal(false)
+ const [includeHash, setIncludeHash] = createSignal(true)
+ const [show, setShow] = createSignal(false)
+ onMount(() => setMounted(true))
+ return (
+
+ {cases.map((entry) => (
+
+ {({ isActive }) =>
+ isActive ? active : inactive
+ }
+
+ ))}
+
+
+ {show() && (
+
+ {({ isActive }) => String(isActive)}
+
+ )}
+
+ Other hash
+
+
+ )
+}
diff --git a/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts b/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts
new file mode 100644
index 0000000000..4d473fe09f
--- /dev/null
+++ b/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts
@@ -0,0 +1,139 @@
+import { expect, test } from '@playwright/test'
+
+test('hash-dependent links reproduce the server state before using the browser hash', async ({
+ page,
+}) => {
+ test.skip(process.env.MODE === 'spa', 'Requires server-rendered links')
+ const errors: Array = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ page.on('console', (message) => {
+ if (/hydration|mismatch/i.test(message.text())) {
+ errors.push(message.text())
+ }
+ })
+ let releaseScripts!: () => void
+ const scripts = new Promise((resolve) => {
+ releaseScripts = resolve
+ })
+ await page.route('**/*.js', async (route) => {
+ await scripts
+ await route.continue()
+ })
+ const path = '/link-hash-hydration'
+ const cases = [
+ {
+ id: 'explicit-source',
+ server: ['#preset', false],
+ client: ['#preset', false],
+ },
+ {
+ id: 'explicit-source-function',
+ server: ['#preset-child', false],
+ client: ['#preset-child', false],
+ },
+ {
+ id: 'explicit-href',
+ server: ['#fixed', false],
+ client: ['#fixed', false],
+ },
+ { id: 'matching', server: ['#details', false], client: ['#details', true] },
+ { id: 'nonmatching', server: ['#other', false], client: ['#other', false] },
+ { id: 'empty', server: ['', true], client: ['', false] },
+ { id: 'omitted', server: ['', true], client: ['', false] },
+ { id: 'inherited', server: ['', true], client: ['#details', true] },
+ { id: 'identity', server: ['', true], client: ['#details', true] },
+ {
+ id: 'derived',
+ server: ['#-child', false],
+ client: ['#details-child', false],
+ },
+ {
+ id: 'inherited-insensitive',
+ server: ['', true],
+ client: ['#details', true],
+ },
+ {
+ id: 'derived-insensitive',
+ server: ['#-child', true],
+ client: ['#details-child', true],
+ },
+ { id: 'ordinary', server: ['', true], client: ['', true] },
+ ] as const
+ await page.goto(`${path}#details`, { waitUntil: 'commit' })
+ try {
+ for (const entry of cases) {
+ const anchor = page.locator(`#${entry.id}`)
+ await expect(anchor).toHaveAttribute('href', path + entry.server[0])
+ await expect(anchor).toHaveText(entry.server[1] ? 'active' : 'inactive')
+ if (entry.server[1]) {
+ await expect(anchor).toHaveAttribute('aria-current', 'page')
+ await expect(anchor).toHaveAttribute('data-status', 'active')
+ } else {
+ await expect(anchor).not.toHaveAttribute('aria-current')
+ await expect(anchor).not.toHaveAttribute('data-status')
+ }
+ }
+ await page.evaluate(() => {
+ const anchors = Array.from(
+ document.querySelectorAll('[data-testid="hash-links"] a'),
+ )
+ ;(window as any).serverHashLinks = anchors
+ })
+ } finally {
+ releaseScripts()
+ }
+ await expect(page.getByTestId('hash-links')).toHaveAttribute(
+ 'data-mounted',
+ 'true',
+ )
+ const initial = await page.evaluate(() => window.initialHashLinks)
+ for (const entry of cases) {
+ expect(initial[entry.id], entry.id).toEqual({
+ href: path + entry.server[0],
+ active: entry.server[1],
+ })
+ const anchor = page.locator(`#${entry.id}`)
+ await expect(anchor).toHaveAttribute('href', path + entry.client[0])
+ await expect(anchor).toContainClass(entry.client[1] ? 'active' : 'inactive')
+ await expect(anchor).toHaveText(entry.client[1] ? 'active' : 'inactive')
+ if (entry.client[1]) {
+ await expect(anchor).toHaveAttribute('aria-current', 'page')
+ await expect(anchor).toHaveAttribute('data-status', 'active')
+ } else {
+ await expect(anchor).not.toHaveAttribute('aria-current')
+ await expect(anchor).not.toHaveAttribute('data-status')
+ }
+ await expect(
+ anchor.locator(entry.client[1] ? 'strong' : 'em'),
+ ).toBeVisible()
+ }
+ expect(
+ await page.evaluate(() =>
+ (window as any).serverHashLinks.every(
+ (anchor: Element) => document.getElementById(anchor.id) === anchor,
+ ),
+ ),
+ ).toBe(true)
+ await page.getByRole('button', { name: 'Mount link' }).click()
+ expect(await page.evaluate(() => window.initialHashLinks.later)).toEqual({
+ href: `${path}#details`,
+ active: true,
+ })
+ await page.getByRole('button', { name: 'Toggle hash matching' }).click()
+ await expect(page.locator('#empty')).toContainClass('active')
+ await expect(page.locator('#nonmatching')).toContainClass('active')
+ await page.getByRole('button', { name: 'Toggle hash matching' }).click()
+ await expect(page.locator('#empty')).toContainClass('inactive')
+ await page.locator('#navigate-other').click()
+ await expect(page.locator('#matching')).toContainClass('inactive')
+ await expect(page.locator('#nonmatching')).toContainClass('active')
+ await expect(page.locator('#inherited')).toHaveAttribute(
+ 'href',
+ `${path}#other`,
+ )
+ await expect(page.locator('#derived')).toHaveAttribute(
+ 'href',
+ `${path}#other-child`,
+ )
+ expect(errors).toEqual([])
+})
diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx
index c139b05d0a..c32f6a49a7 100644
--- a/packages/react-router/src/ClientOnly.tsx
+++ b/packages/react-router/src/ClientOnly.tsx
@@ -56,8 +56,15 @@ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
* ```
* @returns True if the JS has been hydrated already, false otherwise.
*/
-export function useHydrated(): boolean {
- return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+export function useHydrated(): boolean
+/** @internal Skip the hydration update for consumers that don't need it. */
+export function useHydrated(enabled: boolean): boolean
+export function useHydrated(enabled = true): boolean {
+ return React.useSyncExternalStore(
+ subscribe,
+ getSnapshot,
+ enabled ? getServerSnapshot : getSnapshot,
+ )
}
function subscribe() {
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index 26ec05a848..58a66aa7cf 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -92,7 +92,7 @@ function resolveIsActive(
next: ParsedLocation,
activeOptions: ActiveOptions | undefined,
basepath: string,
- isHydrated: boolean,
+ hydrating = false,
): boolean {
const currentPath = removeTrailingSlash(location.pathname, basepath)
const nextPath = removeTrailingSlash(next.pathname, basepath)
@@ -123,7 +123,7 @@ function resolveIsActive(
}
if (activeOptions?.includeHash) {
- return isHydrated && location.hash === next.hash
+ return (hydrating ? '' : location.hash) === next.hash
}
return true
}
@@ -256,8 +256,12 @@ export function useLinkProps<
onTouchStart,
} = options as typeof options & { to?: string }
+ const hashFromLocation =
+ !options.href &&
+ !options._fromLocation &&
+ (options.hash === true || typeof options.hash === 'function')
// eslint-disable-next-line react-hooks/rules-of-hooks
- const isHydrated = useHydrated()
+ const isHydrated = useHydrated(activeOptions?.includeHash || hashFromLocation)
// eslint-disable-next-line react-hooks/rules-of-hooks
const [stableSearch, stableParams, stableActiveOptions] = useStableValues(
@@ -277,6 +281,7 @@ export function useLinkProps<
options.from,
options._fromLocation,
options.hash,
+ options.href,
options.to,
stableSearch,
stableParams,
@@ -303,7 +308,13 @@ export function useLinkProps<
if (!_options._fromLocation) {
dest._fromLocation = location
}
- const next = router.buildLocation(dest)
+ // Only hash-dependent destinations need the server's empty hash. Keep
+ // the source location identity so links share the route-match cache.
+ const next = router.buildLocation(
+ !isHydrated && hashFromLocation
+ ? { ...dest, hash: dest.hash === true ? '' : dest.hash('') }
+ : dest,
+ )
// Use publicHref - it contains the correct href for display
// When a rewrite changes the origin, publicHref is the full URL
@@ -319,11 +330,20 @@ export function useLinkProps<
next,
stableActiveOptions,
router.basepath,
- isHydrated,
+ !isHydrated,
),
]
},
- [stableActiveOptions, disabled, isHydrated, _options, dest, router, to],
+ [
+ stableActiveOptions,
+ disabled,
+ isHydrated,
+ hashFromLocation,
+ _options,
+ dest,
+ router,
+ to,
+ ],
)
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -609,7 +629,6 @@ function getServerLinkProps(
}
const blockedLink = !disabled && !hrefOption
- // Hash is not available on the server, so it never counts as hydrated.
const isActive =
!!next &&
!blockedLink &&
@@ -618,7 +637,6 @@ function getServerLinkProps(
next,
activeOptions,
router.basepath,
- false,
)
return applyLinkState(
props,
diff --git a/packages/react-router/tests/link-hash-hydration.test.tsx b/packages/react-router/tests/link-hash-hydration.test.tsx
new file mode 100644
index 0000000000..83e7dcbaa9
--- /dev/null
+++ b/packages/react-router/tests/link-hash-hydration.test.tsx
@@ -0,0 +1,349 @@
+import React from 'react'
+import { renderToString } from 'react-dom/server'
+import { createRoot, hydrateRoot } from 'react-dom/client'
+import { act } from '@testing-library/react'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ Link,
+ RouterContextProvider,
+ createLink,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+} from '../src'
+import type { LinkOptions } from '../src'
+
+const cleanups: Array<() => void> = []
+
+afterEach(async () => {
+ await act(() => {
+ while (cleanups.length) {
+ cleanups.pop()!()
+ }
+ })
+ vi.restoreAllMocks()
+})
+
+function makeRouter(isServer: boolean, url: string) {
+ const router = createRouter({
+ routeTree: createRootRoute(),
+ history: createMemoryHistory({ initialEntries: [url] }),
+ isServer,
+ ssr: {},
+ defaultHashScrollIntoView: false,
+ })
+ cleanups.push(() => router.history.destroy())
+ return router
+}
+
+const cases: Array<{
+ name: string
+ hash?: LinkOptions['hash']
+ href?: string
+ sourceHash?: string
+ includeHash?: boolean
+ server: [string, boolean]
+ client: [string, boolean]
+}> = [
+ {
+ name: 'explicit-source',
+ hash: true,
+ sourceHash: 'preset',
+ includeHash: true,
+ server: ['/#preset', false],
+ client: ['/#preset', false],
+ },
+ {
+ name: 'explicit-source-function',
+ hash: (hash = '') => `${hash}-child`,
+ sourceHash: 'preset',
+ includeHash: true,
+ server: ['/#preset-child', false],
+ client: ['/#preset-child', false],
+ },
+ {
+ name: 'explicit-href',
+ href: '/#fixed',
+ hash: () => {
+ throw new Error('href overrides hash')
+ },
+ includeHash: true,
+ server: ['/#fixed', false],
+ client: ['/#fixed', false],
+ },
+ {
+ name: 'matching',
+ hash: 'details',
+ includeHash: true,
+ server: ['/#details', false],
+ client: ['/#details', true],
+ },
+ {
+ name: 'nonmatching',
+ hash: 'other',
+ includeHash: true,
+ server: ['/#other', false],
+ client: ['/#other', false],
+ },
+ {
+ name: 'empty',
+ hash: '',
+ includeHash: true,
+ server: ['/', true],
+ client: ['/', false],
+ },
+ {
+ name: 'omitted',
+ includeHash: true,
+ server: ['/', true],
+ client: ['/', false],
+ },
+ {
+ name: 'inherited',
+ hash: true,
+ includeHash: true,
+ server: ['/', true],
+ client: ['/#details', true],
+ },
+ {
+ name: 'identity',
+ hash: (hash = '') => hash,
+ includeHash: true,
+ server: ['/', true],
+ client: ['/#details', true],
+ },
+ {
+ name: 'derived',
+ hash: (hash) => `${hash}-child`,
+ includeHash: true,
+ server: ['/#-child', false],
+ client: ['/#details-child', false],
+ },
+ {
+ name: 'inherited-insensitive',
+ hash: true,
+ server: ['/', true],
+ client: ['/#details', true],
+ },
+ {
+ name: 'derived-insensitive',
+ hash: (hash) => `${hash}-child`,
+ server: ['/#-child', true],
+ client: ['/#details-child', true],
+ },
+ { name: 'ordinary', server: ['/', true], client: ['/', true] },
+]
+
+test.each(
+ cases.flatMap((entry) =>
+ ['/', '/#details'].map((url) => ({ ...entry, url })),
+ ),
+)(
+ 'hydrates $name links at $url without changing server DOM during hydration',
+ async (entry) => {
+ const renders: Array = []
+ const source = entry.sourceHash
+ ? makeRouter(true, `/#${entry.sourceHash}`).stores.location.get()
+ : undefined
+ const link = (
+
+ {({ isActive }) => {
+ renders.push(isActive)
+ return String(isActive)
+ }}
+
+ )
+ const container = document.createElement('div')
+ const server = makeRouter(true, '/')
+ container.innerHTML = renderToString(
+ {link},
+ )
+ const anchor = container.querySelector('a')!
+ function check([href, active]: [string, boolean]) {
+ expect(container.querySelector('a')).toBe(anchor)
+ expect(anchor.getAttribute('href')).toBe(href)
+ expect(anchor.className).toBe(active ? 'active' : 'inactive')
+ expect(anchor.getAttribute('aria-current')).toBe(active ? 'page' : null)
+ expect(anchor.getAttribute('data-status')).toBe(active ? 'active' : null)
+ expect(anchor.textContent).toBe(String(active))
+ }
+ check(entry.server)
+ renders.length = 0
+ const client = makeRouter(false, entry.url)
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const recoverable = vi.fn()
+ await act(() => {
+ const root = hydrateRoot(
+ container,
+ {link},
+ { onRecoverableError: recoverable },
+ )
+ cleanups.push(() => root.unmount())
+ })
+ expect(renders[0]).toBe(entry.server[1])
+ check(entry.url === '/' ? entry.server : entry.client)
+ expect(client.stores.location.get().hash).toBe(
+ entry.url === '/' ? '' : 'details',
+ )
+ expect(error).not.toHaveBeenCalled()
+ expect(recoverable).not.toHaveBeenCalled()
+ if (entry.name === 'ordinary') {
+ expect(renders).toEqual([true])
+ }
+
+ await act(() => client.navigate({ to: '/', hash: 'other' }))
+ expect(container.querySelector('a')).toBe(anchor)
+ const inheritsHash = entry.hash === true || typeof entry.hash === 'function'
+ check([
+ inheritsHash
+ ? entry.client[0].replace('details', 'other')
+ : entry.client[0],
+ !entry.includeHash ||
+ ['nonmatching', 'inherited', 'identity'].includes(entry.name),
+ ])
+ await act(() => client.navigate({ to: '/', hash: '' }))
+ check(entry.server)
+ },
+)
+
+test.each(
+ [false, true].flatMap((later) => [
+ {
+ later,
+ hash: 'details' as LinkOptions['hash'],
+ href: '/#details',
+ active: true,
+ },
+ { later, hash: true as const, href: '/#details', active: true },
+ {
+ later,
+ hash: (hash = '') => `${hash}-child`,
+ href: '/#details-child',
+ active: false,
+ },
+ ]),
+)(
+ 'uses live hashes on the first client-only render ($href, later mount: $later)',
+ async ({ later, hash, href, active }) => {
+ const router = makeRouter(false, '/#details')
+ const renders: Array<{ href?: string; active: boolean }> = []
+ const ObservedLink = createLink(
+ React.forwardRef>(
+ (props, ref) => {
+ renders.push({
+ href: props.href,
+ active: props['aria-current'] === 'page',
+ })
+ return
+ },
+ ),
+ )
+ const container = document.createElement('div')
+ const tree = (show: boolean) => (
+
+ {show && (
+
+ {({ isActive }) => String(isActive)}
+
+ )}
+
+ )
+ let root: ReturnType
+ await act(() => {
+ if (later) {
+ container.innerHTML = renderToString(tree(false))
+ root = hydrateRoot(container, tree(false))
+ } else {
+ root = createRoot(container)
+ }
+ cleanups.push(() => root.unmount())
+ })
+ await act(() => root.render(tree(true)))
+ expect(renders[0]).toEqual({ href, active })
+ expect(container.querySelector('a')).toHaveAttribute('href', href)
+ },
+)
+
+test('hash-dependent links share current-route validation while hydrating', async () => {
+ const validateSearch = vi.fn((search: Record) => search)
+ const make = (isServer: boolean, url: string) => {
+ const router = createRouter({
+ routeTree: createRootRoute({ validateSearch }),
+ history: createMemoryHistory({ initialEntries: [url] }),
+ isServer,
+ })
+ cleanups.push(() => router.history.destroy())
+ return router
+ }
+ const links = Array.from({ length: 10 }, (_, index) => (
+
+ Link
+
+ ))
+ const container = document.createElement('div')
+ container.innerHTML = renderToString(
+
+ {links}
+ ,
+ )
+ const client = make(false, '/?q=test#details')
+ validateSearch.mockClear()
+ await act(() => {
+ const root = hydrateRoot(
+ container,
+ {links},
+ )
+ cleanups.push(() => root.unmount())
+ })
+ expect(validateSearch).toHaveBeenCalledTimes(1)
+ expect(container.querySelectorAll('a')).toHaveLength(10)
+ for (const anchor of container.querySelectorAll('a')) {
+ expect(anchor).toHaveAttribute('href', '/?q=test#details')
+ }
+})
+
+test('updates hash and active options on the same hydrated link', async () => {
+ const tree = (
+ router: ReturnType,
+ hash?: LinkOptions['hash'],
+ includeHash = false,
+ ) => (
+
+
+ {({ isActive }) => String(isActive)}
+
+
+ )
+ const container = document.createElement('div')
+ container.innerHTML = renderToString(tree(makeRouter(true, '/')))
+ const anchor = container.querySelector('a')!
+ const router = makeRouter(false, '/#details')
+ let root: ReturnType
+ await act(() => {
+ root = hydrateRoot(container, tree(router))
+ cleanups.push(() => root.unmount())
+ })
+ for (const [hash, includeHash, href, active] of [
+ [undefined, true, '/', false],
+ [true, true, '/#details', true],
+ [(previous = '') => `${previous}-child`, true, '/#details-child', false],
+ ['other', false, '/#other', true],
+ [undefined, false, '/', true],
+ ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) {
+ await act(() => root.render(tree(router, hash, includeHash)))
+ expect(container.querySelector('a')).toBe(anchor)
+ expect(anchor).toHaveAttribute('href', href)
+ expect(anchor.textContent).toBe(String(active))
+ }
+})
diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx
index a5fc4bca57..0ed9645e8a 100644
--- a/packages/solid-router/src/link.tsx
+++ b/packages/solid-router/src/link.tsx
@@ -18,7 +18,6 @@ import { useRouter } from './useRouter'
import { useIntersectionObserver } from './utils'
-import { useHydrated } from './ClientOnly'
import type {
AnyRouter,
Constrain,
@@ -124,6 +123,13 @@ export function useLinkProps<
'href',
])
+ const [hydrating, setHydrating] = Solid.createSignal(
+ !(isServer ?? router.isServer) && !!Solid.sharedConfig.context,
+ )
+ if (hydrating()) {
+ Solid.onMount(() => setHydrating(false))
+ }
+
const currentLocation = Solid.createMemo(
() => router.stores.location.get(),
undefined,
@@ -134,8 +140,21 @@ export function useLinkProps<
// Rebuild when inherited search/hash or the current route context changes.
const _fromLocation = currentLocation()
const nextOptions = { _fromLocation, ...options } as any
+ const hash = nextOptions.hash
+ const hydrateHash =
+ !options.href &&
+ !options._fromLocation &&
+ (hash === true || typeof hash === 'function') &&
+ hydrating()
// untrack because router-core will also access stores, which are signals in solid
- return Solid.untrack(() => router.buildLocation(nextOptions))
+ return Solid.untrack(() => {
+ // Keep the source location identity for shared route matching. Literal
+ // destinations don't depend on hydration and need no post-mount rebuild.
+ if (hydrateHash) {
+ nextOptions.hash = hash === true ? '' : hash('')
+ }
+ return router.buildLocation(nextOptions)
+ })
})
const hrefOption = Solid.createMemo(() => {
@@ -184,9 +203,6 @@ export function useLinkProps<
return _href && getUrlScheme(_href) ? _href : undefined
})
- const shouldHydrateHash = !isServer && !!router.options.ssr
- const hasHydrated = (isServer ?? router.isServer) ? undefined : useHydrated()
-
const isActive = Solid.createMemo(() => {
if (externalLink() !== undefined) {
return false
@@ -224,9 +240,7 @@ export function useLinkProps<
}
if (activeOptions?.includeHash) {
- const currentHash =
- shouldHydrateHash && !hasHydrated?.() ? '' : current.hash
- return currentHash === nextLocation.hash
+ return (hydrating() ? '' : current.hash) === nextLocation.hash
}
return true
})
diff --git a/packages/solid-router/tests/link-hash.test.tsx b/packages/solid-router/tests/link-hash.test.tsx
new file mode 100644
index 0000000000..39032bb926
--- /dev/null
+++ b/packages/solid-router/tests/link-hash.test.tsx
@@ -0,0 +1,96 @@
+import { cleanup, render } from '@solidjs/testing-library'
+import { afterEach, expect, test } from 'vitest'
+import { createSignal } from 'solid-js'
+import {
+ Link,
+ RouterContextProvider,
+ createLink,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+} from '../src'
+import type { ComponentProps } from 'solid-js'
+import type { LinkOptions } from '../src'
+
+afterEach(cleanup)
+
+test.each([
+ { hash: 'details' as LinkOptions['hash'], href: '/#details', active: true },
+ { hash: true as const, href: '/#details', active: true },
+ {
+ hash: (previous = '') => `${previous}-child`,
+ href: '/#details-child',
+ active: false,
+ },
+])(
+ 'client-only links on an SSR router use the live hash on their first render ($href)',
+ ({ hash, href, active }) => {
+ const router = createRouter({
+ routeTree: createRootRoute(),
+ history: createMemoryHistory({ initialEntries: ['/#details'] }),
+ isServer: false,
+ ssr: {},
+ })
+ const renders: Array<{ href?: string; active: boolean }> = []
+ const ObservedLink = createLink((props: ComponentProps<'a'>) => {
+ renders.push({
+ href: props.href,
+ active: props['aria-current'] === 'page',
+ })
+ return
+ })
+ const { container } = render(() => (
+
+ {() => (
+
+ {({ isActive }) => String(isActive)}
+
+ )}
+
+ ))
+ expect(renders[0]).toEqual({ href, active })
+ expect(container.querySelector('a')).toHaveAttribute('href', href)
+ router.history.destroy()
+ },
+)
+
+test('hash and active options stay reactive on an initially ordinary link', () => {
+ const router = createRouter({
+ routeTree: createRootRoute(),
+ history: createMemoryHistory({ initialEntries: ['/#details'] }),
+ })
+ const [hash, setHash] = createSignal()
+ const [includeHash, setIncludeHash] = createSignal(false)
+ const { container } = render(() => (
+
+ {() => (
+
+ {({ isActive }) => String(isActive)}
+
+ )}
+
+ ))
+ const anchor = container.querySelector('a')!
+ for (const [nextHash, include, href, active] of [
+ [undefined, true, '/', false],
+ [true, true, '/#details', true],
+ [(previous = '') => `${previous}-child`, true, '/#details-child', false],
+ ['other', false, '/#other', true],
+ [undefined, false, '/', true],
+ ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) {
+ setHash(() => nextHash)
+ setIncludeHash(include)
+ expect(container.querySelector('a')).toBe(anchor)
+ expect(anchor).toHaveAttribute('href', href)
+ expect(anchor.textContent).toBe(String(active))
+ }
+ router.history.destroy()
+})
diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx
index e65a032648..9884a15313 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -192,24 +192,28 @@ function useLinkPropsImpl(
// Vue assigns vnode.el before setup only when reusing server DOM. A fresh
// client mount must use the live hash immediately, even on an SSR router.
const hydrating = Vue.ref(Vue.getCurrentInstance()?.vnode.el != null)
- Vue.onMounted(() => {
- hydrating.value = false
- })
-
- const renderLocation = Vue.computed(() => {
- const location = currentLocation.value
- // Fragments are not sent to the server. Reproduce its empty hash for both
- // active matching and inherited/function hash hrefs until hydration ends.
- return hydrating.value && location.hash
- ? { ...location, hash: '' }
- : location
- })
+ if (hydrating.value) {
+ Vue.onMounted(() => {
+ hydrating.value = false
+ })
+ }
const next = Vue.computed(() => {
// Rebuild when inherited search/hash or the current route context changes.
const options = getOptions()
- const opts = { _fromLocation: renderLocation.value, ...options }
+ const opts = { _fromLocation: currentLocation.value, ...options }
+ const hash = options.hash
+ // Only hash-dependent destinations need the server's empty hash. Keep
+ // the source location identity so links share the route-match cache.
+ if (
+ !options.href &&
+ !options._fromLocation &&
+ (hash === true || typeof hash === 'function') &&
+ hydrating.value
+ ) {
+ opts.hash = hash === true ? '' : hash('')
+ }
return router.buildLocation(opts)
})
@@ -246,10 +250,11 @@ function useLinkPropsImpl(
return false
}
return getIsActive(
- renderLocation.value,
+ currentLocation.value,
next.value,
options.activeOptions,
router,
+ options.activeOptions?.includeHash && hydrating.value,
)
})
@@ -679,6 +684,7 @@ function getIsActive(
},
activeOptions: LinkOptions['activeOptions'],
router: AnyRouter,
+ hydrating = false,
) {
const currentPath = removeTrailingSlash(loc.pathname, router.basepath)
const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath)
@@ -709,7 +715,7 @@ function getIsActive(
}
if (activeOptions?.includeHash) {
- return loc.hash === nextLoc.hash
+ return (hydrating ? '' : loc.hash) === nextLoc.hash
}
return true
}
diff --git a/packages/vue-router/tests/link-hash-hydration.test.tsx b/packages/vue-router/tests/link-hash-hydration.test.tsx
index 1622790e12..aaf3640f22 100644
--- a/packages/vue-router/tests/link-hash-hydration.test.tsx
+++ b/packages/vue-router/tests/link-hash-hydration.test.tsx
@@ -171,6 +171,12 @@ async function checkHashHydration(clientUrl: string) {
details: ['/', false],
other: ['/', false],
},
+ {
+ id: 'omitted',
+ server: ['/', true],
+ details: ['/', false],
+ other: ['/', false],
+ },
{
id: 'inherited',
hash: true,
@@ -185,6 +191,21 @@ async function checkHashHydration(clientUrl: string) {
details: ['/#details-child', false],
other: ['/#other-child', false],
},
+ {
+ id: 'identity',
+ hash: (previous = '') => previous,
+ server: ['/', true],
+ details: ['/#details', true],
+ other: ['/#other', true],
+ },
+ {
+ id: 'function-insensitive',
+ hash: (previous) => `${previous}-child`,
+ insensitive: true,
+ server: ['/#-child', true],
+ details: ['/#details-child', true],
+ other: ['/#other-child', true],
+ },
{
id: 'inherited-insensitive',
hash: true,
@@ -259,49 +280,69 @@ async function checkHashHydration(clientUrl: string) {
expect(error).not.toHaveBeenCalled()
}
-test('uses the live hash on the first render of a client-only mount', async () => {
- const renders: Array = []
- const page = Vue.defineComponent({
- setup: () => () => (
-
- ),
- })
- const router = makeRouter(false, '/#details', page)
- await router.load()
- const container = document.createElement('div')
- document.body.appendChild(container)
- const app = Vue.createApp({
- setup: () => () => ,
- })
- app.mount(container)
- cleanups.push(() => app.unmount())
- expect(renders).toEqual([true, true])
- for (const anchor of container.querySelectorAll('a')) {
- expect(anchor.getAttribute('href')).toBe('/#details')
- expect(anchor.className).toBe('active')
- expect(anchor.getAttribute('aria-current')).toBe('page')
- }
- await Vue.nextTick()
- expect(renders.every(Boolean)).toBe(true)
-})
+test.each([false, true])(
+ 'uses the live hash on the first render of a client-only mount (SSR options: %s)',
+ async (ssr) => {
+ const renders: Array = []
+ const hashInputs: Array = []
+ const inherit = (previous = '') => {
+ hashInputs.push(previous)
+ return previous
+ }
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ ),
+ })
+ const router = makeRouter(false, '/#details', page)
+ if (ssr) {
+ router.options.ssr = {}
+ }
+ await router.load()
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const app = Vue.createApp({
+ setup: () => () => ,
+ })
+ app.mount(container)
+ cleanups.push(() => app.unmount())
+ expect(renders).toEqual([true, true, true])
+ expect(hashInputs[0]).toBe('details')
+ for (const anchor of container.querySelectorAll('a')) {
+ expect(anchor.getAttribute('href')).toBe('/#details')
+ expect(anchor.className).toBe('active')
+ expect(anchor.getAttribute('aria-current')).toBe('page')
+ }
+ await Vue.nextTick()
+ expect(renders.every(Boolean)).toBe(true)
+ },
+)
test('new Links mounted after hydration use the live hash immediately', async () => {
const show = Vue.ref(false)
const renders: Array = []
+ const hashInputs: Array = []
const page = Vue.defineComponent({
setup: () => () => (
{show.value && (
-
+
{
+ hashInputs.push(previous)
+ return previous
+ }}
+ activeOptions={{ includeHash: true }}
+ >
{({ isActive }: { isActive: boolean }) => {
renders.push(isActive)
return String(isActive)
@@ -320,9 +361,130 @@ test('new Links mounted after hydration use the live hash immediately', async ()
await Vue.nextTick()
const anchor = container.querySelector('a')!
expect(renders[0]).toBe(true)
+ expect(hashInputs[0]).toBe('details')
expect(anchor.getAttribute('href')).toBe('/#details')
expect(anchor.className).toBe('active')
expect(anchor.getAttribute('aria-current')).toBe('page')
expect(warn).not.toHaveBeenCalled()
expect(error).not.toHaveBeenCalled()
})
+
+test('hydration only rebuilds destinations that depend on the current hash', async () => {
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ ),
+ })
+ const { router, mount, container } = await prepareHydration(page)
+ const build = vi.spyOn(router, 'buildLocation')
+ const count = (id: string) =>
+ build.mock.calls.filter(
+ ([options]) => (options as { id?: string }).id === id,
+ ).length
+ mount()
+ const initial = ['ordinary', 'literal', 'inherited'].map(count)
+ expect(initial.every((calls) => calls > 0)).toBe(true)
+ await Vue.nextTick()
+ expect(count('ordinary')).toBe(initial[0])
+ expect(count('literal')).toBe(initial[1])
+ expect(count('inherited')).toBeGreaterThan(initial[2]!)
+ expect(container.querySelector('#literal')).toHaveAttribute(
+ 'aria-current',
+ 'page',
+ )
+ expect(container.querySelector('#inherited')).toHaveAttribute(
+ 'href',
+ '/#details',
+ )
+})
+
+test('hash options stay reactive when an ordinary link becomes hash-dependent', async () => {
+ const hash = Vue.ref
()
+ const activeOptions = Vue.reactive({ includeHash: false })
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ {({ isActive }: { isActive: boolean }) => String(isActive)}
+
+ ),
+ })
+ const { container, mount } = await prepareHydration(page)
+ const anchor = container.querySelector('a')!
+ mount()
+ await Vue.nextTick()
+ for (const [nextHash, includeHash, href, active] of [
+ [undefined, true, '/', false],
+ [true, true, '/#details', true],
+ [(previous = '') => `${previous}-child`, true, '/#details-child', false],
+ ['other', false, '/#other', true],
+ [undefined, false, '/', true],
+ ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) {
+ hash.value = nextHash
+ activeOptions.includeHash = includeHash
+ await Vue.nextTick()
+ expect(container.querySelector('a')).toBe(anchor)
+ expect(anchor).toHaveAttribute('href', href)
+ expect(anchor.textContent).toBe(String(active))
+ }
+})
+
+test.each(['inherit', 'function', 'href'] as const)(
+ 'preserves explicit source/href precedence during hydration (%s)',
+ async (kind) => {
+ const source = makeRouter(true, '/#preset', undefined).stores.location.get()
+ const updater = vi.fn((previous = '') => `${previous}-child`)
+ const page = Vue.defineComponent({
+ setup: () => () => (
+
+ {({ isActive }: { isActive: boolean }) => String(isActive)}
+
+ ),
+ })
+ const { container, mount } = await prepareHydration(page)
+ const anchor = container.querySelector('a')!
+ const href =
+ kind === 'href'
+ ? '/#fixed'
+ : kind === 'inherit'
+ ? '/#preset'
+ : '/#preset-child'
+ expect(anchor).toHaveAttribute('href', href)
+ expect(anchor.textContent).toBe('false')
+ const warn = vi.spyOn(console, 'warn')
+ const error = vi.spyOn(console, 'error')
+ mount()
+ await Vue.nextTick()
+ expect(container.querySelector('a')).toBe(anchor)
+ expect(anchor).toHaveAttribute('href', href)
+ expect(anchor.textContent).toBe('false')
+ expect(warn).not.toHaveBeenCalled()
+ expect(error).not.toHaveBeenCalled()
+ if (kind === 'href') {
+ expect(updater).not.toHaveBeenCalled()
+ } else if (kind === 'function') {
+ expect(updater).toHaveBeenCalledWith('preset')
+ expect(updater).not.toHaveBeenCalledWith('')
+ }
+ },
+)
From e50bc39a3a424bee940c73649a13431006ae29f1 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Wed, 16 Sep 2026 00:05:50 +0200
Subject: [PATCH 3/3] chore: split React and Solid hydration fixes into #8442
---
.changeset/sixty-rings-find.md | 2 -
e2e/solid-start/basic/src/routeTree.gen.ts | 21 --
.../basic/src/routes/link-hash-hydration.tsx | 115 ------
.../basic/tests/link-hash-hydration.spec.ts | 139 -------
packages/react-router/src/ClientOnly.tsx | 11 +-
packages/react-router/src/link.tsx | 34 +-
.../tests/link-hash-hydration.test.tsx | 349 ------------------
packages/solid-router/src/link.tsx | 30 +-
.../solid-router/tests/link-hash.test.tsx | 96 -----
9 files changed, 18 insertions(+), 779 deletions(-)
delete mode 100644 e2e/solid-start/basic/src/routes/link-hash-hydration.tsx
delete mode 100644 e2e/solid-start/basic/tests/link-hash-hydration.spec.ts
delete mode 100644 packages/react-router/tests/link-hash-hydration.test.tsx
delete mode 100644 packages/solid-router/tests/link-hash.test.tsx
diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md
index 11dfc0813f..1bb820415d 100644
--- a/.changeset/sixty-rings-find.md
+++ b/.changeset/sixty-rings-find.md
@@ -1,6 +1,4 @@
---
-'@tanstack/react-router': patch
-'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index 3f01b45c13..07ba58b219 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -15,7 +15,6 @@ import { Route as DeferredRouteImport } from './routes/deferred'
import { Route as DeferredWithoutSuspenseRouteImport } from './routes/deferred-without-suspense'
import { Route as ErrorNormalizationRouteImport } from './routes/error-normalization'
import { Route as InlineScriptsRouteImport } from './routes/inline-scripts'
-import { Route as LinkHashHydrationRouteImport } from './routes/link-hash-hydration'
import { Route as LinksRouteImport } from './routes/links'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
import { Route as PostsRouteImport } from './routes/posts'
@@ -101,11 +100,6 @@ const InlineScriptsRoute = InlineScriptsRouteImport.update({
path: '/inline-scripts',
getParentRoute: () => rootRouteImport,
} as any)
-const LinkHashHydrationRoute = LinkHashHydrationRouteImport.update({
- id: '/link-hash-hydration',
- path: '/link-hash-hydration',
- getParentRoute: () => rootRouteImport,
-} as any)
const LinksRoute = LinksRouteImport.update({
id: '/links',
path: '/links',
@@ -407,7 +401,6 @@ export interface FileRoutesByFullPath {
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
- '/link-hash-hydration': typeof LinkHashHydrationRoute
'/links': typeof LinksRoute
'/posts': typeof PostsRouteWithChildren
'/raw-stream': typeof RawStreamRouteWithChildren
@@ -467,7 +460,6 @@ export interface FileRoutesByTo {
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
- '/link-hash-hydration': typeof LinkHashHydrationRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
@@ -527,7 +519,6 @@ export interface FileRoutesById {
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
- '/link-hash-hydration': typeof LinkHashHydrationRoute
'/links': typeof LinksRoute
'/posts': typeof PostsRouteWithChildren
'/raw-stream': typeof RawStreamRouteWithChildren
@@ -592,7 +583,6 @@ export interface FileRouteTypes {
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
- | '/link-hash-hydration'
| '/links'
| '/posts'
| '/raw-stream'
@@ -652,7 +642,6 @@ export interface FileRouteTypes {
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
- | '/link-hash-hydration'
| '/links'
| '/scripts'
| '/stream'
@@ -711,7 +700,6 @@ export interface FileRouteTypes {
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
- | '/link-hash-hydration'
| '/links'
| '/posts'
| '/raw-stream'
@@ -776,7 +764,6 @@ export interface RootRouteChildren {
DeferredWithoutSuspenseRoute: typeof DeferredWithoutSuspenseRoute
ErrorNormalizationRoute: typeof ErrorNormalizationRoute
InlineScriptsRoute: typeof InlineScriptsRoute
- LinkHashHydrationRoute: typeof LinkHashHydrationRoute
LinksRoute: typeof LinksRoute
PostsRoute: typeof PostsRouteWithChildren
RawStreamRoute: typeof RawStreamRouteWithChildren
@@ -837,13 +824,6 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof InlineScriptsRouteImport
parentRoute: typeof rootRouteImport
}
- '/link-hash-hydration': {
- id: '/link-hash-hydration'
- path: '/link-hash-hydration'
- fullPath: '/link-hash-hydration'
- preLoaderRoute: typeof LinkHashHydrationRouteImport
- parentRoute: typeof rootRouteImport
- }
'/links': {
id: '/links'
path: '/links'
@@ -1440,7 +1420,6 @@ const rootRouteChildren: RootRouteChildren = {
DeferredWithoutSuspenseRoute: DeferredWithoutSuspenseRoute,
ErrorNormalizationRoute: ErrorNormalizationRoute,
InlineScriptsRoute: InlineScriptsRoute,
- LinkHashHydrationRoute: LinkHashHydrationRoute,
LinksRoute: LinksRoute,
PostsRoute: PostsRouteWithChildren,
RawStreamRoute: RawStreamRouteWithChildren,
diff --git a/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx b/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx
deleted file mode 100644
index dc9ad90651..0000000000
--- a/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { createSignal, onMount } from 'solid-js'
-import {
- Link,
- createFileRoute,
- createLink,
- useRouter,
-} from '@tanstack/solid-router'
-import type { ComponentProps } from 'solid-js'
-import type { LinkOptions } from '@tanstack/solid-router'
-
-declare global {
- interface Window {
- initialHashLinks: Record
- }
-}
-
-const cases: Array<{
- id: string
- hash?: LinkOptions['hash']
- insensitive?: boolean
- sourceHash?: string
- href?: string
-}> = [
- { id: 'explicit-source', hash: true, sourceHash: 'preset' },
- {
- id: 'explicit-source-function',
- hash: (hash = '') => `${hash}-child`,
- sourceHash: 'preset',
- },
- {
- id: 'explicit-href',
- href: '/link-hash-hydration#fixed',
- hash: () => {
- throw new Error('href overrides hash')
- },
- },
- { id: 'matching', hash: 'details' },
- { id: 'nonmatching', hash: 'other' },
- { id: 'empty', hash: '' },
- { id: 'omitted' },
- { id: 'inherited', hash: true },
- { id: 'identity', hash: (hash = '') => hash },
- { id: 'derived', hash: (hash) => `${hash}-child` },
- { id: 'inherited-insensitive', hash: true, insensitive: true },
- {
- id: 'derived-insensitive',
- hash: (hash) => `${hash}-child`,
- insensitive: true,
- },
- { id: 'ordinary', insensitive: true },
-]
-
-// Capture the first client props, before onMount can conceal a mismatch.
-const ObservedLink = createLink((props: ComponentProps<'a'>) => {
- if (typeof window !== 'undefined') {
- window.initialHashLinks ??= {}
- window.initialHashLinks[props.id!] ??= {
- href: props.href,
- active: props['aria-current'] === 'page',
- }
- }
- return
-})
-
-export const Route = createFileRoute('/link-hash-hydration')({
- component: Page,
-})
-
-function Page() {
- const router = useRouter()
- const [mounted, setMounted] = createSignal(false)
- const [includeHash, setIncludeHash] = createSignal(true)
- const [show, setShow] = createSignal(false)
- onMount(() => setMounted(true))
- return (
-
- {cases.map((entry) => (
-
- {({ isActive }) =>
- isActive ? active : inactive
- }
-
- ))}
-
-
- {show() && (
-
- {({ isActive }) => String(isActive)}
-
- )}
-
- Other hash
-
-
- )
-}
diff --git a/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts b/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts
deleted file mode 100644
index 4d473fe09f..0000000000
--- a/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { expect, test } from '@playwright/test'
-
-test('hash-dependent links reproduce the server state before using the browser hash', async ({
- page,
-}) => {
- test.skip(process.env.MODE === 'spa', 'Requires server-rendered links')
- const errors: Array = []
- page.on('pageerror', (error) => errors.push(error.message))
- page.on('console', (message) => {
- if (/hydration|mismatch/i.test(message.text())) {
- errors.push(message.text())
- }
- })
- let releaseScripts!: () => void
- const scripts = new Promise((resolve) => {
- releaseScripts = resolve
- })
- await page.route('**/*.js', async (route) => {
- await scripts
- await route.continue()
- })
- const path = '/link-hash-hydration'
- const cases = [
- {
- id: 'explicit-source',
- server: ['#preset', false],
- client: ['#preset', false],
- },
- {
- id: 'explicit-source-function',
- server: ['#preset-child', false],
- client: ['#preset-child', false],
- },
- {
- id: 'explicit-href',
- server: ['#fixed', false],
- client: ['#fixed', false],
- },
- { id: 'matching', server: ['#details', false], client: ['#details', true] },
- { id: 'nonmatching', server: ['#other', false], client: ['#other', false] },
- { id: 'empty', server: ['', true], client: ['', false] },
- { id: 'omitted', server: ['', true], client: ['', false] },
- { id: 'inherited', server: ['', true], client: ['#details', true] },
- { id: 'identity', server: ['', true], client: ['#details', true] },
- {
- id: 'derived',
- server: ['#-child', false],
- client: ['#details-child', false],
- },
- {
- id: 'inherited-insensitive',
- server: ['', true],
- client: ['#details', true],
- },
- {
- id: 'derived-insensitive',
- server: ['#-child', true],
- client: ['#details-child', true],
- },
- { id: 'ordinary', server: ['', true], client: ['', true] },
- ] as const
- await page.goto(`${path}#details`, { waitUntil: 'commit' })
- try {
- for (const entry of cases) {
- const anchor = page.locator(`#${entry.id}`)
- await expect(anchor).toHaveAttribute('href', path + entry.server[0])
- await expect(anchor).toHaveText(entry.server[1] ? 'active' : 'inactive')
- if (entry.server[1]) {
- await expect(anchor).toHaveAttribute('aria-current', 'page')
- await expect(anchor).toHaveAttribute('data-status', 'active')
- } else {
- await expect(anchor).not.toHaveAttribute('aria-current')
- await expect(anchor).not.toHaveAttribute('data-status')
- }
- }
- await page.evaluate(() => {
- const anchors = Array.from(
- document.querySelectorAll('[data-testid="hash-links"] a'),
- )
- ;(window as any).serverHashLinks = anchors
- })
- } finally {
- releaseScripts()
- }
- await expect(page.getByTestId('hash-links')).toHaveAttribute(
- 'data-mounted',
- 'true',
- )
- const initial = await page.evaluate(() => window.initialHashLinks)
- for (const entry of cases) {
- expect(initial[entry.id], entry.id).toEqual({
- href: path + entry.server[0],
- active: entry.server[1],
- })
- const anchor = page.locator(`#${entry.id}`)
- await expect(anchor).toHaveAttribute('href', path + entry.client[0])
- await expect(anchor).toContainClass(entry.client[1] ? 'active' : 'inactive')
- await expect(anchor).toHaveText(entry.client[1] ? 'active' : 'inactive')
- if (entry.client[1]) {
- await expect(anchor).toHaveAttribute('aria-current', 'page')
- await expect(anchor).toHaveAttribute('data-status', 'active')
- } else {
- await expect(anchor).not.toHaveAttribute('aria-current')
- await expect(anchor).not.toHaveAttribute('data-status')
- }
- await expect(
- anchor.locator(entry.client[1] ? 'strong' : 'em'),
- ).toBeVisible()
- }
- expect(
- await page.evaluate(() =>
- (window as any).serverHashLinks.every(
- (anchor: Element) => document.getElementById(anchor.id) === anchor,
- ),
- ),
- ).toBe(true)
- await page.getByRole('button', { name: 'Mount link' }).click()
- expect(await page.evaluate(() => window.initialHashLinks.later)).toEqual({
- href: `${path}#details`,
- active: true,
- })
- await page.getByRole('button', { name: 'Toggle hash matching' }).click()
- await expect(page.locator('#empty')).toContainClass('active')
- await expect(page.locator('#nonmatching')).toContainClass('active')
- await page.getByRole('button', { name: 'Toggle hash matching' }).click()
- await expect(page.locator('#empty')).toContainClass('inactive')
- await page.locator('#navigate-other').click()
- await expect(page.locator('#matching')).toContainClass('inactive')
- await expect(page.locator('#nonmatching')).toContainClass('active')
- await expect(page.locator('#inherited')).toHaveAttribute(
- 'href',
- `${path}#other`,
- )
- await expect(page.locator('#derived')).toHaveAttribute(
- 'href',
- `${path}#other-child`,
- )
- expect(errors).toEqual([])
-})
diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx
index c32f6a49a7..c139b05d0a 100644
--- a/packages/react-router/src/ClientOnly.tsx
+++ b/packages/react-router/src/ClientOnly.tsx
@@ -56,15 +56,8 @@ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
* ```
* @returns True if the JS has been hydrated already, false otherwise.
*/
-export function useHydrated(): boolean
-/** @internal Skip the hydration update for consumers that don't need it. */
-export function useHydrated(enabled: boolean): boolean
-export function useHydrated(enabled = true): boolean {
- return React.useSyncExternalStore(
- subscribe,
- getSnapshot,
- enabled ? getServerSnapshot : getSnapshot,
- )
+export function useHydrated(): boolean {
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
function subscribe() {
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index 58a66aa7cf..26ec05a848 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -92,7 +92,7 @@ function resolveIsActive(
next: ParsedLocation,
activeOptions: ActiveOptions | undefined,
basepath: string,
- hydrating = false,
+ isHydrated: boolean,
): boolean {
const currentPath = removeTrailingSlash(location.pathname, basepath)
const nextPath = removeTrailingSlash(next.pathname, basepath)
@@ -123,7 +123,7 @@ function resolveIsActive(
}
if (activeOptions?.includeHash) {
- return (hydrating ? '' : location.hash) === next.hash
+ return isHydrated && location.hash === next.hash
}
return true
}
@@ -256,12 +256,8 @@ export function useLinkProps<
onTouchStart,
} = options as typeof options & { to?: string }
- const hashFromLocation =
- !options.href &&
- !options._fromLocation &&
- (options.hash === true || typeof options.hash === 'function')
// eslint-disable-next-line react-hooks/rules-of-hooks
- const isHydrated = useHydrated(activeOptions?.includeHash || hashFromLocation)
+ const isHydrated = useHydrated()
// eslint-disable-next-line react-hooks/rules-of-hooks
const [stableSearch, stableParams, stableActiveOptions] = useStableValues(
@@ -281,7 +277,6 @@ export function useLinkProps<
options.from,
options._fromLocation,
options.hash,
- options.href,
options.to,
stableSearch,
stableParams,
@@ -308,13 +303,7 @@ export function useLinkProps<
if (!_options._fromLocation) {
dest._fromLocation = location
}
- // Only hash-dependent destinations need the server's empty hash. Keep
- // the source location identity so links share the route-match cache.
- const next = router.buildLocation(
- !isHydrated && hashFromLocation
- ? { ...dest, hash: dest.hash === true ? '' : dest.hash('') }
- : dest,
- )
+ const next = router.buildLocation(dest)
// Use publicHref - it contains the correct href for display
// When a rewrite changes the origin, publicHref is the full URL
@@ -330,20 +319,11 @@ export function useLinkProps<
next,
stableActiveOptions,
router.basepath,
- !isHydrated,
+ isHydrated,
),
]
},
- [
- stableActiveOptions,
- disabled,
- isHydrated,
- hashFromLocation,
- _options,
- dest,
- router,
- to,
- ],
+ [stableActiveOptions, disabled, isHydrated, _options, dest, router, to],
)
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -629,6 +609,7 @@ function getServerLinkProps(
}
const blockedLink = !disabled && !hrefOption
+ // Hash is not available on the server, so it never counts as hydrated.
const isActive =
!!next &&
!blockedLink &&
@@ -637,6 +618,7 @@ function getServerLinkProps(
next,
activeOptions,
router.basepath,
+ false,
)
return applyLinkState(
props,
diff --git a/packages/react-router/tests/link-hash-hydration.test.tsx b/packages/react-router/tests/link-hash-hydration.test.tsx
deleted file mode 100644
index 83e7dcbaa9..0000000000
--- a/packages/react-router/tests/link-hash-hydration.test.tsx
+++ /dev/null
@@ -1,349 +0,0 @@
-import React from 'react'
-import { renderToString } from 'react-dom/server'
-import { createRoot, hydrateRoot } from 'react-dom/client'
-import { act } from '@testing-library/react'
-import { afterEach, expect, test, vi } from 'vitest'
-import {
- Link,
- RouterContextProvider,
- createLink,
- createMemoryHistory,
- createRootRoute,
- createRouter,
-} from '../src'
-import type { LinkOptions } from '../src'
-
-const cleanups: Array<() => void> = []
-
-afterEach(async () => {
- await act(() => {
- while (cleanups.length) {
- cleanups.pop()!()
- }
- })
- vi.restoreAllMocks()
-})
-
-function makeRouter(isServer: boolean, url: string) {
- const router = createRouter({
- routeTree: createRootRoute(),
- history: createMemoryHistory({ initialEntries: [url] }),
- isServer,
- ssr: {},
- defaultHashScrollIntoView: false,
- })
- cleanups.push(() => router.history.destroy())
- return router
-}
-
-const cases: Array<{
- name: string
- hash?: LinkOptions['hash']
- href?: string
- sourceHash?: string
- includeHash?: boolean
- server: [string, boolean]
- client: [string, boolean]
-}> = [
- {
- name: 'explicit-source',
- hash: true,
- sourceHash: 'preset',
- includeHash: true,
- server: ['/#preset', false],
- client: ['/#preset', false],
- },
- {
- name: 'explicit-source-function',
- hash: (hash = '') => `${hash}-child`,
- sourceHash: 'preset',
- includeHash: true,
- server: ['/#preset-child', false],
- client: ['/#preset-child', false],
- },
- {
- name: 'explicit-href',
- href: '/#fixed',
- hash: () => {
- throw new Error('href overrides hash')
- },
- includeHash: true,
- server: ['/#fixed', false],
- client: ['/#fixed', false],
- },
- {
- name: 'matching',
- hash: 'details',
- includeHash: true,
- server: ['/#details', false],
- client: ['/#details', true],
- },
- {
- name: 'nonmatching',
- hash: 'other',
- includeHash: true,
- server: ['/#other', false],
- client: ['/#other', false],
- },
- {
- name: 'empty',
- hash: '',
- includeHash: true,
- server: ['/', true],
- client: ['/', false],
- },
- {
- name: 'omitted',
- includeHash: true,
- server: ['/', true],
- client: ['/', false],
- },
- {
- name: 'inherited',
- hash: true,
- includeHash: true,
- server: ['/', true],
- client: ['/#details', true],
- },
- {
- name: 'identity',
- hash: (hash = '') => hash,
- includeHash: true,
- server: ['/', true],
- client: ['/#details', true],
- },
- {
- name: 'derived',
- hash: (hash) => `${hash}-child`,
- includeHash: true,
- server: ['/#-child', false],
- client: ['/#details-child', false],
- },
- {
- name: 'inherited-insensitive',
- hash: true,
- server: ['/', true],
- client: ['/#details', true],
- },
- {
- name: 'derived-insensitive',
- hash: (hash) => `${hash}-child`,
- server: ['/#-child', true],
- client: ['/#details-child', true],
- },
- { name: 'ordinary', server: ['/', true], client: ['/', true] },
-]
-
-test.each(
- cases.flatMap((entry) =>
- ['/', '/#details'].map((url) => ({ ...entry, url })),
- ),
-)(
- 'hydrates $name links at $url without changing server DOM during hydration',
- async (entry) => {
- const renders: Array = []
- const source = entry.sourceHash
- ? makeRouter(true, `/#${entry.sourceHash}`).stores.location.get()
- : undefined
- const link = (
-
- {({ isActive }) => {
- renders.push(isActive)
- return String(isActive)
- }}
-
- )
- const container = document.createElement('div')
- const server = makeRouter(true, '/')
- container.innerHTML = renderToString(
- {link},
- )
- const anchor = container.querySelector('a')!
- function check([href, active]: [string, boolean]) {
- expect(container.querySelector('a')).toBe(anchor)
- expect(anchor.getAttribute('href')).toBe(href)
- expect(anchor.className).toBe(active ? 'active' : 'inactive')
- expect(anchor.getAttribute('aria-current')).toBe(active ? 'page' : null)
- expect(anchor.getAttribute('data-status')).toBe(active ? 'active' : null)
- expect(anchor.textContent).toBe(String(active))
- }
- check(entry.server)
- renders.length = 0
- const client = makeRouter(false, entry.url)
- const error = vi.spyOn(console, 'error').mockImplementation(() => {})
- const recoverable = vi.fn()
- await act(() => {
- const root = hydrateRoot(
- container,
- {link},
- { onRecoverableError: recoverable },
- )
- cleanups.push(() => root.unmount())
- })
- expect(renders[0]).toBe(entry.server[1])
- check(entry.url === '/' ? entry.server : entry.client)
- expect(client.stores.location.get().hash).toBe(
- entry.url === '/' ? '' : 'details',
- )
- expect(error).not.toHaveBeenCalled()
- expect(recoverable).not.toHaveBeenCalled()
- if (entry.name === 'ordinary') {
- expect(renders).toEqual([true])
- }
-
- await act(() => client.navigate({ to: '/', hash: 'other' }))
- expect(container.querySelector('a')).toBe(anchor)
- const inheritsHash = entry.hash === true || typeof entry.hash === 'function'
- check([
- inheritsHash
- ? entry.client[0].replace('details', 'other')
- : entry.client[0],
- !entry.includeHash ||
- ['nonmatching', 'inherited', 'identity'].includes(entry.name),
- ])
- await act(() => client.navigate({ to: '/', hash: '' }))
- check(entry.server)
- },
-)
-
-test.each(
- [false, true].flatMap((later) => [
- {
- later,
- hash: 'details' as LinkOptions['hash'],
- href: '/#details',
- active: true,
- },
- { later, hash: true as const, href: '/#details', active: true },
- {
- later,
- hash: (hash = '') => `${hash}-child`,
- href: '/#details-child',
- active: false,
- },
- ]),
-)(
- 'uses live hashes on the first client-only render ($href, later mount: $later)',
- async ({ later, hash, href, active }) => {
- const router = makeRouter(false, '/#details')
- const renders: Array<{ href?: string; active: boolean }> = []
- const ObservedLink = createLink(
- React.forwardRef>(
- (props, ref) => {
- renders.push({
- href: props.href,
- active: props['aria-current'] === 'page',
- })
- return
- },
- ),
- )
- const container = document.createElement('div')
- const tree = (show: boolean) => (
-
- {show && (
-
- {({ isActive }) => String(isActive)}
-
- )}
-
- )
- let root: ReturnType
- await act(() => {
- if (later) {
- container.innerHTML = renderToString(tree(false))
- root = hydrateRoot(container, tree(false))
- } else {
- root = createRoot(container)
- }
- cleanups.push(() => root.unmount())
- })
- await act(() => root.render(tree(true)))
- expect(renders[0]).toEqual({ href, active })
- expect(container.querySelector('a')).toHaveAttribute('href', href)
- },
-)
-
-test('hash-dependent links share current-route validation while hydrating', async () => {
- const validateSearch = vi.fn((search: Record) => search)
- const make = (isServer: boolean, url: string) => {
- const router = createRouter({
- routeTree: createRootRoute({ validateSearch }),
- history: createMemoryHistory({ initialEntries: [url] }),
- isServer,
- })
- cleanups.push(() => router.history.destroy())
- return router
- }
- const links = Array.from({ length: 10 }, (_, index) => (
-
- Link
-
- ))
- const container = document.createElement('div')
- container.innerHTML = renderToString(
-
- {links}
- ,
- )
- const client = make(false, '/?q=test#details')
- validateSearch.mockClear()
- await act(() => {
- const root = hydrateRoot(
- container,
- {links},
- )
- cleanups.push(() => root.unmount())
- })
- expect(validateSearch).toHaveBeenCalledTimes(1)
- expect(container.querySelectorAll('a')).toHaveLength(10)
- for (const anchor of container.querySelectorAll('a')) {
- expect(anchor).toHaveAttribute('href', '/?q=test#details')
- }
-})
-
-test('updates hash and active options on the same hydrated link', async () => {
- const tree = (
- router: ReturnType,
- hash?: LinkOptions['hash'],
- includeHash = false,
- ) => (
-
-
- {({ isActive }) => String(isActive)}
-
-
- )
- const container = document.createElement('div')
- container.innerHTML = renderToString(tree(makeRouter(true, '/')))
- const anchor = container.querySelector('a')!
- const router = makeRouter(false, '/#details')
- let root: ReturnType
- await act(() => {
- root = hydrateRoot(container, tree(router))
- cleanups.push(() => root.unmount())
- })
- for (const [hash, includeHash, href, active] of [
- [undefined, true, '/', false],
- [true, true, '/#details', true],
- [(previous = '') => `${previous}-child`, true, '/#details-child', false],
- ['other', false, '/#other', true],
- [undefined, false, '/', true],
- ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) {
- await act(() => root.render(tree(router, hash, includeHash)))
- expect(container.querySelector('a')).toBe(anchor)
- expect(anchor).toHaveAttribute('href', href)
- expect(anchor.textContent).toBe(String(active))
- }
-})
diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx
index 0ed9645e8a..a5fc4bca57 100644
--- a/packages/solid-router/src/link.tsx
+++ b/packages/solid-router/src/link.tsx
@@ -18,6 +18,7 @@ import { useRouter } from './useRouter'
import { useIntersectionObserver } from './utils'
+import { useHydrated } from './ClientOnly'
import type {
AnyRouter,
Constrain,
@@ -123,13 +124,6 @@ export function useLinkProps<
'href',
])
- const [hydrating, setHydrating] = Solid.createSignal(
- !(isServer ?? router.isServer) && !!Solid.sharedConfig.context,
- )
- if (hydrating()) {
- Solid.onMount(() => setHydrating(false))
- }
-
const currentLocation = Solid.createMemo(
() => router.stores.location.get(),
undefined,
@@ -140,21 +134,8 @@ export function useLinkProps<
// Rebuild when inherited search/hash or the current route context changes.
const _fromLocation = currentLocation()
const nextOptions = { _fromLocation, ...options } as any
- const hash = nextOptions.hash
- const hydrateHash =
- !options.href &&
- !options._fromLocation &&
- (hash === true || typeof hash === 'function') &&
- hydrating()
// untrack because router-core will also access stores, which are signals in solid
- return Solid.untrack(() => {
- // Keep the source location identity for shared route matching. Literal
- // destinations don't depend on hydration and need no post-mount rebuild.
- if (hydrateHash) {
- nextOptions.hash = hash === true ? '' : hash('')
- }
- return router.buildLocation(nextOptions)
- })
+ return Solid.untrack(() => router.buildLocation(nextOptions))
})
const hrefOption = Solid.createMemo(() => {
@@ -203,6 +184,9 @@ export function useLinkProps<
return _href && getUrlScheme(_href) ? _href : undefined
})
+ const shouldHydrateHash = !isServer && !!router.options.ssr
+ const hasHydrated = (isServer ?? router.isServer) ? undefined : useHydrated()
+
const isActive = Solid.createMemo(() => {
if (externalLink() !== undefined) {
return false
@@ -240,7 +224,9 @@ export function useLinkProps<
}
if (activeOptions?.includeHash) {
- return (hydrating() ? '' : current.hash) === nextLocation.hash
+ const currentHash =
+ shouldHydrateHash && !hasHydrated?.() ? '' : current.hash
+ return currentHash === nextLocation.hash
}
return true
})
diff --git a/packages/solid-router/tests/link-hash.test.tsx b/packages/solid-router/tests/link-hash.test.tsx
deleted file mode 100644
index 39032bb926..0000000000
--- a/packages/solid-router/tests/link-hash.test.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { cleanup, render } from '@solidjs/testing-library'
-import { afterEach, expect, test } from 'vitest'
-import { createSignal } from 'solid-js'
-import {
- Link,
- RouterContextProvider,
- createLink,
- createMemoryHistory,
- createRootRoute,
- createRouter,
-} from '../src'
-import type { ComponentProps } from 'solid-js'
-import type { LinkOptions } from '../src'
-
-afterEach(cleanup)
-
-test.each([
- { hash: 'details' as LinkOptions['hash'], href: '/#details', active: true },
- { hash: true as const, href: '/#details', active: true },
- {
- hash: (previous = '') => `${previous}-child`,
- href: '/#details-child',
- active: false,
- },
-])(
- 'client-only links on an SSR router use the live hash on their first render ($href)',
- ({ hash, href, active }) => {
- const router = createRouter({
- routeTree: createRootRoute(),
- history: createMemoryHistory({ initialEntries: ['/#details'] }),
- isServer: false,
- ssr: {},
- })
- const renders: Array<{ href?: string; active: boolean }> = []
- const ObservedLink = createLink((props: ComponentProps<'a'>) => {
- renders.push({
- href: props.href,
- active: props['aria-current'] === 'page',
- })
- return
- })
- const { container } = render(() => (
-
- {() => (
-
- {({ isActive }) => String(isActive)}
-
- )}
-
- ))
- expect(renders[0]).toEqual({ href, active })
- expect(container.querySelector('a')).toHaveAttribute('href', href)
- router.history.destroy()
- },
-)
-
-test('hash and active options stay reactive on an initially ordinary link', () => {
- const router = createRouter({
- routeTree: createRootRoute(),
- history: createMemoryHistory({ initialEntries: ['/#details'] }),
- })
- const [hash, setHash] = createSignal()
- const [includeHash, setIncludeHash] = createSignal(false)
- const { container } = render(() => (
-
- {() => (
-
- {({ isActive }) => String(isActive)}
-
- )}
-
- ))
- const anchor = container.querySelector('a')!
- for (const [nextHash, include, href, active] of [
- [undefined, true, '/', false],
- [true, true, '/#details', true],
- [(previous = '') => `${previous}-child`, true, '/#details-child', false],
- ['other', false, '/#other', true],
- [undefined, false, '/', true],
- ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) {
- setHash(() => nextHash)
- setIncludeHash(include)
- expect(container.querySelector('a')).toBe(anchor)
- expect(anchor).toHaveAttribute('href', href)
- expect(anchor.textContent).toBe(String(active))
- }
- router.history.destroy()
-})