@shopify/hydrogen 2026.4.5 → 2026.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
- import { createContext, forwardRef, lazy, useContext, useMemo, useEffect, useRef, useState, createElement, Fragment as Fragment$1, Suspense } from 'react';
1
+ import { createContext, forwardRef, lazy, useContext, useRef, useState, useMemo, useEffect, useCallback, startTransition, createElement, Fragment as Fragment$1, Suspense } from 'react';
2
2
  import { createContext as createContext$1, useRevalidator, useFetcher, useFetchers, RouterContextProvider, createRequestHandler as createRequestHandler$1, useNavigation, useLocation, useNavigate, Link, useMatches } from 'react-router';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
- import { useLoadScript, useShopifyCookies, getTrackingValues, createStorefrontClient as createStorefrontClient$1, SHOPIFY_STOREFRONT_ID_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, SHOPIFY_STOREFRONT_S_HEADER, SHOPIFY_UNIQUE_TOKEN_HEADER, SHOPIFY_VISIT_TOKEN_HEADER, flattenConnection, RichText as RichText$1, ShopPayButton as ShopPayButton$1, parseGid, sendShopifyAnalytics, AnalyticsEventName, AnalyticsPageType, getClientBrowserParameters } from '@shopify/hydrogen-react';
4
+ import { useLoadScript, createStorefrontClient as createStorefrontClient$1, SHOPIFY_STOREFRONT_ID_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, SHOPIFY_STOREFRONT_S_HEADER, SHOPIFY_UNIQUE_TOKEN_HEADER, SHOPIFY_VISIT_TOKEN_HEADER, flattenConnection, RichText as RichText$1, ShopPayButton as ShopPayButton$1, parseGid, getTrackingValues, sendShopifyAnalytics, AnalyticsEventName, AnalyticsPageType, getClientBrowserParameters } from '@shopify/hydrogen-react';
5
5
  export { AnalyticsEventName, AnalyticsPageType, ExternalVideo, IMAGE_FRAGMENT, Image, MediaFile, ModelViewer, Money, ShopifySalesChannel, Video, customerAccountApiCustomScalars, decodeEncodedVariant, flattenConnection, getAdjacentAndFirstAvailableVariants, getClientBrowserParameters, getProductOptions, getShopifyCookies, getTrackingValues, isOptionValueCombinationInEncodedVariant, mapSelectedProductOptionToObject, parseGid, parseMetafield, sendShopifyAnalytics, storefrontApiCustomScalars, useLoadScript, useMoney, useSelectedOptionInUrlParam, useShopifyCookies } from '@shopify/hydrogen-react';
6
- import { useLoadScript as useLoadScript$1 } from '@shopify/hydrogen-react/load-script';
6
+ import { loadScript } from '@shopify/hydrogen-react/load-script';
7
7
  import { createGraphQLClient } from '@shopify/graphql-client';
8
8
  import { parse, stringify } from 'worktop/cookie';
9
9
  import cspBuilder from 'content-security-policy-builder';
@@ -59,6 +59,25 @@ function AnalyticsCustomView(props) {
59
59
  return /* @__PURE__ */ jsx(AnalyticsView, { ...props });
60
60
  }
61
61
 
