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:
parent
b260bca24f
commit
a6b87c7123
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
src/main.jsx
15
src/main.jsx
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue