fix(ui): React was throwing away the prerendered page on every route

Suspected from the code while planning Batch 17, then confirmed in Chromium:
every page logged React error #418, a hydration mismatch. React answers a
mismatch by discarding the server DOM and re-rendering the page on the client.
So the prerender ran, crawlers received it, and every visitor's browser threw it
away and did the work again.

Two causes, both ours:

1. prerender hoisted the JSON-LD scripts out of the body into <head>. React
   hoists only async scripts with a src, so on the client that script stays
   where its component renders it. The DOM and the client's first render
   therefore disagreed on every page that emits structured data. JSON-LD is
   valid anywhere in the document, so it now stays where React puts it. Title,
   meta and link tags are still hoisted, because React hoists those itself.

2. main.jsx rendered sonner's <Toaster> in the first client pass, and the server
   entry never rendered one, so the client expected a <section> the prerendered
   HTML did not have. It mounts after hydration instead, which costs nothing: a
   toast can only follow an interaction.

Measured in a real browser, all 18 sitemap pages, before and after: hydration
errors 18 to 0, other console and page errors 0. Each page keeps its
server-rendered DOM (an h1 stamped before hydration survives), and still has
exactly one head title and one canonical. Structured data is unchanged in
substance: 27 JSON-LD blocks across 19 pages, and OAI-SearchBot still receives
Service, BreadcrumbList and Organization on the contact-center page.

Closes #226.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-09-10 04:37:33 -05:00
parent b260bca24f
commit a6b87c7123
3 changed files with 40 additions and 11 deletions

View File

@ -65,13 +65,25 @@ names its route:
- a template that already carries a canonical, which means the script is being
run over its own output, since `dist/index.html` is both template and home page
Two rules about what it moves. Metadata React leaves inline is hoisted into
`<head>`, **except** inside an inline `<svg>` (an SVG `<title>` is a picture's
label, not the page's) and except `<meta itemprop>` microdata, which belongs
beside the thing it describes. And the hero preload is keyed on the image React
marked with a high fetch priority: keying it on `loading="eager"` matched the
header logo, so for months every page preloaded the logo and no page preloaded
its own hero.
Rules about what it moves, each bought with a defect:
- Metadata React leaves inline is hoisted into `<head>`, **except** inside an
inline `<svg>` (an SVG `<title>` labels a picture, not the page) and except
`<meta itemprop>` microdata, which belongs beside the thing it describes.
- **JSON-LD is never hoisted.** React hoists only async scripts with a `src`, so
on the client the `ld+json` script stays in the body where its component
renders it. Moving it to `<head>` made the prerendered DOM disagree with the
client's first render, and React responded by discarding the whole prerendered
page and re-rendering it, on every page, for months. Structured data is valid
anywhere in the document.
- The hero preload is keyed on the image React marked with a high fetch
priority. Keying it on `loading="eager"` matched the header logo, so every
page preloaded the logo and no page preloaded its own hero.
**Hydration is real again, and it is measurable.** Anything rendered only on the
client, such as sonner's `<Toaster>`, mounts *after* hydration; rendering it in
the first client pass puts a node in the tree that the prerendered HTML never
had, which is a mismatch, and a mismatch costs the entire prerendered page.
### The three boundaries worth knowing about

View File

@ -56,8 +56,14 @@ const TEMPLATE_TAGS_TO_STRIP = [
// <head> in the browser and in its streaming renderer, but renderToString leaves
// them inline, so the prerenderer performs the same hoist. Leaving them in <body>
// would put every title, canonical, and og: tag somewhere crawlers ignore.
const HOISTABLE_TAGS =
/<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>|<script[^>]*type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/g
//
// JSON-LD is deliberately NOT in this list. React hoists only async scripts with
// a src, so on the client the ld+json script stays where its component renders
// it, in the body. Moving it to <head> here made the prerendered DOM disagree
// with the client's first render, and React threw out the whole prerendered page
// and re-rendered it (error #418, on every page). Structured data is valid
// anywhere in the document, so the honest fix is to leave it alone.
const HOISTABLE_TAGS = /<title[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*?\/?>|<link\b[^>]*?\/?>/g
// An inline <svg> may carry its own <title>, and microdata rides in
// <meta itemprop> tags. Neither belongs in <head>: hoisting an SVG title gives

View File

@ -1,4 +1,4 @@
import { StrictMode } from 'react'
import { StrictMode, useEffect, useState } from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
@ -7,13 +7,24 @@ import router from './router.jsx'
import App from './App.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
// sonner renders a <section> that the prerendered HTML does not contain, since
// the server entry mounts the routes and nothing else. Rendering it on the first
// client pass is therefore a hydration mismatch, and React answers a mismatch by
// discarding the prerendered DOM and re-rendering the page. Mounting it after
// hydration costs nothing: a toast can only ever follow an interaction.
const ToasterAfterHydration = () => {
const [hydrated, setHydrated] = useState(false)
useEffect(() => setHydrated(true), [])
return hydrated ? <Toaster position="top-right" /> : null
}
// Wrap the router with providers
const Root = () => (
<StrictMode>
<HelmetProvider>
<ErrorBoundary>
<RouterProvider router={router} />
<Toaster position="top-right" />
<ToasterAfterHydration />
</ErrorBoundary>
</HelmetProvider>
</StrictMode>