Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/use-can-go-back-hydration.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/router/api/router/useCanGoBack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions packages/react-router/src/useCanGoBack.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
}
144 changes: 144 additions & 0 deletions packages/react-router/tests/issue-8211-useCanGoBack-hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>> = []

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: () => <h1>Page one</h1>,
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: function AboutComponent() {
const canGoBack = useCanGoBack()
return (
<div data-testid="can-go-back">
{canGoBack ? 'can go back' : 'cannot go back'}
</div>
)
},
})
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(<RouterProvider router={serverRouter} />)
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<Error> = []
let root!: ReturnType<typeof hydrateRoot>
await act(async () => {
root = hydrateRoot(container, <RouterProvider router={clientRouter} />, {
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')
})
})
})