From a6b87c71232ecaceb8c4080a82b73a5b997d5abc Mon Sep 17 00:00:00 2001 From: null Date: Thu, 10 Sep 2026 04:37:33 -0500 Subject: [PATCH] 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 . 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 in the first client pass, and the server entry never rendered one, so the client expected a
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) --- docs/architecture/README.md | 26 +++++++++++++++++++------- scripts/prerender.js | 10 ++++++++-- src/main.jsx | 15 +++++++++++++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7b83bac..a25ddf5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 -``, **except** inside an inline `` (an SVG `` is a picture's -label, not the page's) and except `` 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 ``, **except** inside an + inline `` (an SVG `` labels a picture, not the page) and except + `` 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 `` 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 ``, 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 diff --git a/scripts/prerender.js b/scripts/prerender.js index fc18b6f..caac327 100644 --- a/scripts/prerender.js +++ b/scripts/prerender.js @@ -56,8 +56,14 @@ const TEMPLATE_TAGS_TO_STRIP = [ // in the browser and in its streaming renderer, but renderToString leaves // them inline, so the prerenderer performs the same hoist. Leaving them in // would put every title, canonical, and og: tag somewhere crawlers ignore. -const HOISTABLE_TAGS = - /]*>[\s\S]*?<\/title>|]*?\/?>|]*?\/?>|]*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 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 = /]*>[\s\S]*?<\/title>|]*?\/?>|]*?\/?>/g // An inline may carry its own , and microdata rides in // tags. Neither belongs in : hoisting an SVG title gives diff --git a/src/main.jsx b/src/main.jsx index 5641361..a06eac6 100644 --- a/src/main.jsx +++ b/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
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 ? : null +} + // Wrap the router with providers const Root = () => ( - +