Skip to content

[Bug] Recoverable hydration #418 on every production page load: hydration cursor runs dry at <Scripts>' modulepreload links under hydrateRoot(document) (RR 7.14-7.16, Hydrogen 2026.4.2-2026.4.4) #3878

Description

@08blakejeffrey-web

What is the location of your error?

Every route of a production Hydrogen storefront — deployed Oxygen production (smplpet.com) and local shopify hydrogen preview behave identically.

What version of Hydrogen are you using?

@shopify/hydrogen 2026.4.2 — also reproduced on 2026.4.4. react-router 7.14.0 — also reproduced on 7.16.0 (the version 2026.4.4 pins). react/react-dom 18.3.1, Vite 8.0.12, Node v22.22.2, pnpm.

Steps to reproduce

Production storefront based on the skeleton template (JS, not TS). entry.client.jsx keeps the skeleton hydration shape — hydrateRoot(document, <StrictMode><NonceProvider value={nonce}><HydratedRouter/></NonceProvider>) — plus, as of this week, the scoped log-filter workaround described at the bottom. All instrumentation and error captures below were taken before that filter existed. CSP via createContentSecurityPolicy in entry.server.jsx. React Router config is just presets: [hydrogenPreset()].

  1. shopify hydrogen build
  2. shopify hydrogen preview
  3. Open any route with the console open.

We have not yet reduced this to a fresh skeleton (happy to help do that) — filing now because we have an unusually complete diagnosis from instrumenting react-dom and react-router internals, and want to know whether this is already known.

Expected behavior

Clean hydration; no recoverable errors.

Actual behavior

On every page load of the production build, React logs recoverable hydration errors — #418, and depending on the page also #422/#423/#425. The page always recovers and works (React client-renders), so the visible cost is console noise + a wasted full client re-render. A per-occurrence counter we added shows the mismatch fires ~40 times per page load before recovery completes. Reproduces identically on deployed Oxygen production.

Diagnosis so far (instrumented evidence)

1. The hydration cursor runs out of DOM nodes at <Scripts>' children. We patched react-dom.development.js's warnNonhydratedInstance (the production client bundle resolves the development builds of react-dom/react-router — the package exports point at dist/development with process.env.NODE_ENV statically replaced — so a source patch there lands in the preview bundle; empirically confirmed since our log lines appeared) to log the fiber and DOM cursor at the moment of mismatch:

NONHYDRATED parentTag= 5 parentType= div fiberTag= 5 fiberType= link text= {"rel":"modulepreload","href":"/app/routes/products.$handle...
NONHYDRATED parentTag= 5 parentType= div fiberTag= 5 fiberType= script text= {"nonce":"23f5...","suppressHydrati...
THROW_HYDRATION ... nextDOM= null

i.e. while claiming <Scripts>' modulepreload <link>s / framework <script>s, nextHydratableInstance is already null — the client tree has more of these nodes than remain in the server DOM at the cursor. All of these children carry suppressHydrationWarning, so only the structural shortfall throws.

2. <Scripts> itself intends the same output on both sides. We instrumented the module-level isHydrated flag inside Scripts (chunk-QFMPRPBF): on the client's first hydration render it logs isHydrated=false, matches=2 — identical to SSR — so Scripts renders the same preload list. Taken together with (1), the walker appears to desync somewhere earlier in <body> and the shortfall only becomes visible when it reaches <Scripts>' suppressed children.

3. Who logs it: the root RemixErrorBoundary transiently catches the mismatch. Tracing the console.error caller shows the logging frame is RemixRootDefaultErrorBoundary (rendered by RemixErrorBoundary with isOutsideRemixApp: true) — its render body does console.error(error). That fallback render is then discarded when React's client retry succeeds. A second copy of each error surfaces as an uncaught rethrow from the scheduler task (MessagePort frame), and a third via default onRecoverableErrorreportError.

Ruled out empirically (each tested in isolation, production build)

  • Third-party scripts: reproduces with zero analytics/marketing scripts in the page — during all local repros window.gtag and window.fbq were undefined (GA4 unconfigured locally, Meta Pixel env unset), and the error predates those integrations existing in the app at all.
  • Streaming/deferred data: awaited every deferred root-loader value (cart.get(), getShopAnalytics, isLoggedIn) → SSR HTML contains zero Suspense streaming placeholders → still reproduces.
  • routeDiscovery: reproduces with both lazy (default) and {mode: 'initial'}.
  • Versions: Hydrogen 2026.4.2 + RR 7.14.0 and Hydrogen 2026.4.4 + RR 7.16.0 both reproduce.
  • StrictMode: the tree is wrapped in <StrictMode>, but its double-invoke behaviour is dev-only and the repro is a production build.
  • App code: diffed decoded SSR HTML vs hydrated DOM element-by-element (tags + attributes) — apart from an intentionally client-only cart badge (rendered only after mount; error persists with that component stubbed to null), SSR and client are identical. React.lazy was removed from the tree entirely. CSP nonce warnings are a separate dev-only artifact.
  • <Link prefetch>: all links are prefetch="intent" (post-hydration), one "viewport" inside a closed drawer.

Question

Is this a known issue with <Scripts>/module-preload hydration under hydrateRoot(document) on RR 7.14–7.16? The cursor running dry inside <Scripts>' own suppressed children — while <Scripts> provably renders the same list — points at an earlier <body> desync we've been unable to attribute to app code after exhausting the candidates above.

Current workaround (would love to delete it): a scoped, log-level-only filter in entry.client.jsxonRecoverableError + a window error-event preventDefault() + a console.error wrapper, each matching only the exact minified #418/#422/#423/#425 messages, with a window.__recoveredHydrationNoise counter so the family stays observable in production. SSR/hydration behaviour untouched.

Happy to provide the instrumentation patches as a gist, a preview deployment without the filter, or to help reduce this to a fresh skeleton.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions