Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/fresh-ducks-hydrate.md
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.
5 changes: 5 additions & 0 deletions e2e/react-start/root-document-hydration/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
test-results
playwright-report
port*.txt
1 change: 1 addition & 0 deletions e2e/react-start/root-document-hydration/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/routeTree.gen.ts
40 changes: 40 additions & 0 deletions e2e/react-start/root-document-hydration/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "tanstack-react-start-e2e-root-document-hydration",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"dev:e2e": "vite dev --port $PORT",
"build": "vite build && tsc --noEmit",
"start": "pnpx srvx --prod -s ../client dist/server/server.js",
"test:e2e:dev": "MODE=dev playwright test --project=chromium",
"test:e2e:prod": "MODE=prod playwright test --project=chromium",
"test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev && pnpm run test:e2e:prod"
},
"dependencies": {
"@tanstack/react-router": "workspace:*",
"@tanstack/react-start": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@tanstack/router-e2e-utils": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"srvx": "^0.11.9",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.0.14"
},
"nx": {
"targets": {
"test:e2e": {
"parallelism": false
}
}
}
}
36 changes: 36 additions & 0 deletions e2e/react-start/root-document-hydration/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const isDev = process.env.MODE === 'dev'
const PORT = await getTestServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],

use: {
baseURL,
},

webServer: {
command: isDev ? 'pnpm dev:e2e' : `pnpm build && PORT=${PORT} pnpm start`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
env: {
PORT: String(PORT),
},
},

projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
],
})
68 changes: 68 additions & 0 deletions e2e/react-start/root-document-hydration/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
6 changes: 6 additions & 0 deletions e2e/react-start/root-document-hydration/src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function getRouter() {
return createRouter({ routeTree })
}
36 changes: 36 additions & 0 deletions e2e/react-start/root-document-hydration/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { useEffect } from 'react'

export const Route = createRootRoute({
pendingComponent: () => null,
component: RootComponent,
})

function RootComponent() {
useEffect(() => {
document.documentElement.dataset.hydrated = 'true'
}, [])

return (
<html>
<head>
<HeadContent />
</head>
<body>
<div data-testid="ssr-node" />
<script
dangerouslySetInnerHTML={{
__html: `window.__ssrNode ??= document.currentScript.previousElementSibling`,
}}
/>
<Outlet />
<Scripts />
</body>
</html>
)
}
5 changes: 5 additions & 0 deletions e2e/react-start/root-document-hydration/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => <main>Root document hydration</main>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'

test('a root pendingComponent preserves the SSR document during hydration', async ({
page,
}) => {
await page.addInitScript(() => {
const state = { removed: false }
;(window as any).__hydration = state

new MutationObserver((records) => {
const ssrNode = (window as any).__ssrNode as Node | undefined
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 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).__ssrNode,
removed: (window as any).__hydration.removed,
replaced:
(window as any).__ssrNode !==
document.querySelector('[data-testid="ssr-node"]'),
})),
).toEqual({
captured: true,
removed: false,
replaced: false,
})
})
18 changes: 18 additions & 0 deletions e2e/react-start/root-document-hydration/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "ES2022",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
}
}
11 changes: 11 additions & 0 deletions e2e/react-start/root-document-hydration/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
resolve: { tsconfigPaths: true },
server: {
port: 3000,
},
plugins: [tanstackStart(), viteReact()],
})
20 changes: 18 additions & 2 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const outletMatchSelectionEqual = (
b: OutletMatchSelection,
) => a[0] === b[0] && a[1] === b[1]

const canWrapRouteInSuspense = (route: AnyRoute, resolvedNoSsr: boolean) =>
!route.isRoot ||
(route.options as RootRouteOptions).shellComponent ||
route.options.wrapInSuspense ||
resolvedNoSsr

export const Match = React.memo(function MatchImpl({
routeId,
}: {
Expand Down Expand Up @@ -89,10 +95,13 @@ function MatchView({
: route.options.notFoundComponent

const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only'
// A root component may render the document itself. Only place its Suspense
// boundary inside an explicit shell, unless the route forcefully opts in.
const ResolvedSuspenseBoundary =
canWrapRouteInSuspense(route, resolvedNoSsr) &&
(route.options.wrapInSuspense ??
pendingElement ??
((route.options.errorComponent as any)?.preload || resolvedNoSsr))
pendingElement ??
((route.options.errorComponent as any)?.preload || resolvedNoSsr))
? React.Suspense
: SafeFragment

Expand Down Expand Up @@ -198,6 +207,13 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}, [key, route.options.component, router.options.defaultComponent])

if (match.status === 'pending') {
const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only'
if (!canWrapRouteInSuspense(route, resolvedNoSsr)) {
// Replacing an SSR document root with pending UI would remove <html>.
// Hydrated matches retain their prior data, so keep rendering it while
// pure CSR roots can safely render the pending element directly.
return router.ssr ? out : renderPending(router, route)
}
if (router._tx) {
throw router._tx[5]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ test('a global not-found destination does not retain the mounted root success',
})

expect(await screen.findByTestId('pending')).toBeVisible()
expect(screen.getByTestId('content')).not.toBeVisible()
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
Comment thread
Sheraff marked this conversation as resolved.
Outdated

await act(async () => {
missingLoader.resolve()
Expand Down
Loading
Loading