62
+ // src/analytics-manager/consent.ts
63
+ function hasAnalyticsConsent() {
64
+ try {
65
+ const privacy = window.Shopify?.customerPrivacy;
66
+ if (privacy?.consentStatus !== "loaded") return false;
67
+ if (privacy.currentVisitorConsent?.().analytics === "no") return false;
68
+ return privacy.analyticsProcessingAllowed?.() ?? false;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+ function shouldWaitForPrivacyBanner(privacy) {
74
+ if (!privacy.shouldShowBanner()) return false;
75
+ const consent = privacy.currentVisitorConsent();
76
+ return ![consent.marketing, consent.analytics, consent.preferences].some(
77
+ (value) => value === "yes" || value === "no"
78
+ );
79
+ }
80
+
62
81
  // src/analytics-manager/events.ts
63
82
  var AnalyticsEvent = {
64
83
  // Views
@@ -74,61 +93,6 @@ var AnalyticsEvent = {
74
93
  // Custom
75
94
  CUSTOM_EVENT: `custom_`
76
95
  };
77
-
78
- // src/constants.ts
79
- var STOREFRONT_REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID";
80
- var STOREFRONT_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
81
- var SDK_VARIANT_HEADER = "X-SDK-Variant";
82
- var SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
83
- var SDK_VERSION_HEADER = "X-SDK-Version";
84
- var SHOPIFY_CLIENT_IP_HEADER = "X-Shopify-Client-IP";
85
- var SHOPIFY_CLIENT_IP_SIG_HEADER = "X-Shopify-Client-IP-Sig";
86
- var HYDROGEN_SFAPI_PROXY_KEY = "_sfapi_proxy";
87
- var HYDROGEN_SERVER_TRACKING_KEY = "_server_tracking";
88
-
89
- // src/utils/server-timing.ts
90
- function buildServerTimingHeader(values) {
91
- return Object.entries(values).map(([key, value]) => value ? `${key};desc=${value}` : void 0).filter(Boolean).join(", ");
92
- }
93
- function appendServerTimingHeader(response, values) {
94
- const header = typeof values === "string" ? values : buildServerTimingHeader(values);
95
- if (header) {
96
- response.headers.append("Server-Timing", header);
97
- }
98
- }
99
- var trackedTimings = ["_y", "_s", "_cmp"];
100
- function extractServerTimingHeader(serverTimingHeader) {
101
- const values = {};
102
- if (!serverTimingHeader) return values;
103
- const re = new RegExp(
104
- `\\b(${trackedTimings.join("|")});desc="?([^",]+)"?`,
105
- "g"
106
- );
107
- let match;
108
- while ((match = re.exec(serverTimingHeader)) !== null) {
109
- values[match[1]] = match[2];
110
- }
111
- return values;
112
- }
113
- function hasServerTimingInNavigationEntry(key) {
114
- if (typeof window === "undefined") return false;
115
- try {
116
- const navigationEntry = window.performance.getEntriesByType(
117
- "navigation"
118
- )[0];
119
- return !!navigationEntry?.serverTiming?.some((entry) => entry.name === key);
120
- } catch (e) {
121
- return false;
122
- }
123
- }
124
- function isSfapiProxyEnabled() {
125
- return hasServerTimingInNavigationEntry(HYDROGEN_SFAPI_PROXY_KEY);
126
- }
127
- function hasServerReturnedTrackingValues() {
128
- return hasServerTimingInNavigationEntry(HYDROGEN_SERVER_TRACKING_KEY);
129
- }
130
-
131
- // src/customer-privacy/ShopifyCustomerPrivacy.tsx
132
96
  var CONSENT_API = "https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js";
133
97
  var CONSENT_API_WITH_BANNER = "https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js";
134
98
  function logMissingConfig(fieldName) {
@@ -136,6 +100,11 @@ function logMissingConfig(fieldName) {
136
100
  `[h2:error:useCustomerPrivacy] Unable to setup Customer Privacy API: Missing consent.${fieldName} configuration.`
137
101
  );
138
102
  }
103
+ function logIgnoredCrossDomainConsent() {
104
+ console.warn(
105
+ "[h2:warn:useCustomerPrivacy] `sameDomainForStorefrontApi: false` is ignored. Consent and analytics require the same-origin Storefront API proxy included in Hydrogen's `createRequestHandler`, so consent requests always use it. Remove this option. See https://shopify.dev/changelog/posts/tracking-cookie-deprecation-hydrogen"
106
+ );
107
+ }
139
108
  function useCustomerPrivacy(props) {
140
109
  const {
141
110
  withPrivacyBanner = false,
@@ -147,27 +116,15 @@ function useCustomerPrivacy(props) {
147
116
  locale,
148
117
  sameDomainForStorefrontApi
149
118
  } = props;
150
- const hasSfapiProxy = useMemo(
151
- () => sameDomainForStorefrontApi ?? isSfapiProxyEnabled(),
152
- [sameDomainForStorefrontApi]
153
- );
154
- const fetchTrackingValuesFromBrowser = useMemo(
155
- () => hasSfapiProxy && !hasServerReturnedTrackingValues(),
156
- [hasSfapiProxy]
157
- );
158
- const cookiesReady = useShopifyCookies({
159
- fetchTrackingValues: fetchTrackingValuesFromBrowser,
160
- storefrontAccessToken,
161
- ignoreDeprecatedCookies: true
162
- });
163
- const initialTrackingValues = useMemo(getTrackingValues, [cookiesReady]);
164
119
  const { revalidate } = useRevalidator();
165
- useLoadScript$1(withPrivacyBanner ? CONSENT_API_WITH_BANNER : CONSENT_API, {
166
- attributes: {
167
- id: "customer-privacy-api"
168
- }
169
- });
170
- const { observing, setLoaded, apisLoaded } = useApisLoaded({ withPrivacyBanner });
120
+ const callbacks = useRef({ onReady, onVisitorConsentCollected, revalidate });
121
+ callbacks.current = { onReady, onVisitorConsentCollected, revalidate };
122
+ const notifiedReady = useRef(false);
123
+ const previousTrackingValues = useRef();
124
+ const [apis, setApis] = useState(() => ({
125
+ customerPrivacy: getCustomerPrivacy(),
126
+ privacyBanner: getPrivacyBanner()
127
+ }));
171
128
  const config = useMemo(() => {
172
129
  if (!checkoutDomain) logMissingConfig("checkoutDomain");
173
130
  if (!storefrontAccessToken) logMissingConfig("storefrontAccessToken");
@@ -176,196 +133,155 @@ function useCustomerPrivacy(props) {
176
133
  `[h2:error:useCustomerPrivacy] It looks like you passed a private access token, make sure to use the public token`
177
134
  );
178
135
  }
136
+ if (sameDomainForStorefrontApi === false) logIgnoredCrossDomainConsent();
179
137
  const commonAncestorDomain = parseStoreDomain(checkoutDomain);
180
- const sfapiDomain = (
181
- // Check if standard route proxy is enabled in Hydrogen server
182
- // to use it instead of doing a cross-origin request to checkout.
183
- hasSfapiProxy && typeof window !== "undefined" ? window.location.host : checkoutDomain
184
- );
185
- const config2 = {
186
- // This domain is used to send requests to SFAPI for setting and getting consent.
187
- checkoutRootDomain: sfapiDomain,
188
- // Prefix with a dot to ensure this domain is different from checkoutRootDomain.
189
- // This will ensure old cookies are set for a cross-subdomain checkout setup
190
- // so that we keep backward compatibility until new cookies are rolled out.
191
- // Once consent-tracking-api is updated to not rely on cookies anymore, we can remove this.
138
+ return {
139
+ // Always the same-origin proxy: cross-origin consent requests carry no
140
+ // cookies, so consent and visitor tokens would reset on every page load
141
+ // (and an opt-out would be forgotten). The server render never sends
142
+ // consent requests, so it keeps the checkout domain.
143
+ checkoutRootDomain: typeof window !== "undefined" ? window.location.host : checkoutDomain,
192
144
  storefrontRootDomain: commonAncestorDomain ? "." + commonAncestorDomain : void 0,
193
145
  storefrontAccessToken,
194
146
  country,
195
147
  locale
196
148
  };
197
- return config2;
198
149
  }, [
199
- logMissingConfig,
150
+ sameDomainForStorefrontApi,
200
151
  checkoutDomain,
201
152
  storefrontAccessToken,
202
153
  country,
203
154
  locale
204
155
  ]);
205
156
  useEffect(() => {
206
- const consentCollectedHandler = (event) => {
207
- const latestTrackingValues = getTrackingValues();
208
- if (initialTrackingValues.visitToken !== latestTrackingValues.visitToken || initialTrackingValues.uniqueToken !== latestTrackingValues.uniqueToken) {
209
- revalidate().catch(() => {
157
+ let active = true;
158
+ const shopify = window.Shopify ??= {};
159
+ const privacy = shopify.customerPrivacy ??= {};
160
+ privacy.config = {
161
+ ...privacy.config,
162
+ isHeadless: true,
163
+ asyncConsent: true,
164
+ asyncVisitorState: true,
165
+ consentDomain: config.checkoutRootDomain,
166
+ storefrontAccessToken: config.storefrontAccessToken,
167
+ debug: {
168
+ ...privacy.config?.debug,
169
+ hydrogen: HYDROGEN_DEBUG_METADATA
170
+ }
171
+ };
172
+ if (config.country) shopify.country = config.country;
173
+ if (config.locale) shopify.locale = config.locale.toLowerCase();
174
+ const updateTrackingValues = () => {
175
+ const latest = getTrackingValues();
176
+ const previous = previousTrackingValues.current;
177
+ previousTrackingValues.current = latest;
178
+ if (previous && (previous.visitToken !== latest.visitToken || previous.uniqueToken !== latest.uniqueToken)) {
179
+ callbacks.current.revalidate().catch(() => {
210
180
  console.warn(
211
181
  "[h2:warn:useCustomerPrivacy] Revalidation failed after consent change."
212
182
  );
213
183
  });
214
184
  }
215
- if (onVisitorConsentCollected) {
216
- const customerPrivacy = getCustomerPrivacy();
217
- if (customerPrivacy?.shouldShowBanner()) {
218
- const consentValues = customerPrivacy.currentVisitorConsent();
219
- if (consentValues) {
220
- const NO_VALUE = "";
221
- const noInteraction = consentValues.marketing === NO_VALUE && consentValues.analytics === NO_VALUE && consentValues.preferences === NO_VALUE;
222
- if (noInteraction) {
223
- return;
224
- }
225
- }
226
- }
227
- onVisitorConsentCollected(event.detail);
185
+ };
186
+ const checkReady = () => {
187
+ if (!active) return false;
188
+ const customerPrivacy = getCustomerPrivacy();
189
+ const privacyBanner = getPrivacyBanner();
190
+ if (customerPrivacy) configureCustomerPrivacy(customerPrivacy, config);
191
+ if (withPrivacyBanner && privacyBanner) {
192
+ configurePrivacyBanner(privacyBanner, config);
193
+ }
194
+ setApis(
195
+ (previous) => previous.customerPrivacy === customerPrivacy && previous.privacyBanner === privacyBanner ? previous : { customerPrivacy, privacyBanner }
196
+ );
197
+ if (customerPrivacy?.consentStatus !== "loaded" || withPrivacyBanner && !privacyBanner) {
198
+ return false;
228
199
  }
200
+ if (!notifiedReady.current) {
201
+ notifiedReady.current = true;
202
+ updateTrackingValues();
203
+ emitCustomerPrivacyApiLoaded(customerPrivacy);
204
+ callbacks.current.onReady?.();
205
+ }
206
+ return true;
207
+ };
208
+ const consentCollectedHandler = (event) => {
209
+ if (!checkReady()) return;
210
+ updateTrackingValues();
211
+ callbacks.current.onVisitorConsentCollected?.(event.detail);
229
212
  };
213
+ document.addEventListener("consentTrackingApiLoaded", checkReady);
230
214
  document.addEventListener(
231
215
  "visitorConsentCollected",
232
216
  consentCollectedHandler
233
217
  );
218
+ if (!checkReady()) {
219
+ void loadScript(
220
+ withPrivacyBanner ? CONSENT_API_WITH_BANNER : CONSENT_API,
221
+ {
222
+ attributes: { id: "customer-privacy-api" }
223
+ }
224
+ ).then(checkReady, () => {
225
+ if (active) {
226
+ console.error(
227
+ "[h2:error:useCustomerPrivacy] Unable to load the Customer Privacy API."
228
+ );
229
+ }
230
+ });
231
+ }
234
232
  return () => {
233
+ active = false;
234
+ document.removeEventListener("consentTrackingApiLoaded", checkReady);
235
235
  document.removeEventListener(
236
236
  "visitorConsentCollected",
237
237
  consentCollectedHandler
238
238
  );
239
239
  };
240
- }, [onVisitorConsentCollected]);
241
- useEffect(() => {
242
- if (!withPrivacyBanner || observing.current.privacyBanner) return;
243
- observing.current.privacyBanner = true;
244
- let customPrivacyBanner = window.privacyBanner || void 0;
245
- const privacyBannerWatcher = {
246
- configurable: true,
247
- get() {
248
- return customPrivacyBanner;
249
- },
250
- set(value) {
251
- if (typeof value === "object" && value !== null && "showPreferences" in value && "loadBanner" in value) {
252
- customPrivacyBanner = overridePrivacyBannerMethods({
253
- privacyBanner: value,
254
- config
255
- });
256
- setLoaded.privacyBanner();
257
- }
258
- }
259
- };
260
- Object.defineProperty(window, "privacyBanner", privacyBannerWatcher);
261
- }, [
262
- withPrivacyBanner,
263
- config,
264
- overridePrivacyBannerMethods,
265
- setLoaded.privacyBanner
266
- ]);
267
- useEffect(() => {
268
- if (observing.current.customerPrivacy) return;
269
- observing.current.customerPrivacy = true;
270
- let backendConsentStub = null;
271
- let fullCustomerPrivacy = null;
272
- let customShopify = window.Shopify || void 0;
273
- Object.defineProperty(window, "Shopify", {
274
- configurable: true,
275
- get() {
276
- return customShopify;
277
- },
278
- set(value) {
279
- if (typeof value === "object" && value !== null && Object.keys(value).length === 0) {
280
- customShopify = value;
281
- backendConsentStub = { backendConsentEnabled: true };
282
- Object.defineProperty(window.Shopify, "customerPrivacy", {
283
- configurable: true,
284
- get() {
285
- return fullCustomerPrivacy ?? backendConsentStub;
286
- },
287
- set(value2) {
288
- if (typeof value2 === "object" && value2 !== null && "setTrackingConsent" in value2) {
289
- const customerPrivacy = value2;
290
- fullCustomerPrivacy = {
291
- ...customerPrivacy,
292
- // Note: this method is not used by the privacy-banner,
293
- // it bundles its own setTrackingConsent.
294
- setTrackingConsent: overrideCustomerPrivacySetTrackingConsent(
295
- { customerPrivacy, config }
296
- )
297
- };
298
- customShopify = {
299
- ...customShopify,
300
- customerPrivacy: fullCustomerPrivacy
301
- };
302
- setLoaded.customerPrivacy();
303
- }
304
- }
305
- });
306
- }
307
- }
308
- });
309
- }, [
310
- config,
311
- overrideCustomerPrivacySetTrackingConsent,
312
- setLoaded.customerPrivacy
313
- ]);
314
- useEffect(() => {
315
- if (!apisLoaded || !cookiesReady) return;
316
- const customerPrivacy = getCustomerPrivacy();
317
- if (customerPrivacy && !customerPrivacy.cachedConsent) {
318
- const trackingValues = getTrackingValues();
319
- if (trackingValues.consent) {
320
- customerPrivacy.cachedConsent = trackingValues.consent;
321
- }
322
- }
323
- if (withPrivacyBanner) {
324
- const privacyBanner = getPrivacyBanner();
325
- if (privacyBanner) {
326
- privacyBanner.loadBanner(config);
327
- }
328
- }
329
- emitCustomerPrivacyApiLoaded();
330
- onReady?.();
331
- }, [apisLoaded, cookiesReady]);
332
- const result = {
333
- customerPrivacy: getCustomerPrivacy()
240
+ }, [config, withPrivacyBanner]);
241
+ return {
242
+ customerPrivacy: apis.customerPrivacy,
243
+ ...withPrivacyBanner ? { privacyBanner: apis.privacyBanner } : {}
334
244
  };
335
- if (withPrivacyBanner) {
336
- result.privacyBanner = getPrivacyBanner();
337
- }
338
- return result;
339
245
  }
340
- var hasEmitted = false;
341
- function emitCustomerPrivacyApiLoaded() {
342
- if (hasEmitted) return;
343
- hasEmitted = true;
344
- const event = new CustomEvent("shopifyCustomerPrivacyApiLoaded");
345
- document.dispatchEvent(event);
346
- }
347
- function useApisLoaded({ withPrivacyBanner }) {
348
- const observing = useRef({ customerPrivacy: false, privacyBanner: false });
349
- const [apisLoadedArray, setApisLoaded] = useState(
350
- withPrivacyBanner ? [false, false] : [false]
351
- );
352
- const apisLoaded = apisLoadedArray.every(Boolean);
353
- const setLoaded = {
354
- customerPrivacy: () => {
355
- if (withPrivacyBanner) {
356
- setApisLoaded((prev) => [true, prev[1]]);
357
- } else {
358
- setApisLoaded(() => [true]);
359
- }
360
- },
361
- privacyBanner: () => {
362
- if (!withPrivacyBanner) {
363
- return;
364
- }
365
- setApisLoaded((prev) => [prev[0], true]);
366
- }
246
+ var readyApis = /* @__PURE__ */ new WeakSet();
247
+ function emitCustomerPrivacyApiLoaded(customerPrivacy) {
248
+ if (readyApis.has(customerPrivacy)) return;
249
+ readyApis.add(customerPrivacy);
250
+ document.dispatchEvent(new CustomEvent("shopifyCustomerPrivacyApiLoaded"));
251
+ }
252
+ var HYDROGEN_DEBUG_METADATA = { generation: 2, serverTiming: false };
253
+ var configuredCustomerPrivacy = /* @__PURE__ */ new WeakMap();
254
+ function configureCustomerPrivacy(customerPrivacy, config) {
255
+ const existing = configuredCustomerPrivacy.get(customerPrivacy);
256
+ if (existing) {
257
+ existing.config = config;
258
+ return;
259
+ }
260
+ const state = { config };
261
+ configuredCustomerPrivacy.set(customerPrivacy, state);
262
+ const original = customerPrivacy.setTrackingConsent;
263
+ customerPrivacy.setTrackingConsent = (consent, callback) => {
264
+ const { locale, country, ...headlessConfig } = state.config;
265
+ const params = {
266
+ ...headlessConfig,
267
+ headlessStorefront: true,
268
+ ...consent
269
+ };
270
+ original.call(customerPrivacy, params, callback);
367
271
  };
368
- return { observing, setLoaded, apisLoaded };
272
+ }
273
+ var configuredPrivacyBanners = /* @__PURE__ */ new WeakMap();
274
+ function configurePrivacyBanner(privacyBanner, config) {
275
+ const existing = configuredPrivacyBanners.get(privacyBanner);
276
+ if (existing) {
277
+ existing.config = config;
278
+ return;
279
+ }
280
+ const state = { config };
281
+ configuredPrivacyBanners.set(privacyBanner, state);
282
+ const { loadBanner, showPreferences } = privacyBanner;
283
+ privacyBanner.loadBanner = (options) => loadBanner.call(privacyBanner, { ...state.config, ...options });
284
+ privacyBanner.showPreferences = (options) => showPreferences.call(privacyBanner, { ...state.config, ...options });
369
285
  }
370
286
  function parseStoreDomain(checkoutDomain) {
371
287
  if (typeof window === "undefined") return;
@@ -380,64 +296,25 @@ function parseStoreDomain(checkoutDomain) {
380
296
  });
381
297
  return sameDomainParts.reverse().join(".") || void 0;
382
298
  }
383
- function overrideCustomerPrivacySetTrackingConsent({
384
- customerPrivacy,
385
- config
386
- }) {
387
- const original = customerPrivacy.setTrackingConsent;
388
- const { locale, country, ...rest } = config;
389
- function updatedSetTrackingConsent(consent, callback) {
390
- original(
391
- {
392
- ...rest,
393
- headlessStorefront: true,
394
- ...consent
395
- },
396
- callback
397
- );
398
- }
399
- return updatedSetTrackingConsent;
400
- }
401
- function overridePrivacyBannerMethods({
402
- privacyBanner,
403
- config
404
- }) {
405
- const originalLoadBanner = privacyBanner.loadBanner;
406
- const originalShowPreferences = privacyBanner.showPreferences;
407
- function loadBanner(userConfig) {
408
- if (typeof userConfig === "object") {
409
- originalLoadBanner({ ...config, ...userConfig });
410
- return;
411
- }
412
- originalLoadBanner(config);
413
- }
414
- function showPreferences(userConfig) {
415
- if (typeof userConfig === "object") {
416
- originalShowPreferences({ ...config, ...userConfig });
417
- return;
418
- }
419
- originalShowPreferences(config);
420
- }
421
- return { loadBanner, showPreferences };
422
- }
423
299
  function getCustomerPrivacy() {
424
300
  try {
425
301
  const cp = window.Shopify?.customerPrivacy;
426
- return cp && "setTrackingConsent" in cp ? cp : null;
302
+ return typeof cp?.setTrackingConsent === "function" ? cp : null;
427
303
  } catch (e) {
428
304
  return null;
429
305
  }
430
306
  }
431
307
  function getPrivacyBanner() {
432
308
  try {
433
- return window && window?.privacyBanner ? window.privacyBanner : null;
309
+ const banner = window.privacyBanner;
310
+ return typeof banner?.loadBanner === "function" && typeof banner.showPreferences === "function" ? banner : null;
434
311
  } catch (e) {
435
312
  return null;
436
313
  }
437
314
  }
438
315
 
439
316
  // package.json
440
- var version = "2026.4.5";
317
+ var version = "2026.4.7";
441
318
 
442
319
  // src/analytics-manager/ShopifyAnalytics.tsx
443
320
  function getCustomerPrivacyRequired() {
@@ -451,47 +328,13 @@ function getCustomerPrivacyRequired() {
451
328
  }
452
329
  function ShopifyAnalytics({
453
330
  consent,
454
- onReady,
455
- domain
331
+ onConsentChange
456
332
  }) {
457
- const { subscribe: subscribe2, register: register2, canTrack } = useAnalytics();
458
- const [shopifyReady, setShopifyReady] = useState(false);
333
+ const { subscribe: subscribe2, register: register2 } = useAnalytics();
459
334
  const [privacyReady, setPrivacyReady] = useState(false);
460
- const [collectedConsent, setCollectedConsent] = useState("");
461
335
  const init = useRef(false);
462
336
  const { checkoutDomain, storefrontAccessToken, language } = consent;
463
337
  const { ready: shopifyAnalyticsReady } = register2("Internal_Shopify_Analytics");
464
- useCustomerPrivacy({
465
- ...consent,
466
- locale: language,
467
- checkoutDomain: !checkoutDomain ? "mock.shop" : checkoutDomain,
468
- storefrontAccessToken: !storefrontAccessToken ? "abcdefghijklmnopqrstuvwxyz123456" : storefrontAccessToken,
469
- // If we use privacy banner, we should wait until consent is collected.
470
- // Otherwise, we can consider privacy ready immediately:
471
- onReady: () => !consent.withPrivacyBanner && setPrivacyReady(true),
472
- onVisitorConsentCollected: (consent2) => {
473
- try {
474
- setCollectedConsent(JSON.stringify(consent2));
475
- } catch (e) {
476
- }
477
- setPrivacyReady(true);
478
- }
479
- });
480
- const hasUserConsent = useMemo(
481
- // must be initialized with true to avoid removing cookies too early
482
- () => privacyReady ? canTrack() : true,
483
- // Make this value depend on collectedConsent to re-run `canTrack()` when consent changes
484
- [privacyReady, canTrack, collectedConsent]
485
- );
486
- useShopifyCookies({
487
- hasUserConsent,
488
- domain,
489
- checkoutDomain,
490
- // Already done inside useCustomerPrivacy
491
- fetchTrackingValues: false,
492
- // Avoid creating local cookies too early
493
- ignoreDeprecatedCookies: !privacyReady
494
- });
495
338
  useEffect(() => {
496
339
  if (init.current) return;
497
340
  init.current = true;
@@ -500,14 +343,28 @@ function ShopifyAnalytics({
500
343
  subscribe2(AnalyticsEvent.COLLECTION_VIEWED, collectionViewHandler);
501
344
  subscribe2(AnalyticsEvent.SEARCH_VIEWED, searchViewHandler);
502
345
  subscribe2(AnalyticsEvent.PRODUCT_ADD_TO_CART, productAddedToCartHandler);
503
- setShopifyReady(true);
504
346
  }, [subscribe2]);
347
+ const updatePrivacyReadiness = () => {
348
+ if (privacyReady) {
349
+ onConsentChange();
350
+ } else if (!consent.withPrivacyBanner || !shouldWaitForPrivacyBanner(getCustomerPrivacyRequired())) {
351
+ setPrivacyReady(true);
352
+ }
353
+ };
354
+ useCustomerPrivacy({
355
+ ...consent,
356
+ locale: language,
357
+ checkoutDomain: !checkoutDomain ? "mock.shop" : checkoutDomain,
358
+ storefrontAccessToken: !storefrontAccessToken ? "abcdefghijklmnopqrstuvwxyz123456" : storefrontAccessToken,
359
+ onReady: updatePrivacyReadiness,
360
+ onVisitorConsentCollected: updatePrivacyReadiness
361
+ });
505
362
  useEffect(() => {
506
- if (shopifyReady && privacyReady) {
363
+ if (privacyReady) {
507
364
  shopifyAnalyticsReady();
508
- onReady();
365
+ onConsentChange();
509
366
  }
510
- }, [shopifyReady, privacyReady, onReady]);
367
+ }, [privacyReady, onConsentChange]);
511
368
  return null;
512
369
  }
513
370
  function logMissingConfig2(fieldName) {
@@ -517,7 +374,8 @@ function logMissingConfig2(fieldName) {
517
374
  }
518
375
  function prepareBasePageViewPayload(payload) {
519
376
  const customerPrivacy = getCustomerPrivacyRequired();
520
- const hasUserConsent = customerPrivacy.analyticsProcessingAllowed();
377
+ const hasUserConsent = hasAnalyticsConsent();
378
+ if (!hasUserConsent) return;
521
379
  if (!payload?.shop?.shopId) {
522
380
  logMissingConfig2("shopId");
523
381
  return;
@@ -958,13 +816,8 @@ function register(key) {
958
816
  }
959
817
  };
960
818
  }
961
- function shopifyCanTrack() {
962
- try {
963
- return window.Shopify.customerPrivacy?.analyticsProcessingAllowed?.() ?? false;
964
- } catch (e) {
965
- }
966
- return false;
967
- }
819
+ var noopPublish = () => {
820
+ };
968
821
  function messageOnError(field, envVar) {
969
822
  return `[h2:error:Analytics.Provider] - ${field} is required. Make sure ${envVar} is defined in your environment variables. See https://h2o.fyi/analytics/consent to learn how to setup environment variables in the Shopify admin.`;
970
823
  }
@@ -974,18 +827,31 @@ function AnalyticsProvider({
974
827
  children,
975
828
  consent,
976
829
  customData = {},
977
- shop: shopProp = null,
978
- cookieDomain
830
+ shop: shopProp = null
979
831
  }) {
980
832
  const { shop } = useShopAnalytics(shopProp);
981
- const [analyticsLoaded, setAnalyticsLoaded] = useState(
982
- customCanTrack ? true : false
833
+ const [consentVersion, setConsentVersion] = useState(0);
834
+ const [privacyReady, setPrivacyReady] = useState(false);
835
+ const [carts, setCartsState] = useState({ cart: null, prevCart: null });
836
+ const setCarts = useCallback(
837
+ (update) => startTransition(() => setCartsState(update)),
838
+ []
983
839
  );
984
- const [consentCollected, setConsentCollected] = useState(false);
985
- const [carts, setCarts] = useState({ cart: null, prevCart: null });
986
- const [canTrack, setCanTrack] = useState(
987
- customCanTrack ? () => customCanTrack : () => shopifyCanTrack
840
+ const canTrack = customCanTrack ?? hasAnalyticsConsent;
841
+ const canTrackRef = useRef(canTrack);
842
+ canTrackRef.current = canTrack;
843
+ const guardedPublish = useCallback(
844
+ (event, payload) => {
845
+ if (canTrackRef.current()) publish(event, payload);
846
+ },
847
+ []
988
848
  );
849
+ const onConsentChange = useCallback(() => {
850
+ startTransition(() => {
851
+ setPrivacyReady(true);
852
+ setConsentVersion((version2) => version2 + 1);
853
+ });
854
+ }, []);
989
855
  if (!!shop) {
990
856
  if (/\/68817551382$/.test(shop.shopId)) {
991
857
  warnOnce(
@@ -1022,8 +888,7 @@ function AnalyticsProvider({
1022
888
  canTrack,
1023
889
  ...carts,
1024
890
  customData,
1025
- publish: canTrack() ? publish : () => {
1026
- },
891
+ publish: canTrack() ? guardedPublish : noopPublish,
1027
892
  shop,
1028
893
  subscribe,
1029
894
  register,
@@ -1031,12 +896,12 @@ function AnalyticsProvider({
1031
896
  privacyBanner: getPrivacyBanner()
1032
897
  };
1033
898
  }, [
1034
- analyticsLoaded,
899
+ consentVersion,
1035
900
  canTrack,
1036
901
  carts,
1037
902
  carts.cart?.updatedAt,
1038
903
  carts.prevCart,
1039
- publish,
904
+ guardedPublish,
1040
905
  subscribe,
1041
906
  customData,
1042
907
  shop,
@@ -1049,21 +914,8 @@ function AnalyticsProvider({
1049
914
  children,
1050
915
  !!shop && /* @__PURE__ */ jsx(AnalyticsPageView, {}),
1051
916
  !!shop && !!currentCart && /* @__PURE__ */ jsx(CartAnalytics, { cart: currentCart, setCarts }),
1052
- !!shop && /* @__PURE__ */ jsx(
1053
- ShopifyAnalytics,
1054
- {
1055
- consent,
1056
- onReady: () => {
1057
- setAnalyticsLoaded(true);
1058
- setCanTrack(
1059
- customCanTrack ? () => customCanTrack : () => shopifyCanTrack
1060
- );
1061
- setConsentCollected(true);
1062
- },
1063
- domain: cookieDomain
1064
- }
1065
- ),
1066
- !!shop && consentCollected && /* @__PURE__ */ jsx(PerfKit, { shop })
917
+ !!shop && /* @__PURE__ */ jsx(ShopifyAnalytics, { consent, onConsentChange }),
918
+ !!shop && privacyReady && /* @__PURE__ */ jsx(PerfKit, { shop })
1067
919
  ] });
1068
920
  }
1069
921
  function useAnalytics() {
@@ -1078,7 +930,9 @@ function useAnalytics() {
1078
930
  function useShopAnalytics(shopProp) {
1079
931
  const [shop, setShop] = useState(null);
1080
932
  useEffect(() => {
1081
- Promise.resolve(shopProp).then(setShop);
933
+ Promise.resolve(shopProp).then((resolvedShop) => {
934
+ startTransition(() => setShop(resolvedShop));
935
+ });
1082
936
  return () => {
1083
937
  };
1084
938
  }, [setShop, shopProp]);
@@ -1128,6 +982,17 @@ var Analytics = {
1128
982
  SearchView: AnalyticsSearchView
1129
983
  };
1130
984
 
985
+ // src/constants.ts
986
+ var STOREFRONT_REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID";
987
+ var STOREFRONT_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
988
+ var SDK_VARIANT_HEADER = "X-SDK-Variant";
989
+ var SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
990
+ var SDK_VERSION_HEADER = "X-SDK-Version";
991
+ var SHOPIFY_CLIENT_IP_HEADER = "X-Shopify-Client-IP";
992
+ var SHOPIFY_CLIENT_IP_SIG_HEADER = "X-Shopify-Client-IP-Sig";
993
+ var HYDROGEN_SFAPI_PROXY_KEY = "_sfapi_proxy";
994
+ var STOREFRONT_CONSENT_MANAGEMENT_HEADER = "Shopify-Storefront-Consent-Management";
995
+
1131
996
  // src/utils/request.ts
1132
997
  function getHeader(request, key) {
1133
998
  return getHeaderValue(request.headers, key);
@@ -1833,7 +1698,7 @@ function generateUUID() {
1833
1698
  }
1834
1699
 
1835
1700
  // src/version.ts
1836
- var LIB_VERSION = "2026.4.5";
1701
+ var LIB_VERSION = "2026.4.7";
1837
1702
 
1838
1703
  // src/utils/graphql.ts
1839
1704
  function minifyQuery(string) {
@@ -2094,8 +1959,7 @@ function createStorefrontClient(options) {
2094
1959
  streamConfig,
2095
1960
  onRawHeaders: (headers2) => {
2096
1961
  collectedSubrequestHeaders ??= {
2097
- setCookie: headers2.getSetCookie(),
2098
- serverTiming: headers2.get("server-timing") ?? ""
1962
+ setCookie: headers2.getSetCookie()
2099
1963
  };
2100
1964
  }
2101
1965
  });
@@ -2228,6 +2092,10 @@ function createStorefrontClient(options) {
2228
2092
  "origin",
2229
2093
  "referer",
2230
2094
  "user-agent",
2095
+ // The marker identifies headless consent-management traffic
2096
+ // to the backend, so the proxy forwards it upstream with the
2097
+ // rest of the allowlisted set.
2098
+ STOREFRONT_CONSENT_MANAGEMENT_HEADER,
2231
2099
  STOREFRONT_ACCESS_TOKEN_HEADER,
2232
2100
  SHOPIFY_UNIQUE_TOKEN_HEADER,
2233
2101
  SHOPIFY_VISIT_TOKEN_HEADER
@@ -2256,7 +2124,9 @@ function createStorefrontClient(options) {
2256
2124
  headers: forwardedHeaders
2257
2125
  }
2258
2126
  );
2259
- return new Response(sfapiResponse.body, sfapiResponse);
2127
+ const proxyResponse = new Response(sfapiResponse.body, sfapiResponse);
2128
+ proxyResponse.headers.delete("server-timing");
2129
+ return proxyResponse;
2260
2130
  },
2261
2131
  /**
2262
2132
  * Checks if the request is targeting the Storefront MCP endpoint.
@@ -2305,7 +2175,9 @@ function createStorefrontClient(options) {
2305
2175
  body: request.body,
2306
2176
  headers: forwardedHeaders
2307
2177
  });
2308
- return new Response(mcpResponse.body, mcpResponse);
2178
+ const proxyResponse = new Response(mcpResponse.body, mcpResponse);
2179
+ proxyResponse.headers.delete("server-timing");
2180
+ return proxyResponse;
2309
2181
  } catch (error) {
2310
2182
  const JSON_RPC_INTERNAL_ERROR = -32603;
2311
2183
  const message = error instanceof Error ? error.message : "Internal proxy error";
@@ -2328,21 +2200,6 @@ function createStorefrontClient(options) {
2328
2200
  response.headers.append("Set-Cookie", value);
2329
2201
  }
2330
2202
  }
2331
- const serverTiming = extractServerTimingHeader(
2332
- collectedSubrequestHeaders?.serverTiming
2333
- );
2334
- const isDocumentResponse = response.headers.get("content-type")?.startsWith("text/html");
2335
- const fallbackValues = isDocumentResponse ? { _y: uniqueToken, _s: visitToken } : void 0;
2336
- appendServerTimingHeader(response, {
2337
- ...fallbackValues,
2338
- ...serverTiming
2339
- });
2340
- if (isDocumentResponse && collectedSubrequestHeaders && // _shopify_essential cookie is always set, but we need more than that
2341
- collectedSubrequestHeaders.setCookie.length > 1 && serverTiming?._y && serverTiming?._s && serverTiming?._cmp) {
2342
- appendServerTimingHeader(response, {
2343
- [HYDROGEN_SERVER_TRACKING_KEY]: "1"
2344
- });
2345
- }
2346
2203
  }
2347
2204
  }
2348
2205
  };
@@ -4504,6 +4361,19 @@ function createHydrogenContext(options, additionalContext) {
4504
4361
  });
4505
4362
  return hybridProvider;
4506
4363
  }
4364
+
4365
+ // src/utils/server-timing.ts
4366
+ function buildServerTimingHeader(values) {
4367
+ return Object.entries(values).map(([key, value]) => value ? `${key};desc=${value}` : void 0).filter(Boolean).join(", ");
4368
+ }
4369
+ function appendServerTimingHeader(response, values) {
4370
+ const header = typeof values === "string" ? values : buildServerTimingHeader(values);
4371
+ if (header) {
4372
+ response.headers.append("Server-Timing", header);
4373
+ }
4374
+ }
4375
+
4376
+ // src/createRequestHandler.ts
4507
4377
  function createRequestHandler({
4508
4378
  build,
4509
4379
  mode,
@@ -6018,7 +5888,7 @@ function renderTitle(template, title) {
6018
5888
  return template.replace("%s", title ?? "");
6019
5889
  }
6020
5890
  function inferMimeType(url) {
6021
- const ext = url && url.split(".").pop();
5891
+ const ext = url && url.split(/[?#]/)[0].split(".").pop()?.toLowerCase();
6022
5892
  switch (ext) {
6023
5893
  case "svg":
6024
5894
  return "image/svg+xml";