diff --git a/.changeset/hydrate-missing-bootstrap-fallback.md b/.changeset/hydrate-missing-bootstrap-fallback.md new file mode 100644 index 0000000000..2170dea0d2 --- /dev/null +++ b/.changeset/hydrate-missing-bootstrap-fallback.md @@ -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. diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 2aa5358ac0..0ad0fc8751 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -38,14 +38,22 @@ function hydrateMatch( } export async function hydrate(router: AnyRouter): Promise { + // 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 @@ -64,12 +72,12 @@ export async function hydrate(router: AnyRouter): Promise { 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 diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index efac230e49..a82b506af3 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -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(), @@ -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 () => {