From 92bf3f73cc54214fb19add2dd1db3b64690ba9aa Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 11 Sep 2026 22:25:45 -0600 Subject: [PATCH] docs(start): troubleshoot loader serialization failures --- .../framework/react/guide/hydration-errors.md | 18 ++++++++ e2e/react-start/basic/src/routeTree.gen.ts | 21 +++++++++ e2e/react-start/basic/src/routes/__root.tsx | 3 ++ .../basic/src/routes/loader-serialization.tsx | 32 +++++++++++++ .../basic/tests/loader-serialization.spec.ts | 45 +++++++++++++++++++ .../tests/ssr-loader-serialization.test.ts | 38 ++++++++++++++++ 6 files changed, 157 insertions(+) create mode 100644 e2e/react-start/basic/src/routes/loader-serialization.tsx create mode 100644 e2e/react-start/basic/tests/loader-serialization.spec.ts create mode 100644 packages/router-core/tests/ssr-loader-serialization.test.ts diff --git a/docs/start/framework/react/guide/hydration-errors.md b/docs/start/framework/react/guide/hydration-errors.md index fe0596684ab..dce3e4769f3 100644 --- a/docs/start/framework/react/guide/hydration-errors.md +++ b/docs/start/framework/react/guide/hydration-errors.md @@ -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 diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index d5416aa3524..fcbed470bb3 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 @@ -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 @@ -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 @@ -699,6 +708,7 @@ export interface FileRouteTypes { | '/deferred' | '/inline-scripts' | '/links' + | '/loader-serialization' | '/plain-ts-type-assertion' | '/posts' | '/primitive-beforeload-error' @@ -771,6 +781,7 @@ export interface FileRouteTypes { | '/deferred' | '/inline-scripts' | '/links' + | '/loader-serialization' | '/plain-ts-type-assertion' | '/primitive-beforeload-error' | '/scripts' @@ -839,6 +850,7 @@ export interface FileRouteTypes { | '/deferred' | '/inline-scripts' | '/links' + | '/loader-serialization' | '/plain-ts-type-assertion' | '/posts' | '/primitive-beforeload-error' @@ -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 @@ -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' @@ -1731,6 +1751,7 @@ const rootRouteChildren: RootRouteChildren = { DeferredRoute: DeferredRoute, InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, + LoaderSerializationRoute: LoaderSerializationRoute, PlainTsTypeAssertionRoute: PlainTsTypeAssertionRoute, PostsRoute: PostsRouteWithChildren, PrimitiveBeforeloadErrorRoute: PrimitiveBeforeloadErrorRoute, diff --git a/e2e/react-start/basic/src/routes/__root.tsx b/e2e/react-start/basic/src/routes/__root.tsx index d8fc0d3b247..2a141ec92fe 100644 --- a/e2e/react-start/basic/src/routes/__root.tsx +++ b/e2e/react-start/basic/src/routes/__root.tsx @@ -197,6 +197,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { > Client Only {' '} + + Loader serialization + {' '} ({ + 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 ( + <> +

{data.title}

+

{data.publishedAt.toISOString()}

+

{data.labels.get('language')}

+

{Array.from(data.tags).join(', ')}

+

{(data.total + 1n).toString()}

+

+ {data.optional === undefined ? 'absent' : 'present'} +

+ + + ) +} diff --git a/e2e/react-start/basic/tests/loader-serialization.spec.ts b/e2e/react-start/basic/tests/loader-serialization.spec.ts new file mode 100644 index 00000000000..97151ab48c5 --- /dev/null +++ b/e2e/react-start/basic/tests/loader-serialization.spec.ts @@ -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() + }) +} diff --git a/packages/router-core/tests/ssr-loader-serialization.test.ts b/packages/router-core/tests/ssr-loader-serialization.test.ts new file mode 100644 index 00000000000..02e70dd0610 --- /dev/null +++ b/packages/router-core/tests/ssr-loader-serialization.test.ts @@ -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() + } + }) +}