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
18 changes: 18 additions & 0 deletions docs/start/framework/react/guide/hydration-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ title: Hydration Errors
- **Mismatch**: Server HTML differs from client render during hydration
- **Common causes**: `Intl` (locale/time zone), `Date.now()`, random IDs, responsive-only logic, feature flags, user prefs

### A route works during navigation but fails on refresh

Check the server log for a serialization error before treating this as a markup mismatch. A loader runs in the browser during client navigation, but its SSR result must cross the server-to-browser boundary. Returning a plain function, including a method nested inside an object, can break that transfer.

Return the data the component needs instead:

```tsx
// Cannot be serialized for SSR
loader: () => ({ title: () => 'My page' })

// Return the computed value
loader: () => ({ title: 'My page' })
```

Keep formatting functions in component code or another imported module. Call server-side functions inside the loader and return their supported results. Do not return an ordinary function to make it callable remotely, use [Server Functions](./server-functions.md) for that.

`suppressHydrationWarning` only addresses markup differences, it cannot repair missing loader data. Test a direct request, hydration, and client navigation after fixing the return value. The [loader serialization example](https://github.com/TanStack/router/blob/main/e2e/react-start/basic/src/routes/loader-serialization.tsx) and its [browser tests](https://github.com/TanStack/router/blob/main/e2e/react-start/basic/tests/loader-serialization.spec.ts) cover all three.

### Strategy 1 — Make server and client match

- **Pick a deterministic locale/time zone on the server** and use the same on the client
Expand Down
21 changes: 21 additions & 0 deletions e2e/react-start/basic/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Route as ClientOnlyRouteImport } from './routes/client-only'
import { Route as DeferredRouteImport } from './routes/deferred'
import { Route as InlineScriptsRouteImport } from './routes/inline-scripts'
import { Route as LinksRouteImport } from './routes/links'
import { Route as LoaderSerializationRouteImport } from './routes/loader-serialization'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
import { Route as PlainTsTypeAssertionRouteImport } from './routes/plain-ts-type-assertion'
import { Route as PostsRouteImport } from './routes/posts'
Expand Down Expand Up @@ -123,6 +124,11 @@ const LinksRoute = LinksRouteImport.update({
path: '/links',
getParentRoute: () => rootRouteImport,
} as any)
const LoaderSerializationRoute = LoaderSerializationRouteImport.update({
id: '/loader-serialization',
path: '/loader-serialization',
getParentRoute: () => rootRouteImport,
} as any)
const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
id: '/not-found',
path: '/not-found',
Expand Down Expand Up @@ -481,6 +487,7 @@ export interface FileRoutesByFullPath {
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/loader-serialization': typeof LoaderSerializationRoute
'/plain-ts-type-assertion': typeof PlainTsTypeAssertionRoute
'/posts': typeof PostsRouteWithChildren
'/primitive-beforeload-error': typeof PrimitiveBeforeloadErrorRoute
Expand Down Expand Up @@ -553,6 +560,7 @@ export interface FileRoutesByTo {
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/loader-serialization': typeof LoaderSerializationRoute
'/plain-ts-type-assertion': typeof PlainTsTypeAssertionRoute
'/primitive-beforeload-error': typeof PrimitiveBeforeloadErrorRoute
'/scripts': typeof ScriptsRoute
Expand Down Expand Up @@ -622,6 +630,7 @@ export interface FileRoutesById {
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/loader-serialization': typeof LoaderSerializationRoute
'/plain-ts-type-assertion': typeof PlainTsTypeAssertionRoute
'/posts': typeof PostsRouteWithChildren
'/primitive-beforeload-error': typeof PrimitiveBeforeloadErrorRoute
Expand Down Expand Up @@ -699,6 +708,7 @@ export interface FileRouteTypes {
| '/deferred'
| '/inline-scripts'
| '/links'
| '/loader-serialization'
| '/plain-ts-type-assertion'
| '/posts'
| '/primitive-beforeload-error'
Expand Down Expand Up @@ -771,6 +781,7 @@ export interface FileRouteTypes {
| '/deferred'
| '/inline-scripts'
| '/links'
| '/loader-serialization'
| '/plain-ts-type-assertion'
| '/primitive-beforeload-error'
| '/scripts'
Expand Down Expand Up @@ -839,6 +850,7 @@ export interface FileRouteTypes {
| '/deferred'
| '/inline-scripts'
| '/links'
| '/loader-serialization'
| '/plain-ts-type-assertion'
| '/posts'
| '/primitive-beforeload-error'
Expand Down Expand Up @@ -916,6 +928,7 @@ export interface RootRouteChildren {
DeferredRoute: typeof DeferredRoute
InlineScriptsRoute: typeof InlineScriptsRoute
LinksRoute: typeof LinksRoute
LoaderSerializationRoute: typeof LoaderSerializationRoute
PlainTsTypeAssertionRoute: typeof PlainTsTypeAssertionRoute
PostsRoute: typeof PostsRouteWithChildren
PrimitiveBeforeloadErrorRoute: typeof PrimitiveBeforeloadErrorRoute
Expand Down Expand Up @@ -993,6 +1006,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LinksRouteImport
parentRoute: typeof rootRouteImport
}
'/loader-serialization': {
id: '/loader-serialization'
path: '/loader-serialization'
fullPath: '/loader-serialization'
preLoaderRoute: typeof LoaderSerializationRouteImport
parentRoute: typeof rootRouteImport
}
'/not-found': {
id: '/not-found'
path: '/not-found'
Expand Down Expand Up @@ -1731,6 +1751,7 @@ const rootRouteChildren: RootRouteChildren = {
DeferredRoute: DeferredRoute,
InlineScriptsRoute: InlineScriptsRoute,
LinksRoute: LinksRoute,
LoaderSerializationRoute: LoaderSerializationRoute,
PlainTsTypeAssertionRoute: PlainTsTypeAssertionRoute,
PostsRoute: PostsRouteWithChildren,
PrimitiveBeforeloadErrorRoute: PrimitiveBeforeloadErrorRoute,
Expand Down
3 changes: 3 additions & 0 deletions e2e/react-start/basic/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
Client Only
</Link>{' '}
<Link to="/loader-serialization" preload={false}>
Loader serialization
</Link>{' '}
<Link
to="/raw-stream"
activeProps={{
Expand Down
32 changes: 32 additions & 0 deletions e2e/react-start/basic/src/routes/loader-serialization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/loader-serialization')({
loader: () => ({
title: 'Serializable loader data',
publishedAt: new Date('2026-01-02T03:04:05.000Z'),
labels: new Map([['language', 'TypeScript']]),
tags: new Set(['react', 'start']),
total: 9007199254740993n,
optional: undefined,
}),
component: Example,
})

function Example() {
const data = Route.useLoaderData()
const [count, setCount] = useState(0)
return (
<>
<h1>{data.title}</h1>
<p data-testid="date">{data.publishedAt.toISOString()}</p>
<p data-testid="map">{data.labels.get('language')}</p>
<p data-testid="set">{Array.from(data.tags).join(', ')}</p>
<p data-testid="bigint">{(data.total + 1n).toString()}</p>
<p data-testid="optional">
{data.optional === undefined ? 'absent' : 'present'}
</p>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
</>
)
}
45 changes: 45 additions & 0 deletions e2e/react-start/basic/tests/loader-serialization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'
import { isSpaMode } from './utils/isSpaMode'

for (const navigation of ['direct', 'client'] as const) {
test(`loader values retain their types after ${navigation} navigation`, async ({
page,
request,
}) => {
if (navigation === 'direct') {
const response = await request.get('/loader-serialization')
expect(response.status()).toBe(200)
if (!isSpaMode) {
const html = await response.text()
expect(html).toContain('Serializable loader data')
expect(html).toContain('9007199254740994')
expect(html).toContain('2026-01-02T03:04:05.000Z')
}
await page.goto('/loader-serialization')
} else {
await page.goto('/')
await page.evaluate(() => {
document.documentElement.dataset.navigationTest = 'same-document'
})
await page
.getByRole('link', { name: 'Loader serialization', exact: true })
.click()
await expect(page.locator('html')).toHaveAttribute(
'data-navigation-test',
'same-document',
)
}
await expect(page.getByTestId('date')).toHaveText(
'2026-01-02T03:04:05.000Z',
)
await expect(page.getByTestId('map')).toHaveText('TypeScript')
await expect(page.getByTestId('set')).toHaveText('react, start')
await expect(page.getByTestId('bigint')).toHaveText('9007199254740994')
await expect(page.getByTestId('optional')).toHaveText('absent')
await page.getByRole('button', { name: 'Count: 0', exact: true }).click()
await expect(
page.getByRole('button', { name: 'Count: 1', exact: true }),
).toBeVisible()
})
}
38 changes: 38 additions & 0 deletions packages/router-core/tests/ssr-loader-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createMemoryHistory } from '@tanstack/history'
import { expect, onTestFinished, test, vi } from 'vitest'
import { BaseRootRoute, BaseRoute } from '../src'
import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server'
import { createTestRouter } from './routerTestUtils'

for (const title of [
'Serializable loader data',
() => 'Serializable loader data',
]) {
test(`SSR serialization ${typeof title === 'function' ? 'rejects a plain function' : 'accepts its string result'}`, async () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
onTestFinished(() => errors.mockRestore())
const rootRoute = new BaseRootRoute({})
const route = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/',
loader: () => ({ title }),
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([route]),
history: createMemoryHistory({ initialEntries: ['/'] }),
isServer: true,
})
attachRouterServerSsrUtils({ router, manifest: undefined })
onTestFinished(() => router.serverSsr?.cleanup())
await router.load()
await router.serverSsr!.dehydrate()
if (typeof title === 'function') {
expect(errors).toHaveBeenCalledWith(
'Serialization error:',
expect.any(Error),
)
} else {
expect(errors).not.toHaveBeenCalled()
}
})
}
Loading