From 26e92eb74f4868d82732d7afbd0760ae7985dba7 Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Wed, 2 Sep 2026 22:34:24 +0100 Subject: [PATCH] fix(react-router): defer useCanGoBack to the server value while hydrating The server builds a fresh single entry memory history for each request, so `useCanGoBack` renders `false` in the SSR markup. The browser preserves `history.state` across a reload, so after navigating and refreshing the client router starts on an entry whose `__TSR_index` is already non-zero. The client branch read that index during the hydration render, so the hydration output disagreed with the server markup and React reported a hydration mismatch. Gate the client value on hydration, the same way `resolveIsActive` already gates hash matching in `link.tsx`. While hydrating the hook returns `false`, which matches the server, and React then re-renders with the browser's real history index. Client-only renders are unaffected because `useHydrated` reads its client snapshot on the first render when there is no hydration pass. --- .changeset/use-can-go-back-hydration.md | 5 + docs/router/api/router/useCanGoBack.md | 2 + packages/react-router/src/useCanGoBack.ts | 14 +- ...issue-8211-useCanGoBack-hydration.test.tsx | 144 ++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 .changeset/use-can-go-back-hydration.md create mode 100644 packages/react-router/tests/issue-8211-useCanGoBack-hydration.test.tsx diff --git a/.changeset/use-can-go-back-hydration.md b/.changeset/use-can-go-back-hydration.md new file mode 100644 index 00000000000..3c304e31c61 --- /dev/null +++ b/.changeset/use-can-go-back-hydration.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +Fix `useCanGoBack` reporting the browser history index during the hydration render, which contradicted the server markup and produced a hydration mismatch after a page refresh. The hook now defers to the server value while hydrating and reports the real history once hydration has settled. diff --git a/docs/router/api/router/useCanGoBack.md b/docs/router/api/router/useCanGoBack.md index 39b84fbdf9b..0e2a55d0ca2 100644 --- a/docs/router/api/router/useCanGoBack.md +++ b/docs/router/api/router/useCanGoBack.md @@ -16,6 +16,8 @@ The `useCanGoBack` hook returns a boolean representing if the router history can The router history index is reset after a navigation with [`reloadDocument`](./NavigateOptionsType.md#reloaddocument) set as `true`. This causes the router history to consider the new location as the initial one and will cause `useCanGoBack` to return `false`. +During server-side rendering the server builds a fresh single entry history for each request, so it cannot know how deep the browser's history is and always renders `false`. To keep the hydration render consistent with that markup, `useCanGoBack` also returns `false` while hydrating, then reports the browser's real history once hydration has settled. A server-rendered application therefore renders one frame of `false` before the value becomes accurate. If that frame is visible in your UI, render the dependent markup with [`ClientOnly`](./clientOnlyComponent.md) or keep the layout stable by disabling the control rather than removing it. + ## Examples ### Showing a back button diff --git a/packages/react-router/src/useCanGoBack.ts b/packages/react-router/src/useCanGoBack.ts index a20f947f438..c83d9b63351 100644 --- a/packages/react-router/src/useCanGoBack.ts +++ b/packages/react-router/src/useCanGoBack.ts @@ -1,5 +1,6 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' +import { useHydrated } from './ClientOnly' import { useRouter } from './useRouter' export function useCanGoBack() { @@ -9,9 +10,18 @@ export function useCanGoBack() { return router.stores.location.get().state.__TSR_index !== 0 } - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore( + /* eslint-disable react-hooks/rules-of-hooks -- condition is static */ + // The server renders a fresh single entry history per request, so it always + // reports `false`. The browser preserves `history.state` across a reload and + // can start on a deeper entry, so reporting the real index while hydrating + // would contradict the server markup. Defer to the server value until + // hydration has settled, then report the browser's history. + const isHydrated = useHydrated() + const canGoBack = useStore( router.stores.location, (location) => location.state.__TSR_index !== 0, ) + /* eslint-enable react-hooks/rules-of-hooks */ + + return isHydrated && canGoBack } diff --git a/packages/react-router/tests/issue-8211-useCanGoBack-hydration.test.tsx b/packages/react-router/tests/issue-8211-useCanGoBack-hydration.test.tsx new file mode 100644 index 00000000000..bed1b548b37 --- /dev/null +++ b/packages/react-router/tests/issue-8211-useCanGoBack-hydration.test.tsx @@ -0,0 +1,144 @@ +import * as React from 'react' +import { act, waitFor } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' +import { hydrate } from '../src/ssr/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useCanGoBack, +} from '../src' +import type { TsrSsrGlobal } from '../src/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +function makeRouteTree() { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Page one

, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: function AboutComponent() { + const canGoBack = useCanGoBack() + return ( +
+ {canGoBack ? 'can go back' : 'cannot go back'} +
+ ) + }, + }) + return rootRoute.addChildren([indexRoute, aboutRoute]) +} + +describe('useCanGoBack during hydration', () => { + test('does not report a hydration mismatch when the browser has history behind the entry', async () => { + // The server builds a fresh single entry history per request, so it can + // never know how deep the browser's history is. + const serverRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ initialEntries: ['/about'] }), + }) + serverRouter.isServer = true + await serverRouter.load() + const serverMatches = serverRouter.stores.matches.get() + const serverHtml = renderToString() + expect(serverHtml).toContain('cannot go back') + + // The browser preserves history.state across a reload, so the client + // router starts on an entry whose __TSR_index is already 1. + const clientRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ initialEntries: ['/', '/about'] }), + }) + expect(clientRouter.stores.location.get().state.__TSR_index).toBe(1) + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverMatches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(clientRouter) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + + const recoverableHydrationErrors: Array = [] + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: (error) => { + const messages = [ + error instanceof Error ? error.message : String(error), + error instanceof Error && error.cause instanceof Error + ? error.cause.message + : '', + ] + if ( + messages.some((message) => + /hydration (?:failed|mismatch)|server rendered HTML.*client|server rendered text/i.test( + message, + ), + ) + ) { + recoverableHydrationErrors.push(error as Error) + return + } + throw error + }, + }) + testCleanups.push(async () => { + await act(() => root.unmount()) + }) + await Promise.resolve() + }) + + expect(recoverableHydrationErrors).toHaveLength(0) + + // Once hydration has settled the hook reports the real browser history. + await waitFor(() => { + expect(container).toHaveTextContent('can go back') + }) + }) +})