Skip to content

fix(hydrogen): keep analytics and consent bootstrap out of hydration - #3899

Open
div-cowboy wants to merge 2 commits into
Shopify:mainfrom
div-cowboy:fix/consent-analytics-hydration
Open

fix(hydrogen): keep analytics and consent bootstrap out of hydration#3899
div-cowboy wants to merge 2 commits into
Shopify:mainfrom
div-cowboy:fix/consent-analytics-hydration

Conversation

@div-cowboy

@div-cowboy div-cowboy commented Aug 1, 2026

Copy link
Copy Markdown

Fixes #3838
Fixes #3575

TL;DR: Two client-side bugs in the analytics and consent bootstrap. Both come from the same place — work that lands at an unpredictable point relative to React hydration. One throws away server HTML on every warm-cart page load; the other crashes the whole page when a browser extension has claimed window.Shopify.

They are grouped because they live in two adjacent modules (analytics-manager/ and customer-privacy/), share a root cause, and each is a few lines. Reviewing them together is cheaper than twice separately.

#3838Analytics.Provider interrupts hydration

Analytics.Provider wraps the whole app, so its state sits above every route-level Suspense boundary. It applied three updates urgently right after hydration begins:

  1. useShopAnalytics resolving the (possibly deferred) shop promise into state
  2. CartAnalytics calling setCarts inside the cart promise's .then
  3. The ShopifyAnalytics onReady callback firing setAnalyticsLoaded / setCanTrack / setConsentCollected

When one of those lands while a streamed boundary below is still dehydrated, React abandons hydration for that boundary, discards its server-rendered HTML, and client-renders it:

Uncaught Error: This Suspense boundary received an update before it finished hydrating.
This caused the boundary to switch to client rendering.

Two conditions are needed and both are ordinary in production: a non-empty cart, and a streamed boundary whose chunk has not arrived by the time the shell finishes hydrating. The skeleton template ships both. The reporter measured ~40 mismatches per page load, with the header cart badge and deferred sections flashing empty and repainting.

Before

Promise.resolve(shopProp).then(setShop);

After

Promise.resolve(shopProp).then((resolvedShop) => {
  startTransition(() => setShop(resolvedShop));
});

Transitions do not interrupt in-progress hydration, so dehydrated boundaries keep their server HTML. None of these updates is urgent — they are analytics bookkeeping. No other behavior changes; the state still lands through the same effects.

Credit to @Sean-R-Wilson's report, which diagnosed this down to the exact three call sites and named the fix.

#3575 — a browser extension can crash the page

useCustomerPrivacy installs watchers so it can override setTrackingConsent with the merchant's config once the CDN assigns the consent API:

Object.defineProperty(window, 'Shopify', {configurable: true, get() {}, set(value) {}});

Object.defineProperty throws TypeError: Cannot redefine property: Shopify when the property already exists and is non-configurable. Browser extensions do exactly that — Urban VPN is the one users keep naming, and four people confirmed it on the issue. The throw escaped the effect and took the entire app down through the router error boundary. Merchants have been shipping copy telling shoppers to disable their VPN extension.

Both watcher installs now go through a helper that never throws:

const installed = tryDefineProperty(window, 'Shopify', {});
if (!installed) watcherFailed.current.customerPrivacy = true;

When a watcher cannot be installed there is no setter to notify us, so a fallback reads the consent APIs directly once the consent script has settled, applies the setTrackingConsent override by assignment where the container allows it, and marks the API loaded. Consent still works; it just loses the interception point. The foreign window.Shopify is left intact rather than clobbered.

The happy path is untouched — when the property is configurable, the watchers install and behave exactly as before.

Developer impact

Includes a patch changeset for @shopify/hydrogen. No public API changes, no new exports, no signature changes. Both fixes are internal to Analytics.Provider and useCustomerPrivacy.

UX impact

  • Streamed sections (header cart badge, deferred homepage content) no longer flash empty and repaint on page loads with a warm cart on a slow connection.
  • Shoppers running an extension that claims window.Shopify get a working storefront instead of an error boundary.

Out of scope

#3752 (customer privacy utilities returning false positives). This was the third issue in the group and I pulled it back out. It is the same bootstrap layer, but the fix is not mechanical and I could not verify it without a real store:

  • Making canTrack() fail closed until the consent APIs load opens a render window where ShopifyAnalytics.tsx:93-98 sees privacyReady === true while the provider still reports canTrack() === false. That computes hasUserConsent: false and can clear cookies early — the code comment there explicitly warns against it.
  • Gating shopifyCanTrack() on customerPrivacy.cachedConsent instead would permanently report false for any visitor with no consent cookie, because ShopifyCustomerPrivacy.tsx:500-507 only seeds that field when trackingValues.consent is already truthy. That would silently kill analytics for merchants who do not show a banner.

