@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.
@@ -235,6 +235,25 @@ function AnalyticsCustomView(props) {
235
235
  return /* @__PURE__ */ jsxRuntime.jsx(AnalyticsView, { ...props });
236
236
  }
237
237
 
238
+ // src/analytics-manager/consent.ts
239
+ function hasAnalyticsConsent() {
240
+ try {
241
+ const privacy = window.Shopify?.customerPrivacy;
242
+ if (privacy?.consentStatus !== "loaded") return false;
243
+ if (privacy.currentVisitorConsent?.().analytics === "no") return false;
244
+ return privacy.analyticsProcessingAllowed?.() ?? false;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+ function shouldWaitForPrivacyBanner(privacy) {
250
+ if (!privacy.shouldShowBanner()) return false;
251
+ const consent = privacy.currentVisitorConsent();
252
+ return ![consent.marketing, consent.analytics, consent.preferences].some(
253
+ (value) => value === "yes" || value === "no"
254
+ );
255
+ }
256
+
238
257
  // src/analytics-manager/events.ts
239
258
  var AnalyticsEvent = {
240
259
  // Views
@@ -250,61 +269,6 @@ var AnalyticsEvent = {
250
269
  // Custom
251
270
  CUSTOM_EVENT: `custom_`
252
271
  };
253
-
254
- // src/constants.ts
255
- var STOREFRONT_REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID";
256
- var STOREFRONT_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
257
- var SDK_VARIANT_HEADER = "X-SDK-Variant";
258
- var SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
259
- var SDK_VERSION_HEADER = "X-SDK-Version";
260
- var SHOPIFY_CLIENT_IP_HEADER = "X-Shopify-Client-IP";
261
- var SHOPIFY_CLIENT_IP_SIG_HEADER = "X-Shopify-Client-IP-Sig";
262
- var HYDROGEN_SFAPI_PROXY_KEY = "_sfapi_proxy";
263
- var HYDROGEN_SERVER_TRACKING_KEY = "_server_tracking";
264
-
265
- // src/utils/server-timing.ts
266
- function buildServerTimingHeader(values) {
267
- return Object.entries(values).map(([key, value]) => value ? `${key};desc=${value}` : void 0).filter(Boolean).join(", ");
268
- }
269
- function appendServerTimingHeader(response, values) {
270
- const header = typeof values === "string" ? values : buildServerTimingHeader(values);
271
- if (header) {
272
- response.headers.append("Server-Timing", header);
273
- }
274
- }
275
- var trackedTimings = ["_y", "_s", "_cmp"];
276
- function extractServerTimingHeader(serverTimingHeader) {
277
- const values = {};
278
- if (!serverTimingHeader) return values;
279
- const re = new RegExp(
280
- `\\b(${trackedTimings.join("|")});desc="?([^",]+)"?`,
281
- "g"
282
- );
283
- let match;
284
- while ((match = re.exec(serverTimingHeader)) !== null) {
285
- values[match[1]] = match[2];
286
- }
287
- return values;
288
- }
289
- function hasServerTimingInNavigationEntry(key) {
290
- if (typeof window === "undefined") return false;
291
- try {
292
- const navigationEntry = window.performance.getEntriesByType(
293
- "navigation"
294
- )[0];
295
- return !!navigationEntry?.serverTiming?.some((entry) => entry.name === key);
296
- } catch (e) {
297
- return false;
298
- }
299
- }
300
- function isSfapiProxyEnabled() {
301
- return hasServerTimingInNavigationEntry(HYDROGEN_SFAPI_PROXY_KEY);
302
- }
303
- function hasServerReturnedTrackingValues() {
304
- return hasServerTimingInNavigationEntry(HYDROGEN_SERVER_TRACKING_KEY);
305
- }
306
-
307
- // src/customer-privacy/ShopifyCustomerPrivacy.tsx
308
272
  var CONSENT_API = "https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js";
309
273
  var CONSENT_API_WITH_BANNER = "https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js";
310
274
  function logMissingConfig(fieldName) {
@@ -312,6 +276,11 @@ function logMissingConfig(fieldName) {
312
276
  `[h2:error:useCustomerPrivacy] Unable to setup Customer Privacy API: Missing consent.${fieldName} configuration.`
313
277
  );
314
278
  }
279
+ function logIgnoredCrossDomainConsent() {
280
+ console.warn(
281
+ "[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"
282
+ );
283
+ }
315
284
  function useCustomerPrivacy(props) {
316
285
  const {
317
286
  withPrivacyBanner = false,
@@ -323,27 +292,15 @@ function useCustomerPrivacy(props) {
323
292
  locale,
324
293
  sameDomainForStorefrontApi
325
294
  } = props;
326
- const hasSfapiProxy = react.useMemo(
327
- () => sameDomainForStorefrontApi ?? isSfapiProxyEnabled(),
328
- [sameDomainForStorefrontApi]
329
- );
330
- const fetchTrackingValuesFromBrowser = react.useMemo(
331
- () => hasSfapiProxy && !hasServerReturnedTrackingValues(),
332
- [hasSfapiProxy]
333
- );
334
- const cookiesReady = hydrogenReact.useShopifyCookies({
335
- fetchTrackingValues: fetchTrackingValuesFromBrowser,
336
- storefrontAccessToken,
337
- ignoreDeprecatedCookies: true
338
- });
339
- const initialTrackingValues = react.useMemo(hydrogenReact.getTrackingValues, [cookiesReady]);
340
295
  const { revalidate } = reactRouter.useRevalidator();
341
- loadScript.useLoadScript(withPrivacyBanner ? CONSENT_API_WITH_BANNER : CONSENT_API, {
342
- attributes: {
343
- id: "customer-privacy-api"
344
- }
345
- });
346
- const { observing, setLoaded, apisLoaded } = useApisLoaded({ withPrivacyBanner });
296
+ const callbacks = react.useRef({ onReady, onVisitorConsentCollected, revalidate });
297
+ callbacks.current = { onReady, onVisitorConsentCollected, revalidate };
298
+ const notifiedReady = react.useRef(false);
299
+ const previousTrackingValues = react.useRef();
300
+ const [apis, setApis] = react.useState(() => ({
301
+ customerPrivacy: getCustomerPrivacy(),
302
+ privacyBanner: getPrivacyBanner()
303
+ }));
347
304
  const config = react.useMemo(() => {
348
305
  if (!checkoutDomain) logMissingConfig("checkoutDomain");
349
306
  if (!storefrontAccessToken) logMissingConfig("storefrontAccessToken");
@@ -352,196 +309,155 @@ function useCustomerPrivacy(props) {
352
309
  `[h2:error:useCustomerPrivacy] It looks like you passed a private access token, make sure to use the public token`
353
310
  );
354
311
  }
312
+ if (sameDomainForStorefrontApi === false) logIgnoredCrossDomainConsent();
355
313
  const commonAncestorDomain = parseStoreDomain(checkoutDomain);
356
- const sfapiDomain = (
357
- // Check if standard route proxy is enabled in Hydrogen server
358
- // to use it instead of doing a cross-origin request to checkout.
359
- hasSfapiProxy && typeof window !== "undefined" ? window.location.host : checkoutDomain
360
- );
361
- const config2 = {
362
- // This domain is used to send requests to SFAPI for setting and getting consent.
363
- checkoutRootDomain: sfapiDomain,
364
- // Prefix with a dot to ensure this domain is different from checkoutRootDomain.
365
- // This will ensure old cookies are set for a cross-subdomain checkout setup
366
- // so that we keep backward compatibility until new cookies are rolled out.
367
- // Once consent-tracking-api is updated to not rely on cookies anymore, we can remove this.
314
+ return {
315
+ // Always the same-origin proxy: cross-origin consent requests carry no
316
+ // cookies, so consent and visitor tokens would reset on every page load
317
+ // (and an opt-out would be forgotten). The server render never sends
318
+ // consent requests, so it keeps the checkout domain.
319
+ checkoutRootDomain: typeof window !== "undefined" ? window.location.host : checkoutDomain,
368
320
  storefrontRootDomain: commonAncestorDomain ? "." + commonAncestorDomain : void 0,
369
321
  storefrontAccessToken,
370
322
  country,
371
323
  locale
372
324
  };
373
- return config2;
374
325
  }, [
375
- logMissingConfig,
326
+ sameDomainForStorefrontApi,
376
327
  checkoutDomain,
377
328
  storefrontAccessToken,
378
329
  country,
379
330
  locale
380
331
  ]);
381
332
  react.useEffect(() => {
382
- const consentCollectedHandler = (event) => {
383
- const latestTrackingValues = hydrogenReact.getTrackingValues();
384
- if (initialTrackingValues.visitToken !== latestTrackingValues.visitToken || initialTrackingValues.uniqueToken !== latestTrackingValues.uniqueToken) {
385
- revalidate().catch(() => {
333
+ let active = true;
334
+ const shopify = window.Shopify ??= {};
335
+ const privacy = shopify.customerPrivacy ??= {};
336
+ privacy.config = {
337
+ ...privacy.config,
338
+ isHeadless: true,
339
+ asyncConsent: true,
340
+ asyncVisitorState: true,
341
+ consentDomain: config.checkoutRootDomain,
342
+ storefrontAccessToken: config.storefrontAccessToken,
343
+ debug: {
344
+ ...privacy.config?.debug,
345
+ hydrogen: HYDROGEN_DEBUG_METADATA
346
+ }
347
+ };
348
+ if (config.country) shopify.country = config.country;
349
+ if (config.locale) shopify.locale = config.locale.toLowerCase();
350
+ const updateTrackingValues = () => {
351
+ const latest = hydrogenReact.getTrackingValues();
352
+ const previous = previousTrackingValues.current;
353
+ previousTrackingValues.current = latest;
354
+ if (previous && (previous.visitToken !== latest.visitToken || previous.uniqueToken !== latest.uniqueToken)) {
355
+ callbacks.current.revalidate().catch(() => {
386
356
  console.warn(
387
357
  "[h2:warn:useCustomerPrivacy] Revalidation failed after consent change."
388
358
  );
389
359
  });
390
360
  }
391
- if (onVisitorConsentCollected) {
392
- const customerPrivacy = getCustomerPrivacy();
393
- if (customerPrivacy?.shouldShowBanner()) {
394
- const consentValues = customerPrivacy.currentVisitorConsent();
395
- if (consentValues) {
396
- const NO_VALUE = "";
397
- const noInteraction = consentValues.marketing === NO_VALUE && consentValues.analytics === NO_VALUE && consentValues.preferences === NO_VALUE;
398
- if (noInteraction) {
399
- return;
400
- }
401
- }
402
- }
403
- onVisitorConsentCollected(event.detail);
361
+ };
362
+ const checkReady = () => {
363
+ if (!active) return false;
364
+ const customerPrivacy = getCustomerPrivacy();
365
+ const privacyBanner = getPrivacyBanner();
366
+ if (customerPrivacy) configureCustomerPrivacy(customerPrivacy, config);
367
+ if (withPrivacyBanner && privacyBanner) {
368
+ configurePrivacyBanner(privacyBanner, config);
369
+ }
370
+ setApis(
371
+ (previous) => previous.customerPrivacy === customerPrivacy && previous.privacyBanner === privacyBanner ? previous : { customerPrivacy, privacyBanner }
372
+ );
373
+ if (customerPrivacy?.consentStatus !== "loaded" || withPrivacyBanner && !privacyBanner) {
374
+ return false;
404
375
  }
376
+ if (!notifiedReady.current) {
377
+ notifiedReady.current = true;
378
+ updateTrackingValues();
379
+ emitCustomerPrivacyApiLoaded(customerPrivacy);
380
+ callbacks.current.onReady?.();
381
+ }
382
+ return true;
383
+ };
384
+ const consentCollectedHandler = (event) => {
385
+ if (!checkReady()) return;
386
+ updateTrackingValues();
387
+ callbacks.current.onVisitorConsentCollected?.(event.detail);
405
388
  };
389
+ document.addEventListener("consentTrackingApiLoaded", checkReady);
406
390
  document.addEventListener(
407
391
  "visitorConsentCollected",
408
392
  consentCollectedHandler
409
393
  );
394
+ if (!checkReady()) {
395
+ void loadScript.loadScript(
396
+ withPrivacyBanner ? CONSENT_API_WITH_BANNER : CONSENT_API,
397
+ {
398
+ attributes: { id: "customer-privacy-api" }
399
+ }
400
+ ).then(checkReady, () => {
401
+ if (active) {
402
+ console.error(
403
+ "[h2:error:useCustomerPrivacy] Unable to load the Customer Privacy API."
404
+ );
405
+ }
406
+ });
407
+ }
410
408
  return () => {
409
+ active = false;
410
+ document.removeEventListener("consentTrackingApiLoaded", checkReady);
411
411
  document.removeEventListener(
412
412
  "visitorConsentCollected",
413
413
  consentCollectedHandler
414
414
  );
415
415
  };
416
- }, [onVisitorConsentCollected]);
417
- react.useEffect(() => {
418
- if (!withPrivacyBanner || observing.current.privacyBanner) return;
419
- observing.current.privacyBanner = true;
420
- let customPrivacyBanner = window.privacyBanner || void 0;
421
- const privacyBannerWatcher = {
422
- configurable: true,
423
- get() {
424
- return customPrivacyBanner;
425
- },
426
- set(value) {
427
- if (typeof value === "object" && value !== null && "showPreferences" in value && "loadBanner" in value) {
428
- customPrivacyBanner = overridePrivacyBannerMethods({
429
- privacyBanner: value,
430
- config
431
- });
432
- setLoaded.privacyBanner();
433
- }
434
- }
435
- };
436
- Object.defineProperty(window, "privacyBanner", privacyBannerWatcher);
437
- }, [
438
- withPrivacyBanner,
439
- config,
440
- overridePrivacyBannerMethods,
441
- setLoaded.privacyBanner
442
- ]);
443
- react.useEffect(() => {
444
- if (observing.current.customerPrivacy) return;
445
- observing.current.customerPrivacy = true;
446
- let backendConsentStub = null;
447
- let fullCustomerPrivacy = null;
448
- let customShopify = window.Shopify || void 0;
449
- Object.defineProperty(window, "Shopify", {
450
- configurable: true,
451
- get() {
452
- return customShopify;
453
- },
454
- set(value) {
455
- if (typeof value === "object" && value !== null && Object.keys(value).length === 0) {
456
- customShopify = value;
457
- backendConsentStub = { backendConsentEnabled: true };
458
- Object.defineProperty(window.Shopify, "customerPrivacy", {
459
- configurable: true,
460
- get() {
461
- return fullCustomerPrivacy ?? backendConsentStub;
462
- },
463
- set(value2) {
464
- if (typeof value2 === "object" && value2 !== null && "setTrackingConsent" in value2) {
465
- const customerPrivacy = value2;
466
- fullCustomerPrivacy = {
467
- ...customerPrivacy,
468
- // Note: this method is not used by the privacy-banner,
469
- // it bundles its own setTrackingConsent.
470
- setTrackingConsent: overrideCustomerPrivacySetTrackingConsent(
471
- { customerPrivacy, config }
472
- )
473
- };
474
- customShopify = {
475
- ...customShopify,
476
- customerPrivacy: fullCustomerPrivacy
477
- };
478
- setLoaded.customerPrivacy();
479
- }
480
- }
481
- });
482
- }
483
- }
484
- });
485
- }, [
486
- config,
487
- overrideCustomerPrivacySetTrackingConsent,
488
- setLoaded.customerPrivacy
489
- ]);
490
- react.useEffect(() => {
491
- if (!apisLoaded || !cookiesReady) return;
492
- const customerPrivacy = getCustomerPrivacy();
493
- if (customerPrivacy && !customerPrivacy.cachedConsent) {
494
- const trackingValues = hydrogenReact.getTrackingValues();
495
- if (trackingValues.consent) {
496
- customerPrivacy.cachedConsent = trackingValues.consent;
497
- }
498
- }
499
- if (withPrivacyBanner) {
500
- const privacyBanner = getPrivacyBanner();
501
- if (privacyBanner) {
502
- privacyBanner.loadBanner(config);
503
- }
504
- }
505
- emitCustomerPrivacyApiLoaded();
506
- onReady?.();
507
- }, [apisLoaded, cookiesReady]);
508
- const result = {
509
- customerPrivacy: getCustomerPrivacy()
416
+ }, [config, withPrivacyBanner]);
417
+ return {
418
+ customerPrivacy: apis.customerPrivacy,
419
+ ...withPrivacyBanner ? { privacyBanner: apis.privacyBanner } : {}
510
420
  };
511
- if (withPrivacyBanner) {
512
- result.privacyBanner = getPrivacyBanner();
513
- }
514
- return result;
515
421
  }
516
- var hasEmitted = false;
517
- function emitCustomerPrivacyApiLoaded() {
518
- if (hasEmitted) return;
519
- hasEmitted = true;
520
- const event = new CustomEvent("shopifyCustomerPrivacyApiLoaded");
521
- document.dispatchEvent(event);
522
- }
523
- function useApisLoaded({ withPrivacyBanner }) {
524
- const observing = react.useRef({ customerPrivacy: false, privacyBanner: false });
525
- const [apisLoadedArray, setApisLoaded] = react.useState(
526
- withPrivacyBanner ? [false, false] : [false]
527
- );
528
- const apisLoaded = apisLoadedArray.every(Boolean);
529
- const setLoaded = {
530
- customerPrivacy: () => {
531
- if (withPrivacyBanner) {
532
- setApisLoaded((prev) => [true, prev[1]]);
533
- } else {
534
- setApisLoaded(() => [true]);
535
- }
536
- },
537
- privacyBanner: () => {
538
- if (!withPrivacyBanner) {
539
- return;
540
- }
541
- setApisLoaded((prev) => [prev[0], true]);
542
- }
422
+ var readyApis = /* @__PURE__ */ new WeakSet();
423
+ function emitCustomerPrivacyApiLoaded(customerPrivacy) {
424
+ if (readyApis.has(customerPrivacy)) return;
425
+ readyApis.add(customerPrivacy);
426
+ document.dispatchEvent(new CustomEvent("shopifyCustomerPrivacyApiLoaded"));
427
+ }
428
+ var HYDROGEN_DEBUG_METADATA = { generation: 2, serverTiming: false };
429
+ var configuredCustomerPrivacy = /* @__PURE__ */ new WeakMap();
430
+ function configureCustomerPrivacy(customerPrivacy, config) {
431
+ const existing = configuredCustomerPrivacy.get(customerPrivacy);
432
+ if (existing) {
433
+ existing.config = config;
434
+ return;
435
+ }
436
+ const state = { config };
437
+ configuredCustomerPrivacy.set(customerPrivacy, state);
438
+ const original = customerPrivacy.setTrackingConsent;
439
+ customerPrivacy.setTrackingConsent = (consent, callback) => {
440
+ const { locale, country, ...headlessConfig } = state.config;
441
+ const params = {
442
+ ...headlessConfig,
443
+ headlessStorefront: true,
444
+ ...consent
445
+ };
446
+ original.call(customerPrivacy, params, callback);
543
447
  };
544
- return { observing, setLoaded, apisLoaded };
448
+ }
449
+ var configuredPrivacyBanners = /* @__PURE__ */ new WeakMap();
450
+ function configurePrivacyBanner(privacyBanner, config) {
451
+ const existing = configuredPrivacyBanners.get(privacyBanner);
452
+ if (existing) {
453
+ existing.config = config;
454
+ return;
455
+ }
456
+ const state = { config };
457
+ configuredPrivacyBanners.set(privacyBanner, state);
458
+ const { loadBanner, showPreferences } = privacyBanner;
459
+ privacyBanner.loadBanner = (options) => loadBanner.call(privacyBanner, { ...state.config, ...options });
460
+ privacyBanner.showPreferences = (options) => showPreferences.call(privacyBanner, { ...state.config, ...options });
545
461
  }
546
462
  function parseStoreDomain(checkoutDomain) {
547
463
  if (typeof window === "undefined") return;
@@ -556,64 +472,25 @@ function parseStoreDomain(checkoutDomain) {
556
472
  });
557
473
  return sameDomainParts.reverse().join(".") || void 0;
558
474
  }
559
- function overrideCustomerPrivacySetTrackingConsent({
560
- customerPrivacy,
561
- config
562
- }) {
563
- const original = customerPrivacy.setTrackingConsent;
564
- const { locale, country, ...rest } = config;
565
- function updatedSetTrackingConsent(consent, callback) {
566
- original(
567
- {
568
- ...rest,
569
- headlessStorefront: true,
570
- ...consent
571
- },
572
- callback
573
- );
574
- }
575
- return updatedSetTrackingConsent;
576
- }
577
- function overridePrivacyBannerMethods({
578
- privacyBanner,
579
- config
580
- }) {
581
- const originalLoadBanner = privacyBanner.loadBanner;
582
- const originalShowPreferences = privacyBanner.showPreferences;
583
- function loadBanner(userConfig) {
584
- if (typeof userConfig === "object") {
585
- originalLoadBanner({ ...config, ...userConfig });
586
- return;
587
- }
588
- originalLoadBanner(config);
589
- }
590
- function showPreferences(userConfig) {
591
- if (typeof userConfig === "object") {
592
- originalShowPreferences({ ...config, ...userConfig });
593
- return;
594
- }
595
- originalShowPreferences(config);
596
- }
597
- return { loadBanner, showPreferences };
598
- }
599
475
  function getCustomerPrivacy() {
600
476
  try {
601
477
  const cp = window.Shopify?.customerPrivacy;
602
- return cp && "setTrackingConsent" in cp ? cp : null;
478
+ return typeof cp?.setTrackingConsent === "function" ? cp : null;
603
479
  } catch (e) {
604
480
  return null;
605
481
  }
606
482
  }
607
483
  function getPrivacyBanner() {
608
484
  try {
609
- return window && window?.privacyBanner ? window.privacyBanner : null;
485
+ const banner = window.privacyBanner;
486
+ return typeof banner?.loadBanner === "function" && typeof banner.showPreferences === "function" ? banner : null;
610
487
  } catch (e) {
611
488
  return null;
612
489
  }
613
490
  }
614
491
 
615
492
  // package.json
616
- var version = "2026.4.5";
493
+ var version = "2026.4.7";
617
494
 
618
495
  // src/analytics-manager/ShopifyAnalytics.tsx
619
496
  function getCustomerPrivacyRequired() {
@@ -627,47 +504,13 @@ function getCustomerPrivacyRequired() {
627
504
  }
628
505
  function ShopifyAnalytics({
629
506
  consent,
630
- onReady,
631
- domain
507
+ onConsentChange
632
508
  }) {
633
- const { subscribe: subscribe2, register: register2, canTrack } = useAnalytics();
634
- const [shopifyReady, setShopifyReady] = react.useState(false);
509
+ const { subscribe: subscribe2, register: register2 } = useAnalytics();
635
510
  const [privacyReady, setPrivacyReady] = react.useState(false);
636
- const [collectedConsent, setCollectedConsent] = react.useState("");
637
511
  const init = react.useRef(false);
638
512
  const { checkoutDomain, storefrontAccessToken, language } = consent;
639
513
  const { ready: shopifyAnalyticsReady } = register2("Internal_Shopify_Analytics");
640
- useCustomerPrivacy({
641
- ...consent,
642
- locale: language,
643
- checkoutDomain: !checkoutDomain ? "mock.shop" : checkoutDomain,
644
- storefrontAccessToken: !storefrontAccessToken ? "abcdefghijklmnopqrstuvwxyz123456" : storefrontAccessToken,
645
- // If we use privacy banner, we should wait until consent is collected.
646
- // Otherwise, we can consider privacy ready immediately:
647
- onReady: () => !consent.withPrivacyBanner && setPrivacyReady(true),
648
- onVisitorConsentCollected: (consent2) => {
649
- try {
650
- setCollectedConsent(JSON.stringify(consent2));
651
- } catch (e) {
652
- }
653
- setPrivacyReady(true);
654
- }
655
- });
656
- const hasUserConsent = react.useMemo(
657
- // must be initialized with true to avoid removing cookies too early
658
- () => privacyReady ? canTrack() : true,
659
- // Make this value depend on collectedConsent to re-run `canTrack()` when consent changes
660
- [privacyReady, canTrack, collectedConsent]
661
- );
662
- hydrogenReact.useShopifyCookies({
663
- hasUserConsent,
664
- domain,
665
- checkoutDomain,
666
- // Already done inside useCustomerPrivacy
667
- fetchTrackingValues: false,
668
- // Avoid creating local cookies too early
669
- ignoreDeprecatedCookies: !privacyReady
670
- });
671
514
  react.useEffect(() => {
672
515
  if (init.current) return;
673
516
  init.current = true;
@@ -676,14 +519,28 @@ function ShopifyAnalytics({
676
519
  subscribe2(AnalyticsEvent.COLLECTION_VIEWED, collectionViewHandler);
677
520
  subscribe2(AnalyticsEvent.SEARCH_VIEWED, searchViewHandler);
678
521
  subscribe2(AnalyticsEvent.PRODUCT_ADD_TO_CART, productAddedToCartHandler);
679
- setShopifyReady(true);
680
522
  }, [subscribe2]);
523
+ const updatePrivacyReadiness = () => {
524
+ if (privacyReady) {
525
+ onConsentChange();
526
+ } else if (!consent.withPrivacyBanner || !shouldWaitForPrivacyBanner(getCustomerPrivacyRequired())) {
527
+ setPrivacyReady(true);
528
+ }
529
+ };
530
+ useCustomerPrivacy({
531
+ ...consent,
532
+ locale: language,
533
+ checkoutDomain: !checkoutDomain ? "mock.shop" : checkoutDomain,
534
+ storefrontAccessToken: !storefrontAccessToken ? "abcdefghijklmnopqrstuvwxyz123456" : storefrontAccessToken,
535
+ onReady: updatePrivacyReadiness,
536
+ onVisitorConsentCollected: updatePrivacyReadiness
537
+ });
681
538
  react.useEffect(() => {
682
- if (shopifyReady && privacyReady) {
539
+ if (privacyReady) {
683
540
  shopifyAnalyticsReady();
684
- onReady();
541
+ onConsentChange();
685
542
  }
686
- }, [shopifyReady, privacyReady, onReady]);
543
+ }, [privacyReady, onConsentChange]);
687
544
  return null;
688
545
  }
689
546
  function logMissingConfig2(fieldName) {
@@ -693,7 +550,8 @@ function logMissingConfig2(fieldName) {
693
550
  }
694
551
  function prepareBasePageViewPayload(payload) {
695
552
  const customerPrivacy = getCustomerPrivacyRequired();
696
- const hasUserConsent = customerPrivacy.analyticsProcessingAllowed();
553
+ const hasUserConsent = hasAnalyticsConsent();
554
+ if (!hasUserConsent) return;
697
555
  if (!payload?.shop?.shopId) {
698
556
  logMissingConfig2("shopId");
699
557
  return;
@@ -1134,13 +992,8 @@ function register(key) {
1134
992
  }
1135
993
  };
1136
994
  }
1137
- function shopifyCanTrack() {
1138
- try {
1139
- return window.Shopify.customerPrivacy?.analyticsProcessingAllowed?.() ?? false;
1140
- } catch (e) {
1141
- }
1142
- return false;
1143
- }
995
+ var noopPublish = () => {
996
+ };
1144
997
  function messageOnError(field, envVar) {
1145
998
  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.`;
1146
999
  }
@@ -1150,18 +1003,31 @@ function AnalyticsProvider({
1150
1003
  children,
1151
1004
  consent,
1152
1005
  customData = {},
1153
- shop: shopProp = null,
1154
- cookieDomain
1006
+ shop: shopProp = null
1155
1007
  }) {
1156
1008
  const { shop } = useShopAnalytics(shopProp);
1157
- const [analyticsLoaded, setAnalyticsLoaded] = react.useState(
1158
- customCanTrack ? true : false
1009
+ const [consentVersion, setConsentVersion] = react.useState(0);
1010
+ const [privacyReady, setPrivacyReady] = react.useState(false);
1011
+ const [carts, setCartsState] = react.useState({ cart: null, prevCart: null });
1012
+ const setCarts = react.useCallback(
1013
+ (update) => react.startTransition(() => setCartsState(update)),
1014
+ []
1159
1015
  );
1160
- const [consentCollected, setConsentCollected] = react.useState(false);
1161
- const [carts, setCarts] = react.useState({ cart: null, prevCart: null });
1162
- const [canTrack, setCanTrack] = react.useState(
1163
- customCanTrack ? () => customCanTrack : () => shopifyCanTrack
1016
+ const canTrack = customCanTrack ?? hasAnalyticsConsent;
1017
+ const canTrackRef = react.useRef(canTrack);
1018
+ canTrackRef.current = canTrack;
1019
+ const guardedPublish = react.useCallback(
1020
+ (event, payload) => {
1021
+ if (canTrackRef.current()) publish(event, payload);
1022
+ },
1023
+ []
1164
1024
  );
1025
+ const onConsentChange = react.useCallback(() => {
1026
+ react.startTransition(() => {
1027
+ setPrivacyReady(true);
1028
+ setConsentVersion((version2) => version2 + 1);
1029
+ });
1030
+ }, []);
1165
1031
  if (!!shop) {
1166
1032
  if (/\/68817551382$/.test(shop.shopId)) {
1167
1033
  warnOnce(
@@ -1198,8 +1064,7 @@ function AnalyticsProvider({
1198
1064
  canTrack,
1199
1065
  ...carts,
1200
1066
  customData,
1201
- publish: canTrack() ? publish : () => {
1202
- },
1067
+ publish: canTrack() ? guardedPublish : noopPublish,
1203
1068
  shop,
1204
1069
  subscribe,
1205
1070
  register,
@@ -1207,12 +1072,12 @@ function AnalyticsProvider({
1207
1072
  privacyBanner: getPrivacyBanner()
1208
1073
  };
1209
1074
  }, [
1210
- analyticsLoaded,
1075
+ consentVersion,
1211
1076
  canTrack,
1212
1077
  carts,
1213
1078
  carts.cart?.updatedAt,
1214
1079
  carts.prevCart,
1215
- publish,
1080
+ guardedPublish,
1216
1081
  subscribe,
1217
1082
  customData,
1218
1083
  shop,
@@ -1225,21 +1090,8 @@ function AnalyticsProvider({
1225
1090
  children,
1226
1091
  !!shop && /* @__PURE__ */ jsxRuntime.jsx(AnalyticsPageView, {}),
1227
1092
  !!shop && !!currentCart && /* @__PURE__ */ jsxRuntime.jsx(CartAnalytics, { cart: currentCart, setCarts }),
1228
- !!shop && /* @__PURE__ */ jsxRuntime.jsx(
1229
- ShopifyAnalytics,
1230
- {
1231
- consent,
1232
- onReady: () => {
1233
- setAnalyticsLoaded(true);
1234
- setCanTrack(
1235
- customCanTrack ? () => customCanTrack : () => shopifyCanTrack
1236
- );
1237
- setConsentCollected(true);
1238
- },
1239
- domain: cookieDomain
1240
- }
1241
- ),
1242
- !!shop && consentCollected && /* @__PURE__ */ jsxRuntime.jsx(PerfKit, { shop })
1093
+ !!shop && /* @__PURE__ */ jsxRuntime.jsx(ShopifyAnalytics, { consent, onConsentChange }),
1094
+ !!shop && privacyReady && /* @__PURE__ */ jsxRuntime.jsx(PerfKit, { shop })
1243
1095
  ] });
1244
1096
  }
1245
1097
  function useAnalytics() {
@@ -1254,7 +1106,9 @@ function useAnalytics() {
1254
1106
  function useShopAnalytics(shopProp) {
1255
1107
  const [shop, setShop] = react.useState(null);
1256
1108
  react.useEffect(() => {
1257
- Promise.resolve(shopProp).then(setShop);
1109
+ Promise.resolve(shopProp).then((resolvedShop) => {
1110
+ react.startTransition(() => setShop(resolvedShop));
1111
+ });
1258
1112
  return () => {
1259
1113
  };
1260
1114
  }, [setShop, shopProp]);
@@ -1304,6 +1158,17 @@ var Analytics = {
1304
1158
  SearchView: AnalyticsSearchView
1305
1159
  };
1306
1160
 
1161
+ // src/constants.ts
1162
+ var STOREFRONT_REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID";
1163
+ var STOREFRONT_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
1164
+ var SDK_VARIANT_HEADER = "X-SDK-Variant";
1165
+ var SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
1166
+ var SDK_VERSION_HEADER = "X-SDK-Version";
1167
+ var SHOPIFY_CLIENT_IP_HEADER = "X-Shopify-Client-IP";
1168
+ var SHOPIFY_CLIENT_IP_SIG_HEADER = "X-Shopify-Client-IP-Sig";
1169
+ var HYDROGEN_SFAPI_PROXY_KEY = "_sfapi_proxy";
1170
+ var STOREFRONT_CONSENT_MANAGEMENT_HEADER = "Shopify-Storefront-Consent-Management";
1171
+
1307
1172
  // src/utils/request.ts
1308
1173
  function getHeader(request, key) {
1309
1174
  return getHeaderValue(request.headers, key);
@@ -2009,7 +1874,7 @@ function generateUUID() {
2009
1874
  }
2010
1875
 
2011
1876
  // src/version.ts
2012
- var LIB_VERSION = "2026.4.5";
1877
+ var LIB_VERSION = "2026.4.7";
2013
1878
 
2014
1879
  // src/utils/graphql.ts
2015
1880
  function minifyQuery(string) {
@@ -2270,8 +2135,7 @@ function createStorefrontClient(options) {
2270
2135
  streamConfig,
2271
2136
  onRawHeaders: (headers2) => {
2272
2137
  collectedSubrequestHeaders ??= {
2273
- setCookie: headers2.getSetCookie(),
2274
- serverTiming: headers2.get("server-timing") ?? ""
2138
+ setCookie: headers2.getSetCookie()
2275
2139
  };
2276
2140
  }
2277
2141
  });
@@ -2404,6 +2268,10 @@ function createStorefrontClient(options) {
2404
2268
  "origin",
2405
2269
  "referer",
2406
2270
  "user-agent",
2271
+ // The marker identifies headless consent-management traffic
2272
+ // to the backend, so the proxy forwards it upstream with the
2273
+ // rest of the allowlisted set.
2274
+ STOREFRONT_CONSENT_MANAGEMENT_HEADER,
2407
2275
  STOREFRONT_ACCESS_TOKEN_HEADER,
2408
2276
  hydrogenReact.SHOPIFY_UNIQUE_TOKEN_HEADER,
2409
2277
  hydrogenReact.SHOPIFY_VISIT_TOKEN_HEADER
@@ -2432,7 +2300,9 @@ function createStorefrontClient(options) {
2432
2300
  headers: forwardedHeaders
2433
2301
  }
2434
2302
  );
2435
- return new Response(sfapiResponse.body, sfapiResponse);
2303
+ const proxyResponse = new Response(sfapiResponse.body, sfapiResponse);
2304
+ proxyResponse.headers.delete("server-timing");
2305
+ return proxyResponse;
2436
2306
  },
2437
2307
  /**
2438
2308
  * Checks if the request is targeting the Storefront MCP endpoint.
@@ -2481,7 +2351,9 @@ function createStorefrontClient(options) {
2481
2351
  body: request.body,
2482
2352
  headers: forwardedHeaders
2483
2353
  });
2484
- return new Response(mcpResponse.body, mcpResponse);
2354
+ const proxyResponse = new Response(mcpResponse.body, mcpResponse);
2355
+ proxyResponse.headers.delete("server-timing");
2356
+ return proxyResponse;
2485
2357
  } catch (error) {
2486
2358
  const JSON_RPC_INTERNAL_ERROR = -32603;
2487
2359
  const message = error instanceof Error ? error.message : "Internal proxy error";
@@ -2504,21 +2376,6 @@ function createStorefrontClient(options) {
2504
2376
  response.headers.append("Set-Cookie", value);
2505
2377
  }
2506
2378
  }
2507
- const serverTiming = extractServerTimingHeader(
2508
- collectedSubrequestHeaders?.serverTiming
2509
- );
2510
- const isDocumentResponse = response.headers.get("content-type")?.startsWith("text/html");
2511
- const fallbackValues = isDocumentResponse ? { _y: uniqueToken, _s: visitToken } : void 0;
2512
- appendServerTimingHeader(response, {
2513
- ...fallbackValues,
2514
- ...serverTiming
2515
- });
2516
- if (isDocumentResponse && collectedSubrequestHeaders && // _shopify_essential cookie is always set, but we need more than that
2517
- collectedSubrequestHeaders.setCookie.length > 1 && serverTiming?._y && serverTiming?._s && serverTiming?._cmp) {
2518
- appendServerTimingHeader(response, {
2519
- [HYDROGEN_SERVER_TRACKING_KEY]: "1"
2520
- });
2521
- }
2522
2379
  }
2523
2380
  }
2524
2381
  };
@@ -4680,6 +4537,19 @@ function createHydrogenContext(options, additionalContext) {
4680
4537
  });
4681
4538
  return hybridProvider;
4682
4539
  }
4540
+
4541
+ // src/utils/server-timing.ts
4542
+ function buildServerTimingHeader(values) {
4543
+ return Object.entries(values).map(([key, value]) => value ? `${key};desc=${value}` : void 0).filter(Boolean).join(", ");
4544
+ }
4545
+ function appendServerTimingHeader(response, values) {
4546
+ const header = typeof values === "string" ? values : buildServerTimingHeader(values);
4547
+ if (header) {
4548
+ response.headers.append("Server-Timing", header);
4549
+ }
4550
+ }
4551
+
4552
+ // src/createRequestHandler.ts
4683
4553
  function createRequestHandler({
4684
4554
  build,
4685
4555
  mode,
@@ -6194,7 +6064,7 @@ function renderTitle(template, title) {
6194
6064
  return template.replace("%s", title ?? "");
6195
6065
  }
6196
6066
  function inferMimeType(url) {
6197
- const ext = url && url.split(".").pop();
6067
+ const ext = url && url.split(/[?#]/)[0].split(".").pop()?.toLowerCase();
6198
6068
  switch (ext) {
6199
6069
  case "svg":
6200
6070
  return "image/svg+xml";