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()].
shopify hydrogen build
shopify hydrogen preview
- 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 onRecoverableError → reportError.
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.jsx — onRecoverableError + 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.
What is the location of your error?
Every route of a production Hydrogen storefront — deployed Oxygen production (smplpet.com) and local
shopify hydrogen previewbehave identically.What version of Hydrogen are you using?
@shopify/hydrogen2026.4.2 — also reproduced on 2026.4.4.react-router7.14.0 — also reproduced on 7.16.0 (the version 2026.4.4 pins).react/react-dom18.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.jsxkeeps 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 viacreateContentSecurityPolicyinentry.server.jsx. React Router config is justpresets: [hydrogenPreset()].shopify hydrogen buildshopify hydrogen previewWe 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 patchedreact-dom.development.js'swarnNonhydratedInstance(the production client bundle resolves the development builds of react-dom/react-router — the packageexportspoint atdist/developmentwithprocess.env.NODE_ENVstatically 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:i.e. while claiming
<Scripts>'modulepreload<link>s / framework<script>s,nextHydratableInstanceis alreadynull— the client tree has more of these nodes than remain in the server DOM at the cursor. All of these children carrysuppressHydrationWarning, so only the structural shortfall throws.2.
<Scripts>itself intends the same output on both sides. We instrumented the module-levelisHydratedflag insideScripts(chunk-QFMPRPBF): on the client's first hydration render it logsisHydrated=false, matches=2— identical to SSR — soScriptsrenders 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
RemixErrorBoundarytransiently catches the mismatch. Tracing theconsole.errorcaller shows the logging frame isRemixRootDefaultErrorBoundary(rendered byRemixErrorBoundarywithisOutsideRemixApp: true) — its render body doesconsole.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 (MessagePortframe), and a third via defaultonRecoverableError→reportError.Ruled out empirically (each tested in isolation, production build)
window.gtagandwindow.fbqwereundefined(GA4 unconfigured locally, Meta Pixel env unset), and the error predates those integrations existing in the app at all.cart.get(),getShopAnalytics,isLoggedIn) → SSR HTML contains zero Suspense streaming placeholders → still reproduces.routeDiscovery: reproduces with bothlazy(default) and{mode: 'initial'}.<StrictMode>, but its double-invoke behaviour is dev-only and the repro is a production build.null), SSR and client are identical.React.lazywas removed from the tree entirely. CSP nonce warnings are a separate dev-only artifact.<Link prefetch>: all links areprefetch="intent"(post-hydration), one"viewport"inside a closed drawer.Question
Is this a known issue with
<Scripts>/module-preload hydration underhydrateRoot(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.jsx—onRecoverableError+ awindowerror-eventpreventDefault()+ aconsole.errorwrapper, each matching only the exact minified#418/#422/#423/#425messages, with awindow.__recoveredHydrationNoisecounter 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.