fix(hydrogen): keep analytics and consent bootstrap out of hydration - #3899
Open
div-cowboy wants to merge 2 commits into
Open
fix(hydrogen): keep analytics and consent bootstrap out of hydration#3899div-cowboy wants to merge 2 commits into
div-cowboy wants to merge 2 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/andcustomer-privacy/), share a root cause, and each is a few lines. Reviewing them together is cheaper than twice separately.#3838 —
Analytics.Providerinterrupts hydrationAnalytics.Providerwraps the whole app, so its state sits above every route-level Suspense boundary. It applied three updates urgently right after hydration begins:useShopAnalyticsresolving the (possibly deferred) shop promise into stateCartAnalyticscallingsetCartsinside the cart promise's.thenShopifyAnalyticsonReadycallback firingsetAnalyticsLoaded/setCanTrack/setConsentCollectedWhen 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:
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
After
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
useCustomerPrivacyinstalls watchers so it can overridesetTrackingConsentwith the merchant's config once the CDN assigns the consent API:Object.definePropertythrowsTypeError: Cannot redefine property: Shopifywhen 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:
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
setTrackingConsentoverride by assignment where the container allows it, and marks the API loaded. Consent still works; it just loses the interception point. The foreignwindow.Shopifyis 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 toAnalytics.ProvideranduseCustomerPrivacy.UX impact
window.Shopifyget 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:
canTrack()fail closed until the consent APIs load opens a render window whereShopifyAnalytics.tsx:93-98seesprivacyReady === truewhile the provider still reportscanTrack() === false. That computeshasUserConsent: falseand can clear cookies early — the code comment there explicitly warns against it.shopifyCanTrack()oncustomerPrivacy.cachedConsentinstead would permanently reportfalsefor any visitor with no consent cookie, becauseShopifyCustomerPrivacy.tsx:500-507only seeds that field whentrackingValues.consentis 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
#3575fallback 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 assignswindow.Shopify.customerPrivacywell 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.setTrackingConsentoverride is best-effort. If the container is read-only the un-overridden CDN API is used as-is.startTransitionrequires 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
startTransitioncalls without the fix, 1 with). React fires its ownstartTransitionwhile mountingShopifyAnalytics, which happens exactly when the deferred shop lands, so a spy cannot separate our wrapper from React's in the shop andonReadywindows — 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.
pnpm install && pnpm run build:pkg.pnpm run dev:appto start the skeleton template.entry.client.jsx:mainthe page dies withTypeError: Cannot redefine property: Shopifyand the error boundary renders. On this branch the page renders normally and logs a single[h2:warn:useCustomerPrivacy] Could not observe \Shopify`` warning.#3838 — needs a delayed streaming tail.
CartAnalyticshas a resolution to apply.mainyou getThis 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.