@shopify/hydrogen 2026.4.4 → 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.
@@ -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;
405
383
  };
384
+ const consentCollectedHandler = (event) => {
385
+ if (!checkReady()) return;
386
+ updateTrackingValues();
387
+ callbacks.current.onVisitorConsentCollected?.(event.detail);
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.4";
493
+ var version = "2026.4.6";
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,6 @@ 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
- }
1144
995
  function messageOnError(field, envVar) {
1145
996
  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
997
  }
@@ -1150,18 +1001,17 @@ function AnalyticsProvider({
1150
1001
  children,
1151
1002
  consent,
1152
1003
  customData = {},
1153
- shop: shopProp = null,
1154
- cookieDomain
1004
+ shop: shopProp = null
1155
1005
  }) {
1156
1006
  const { shop } = useShopAnalytics(shopProp);
1157
- const [analyticsLoaded, setAnalyticsLoaded] = react.useState(
1158
- customCanTrack ? true : false
1159
- );
1160
- const [consentCollected, setConsentCollected] = react.useState(false);
1007
+ const [consentVersion, setConsentVersion] = react.useState(0);
1008
+ const [privacyReady, setPrivacyReady] = react.useState(false);
1161
1009
  const [carts, setCarts] = react.useState({ cart: null, prevCart: null });
1162
- const [canTrack, setCanTrack] = react.useState(
1163
- customCanTrack ? () => customCanTrack : () => shopifyCanTrack
1164
- );
1010
+ const canTrack = customCanTrack ?? hasAnalyticsConsent;
1011
+ const onConsentChange = react.useCallback(() => {
1012
+ setPrivacyReady(true);
1013
+ setConsentVersion((version2) => version2 + 1);
1014
+ }, []);
1165
1015
  if (!!shop) {
1166
1016
  if (/\/68817551382$/.test(shop.shopId)) {
1167
1017
  warnOnce(
@@ -1207,7 +1057,7 @@ function AnalyticsProvider({
1207
1057
  privacyBanner: getPrivacyBanner()
1208
1058
  };
1209
1059
  }, [
1210
- analyticsLoaded,
1060
+ consentVersion,
1211
1061
  canTrack,
1212
1062
  carts,
1213
1063
  carts.cart?.updatedAt,
@@ -1225,21 +1075,8 @@ function AnalyticsProvider({
1225
1075
  children,
1226
1076
  !!shop && /* @__PURE__ */ jsxRuntime.jsx(AnalyticsPageView, {}),
1227
1077
  !!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 })
1078
+ !!shop && /* @__PURE__ */ jsxRuntime.jsx(ShopifyAnalytics, { consent, onConsentChange }),
1079
+ !!shop && privacyReady && /* @__PURE__ */ jsxRuntime.jsx(PerfKit, { shop })
1243
1080
  ] });
1244
1081
  }
1245
1082
  function useAnalytics() {
@@ -1304,6 +1141,17 @@ var Analytics = {
1304
1141
  SearchView: AnalyticsSearchView
1305
1142
  };
1306
1143
 
1144
+ // src/constants.ts
1145
+ var STOREFRONT_REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID";
1146
+ var STOREFRONT_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
1147
+ var SDK_VARIANT_HEADER = "X-SDK-Variant";
1148
+ var SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
1149
+ var SDK_VERSION_HEADER = "X-SDK-Version";
1150
+ var SHOPIFY_CLIENT_IP_HEADER = "X-Shopify-Client-IP";
1151
+ var SHOPIFY_CLIENT_IP_SIG_HEADER = "X-Shopify-Client-IP-Sig";
1152
+ var HYDROGEN_SFAPI_PROXY_KEY = "_sfapi_proxy";
1153
+ var STOREFRONT_CONSENT_MANAGEMENT_HEADER = "Shopify-Storefront-Consent-Management";
1154
+
1307
1155
  // src/utils/request.ts
1308
1156
  function getHeader(request, key) {
1309
1157
  return getHeaderValue(request.headers, key);
@@ -2009,7 +1857,7 @@ function generateUUID() {
2009
1857
  }
2010
1858
 
2011
1859
  // src/version.ts
2012
- var LIB_VERSION = "2026.4.4";
1860
+ var LIB_VERSION = "2026.4.6";
2013
1861
 
2014
1862
  // src/utils/graphql.ts
2015
1863
  function minifyQuery(string) {
@@ -2270,8 +2118,7 @@ function createStorefrontClient(options) {
2270
2118
  streamConfig,
2271
2119
  onRawHeaders: (headers2) => {
2272
2120
  collectedSubrequestHeaders ??= {
2273
- setCookie: headers2.getSetCookie(),
2274
- serverTiming: headers2.get("server-timing") ?? ""
2121
+ setCookie: headers2.getSetCookie()
2275
2122
  };
2276
2123
  }
2277
2124
  });
@@ -2404,6 +2251,10 @@ function createStorefrontClient(options) {
2404
2251
  "origin",
2405
2252
  "referer",
2406
2253
  "user-agent",
2254
+ // The marker identifies headless consent-management traffic
2255
+ // to the backend, so the proxy forwards it upstream with the
2256
+ // rest of the allowlisted set.
2257
+ STOREFRONT_CONSENT_MANAGEMENT_HEADER,
2407
2258
  STOREFRONT_ACCESS_TOKEN_HEADER,
2408
2259
  hydrogenReact.SHOPIFY_UNIQUE_TOKEN_HEADER,
2409
2260
  hydrogenReact.SHOPIFY_VISIT_TOKEN_HEADER
@@ -2432,7 +2283,9 @@ function createStorefrontClient(options) {
2432
2283
  headers: forwardedHeaders
2433
2284
  }
2434
2285
  );
2435
- return new Response(sfapiResponse.body, sfapiResponse);
2286
+ const proxyResponse = new Response(sfapiResponse.body, sfapiResponse);
2287
+ proxyResponse.headers.delete("server-timing");
2288
+ return proxyResponse;
2436
2289
  },
2437
2290
  /**
2438
2291
  * Checks if the request is targeting the Storefront MCP endpoint.
@@ -2481,7 +2334,9 @@ function createStorefrontClient(options) {
2481
2334
  body: request.body,
2482
2335
  headers: forwardedHeaders
2483
2336
  });
2484
- return new Response(mcpResponse.body, mcpResponse);
2337
+ const proxyResponse = new Response(mcpResponse.body, mcpResponse);
2338
+ proxyResponse.headers.delete("server-timing");
2339
+ return proxyResponse;
2485
2340
  } catch (error) {
2486
2341
  const JSON_RPC_INTERNAL_ERROR = -32603;
2487
2342
  const message = error instanceof Error ? error.message : "Internal proxy error";
@@ -2504,21 +2359,6 @@ function createStorefrontClient(options) {
2504
2359
  response.headers.append("Set-Cookie", value);
2505
2360
  }
2506
2361
  }
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
2362
  }
2523
2363
  }
2524
2364
  };
@@ -3780,11 +3620,13 @@ async function refreshToken({
3780
3620
  const customerAccount = session.get(CUSTOMER_ACCOUNT_SESSION_KEY);
3781
3621
  const refreshToken2 = customerAccount?.refreshToken;
3782
3622
  const idToken = customerAccount?.idToken;
3783
- if (!refreshToken2)
3623
+ if (!refreshToken2) {
3624
+ clearSession(session);
3784
3625
  throw new BadRequest(
3785
3626
  "Unauthorized",
3786
3627
  "No refreshToken found in the session. Make sure your session is configured correctly and passed to `createCustomerAccountClient`."
3787
3628
  );
3629
+ }
3788
3630
  newBody.append("grant_type", "refresh_token");
3789
3631
  newBody.append("refresh_token", refreshToken2);
3790
3632
  newBody.append("client_id", customerAccountId);
@@ -3809,6 +3651,9 @@ async function refreshToken({
3809
3651
  });
3810
3652
  if (!response.ok) {
3811
3653
  const text = await response.text();
3654
+ if (getOAuthError(text) === "invalid_grant") {
3655
+ clearSession(session);
3656
+ }
3812
3657
  throw new Response(text, {
3813
3658
  status: response.status,
3814
3659
  headers: {
@@ -3819,9 +3664,13 @@ async function refreshToken({
3819
3664
  const {
3820
3665
  access_token,
3821
3666
  expires_in,
3822
- refresh_token
3667
+ refresh_token,
3668
+ error
3823
3669
  } = await response.json();
3824
3670
  if (!access_token || access_token.length === 0) {
3671
+ if (error === "invalid_grant") {
3672
+ clearSession(session);
3673
+ }
3825
3674
  throw new BadRequest("Unauthorized", "Invalid access token received.");
3826
3675
  }
3827
3676
  session.set(CUSTOMER_ACCOUNT_SESSION_KEY, {
@@ -3858,7 +3707,6 @@ async function checkExpires({
3858
3707
  await locks.refresh;
3859
3708
  delete locks.refresh;
3860
3709
  } catch (error) {
3861
- clearSession(session);
3862
3710
  if (error && error.status !== 401) {
3863
3711
  throw error;
3864
3712
  } else {
@@ -3870,6 +3718,14 @@ async function checkExpires({
3870
3718
  }
3871
3719
  }
3872
3720
  }
3721
+ function getOAuthError(body) {
3722
+ try {
3723
+ const data = JSON.parse(body);
3724
+ return typeof data?.error === "string" ? data.error : void 0;
3725
+ } catch {
3726
+ return void 0;
3727
+ }
3728
+ }
3873
3729
  function generateCodeVerifier() {
3874
3730
  const rando = generateRandomCode();
3875
3731
  return base64UrlEncode(rando);
@@ -3897,9 +3753,7 @@ function convertBufferToString(hash) {
3897
3753
  return String.fromCharCode(...numberArray);
3898
3754
  }
3899
3755
  function generateState() {
3900
- const timestamp = Date.now().toString();
3901
- const randomString = Math.random().toString(36).substring(2);
3902
- return timestamp + randomString;
3756
+ return base64UrlEncode(generateRandomCode());
3903
3757
  }
3904
3758
  async function exchangeAccessToken(authAccessToken, customerAccountId, customerAccountTokenExchangeUrl, httpsOrigin, debugInfo) {
3905
3759
  const clientId = customerAccountId;
@@ -4053,6 +3907,8 @@ function createCustomerAccountHelper(customerApiVersion, shopId) {
4053
3907
 
4054
3908
  // src/customer/customer.ts
4055
3909
  var HYDROGEN_TUNNEL_DOMAIN_SUFFIX = ".tryhydrogen.dev";
3910
+ var OAUTH_RECOVERY_PARAM = "oauth_recovery";
3911
+ var OAUTH_RECOVERY_STATE_PREFIX = "oauth-recovery.";
4056
3912
  function checkTunnelDomain(hostname, useCustomAuthDomain, redirectUri) {
4057
3913
  if (hostname.endsWith(HYDROGEN_TUNNEL_DOMAIN_SUFFIX)) return;
4058
3914
  if (useCustomAuthDomain) {
@@ -4284,7 +4140,8 @@ function createCustomerAccountClient({
4284
4140
  ensureTunnel(requestUrl.hostname);
4285
4141
  ifInvalidCredentialThrowError();
4286
4142
  const loginUrl = new URL(getCustomerAccountUrl("AUTH" /* AUTH */));
