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 = () => ( - +