@shopify/hydrogen 2026.4.5 → 2026.4.6

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, 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);
228
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;
199
+ }
200
+ if (!notifiedReady.current) {
201
+ notifiedReady.current = true;
202
+ updateTrackingValues();
203
+ emitCustomerPrivacyApiLoaded(customerPrivacy);
204
+ callbacks.current.onReady?.();
205
+ }
206
+ return true;
229
207
  };
208
+ const consentCollectedHandler = (event) => {
209
+ if (!checkReady()) return;
210
+ updateTrackingValues();
211
+ callbacks.current.onVisitorConsentCollected?.(event.detail);
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.6";
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,6 @@ 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
- }
968
819
  function messageOnError(field, envVar) {
969
820
  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
821
  }
@@ -974,18 +825,17 @@ function AnalyticsProvider({
974
825
  children,
975
826
  consent,
976
827
  customData = {},
977
- shop: shopProp = null,
978
- cookieDomain
828
+ shop: shopProp = null
979
829
  }) {
980
830
  const { shop } = useShopAnalytics(shopProp);
981
- const [analyticsLoaded, setAnalyticsLoaded] = useState(
982
- customCanTrack ? true : false
983
- );
984
- const [consentCollected, setConsentCollected] = useState(false);
831
+ const [consentVersion, setConsentVersion] = useState(0);
832
+ const [privacyReady, setPrivacyReady] = useState(false);
985
833
  const [carts, setCarts] = useState({ cart: null, prevCart: null });
986
- const [canTrack, setCanTrack] = useState(
987
- customCanTrack ? () => customCanTrack : () => shopifyCanTrack
988
- );
834
+ const canTrack = customCanTrack ?? hasAnalyticsConsent;
835
+ const onConsentChange = useCallback(() => {
836
+ setPrivacyReady(true);
837
+ setConsentVersion((version2) => version2 + 1);
838
+ }, []);
989
839
  if (!!shop) {
990
840
  if (/\/68817551382$/.test(shop.shopId)) {
991
841
  warnOnce(
@@ -1031,7 +881,7 @@ function AnalyticsProvider({
1031
881
  privacyBanner: getPrivacyBanner()
1032
882
  };
1033
883
  }, [
1034
- analyticsLoaded,
884
+ consentVersion,
1035
885
  canTrack,
1036
886
  carts,
1037
887
  carts.cart?.updatedAt,
@@ -1049,21 +899,8 @@ function AnalyticsProvider({
1049
899
  children,
1050
900
  !!shop && /* @__PURE__ */ jsx(AnalyticsPageView, {}),
1051
901
  !!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 })
902
+ !!shop && /* @__PURE__ */ jsx(ShopifyAnalytics, { consent, onConsentChange }),
903
+ !!shop && privacyReady && /* @__PURE__ */ jsx(PerfKit, { shop })
1067
904
  ] });
1068
905
  }
1069
906
  function useAnalytics() {
@@ -1128,6 +965,17 @@ var Analytics = {
1128
965
  SearchView: AnalyticsSearchView
1129
966
  };
1130
967
 
968
+ // src/constants.ts
969
+ var STOREFRONT_REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID";
970
+ var STOREFRONT_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
971
+ var SDK_VARIANT_HEADER = "X-SDK-Variant";
972
+ var SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
973
+ var SDK_VERSION_HEADER = "X-SDK-Version";
974
+ var SHOPIFY_CLIENT_IP_HEADER = "X-Shopify-Client-IP";
975
+ var SHOPIFY_CLIENT_IP_SIG_HEADER = "X-Shopify-Client-IP-Sig";
976
+ var HYDROGEN_SFAPI_PROXY_KEY = "_sfapi_proxy";
977
+ var STOREFRONT_CONSENT_MANAGEMENT_HEADER = "Shopify-Storefront-Consent-Management";
978
+
1131
979
  // src/utils/request.ts
1132
980
  function getHeader(request, key) {
1133
981
  return getHeaderValue(request.headers, key);
@@ -1833,7 +1681,7 @@ function generateUUID() {
1833
1681
  }
1834
1682
 
1835
1683
  // src/version.ts
1836
- var LIB_VERSION = "2026.4.5";
1684
+ var LIB_VERSION = "2026.4.6";
1837
1685
 
1838
1686
  // src/utils/graphql.ts
1839
1687
  function minifyQuery(string) {
@@ -2094,8 +1942,7 @@ function createStorefrontClient(options) {
2094
1942
  streamConfig,
2095
1943
  onRawHeaders: (headers2) => {
2096
1944
  collectedSubrequestHeaders ??= {
2097
- setCookie: headers2.getSetCookie(),
2098
- serverTiming: headers2.get("server-timing") ?? ""
1945
+ setCookie: headers2.getSetCookie()
2099
1946
  };
2100
1947
  }
2101
1948
  });
@@ -2228,6 +2075,10 @@ function createStorefrontClient(options) {
2228
2075
  "origin",
2229
2076
  "referer",
2230
2077
  "user-agent",
2078
+ // The marker identifies headless consent-management traffic
2079
+ // to the backend, so the proxy forwards it upstream with the
2080
+ // rest of the allowlisted set.
2081
+ STOREFRONT_CONSENT_MANAGEMENT_HEADER,
2231
2082
  STOREFRONT_ACCESS_TOKEN_HEADER,
2232
2083
  SHOPIFY_UNIQUE_TOKEN_HEADER,
2233
2084
  SHOPIFY_VISIT_TOKEN_HEADER
@@ -2256,7 +2107,9 @@ function createStorefrontClient(options) {
2256
2107
  headers: forwardedHeaders
2257
2108
  }
2258
2109
  );
2259
- return new Response(sfapiResponse.body, sfapiResponse);
2110
+ const proxyResponse = new Response(sfapiResponse.body, sfapiResponse);
2111
+ proxyResponse.headers.delete("server-timing");
2112
+ return proxyResponse;
2260
2113
  },
2261
2114
  /**
2262
2115
  * Checks if the request is targeting the Storefront MCP endpoint.
@@ -2305,7 +2158,9 @@ function createStorefrontClient(options) {
2305
2158
  body: request.body,
2306
2159
  headers: forwardedHeaders
2307
2160
  });
2308
- return new Response(mcpResponse.body, mcpResponse);
2161
+ const proxyResponse = new Response(mcpResponse.body, mcpResponse);
2162
+ proxyResponse.headers.delete("server-timing");
2163
+ return proxyResponse;
2309
2164
  } catch (error) {
2310
2165
  const JSON_RPC_INTERNAL_ERROR = -32603;
2311
2166
  const message = error instanceof Error ? error.message : "Internal proxy error";
@@ -2328,21 +2183,6 @@ function createStorefrontClient(options) {
2328
2183
  response.headers.append("Set-Cookie", value);
2329
2184
  }
2330
2185
  }
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
2186
  }
2347
2187
  }
2348
2188
  };
@@ -4504,6 +4344,19 @@ function createHydrogenContext(options, additionalContext) {
4504
4344
  });
4505
4345
  return hybridProvider;
4506
4346
  }
4347
+
4348
+ // src/utils/server-timing.ts
4349
+ function buildServerTimingHeader(values) {
4350
+ return Object.entries(values).map(([key, value]) => value ? `${key};desc=${value}` : void 0).filter(Boolean).join(", ");
4351
+ }
4352
+ function appendServerTimingHeader(response, values) {
4353
+ const header = typeof values === "string" ? values : buildServerTimingHeader(values);
4354
+ if (header) {
4355
+ response.headers.append("Server-Timing", header);
4356
+ }
4357
+ }
4358
+
4359
+ // src/createRequestHandler.ts
4507
4360
  function createRequestHandler({
4508
4361
  build,
4509
4362
  mode,
@@ -6018,7 +5871,7 @@ function renderTitle(template, title) {
6018
5871
  return template.replace("%s", title ?? "");
6019
5872
  }
6020
5873
  function inferMimeType(url) {
6021
- const ext = url && url.split(".").pop();
5874
+ const ext = url && url.split(/[?#]/)[0].split(".").pop()?.toLowerCase();
6022
5875
  switch (ext) {
6023
5876
  case "svg":
6024
5877
  return "image/svg+xml";