4287
- const state = generateState();
4143
+ const isOAuthRecovery = requestUrl.searchParams.get(OAUTH_RECOVERY_PARAM) === "true";
4144
+ const state = `${isOAuthRecovery ? OAUTH_RECOVERY_STATE_PREFIX : ""}${generateState()}`;
4288
4145
  const nonce = generateNonce();
4289
4146
  loginUrl.searchParams.set("client_id", customerAccountId);
4290
4147
  loginUrl.searchParams.set("scope", "openid email");
@@ -4373,17 +4230,30 @@ function createCustomerAccountClient({
4373
4230
  const code = requestUrl.searchParams.get("code");
4374
4231
  const state = requestUrl.searchParams.get("state");
4375
4232
  if (!code || !state) {
4376
- clearSession(session);
4377
4233
  throw new BadRequest(
4378
4234
  "Unauthorized",
4379
4235
  "No code or state parameter found in the redirect URL."
4380
4236
  );
4381
4237
  }
4382
- if (session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.state !== state) {
4383
- clearSession(session);
4384
- throw new BadRequest(
4385
- "Unauthorized",
4386
- "The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`."
4238
+ const customerAccountSession = session.get(CUSTOMER_ACCOUNT_SESSION_KEY);
4239
+ if (customerAccountSession?.state !== state) {
4240
+ if (state.startsWith(OAUTH_RECOVERY_STATE_PREFIX)) {
4241
+ throw new BadRequest(
4242
+ "Unable to complete sign in. Please try again.",
4243
+ "The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`."
4244
+ );
4245
+ }
4246
+ warnOnce(
4247
+ "[h2:warn:customerAccount] OAuth callback state mismatch. Restarting customer login once."
4248
+ );
4249
+ const recoveryLoginUrl = new URL(loginPath, request.url);
4250
+ recoveryLoginUrl.searchParams.set(
4251
+ "return_to",
4252
+ customerAccountSession?.redirectPath ?? defaultRedirectPath
4253
+ );
4254
+ recoveryLoginUrl.searchParams.set(OAUTH_RECOVERY_PARAM, "true");
4255
+ return redirect(
4256
+ `${recoveryLoginUrl.pathname}${recoveryLoginUrl.search}`
4387
4257
  );
4388
4258
  }
4389
4259
  const clientId = customerAccountId;
@@ -4650,6 +4520,19 @@ function createHydrogenContext(options, additionalContext) {
4650
4520
  });
4651
4521
  return hybridProvider;
4652
4522
  }
4523
+
4524
+ // src/utils/server-timing.ts
4525
+ function buildServerTimingHeader(values) {
4526
+ return Object.entries(values).map(([key, value]) => value ? `${key};desc=${value}` : void 0).filter(Boolean).join(", ");
4527
+ }
4528
+ function appendServerTimingHeader(response, values) {
4529
+ const header = typeof values === "string" ? values : buildServerTimingHeader(values);
4530
+ if (header) {
4531
+ response.headers.append("Server-Timing", header);
4532
+ }
4533
+ }
4534
+
4535
+ // src/createRequestHandler.ts
4653
4536
  function createRequestHandler({
4654
4537
  build,
4655
4538
  mode,
@@ -6164,7 +6047,7 @@ function renderTitle(template, title) {
6164
6047
  return template.replace("%s", title ?? "");
6165
6048
  }
6166
6049
  function inferMimeType(url) {
6167
- const ext = url && url.split(".").pop();
6050
+ const ext = url && url.split(/[?#]/)[0].split(".").pop()?.toLowerCase();
6168
6051
  switch (ext) {
6169
6052
  case "svg":
6170
6053
  return "image/svg+xml";