Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bumpy-hats-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@shopify/hydrogen': patch
---

Fix browser extension compatibility issue causing "Cannot redefine property: Shopify" error

Added defensive checks in ShopifyCustomerPrivacy component to handle cases where browser extensions define window.Shopify as non-configurable. The component now detects existing property descriptors and uses polling fallback when property redefinition is not possible, ensuring Hydrogen apps work correctly with extensions.
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,21 @@ export function useCustomerPrivacy(props: CustomerPrivacyApiProps) {
let customShopify: {customerPrivacy: CustomerPrivacy} | undefined | object =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question Re: line 325 - wouldn't window.privacyBanner have the same potential issue. If an extension defines it as non-configurable before Hydrogen initializes, the Object.defineProperty on line 325 might throw too?

window.Shopify || undefined;

const shopifyDescriptor = Object.getOwnPropertyDescriptor(
window,
'Shopify',
);
if (shopifyDescriptor && !shopifyDescriptor.configurable) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: the fallback block skips two things the normal path does:

  1. setTrackingConsent override - the normal path (~15 lines below) wraps this to inject the headless storefront config. In the fallback, consumers get the raw CDN version, which means consent calls might silently fail for headless stores.
  2. backendConsentEnabled stub - the normal path (~35 lines below) installs {backendConsentEnabled: true} so the CDN reads the right flag before assigning the full API. The fallback skips this.

Also - the PR description says "App loads normally with warning in console" but there's no console.warn here.

I think the polling callback should apply both overrides when it detects customerPrivacy, and we should add a console.warn at the top of this block.

customShopify = window.Shopify || undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: customShopify was already assigned to window.Shopify || undefined on line 341. This is a no-op, I think we can remove it.

Suggested change
customShopify = window.Shopify || undefined;
const pollForCustomerPrivacy = setInterval(() => {

const pollForCustomerPrivacy = setInterval(() => {
if (window.Shopify?.customerPrivacy) {
setLoaded.customerPrivacy();
clearInterval(pollForCustomerPrivacy);
}
}, 100);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: the interval runs indefinitely until customerPrivacy appears or the component unmounts - if the CDN script fails to load for any reason, this runs for the page's entire lifetime. I think we should add a max retry count (e.g. 100 tries = 10 seconds at 100ms).

return () => clearInterval(pollForCustomerPrivacy);
}

// monitor for when window.Shopify = {} is first set
Object.defineProperty(window, 'Shopify', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question / possible improvement?: Instead of pre-checking for the descriptor can we try catch the Object.defineProperty(window, 'Shopify', { /* ...code below */ });

Then in the catch intercept like you did above with Object.defineProperty(window.Shopify, 'customerPrivacy', {...}

Something like:


try {
      // Normal path - intercept window.Shopify via property descriptor
      Object.defineProperty(window, 'Shopify', { ....})
} catch {
      console.warn("[h2] browser extension detected, using compatibility mode")

      // Intercept one level deeper Writing to window.Shopify still works via the extension's setter,
      // so we can still install the backendConsentEnabled stub
      window.Shopify.backendConsentEnabled = true

      attempts = 0
      poll every 100ms:
        if window.Shopify.customerPrivacy exists:
          stop polling
          wrap customerPrivacy.setTrackingConsent with headless config pre-applied
          // window.Shopify.customerPrivacy is a plain property on the inner object,
          // so we can still redefine it with our wrapped version
          Object.defineProperty(window.Shopify, 'customerPrivacy', { wrapped version })
          call setLoaded.customerPrivacy()
        else if attempts > 100:
          stop polling
          console.warn("[h2] customerPrivacy never appeared")

      return cleanup: clear interval
}

configurable: true,
Expand Down
Loading