diff --git a/.changeset/large-rockets-push.md b/.changeset/large-rockets-push.md
new file mode 100644
index 0000000000..99f158c890
--- /dev/null
+++ b/.changeset/large-rockets-push.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/react-router': patch
+---
+
+Avoid hydration-triggered rerenders for links that do not compare URL hashes while preserving hash-sensitive active state and ClientOnly behavior.
diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx
index c139b05d0a..010a6d3117 100644
--- a/packages/react-router/src/ClientOnly.tsx
+++ b/packages/react-router/src/ClientOnly.tsx
@@ -56,8 +56,20 @@ 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
+ * callers whose output does not depend on hydration can keep the
+ * same snapshot on both sides, avoiding an unnecessary post-hydration render.
+ */
+export function useHydrated(enabled: boolean): boolean
+/** @internal */
+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..94bf0f5ad1 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -257,7 +257,7 @@ export function useLinkProps<
} = options as typeof options & { to?: string }
// eslint-disable-next-line react-hooks/rules-of-hooks
- const isHydrated = useHydrated()
+ const isHydrated = useHydrated(!!activeOptions?.includeHash)
// eslint-disable-next-line react-hooks/rules-of-hooks
const [stableSearch, stableParams, stableActiveOptions] = useStableValues(
diff --git a/packages/react-router/tests/link-hydration.test.tsx b/packages/react-router/tests/link-hydration.test.tsx
new file mode 100644
index 0000000000..497464b3f8
--- /dev/null
+++ b/packages/react-router/tests/link-hydration.test.tsx
@@ -0,0 +1,203 @@
+import React from 'react'
+import { renderToString } from 'react-dom/server'
+import { hydrateRoot } from 'react-dom/client'
+import { act } from '@testing-library/react'
+import { expect, test, vi } from 'vitest'
+import {
+ Link,
+ RouterContextProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+test('only hash-sensitive links need a second hydration render', async () => {
+ const rootRoute = createRootRoute()
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ createRoute({ getParentRoute: () => rootRoute, path: '/target' }),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/target#section'] }),
+ })
+ await router.load()
+ const serverRouter = createRouter({
+ routeTree: router.routeTree,
+ history: createMemoryHistory({ initialEntries: ['/target'] }),
+ isServer: true,
+ })
+ await serverRouter.load()
+ const ordinary = vi.fn(({ isActive }: { isActive: boolean }) =>
+ String(isActive),
+ )
+ const hashSensitive = vi.fn(({ isActive }: { isActive: boolean }) =>
+ String(isActive),
+ )
+ const tree = (includeHash = false, contextRouter = router) => (
+
+
+ {ordinary}
+
+
+ {hashSensitive}
+
+
+ )
+ const container = document.createElement('div')
+ document.body.append(container)
+ container.innerHTML = renderToString(tree(false, serverRouter))
+ expect(container.querySelector('[data-testid="ordinary"]')?.textContent).toBe(
+ 'true',
+ )
+ expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(
+ 'false',
+ )
+ ordinary.mockClear()
+ hashSensitive.mockClear()
+ const onRecoverableError = vi.fn()
+ const diagnostics = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const serverAnchor = container.querySelector('[data-testid="ordinary"]')
+ let root: ReturnType | undefined
+ try {
+ await act(() => {
+ root = hydrateRoot(container, tree(), { onRecoverableError })
+ })
+ expect(ordinary).toHaveBeenCalledTimes(1)
+ expect(container.querySelector('[data-testid="ordinary"]')).toBe(
+ serverAnchor,
+ )
+ expect(hashSensitive.mock.calls.map(([state]) => state.isActive)).toEqual([
+ false,
+ true,
+ ])
+ expect(container.querySelector('[data-testid="hash"]')).toHaveAttribute(
+ 'aria-current',
+ 'page',
+ )
+ expect(container.querySelector('[data-testid="hash"]')).toHaveAttribute(
+ 'title',
+ 'active',
+ )
+ await act(() => root!.render(tree(true)))
+ expect(
+ container.querySelector('[data-testid="ordinary"]'),
+ ).not.toHaveAttribute('aria-current')
+ expect(container.querySelector('[data-testid="ordinary"]')).toHaveAttribute(
+ 'title',
+ 'inactive',
+ )
+ await act(() => root!.render(tree(false)))
+ expect(container.querySelector('[data-testid="ordinary"]')).toHaveAttribute(
+ 'aria-current',
+ 'page',
+ )
+ expect(onRecoverableError).not.toHaveBeenCalled()
+ expect(diagnostics).not.toHaveBeenCalled()
+ } finally {
+ await act(() => root?.unmount())
+ router.history.destroy()
+ serverRouter.history.destroy()
+ diagnostics.mockRestore()
+ container.remove()
+ }
+})
+
+test('hash-sensitive links retain the server snapshot in a delayed hydration boundary', async () => {
+ const rootRoute = createRootRoute()
+ const routeTree = rootRoute.addChildren([
+ createRoute({ getParentRoute: () => rootRoute, path: '/target' }),
+ ])
+ const serverRouter = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/target'] }),
+ isServer: true,
+ })
+ const clientRouter = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/target#section'] }),
+ })
+ await Promise.all([serverRouter.load(), clientRouter.load()])
+ let ready = true
+ let resolve!: () => void
+ const pending = new Promise((done) => {
+ resolve = done
+ })
+ const ordinary = vi.fn(() => 'ordinary')
+ const hashSensitive = vi.fn(({ isActive }: { isActive: boolean }) =>
+ String(isActive),
+ )
+ function DeferredLinks() {
+ if (!ready) {
+ throw pending
+ }
+ return (
+ <>
+ {ordinary}
+
+ {hashSensitive}
+
+ >
+ )
+ }
+ const tree = (router: typeof clientRouter) => (
+
+ Loading
}>
+
+
+
+ )
+ const container = document.createElement('div')
+ document.body.append(container)
+ container.innerHTML = renderToString(tree(serverRouter))
+ const serverAnchor = container.querySelector('a')
+ ordinary.mockClear()
+ hashSensitive.mockClear()
+ ready = false
+ const onRecoverableError = vi.fn()
+ const diagnostics = vi.spyOn(console, 'error').mockImplementation(() => {})
+ let root: ReturnType | undefined
+ try {
+ await act(() => {
+ root = hydrateRoot(container, tree(clientRouter), { onRecoverableError })
+ })
+ expect(ordinary).not.toHaveBeenCalled()
+ expect(hashSensitive).not.toHaveBeenCalled()
+ await act(async () => {
+ ready = true
+ resolve()
+ await pending
+ })
+ expect(ordinary).toHaveBeenCalledTimes(1)
+ expect(hashSensitive.mock.calls.map(([state]) => state.isActive)).toEqual([
+ false,
+ true,
+ ])
+ expect(container.querySelector('a')).toBe(serverAnchor)
+ expect(container.querySelectorAll('a')[1]).toHaveAttribute(
+ 'aria-current',
+ 'page',
+ )
+ expect(onRecoverableError).not.toHaveBeenCalled()
+ expect(diagnostics).not.toHaveBeenCalled()
+ } finally {
+ await act(() => root?.unmount())
+ serverRouter.history.destroy()
+ clientRouter.history.destroy()
+ diagnostics.mockRestore()
+ container.remove()
+ }
+})