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
2 changes: 1 addition & 1 deletion e2e/react-start/spa-mode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev:e2e": "vite dev",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"start": "npx serve dist/client",
"start": "node spa-server.mjs dist/client",
"test:e2e": "playwright test --project=chromium"
},
"dependencies": {
Expand Down
44 changes: 44 additions & 0 deletions e2e/react-start/spa-mode/spa-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createReadStream, existsSync, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { extname, join, normalize, resolve } from 'node:path'

// Serves the built app the way a static host serves a SPA: an existing file wins, a
// directory's index.html wins, and every other address falls back to the shell with a
// 200. That is `try_files $uri $uri/index.html /index.html` in nginx, and it is what
// makes the prerendered pages and the SPA fallback both reachable, unlike `serve`,
// which either 404s unknown addresses or rewrites every address to the shell.
const root = resolve(process.argv[2] ?? 'dist/client')
const port = Number(process.env.PORT ?? 3000)

const types = {
'.css': 'text/css',
'.html': 'text/html',
'.ico': 'image/x-icon',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
}

const fileFor = (pathname) => {
const candidate = join(root, normalize(pathname))
if (!candidate.startsWith(root)) return join(root, 'index.html')
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
const nested = join(candidate, 'index.html')
if (existsSync(nested)) return nested
return join(root, 'index.html')
}

createServer((req, res) => {
const file = fileFor(new URL(req.url ?? '/', 'http://localhost').pathname)
res.writeHead(200, {
'content-type': types[extname(file)] ?? 'application/octet-stream',
'cache-control': 'no-store',
})
createReadStream(file).pipe(res)
}).listen(port, () => {
console.log(`Listening on http://localhost:${port}`)
})
45 changes: 45 additions & 0 deletions e2e/react-start/spa-mode/tests/hydration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'
import type { Page } from '@playwright/test'

/**
* Loads an address and reports everything the page threw while getting there.
* A hydration mismatch surfaces as an uncaught React error, so the count of
* uncaught errors is the assertion.
*/
async function uncaughtErrorsOnLoad(page: Page, path: string) {
const errors: Array<string> = []
page.on('pageerror', (error) => errors.push(error.message))
await page.goto(path)
await expect(page.getByTestId('root-heading')).toContainText('root')
return errors
}

test.describe('SPA mode hydration', () => {
test(`a prerendered page hydrates without throwing`, async ({
page,
}: {
page: Page
}) => {
expect(await uncaughtErrorsOnLoad(page, '/posts/1')).toEqual([])
})

// The next two fail today: the shell leaves the route area empty inside a
// resolved suspense boundary, and the browser draws into it on its first
// pass whenever the route's component is already available, so React throws
// the document away and renders it again.
// https://github.com/TanStack/router/issues/8473
test.fail(
`the shell hydrates without throwing at the address it was prerendered for`,
async ({ page }: { page: Page }) => {
expect(await uncaughtErrorsOnLoad(page, '/')).toEqual([])
},
)

test.fail(
`the shell hydrates without throwing at an address it was not prerendered for`,
async ({ page }: { page: Page }) => {
expect(await uncaughtErrorsOnLoad(page, '/no-such-address')).toEqual([])
},
)
})