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
7 changes: 7 additions & 0 deletions .changeset/hydrate-missing-bootstrap-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/router-core': patch
---

fall back to client-side rendering when SSR bootstrap data is missing during hydration

`hydrate()` previously threw `Invariant failed` (crashing the whole app through the error boundary) when `window.$_TSR` or `window.$_TSR.router` was absent. That data can legitimately be missing when the streamed HTML is truncated or the inline bootstrap script never runs — aborted navigations, crawlers, in-app webviews, and CSP/extension-blocked inline scripts. Hydration now bails out and lets the client render from scratch (the Transitioner runs `router.load()` on mount when no SSR state is present) instead of taking down the page.
20 changes: 14 additions & 6 deletions packages/router-core/src/ssr/ssr-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,22 @@ function hydrateMatch(
}

export async function hydrate(router: AnyRouter): Promise<any> {
// The server injects SSR bootstrap data via an inline script that runs while
// the streamed HTML is parsed. It can be absent at hydration time when the
// stream was truncated or the inline script never executed (aborted
// navigations, crawlers, in-app webviews, CSP/extension-blocked inline
// scripts). Bail out instead of throwing a fatal invariant that takes down
// the whole app: leaving `router.ssr` unset makes the client render from
// scratch, since the Transitioner runs `router.load()` on mount when it sees
// no SSR state.
if (!window.$_TSR) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
'Invariant failed: Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!',
console.warn(
'TanStack Router: no SSR bootstrap data found on window.$_TSR; falling back to client-side rendering.',
)
}

invariant()
return
}

const serializationAdapters = router.options.serializationAdapters as
Expand All @@ -64,12 +72,12 @@ export async function hydrate(router: AnyRouter): Promise<any> {

if (!window.$_TSR.router) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
'Invariant failed: Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!',
console.warn(
'TanStack Router: no dehydrated router found on window.$_TSR.router; falling back to client-side rendering.',
)
}

invariant()
return
}

const dehydratedRouter = window.$_TSR.router
Expand Down
29 changes: 21 additions & 8 deletions packages/router-core/tests/hydrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,24 @@ describe('hydrate', () => {
delete (global as any).window
})

it('should throw error if window.$_TSR is not available', async () => {
await expect(hydrate(mockRouter)).rejects.toThrow(
'Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!',
)
it('should fall back to client-side rendering if window.$_TSR is not available', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const matchRoutesSpy = vi.spyOn(mockRouter, 'matchRoutes')

await expect(hydrate(mockRouter)).resolves.toBeUndefined()

// No SSR state is applied, so the client renders from scratch via the
// Transitioner's router.load() on mount (it only skips that when router.ssr
// is set).
expect(matchRoutesSpy).not.toHaveBeenCalled()
expect(mockRouter.ssr).toBeUndefined()
expect(warnSpy).toHaveBeenCalled()
})

it('should throw error if window.$_TSR.router is not available', async () => {
it('should fall back to client-side rendering if window.$_TSR.router is not available', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const matchRoutesSpy = vi.spyOn(mockRouter, 'matchRoutes')

mockWindow.$_TSR = {
c: vi.fn(),
p: vi.fn(),
Expand All @@ -71,9 +82,11 @@ describe('hydrate', () => {
// router is missing
} as any

await expect(hydrate(mockRouter)).rejects.toThrow(
'Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!',
)
await expect(hydrate(mockRouter)).resolves.toBeUndefined()

expect(matchRoutesSpy).not.toHaveBeenCalled()
expect(mockRouter.ssr).toBeUndefined()
expect(warnSpy).toHaveBeenCalled()
})

it('should initialize serialization adapters when provided', async () => {
Expand Down