Repository navigation
fix(react-router): avoid Suspense above root documents #8055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
7edb8b3
794368e
a3d57df
e7fcb00
00d71dd
b4ce166
0184d2f
cc4dac6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/react-router': patch | ||
| --- | ||
|
|
||
| Avoid wrapping root components that may render the HTML document in a Suspense boundary during SSR and hydration. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { expect } from '@playwright/test' | ||
| import { test } from '@tanstack/router-e2e-utils' | ||
|
|
||
| test('#8053: a root pendingComponent preserves the SSR document during hydration', async ({ | ||
| page, | ||
| }) => { | ||
| await page.addInitScript(() => { | ||
| const state = { removed: false } | ||
| ;(window as any).__issue8053Hydration = state | ||
|
|
||
| new MutationObserver((records) => { | ||
| const ssrNode = (window as any).__issue8053SsrNode as Node | undefined | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Replace
Proposed fix+declare global {
+ interface Window {
+ __issue8053Hydration?: { removed: boolean }
+ __issue8053SsrNode?: Node
+ }
+}
+
- ;(window as any).__issue8053Hydration = state
+ window.__issue8053Hydration = state
...
- const ssrNode = (window as any).__issue8053SsrNode as Node | undefined
+ const ssrNode = window.__issue8053SsrNode
...
- captured: !!(window as any).__issue8053SsrNode,
- removed: (window as any).__issue8053Hydration.removed,
+ captured: !!window.__issue8053SsrNode,
+ removed: window.__issue8053Hydration!.removed,As per coding guidelines: Also applies to: 45-50 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| if (!ssrNode) { | ||
| return | ||
| } | ||
|
|
||
| for (const record of records) { | ||
| for (const removedNode of record.removedNodes) { | ||
| if ( | ||
| removedNode === ssrNode || | ||
| (removedNode instanceof Element && removedNode.contains(ssrNode)) | ||
| ) { | ||
| state.removed = true | ||
| } | ||
| } | ||
| } | ||
| }).observe(document, { childList: true, subtree: true }) | ||
| }) | ||
|
|
||
| const hydrationErrors: Array<string> = [] | ||
|
Sheraff marked this conversation as resolved.
Resolved
|
||
| page.on('console', (message) => { | ||
| if (message.type() === 'error') { | ||
| hydrationErrors.push(message.text()) | ||
| } | ||
| }) | ||
| page.on('pageerror', (error) => { | ||
| hydrationErrors.push(error.message) | ||
| }) | ||
|
|
||
| const response = await page.goto('/') | ||
| expect(response?.ok()).toBe(true) | ||
| await expect(page.locator('html')).toHaveAttribute('data-hydrated', 'true') | ||
|
|
||
| expect( | ||
| await page.evaluate(() => ({ | ||
| captured: !!(window as any).__issue8053SsrNode, | ||
| removed: (window as any).__issue8053Hydration.removed, | ||
| replaced: | ||
| (window as any).__issue8053SsrNode !== | ||
| document.querySelector('[data-testid="issue-8053-ssr-node"]'), | ||
| })), | ||
| ).toEqual({ | ||
| captured: true, | ||
| removed: false, | ||
| replaced: false, | ||
| }) | ||
| expect( | ||
| hydrationErrors.filter((error) => | ||
| /hydrat|did not match|server rendered HTML/i.test(error), | ||
| ), | ||
| ).toEqual([]) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { expect, test } from 'vitest' | ||
| import { Outlet, createRootRoute, createRoute, createRouter } from '../src' | ||
| import { | ||
| RouterServer, | ||
| createRequestHandler, | ||
| renderRouterToStream, | ||
| } from '../src/ssr/server' | ||
|
|
||
| // https://github.com/TanStack/router/issues/8053 | ||
| test('a root pendingComponent does not wrap the SSR document in Suspense', async () => { | ||
| const rootRoute = createRootRoute({ | ||
| pendingComponent: () => null, | ||
| component: () => ( | ||
| <html> | ||
| <head> | ||
| <title>Root document</title> | ||
| </head> | ||
| <body> | ||
| <Outlet /> | ||
| </body> | ||
| </html> | ||
| ), | ||
| }) | ||
| const indexRoute = createRoute({ | ||
| getParentRoute: () => rootRoute, | ||
| path: '/', | ||
| component: () => <main>Root content</main>, | ||
| }) | ||
| const handler = createRequestHandler({ | ||
| request: new Request('http://localhost/'), | ||
| createRouter: () => | ||
| createRouter({ | ||
| routeTree: rootRoute.addChildren([indexRoute]), | ||
| isServer: true, | ||
| }), | ||
| }) | ||
|
|
||
| const response = await handler(({ request, router, responseHeaders }) => | ||
| renderRouterToStream({ | ||
| request, | ||
| router, | ||
| responseHeaders, | ||
| children: <RouterServer router={router} />, | ||
| }), | ||
| ) | ||
| const html = await response.text() | ||
| const serverDocument = new DOMParser().parseFromString(html, 'text/html') | ||
|
|
||
| expect(response.status).toBe(200) | ||
| expect(serverDocument.body.textContent).toContain('Root content') | ||
| expect(html).not.toContain('<!--html--><!--head--><!--body-->') | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.