From 544927c03d259f5ad378f72580bbe9b1334de8a7 Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 15 Sep 2026 17:05:31 +0200
Subject: [PATCH 1/2] perf(react-router): avoid redundant link hydration
updates
---
.changeset/large-rockets-push.md | 5 +
packages/react-router/src/ClientOnly.tsx | 12 +-
packages/react-router/src/link.tsx | 4 +-
.../tests/link-hydration.test.tsx | 203 ++++++++++++++++++
4 files changed, 221 insertions(+), 3 deletions(-)
create mode 100644 .changeset/large-rockets-push.md
create mode 100644 packages/react-router/tests/link-hydration.test.tsx
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..40904a91b0 100644
--- a/packages/react-router/src/ClientOnly.tsx
+++ b/packages/react-router/src/ClientOnly.tsx
@@ -57,7 +57,17 @@ 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)
+ return useHydratedWhen(true)
+}
+
+// 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 useHydratedWhen(enabled: boolean): 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..861718d686 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -13,7 +13,7 @@ import {
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
-import { useHydrated } from './ClientOnly'
+import { useHydratedWhen } from './ClientOnly'
import type {
ActiveOptions,
AnyRouter,
@@ -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 = useHydratedWhen(!!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()
+ }
+})
From 88a3c208ea989f2ec3fb9a4b25073036cb08f5df Mon Sep 17 00:00:00 2001
From: Sheraff
Date: Tue, 15 Sep 2026 17:21:20 +0200
Subject: [PATCH 2/2] refactor(react-router): hide hydration flag behind
internal overload
---
packages/react-router/src/ClientOnly.tsx | 16 +++++++++-------
packages/react-router/src/link.tsx | 4 ++--
2 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx
index 40904a91b0..010a6d3117 100644
--- a/packages/react-router/src/ClientOnly.tsx
+++ b/packages/react-router/src/ClientOnly.tsx
@@ -56,13 +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 useHydratedWhen(true)
-}
-
-// 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 useHydratedWhen(enabled: boolean): boolean {
+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,
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index 861718d686..94bf0f5ad1 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -13,7 +13,7 @@ import {
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
-import { useHydratedWhen } from './ClientOnly'
+import { useHydrated } from './ClientOnly'
import type {
ActiveOptions,
AnyRouter,
@@ -257,7 +257,7 @@ export function useLinkProps<
} = options as typeof options & { to?: string }
// eslint-disable-next-line react-hooks/rules-of-hooks
- const isHydrated = useHydratedWhen(!!activeOptions?.includeHash)
+ const isHydrated = useHydrated(!!activeOptions?.includeHash)
// eslint-disable-next-line react-hooks/rules-of-hooks
const [stableSearch, stableParams, stableActiveOptions] = useStableValues(