From c1907b997668d7abdf128f5ac7f1850da38fbd52 Mon Sep 17 00:00:00 2001 From: Christian Bernier Date: Sun, 20 Sep 2026 10:07:45 -0400 Subject: [PATCH] test(react-start): cover SPA shell hydration in the spa-mode e2e app The spa-mode app is served in its e2e run by `serve dist/client`, which answers an unknown address with its own 404 page. The shell is therefore never loaded at an address it was not prerendered for, which is the normal case for a deployed SPA, and nothing in the suite listens for uncaught errors, so the React #418 that `/` already throws goes unnoticed. `spa-server.mjs` serves the build the way a static host does: an existing file wins, a directory's index.html wins, anything else falls back to the shell with a 200. That keeps the prerendered /posts/1 reachable, which `serve --single` would not, and makes the fallback reachable, which plain `serve` does not. The new spec asserts that a page load throws nothing uncaught. The prerendered page passes. The two shell cases are marked `test.fail`, since both throw React #418 today (issue #8473); removing the marker is how a fix for that issue proves itself. --- e2e/react-start/spa-mode/package.json | 2 +- e2e/react-start/spa-mode/spa-server.mjs | 44 ++++++++++++++++++ .../spa-mode/tests/hydration.spec.ts | 45 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 e2e/react-start/spa-mode/spa-server.mjs create mode 100644 e2e/react-start/spa-mode/tests/hydration.spec.ts diff --git a/e2e/react-start/spa-mode/package.json b/e2e/react-start/spa-mode/package.json index 5281364692a..f8dcb091896 100644 --- a/e2e/react-start/spa-mode/package.json +++ b/e2e/react-start/spa-mode/package.json @@ -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": { diff --git a/e2e/react-start/spa-mode/spa-server.mjs b/e2e/react-start/spa-mode/spa-server.mjs new file mode 100644 index 00000000000..53c7dc89b81 --- /dev/null +++ b/e2e/react-start/spa-mode/spa-server.mjs @@ -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}`) +}) diff --git a/e2e/react-start/spa-mode/tests/hydration.spec.ts b/e2e/react-start/spa-mode/tests/hydration.spec.ts new file mode 100644 index 00000000000..023bf34b422 --- /dev/null +++ b/e2e/react-start/spa-mode/tests/hydration.spec.ts @@ -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 = [] + 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([]) + }, + ) +})