Shipping either without confirmation seemed worse than the bug. I have asked on the issue what the intended contract is and will follow up separately.

Risk

  • The #3575 fallback path relies on the consent script's load status rather than a setter. It fires when the script settles, which is when the CDN has already assigned the API synchronously during execution. If a storefront somehow assigns window.Shopify.customerPrivacy well after that, the degraded path would miss it — but that path is only reached when the page would previously have crashed outright, so it is strictly an improvement.
  • In the degraded path the config-bound setTrackingConsent override is best-effort. If the container is read-only the un-overridden CDN API is used as-is.
  • startTransition requires React 18+, which the package already requires (^18.3.1 || ~19.0.3 || ~19.1.4 || ^19.2.3).

How to Test

Both fixes have regression tests, verified by stashing the source change and re-running. One caveat worth stating: for #3838 only the cart case is a true guard (0 startTransition calls without the fix, 1 with). React fires its own startTransition while mounting ShopifyAnalytics, which happens exactly when the deferred shop lands, so a spy cannot separate our wrapper from React's in the shop and onReady windows — an assertion there passes with the fix reverted. The shop case is therefore behavioural coverage only, and the test docblock says so.

For manual verification:

#3575 — this one is easy to see by hand.

  1. Run pnpm install && pnpm run build:pkg.
  2. Run pnpm run dev:app to start the skeleton template.
  3. Open the storefront and run this in the console before the page scripts finish, or add it to the top of entry.client.jsx:
    Object.defineProperty(window, 'Shopify', {configurable: false, writable: true, value: {}});
  4. On main the page dies with TypeError: Cannot redefine property: Shopify and the error boundary renders. On this branch the page renders normally and logs a single [h2:warn:useCustomerPrivacy] Could not observe \Shopify`` warning.
  5. Confirm the cookie banner still appears and accepting consent still works.

#3838 — needs a delayed streaming tail.

  1. Add a product to the cart so CartAnalytics has a resolution to apply.
  2. Make the streamed tail of the HTML arrive late — DevTools "Slow 4G" works if a deferred loader is slow enough; a small TCP proxy that forwards the first chunk and holds the rest for ~3s makes it deterministic.
  3. Reload with the console open.
  4. On main you get This Suspense boundary received an update before it finished hydrating, once per dehydrated boundary, and the cart badge flashes empty. On this branch the console is clean and the badge keeps its server-rendered value.

Two client-side failures in the analytics/consent bootstrap, both caused by
work landing at an unpredictable point relative to React hydration.

Analytics.Provider sits above every route-level Suspense boundary and applied
three post-hydration state updates urgently: the deferred shop resolution, the
deferred cart resolution, and the onReady trio. When any landed while a
streamed boundary was still dehydrated, React abandoned hydration for that
boundary and client-rendered it. Wrapping them in startTransition keeps the
server HTML.

useCustomerPrivacy installed its property watchers with Object.defineProperty,
which throws when window.Shopify is already defined non-configurably by a
browser extension. The throw escaped the effect and took the page down through
the router error boundary. Watchers now degrade instead of throwing, reading
the consent APIs once the consent script settles.

Fixes Shopify#3838
Fixes Shopify#3575

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@div-cowboy
div-cowboy requested a review from a team as a code owner August 1, 2026 19:28
Follow-up cleanup on the same change, no behavior difference.

Memoize `setLoaded` in `useApisLoaded`. It was rebuilt every render, giving
fresh identities to every effect that depends on it, so all the watcher effects
re-ran on each render and survived only on their `observing` guards. Fixing the
cause removes the second latch ref the fallback effect needed and lets its
dependency array be honest.

Move the cart transition to where the state is declared. `setCarts` is now
wrapped once in `AnalyticsProvider`, so the invariant holds for any caller
instead of relying on `CartAnalytics` remembering; that file reverts entirely.

Also: extract `withConfiguredTrackingConsent`, shared by the watcher and the
fallback rather than written twice; hoist both property descriptors to consts
to drop a nesting level; use the package's `warnOnce` instead of a raw
`console.warn`; correct a comment that said the script status was unused.

Narrow the transition test to the assertion that actually discriminates. React
fires its own `startTransition` while mounting ShopifyAnalytics, so the shop and
onReady cases passed with the fix reverted; only the cart case goes 0 -> 1. The
shop case stays as behavioral coverage and the docblock records the limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant