Skip to content

[Analytics.Provider] State updates after hydration force streamed Suspense boundaries to client render ("This Suspense boundary received an update before it finished hydrating") #3838

Description

@Sean-R-Wilson

What is the location of your example repository?

Private production storefront, so no public repo. The reproduction below needs only the skeleton template plus network throttling, and the root cause section points at the exact lines.

Which package or tool is having this issue?

Hydrogen

What version of that package or tool are you using?

@shopify/hydrogen 2026.4.1 (where we hit and fixed it). The same unwrapped state updates are still present in the latest release 2026.4.4 and on current main (bccd91a).

What version of React Router 7 are you using?

7.12.0, with React 18.3.1, @shopify/remix-oxygen 3.0.3, Vite 6.

Steps to Reproduce

Two conditions are required, both common in production and easy to miss on a fast dev machine:

  1. A non-empty cart, so CartAnalytics has a state update to apply when the cart promise resolves.
  2. At least one streamed Suspense boundary whose SSR chunk has not arrived by the time React finishes hydrating the shell. The skeleton template already ships these (the <Await> cart badge in the header, deferred recommended products).

Repro:

  1. Scaffold the skeleton template and keep the default <Analytics.Provider> setup at the root.
  2. Add any product to the cart.
  3. Make the streamed tail of the HTML response arrive late. DevTools "Slow 4G" throttling works if a deferred loader is slow enough (ours was a third-party reviews API). To make it deterministic we put a small TCP proxy in front of the dev server that forwards the first chunk of the HTML response immediately and holds back the remainder for ~3 seconds, so hydration of the shell always finishes while a boundary is still dehydrated.
  4. Reload the page with the console open.

Expected Behavior

Analytics bookkeeping updates inside Analytics.Provider should leave streamed Suspense boundaries alone. Boundaries that are still dehydrated should hydrate normally when their content arrives and keep their server-rendered HTML.

Actual Behavior

React throws its recoverable hydration error, once per dehydrated boundary:

Uncaught Error: This Suspense boundary received an update before it finished hydrating.
This caused the boundary to switch to client rendering. The usual way to fix this is
to wrap the original update in startTransition.

Each affected boundary throws away its server-rendered HTML and re-renders on the client. On our storefront the header cart badge and two deferred homepage sections flashed empty and repainted, and every page view with a warm cart on a slow connection logged this error to monitoring via onRecoverableError.

Root cause

<Analytics.Provider> wraps the whole app (per the docs), so its state sits above every route-level Suspense boundary. Immediately after hydration it fires three synchronous state updates from promise callbacks and effects:

  1. useShopAnalytics resolves the (possibly deferred) shop promise straight into state: Promise.resolve(shopProp).then(setShop)
  2. CartAnalytics calls setCarts inside the cart promise .then
  3. The ShopifyAnalytics onReady callback fires the setAnalyticsLoaded / setCanTrack / setConsentCollected trio

When any of these lands while a boundary below the provider is still dehydrated, React 18 abandons hydration for that boundary and client-renders it. This is exactly the situation React's error message describes, and the fix it suggests (startTransition) applies cleanly here because none of these updates is urgent.

Proposed fix

Wrap the three updates in React.startTransition. Transitions do not interrupt in-progress hydration, so dehydrated boundaries keep their server HTML, and the analytics state still lands via the same effects with no other behavior change.

--- a/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
+++ b/packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
@@
 import {
   type ReactNode,
+  startTransition,
   useEffect,
   useState,
   useMemo,
   createContext,
   useContext,
 } from 'react';
@@
           onReady={() => {
-            setAnalyticsLoaded(true);
-            setCanTrack(
-              customCanTrack ? () => customCanTrack : () => shopifyCanTrack,
-            );
-
-            // Delay loading PerfKit until consent is collected
-            // so that it reads updated tracking values from old cookies.
-            setConsentCollected(true);
+            startTransition(() => {
+              setAnalyticsLoaded(true);
+              setCanTrack(
+                customCanTrack ? () => customCanTrack : () => shopifyCanTrack,
+              );
+
+              // Delay loading PerfKit until consent is collected
+              // so that it reads updated tracking values from old cookies.
+              setConsentCollected(true);
+            });
           }}
@@
   // resolve the shop analytics that could have been deferred
   useEffect(() => {
-    Promise.resolve(shopProp).then(setShop);
+    Promise.resolve(shopProp).then((shop) =>
+      startTransition(() => setShop(shop)),
+    );
     return () => {};
   }, [setShop, shopProp]);
--- a/packages/hydrogen/src/analytics-manager/CartAnalytics.tsx
+++ b/packages/hydrogen/src/analytics-manager/CartAnalytics.tsx
@@
-import {useEffect, useRef} from 'react';
+import {startTransition, useEffect, useRef} from 'react';
@@
-      setCarts(({cart, prevCart}: Carts) => {
-        return updatedCart?.updatedAt !== cart?.updatedAt
-          ? {cart: updatedCart, prevCart: cart}
-          : {cart, prevCart};
-      });
+      startTransition(() => {
+        setCarts(({cart, prevCart}: Carts) => {
+          return updatedCart?.updatedAt !== cart?.updatedAt
+            ? {cart: updatedCart, prevCart: cart}
+            : {cart, prevCart};
+        });
+      });

We run exactly this change in production as a patch-package patch against the dist bundles of 2026.4.1. Verified with the deterministic repro above: the hydration errors are gone, the streamed boundaries keep their server HTML, and all analytics events (page view, cart events, consent ready) still fire.

patch-package diff we run in production (development bundle shown; the same three edits are applied to dist/production/index.js)
--- a/node_modules/@shopify/hydrogen/dist/development/index.js
+++ b/node_modules/@shopify/hydrogen/dist/development/index.js
@@ -1,4 +1,4 @@
-import { createContext, forwardRef, lazy, useContext, useMemo, useEffect, useRef, useState, createElement, Fragment as Fragment$1, Suspense } from 'react';
+import { createContext, forwardRef, lazy, startTransition, useContext, useMemo, useEffect, useRef, useState, createElement, Fragment as Fragment$1, Suspense } from 'react';
 import { createContext as createContext$1, useRevalidator, useFetcher, useFetchers, RouterContextProvider, createRequestHandler as createRequestHandler$1, useNavigation, useLocation, useNavigate, Link, useMatches } from 'react-router';
 import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
@@ -754,9 +754,9 @@ function CartAnalytics({
           return;
         }
       }
-      setCarts(({ cart: cart2, prevCart: prevCart2 }) => {
+      startTransition(() => setCarts(({ cart: cart2, prevCart: prevCart2 }) => {
         return updatedCart?.updatedAt !== cart2?.updatedAt ? { cart: updatedCart, prevCart: cart2 } : { cart: cart2, prevCart: prevCart2 };
-      });
+      }));
     });
     return () => {
     };
@@ -1054,11 +1054,13 @@ function AnalyticsProvider({
       {
         consent,
         onReady: () => {
-          setAnalyticsLoaded(true);
-          setCanTrack(
-            customCanTrack ? () => customCanTrack : () => shopifyCanTrack
-          );
-          setConsentCollected(true);
+          startTransition(() => {
+            setAnalyticsLoaded(true);
+            setCanTrack(
+              customCanTrack ? () => customCanTrack : () => shopifyCanTrack
+            );
+            setConsentCollected(true);
+          });
         },
         domain: cookieDomain
       }
@@ -1078,7 +1080,7 @@ function useAnalytics() {
 function useShopAnalytics(shopProp) {
   const [shop, setShop] = useState(null);
   useEffect(() => {
-    Promise.resolve(shopProp).then(setShop);
+    Promise.resolve(shopProp).then((shop2) => startTransition(() => setShop(shop2)));
     return () => {
     };
   }, [setShop, shopProp]);

Happy to open a PR with the source-level change if the approach looks right to the team.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions