Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/currency-before-cart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/hydrogen": patch
---

Update analytics skills to require `i18n.currency` during initial `ShopifyScripts` rendering so `window.Shopify.currency.active` exists before consent replays buffered events.
5 changes: 5 additions & 0 deletions .changeset/shiny-coins-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/hydrogen": minor
---

Require `i18n.currency` in `ShopifyScripts` / `getShopifyScriptTags()` whenever Shopify analytics is enabled (the default). Product events carry prices, and product pages can be viewed before the cart initializes currency, so `window.Shopify.currency.active` must be set from initial script rendering. `i18n` and `currency` remain optional with `shopifyAnalytics: false`. JS consumers and the Vue binding get a runtime warning instead of a compile-time error. Adds the `ShopifyScriptsI18nWithCurrency` type.
5 changes: 4 additions & 1 deletion examples/hydrogen/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import appStyles from "~/styles/app.css?url";
import resetStyles from "~/styles/reset.css?url";

const FALLBACK_STOREFRONT_ID = "0";
// Shopify analytics requires a currency for product events; used until the
// Storefront API localization currency is available.
const FALLBACK_ANALYTICS_CURRENCY = "USD";

export type RootLoader = typeof loader;

Expand Down Expand Up @@ -81,7 +84,7 @@ export async function loader(args: Route.LoaderArgs) {
country: storefront.i18n.country,
language: storefront.i18n.language,
pathPrefix: storefront.i18n.pathPrefix,
...(analyticsCurrency ? { currency: analyticsCurrency } : {}),
currency: analyticsCurrency ?? FALLBACK_ANALYTICS_CURRENCY,
};

return {
Expand Down
12 changes: 9 additions & 3 deletions packages/hydrogen/skills/hydrogen-analytics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Prerequisite: analytics depends on the same-origin SFAPI proxy (see `hydrogen-re

## Core Pattern

Render Shopify runtime scripts once from the app root/document head with the same resolved market used by Storefront API requests. Use `ShopifyScripts` from your framework binding if it exports one, or `getShopifyScriptTags()` / `renderShopifyScriptTags()` from core in other framework heads. Pass the `shop` object matching the `ShopifyScriptsShop` type, and `i18n` matching the `ShopifyScriptsI18n` type and serialize them into ShopifyScripts. — declare them as constants with type annotations to keep errors located close to the source of writing. The analytics bus is created by default; pass `analytics` only for optional bus configuration such as `customData`. Do not pass market `country` or `language` through analytics consent config.
Render Shopify runtime scripts once from the app root/document head with the same resolved market used by Storefront API requests. Use `ShopifyScripts` from your framework binding if it exports one, or `getShopifyScriptTags()` / `renderShopifyScriptTags()` from core in other framework heads. Pass the `shop` object matching the `ShopifyScriptsShop` type, and `i18n` matching the `ShopifyScriptsI18nWithCurrency` type and serialize them into ShopifyScripts. — declare them as constants with type annotations to keep errors located close to the source of writing. The analytics bus is created by default; pass `analytics` only for optional bus configuration such as `customData`. Do not pass market `country` or `language` through analytics consent config.

Read one browser-lazy singleton from the Shopify global created by `ShopifyScripts`:

Expand All @@ -43,7 +43,12 @@ export function getAnalytics(): StorefrontAnalytics | null {
export { trackCartAnalytics };
```

Read `shop` and `i18n` values on the server and pass them to `ShopifyScripts`. Do not read env APIs in browser modules.
Read `shop` and `i18n` values on the server and pass them to `ShopifyScripts`. For analytics-enabled storefronts,
provide `i18n.currency` during initial script rendering so `window.Shopify.currency.active` exists before consent can
replay buffered events. Prefer the configured market currency when it is authoritative; otherwise query
`localization.country.currency.isoCode` under the same Storefront API `@inContext(country:, language:)` values used by
the request. Keep an explicit failure/mock fallback. Do not use `shop.paymentSettings.currencyCode`, which is the shop
currency rather than necessarily the active market's presentment currency. Do not read env APIs in browser modules.

In the ShopifyScripts `analytics` config, `channel` is optional and defaults to `"hydrogen"`. The `"hydrogen"` channel is the one that requires `storefrontId` — it is pulled from the ShopifyScripts `shop` config into analytics payloads. Headless storefronts pass `channel: "headless"` in the ShopifyScripts `analytics` config; the analytics payload then omits `storefrontId`, but `shop.storefrontId` itself is still required (pass `"0"` when the app has no storefront ID).

Expand All @@ -58,7 +63,7 @@ Publish these from route/page boundaries:
- `CART_VIEWED` when the full cart page or cart drawer is viewed.
- Wire cart tracking once per cart store lifecycle with `trackCartAnalytics(cartStore)` — React apps use the `useCartAnalytics()` hook from `@shopify/hydrogen/react` and Vue apps use the `useCartAnalytics()` composable from `@shopify/hydrogen/vue`; both call it with the provider's cart store and clean up on unmount. The tracker subscribes to the cart store itself, skips pending/revalidating/note updates, publishes cart delta events on confirmed cart changes, and returns an unsubscribe function. Call it from a client-only effect (`useEffect` / `onMounted`), never at cart-store creation time — it throws when `window.Shopify.analytics` is missing (SSR). Do not manually publish cart delta events.

The bus defaults `shop` from the top-level `shop` config passed to ShopifyScripts; pass `shop` in an event payload only when intentionally overriding that configured value. Shopify analytics reads language and currency from `window.Shopify.locale` and `window.Shopify.currency.active`.
The bus defaults `shop` from the top-level `shop` config passed to ShopifyScripts; pass `shop` in an event payload only when intentionally overriding that configured value. Shopify analytics reads language and currency from `window.Shopify.locale` and `window.Shopify.currency.active`. Cart tracking may synchronize the currency after cart data arrives, but it is not initial currency setup.

Required product analytics fields include Shopify Product GID, ProductVariant GID when available, title, price, vendor, quantity, and variant title.

Expand All @@ -78,3 +83,4 @@ Required product analytics fields include Shopify Product GID, ProductVariant GI
- Confirmed cart data changes flow through `trackCartAnalytics(cartStore)` (React/Vue bindings: `useCartAnalytics()`), and the cart query includes `updatedAt`.
- Consent-denied visitors do not deliver destination events.
- No browser module reads private or server-only env variables.
- Before and after consent, `window.Shopify.currency.active` matches the resolved market without requiring a cart mutation.
39 changes: 33 additions & 6 deletions packages/hydrogen/skills/hydrogen-setup/references/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
**Prerequisites:**

- A storefront built on `@shopify/hydrogen` with the request interceptors already wired (`handleShopifyRoutes` and `handleShopifyRedirects`). The analytics bus depends on the SFAPI proxy so the browser can observe same-origin Storefront API responses for session cookies. Without the proxy, analytics falls back to deprecated JavaScript-visible cookies and should be treated as incomplete. If you have not installed the interceptors yet, install them first with the local `hydrogen-request-handlers` skill.
- Shopify runtime scripts rendered from the root/document head. Use `ShopifyScripts` from your framework binding if it exports one, or `getShopifyScriptTags()` / `renderShopifyScriptTags()` from core in other framework heads. Pass `{country, language, currency?}` as `i18n`; pass `{shopId: env.SHOP_ID, storefrontId: env.PUBLIC_STOREFRONT_ID ?? "0", myshopifyDomain: env.PUBLIC_STORE_DOMAIN}` as `shop`. Resolve both on the server, declare them as consts annotated with the `ShopifyScriptsShop` / `ShopifyScriptsI18n` types from `@shopify/hydrogen` (so wrong or missing fields fail typecheck where they are built), and serialize them into ShopifyScripts. ShopifyScripts creates `window.Shopify.analytics` by default and exposes the permanent domain as `window.Shopify.shop`. Analytics consent config does not accept `country` or `language`.
- Shopify runtime scripts rendered from the root/document head. Use `ShopifyScripts` from your framework binding if it exports one, or `getShopifyScriptTags()` / `renderShopifyScriptTags()` from core in other framework heads. Pass `{country, language, currency}` as `i18n`; pass `{shopId: env.SHOP_ID, storefrontId: env.PUBLIC_STOREFRONT_ID ?? "0", myshopifyDomain: env.PUBLIC_STORE_DOMAIN}` as `shop`. Resolve both on the server, declare them as consts annotated with the `ShopifyScriptsShop` / `ShopifyScriptsI18nWithCurrency` types from `@shopify/hydrogen` (so wrong or missing fields fail typecheck where they are built), and serialize them into ShopifyScripts. ShopifyScripts creates `window.Shopify.analytics` by default and exposes the permanent domain as `window.Shopify.shop`. Analytics consent config does not accept `country` or `language`.
- A client-side lifecycle hook in your framework (route-change effect, navigation event, `<script>` tag, etc.) so view events can fire on the right URL transitions.

`ShopifyScripts` creates the zero-dependency analytics bus, sets it on `window.Shopify.analytics`, and owns Shopify consent setup, analytics CDN loading, and deprecated-cookie compatibility. Framework adapters stay thin: they translate framework lifecycle events into bus calls and wire cart delta tracking with `trackCartAnalytics()`.
Expand Down Expand Up @@ -73,7 +73,7 @@ import {
type ConsentConfig,
type ShopifyScriptTagsOptions,
type ShopifyScriptsShop,
type ShopifyScriptsI18n,
type ShopifyScriptsI18nWithCurrency,
} from "@shopify/hydrogen";

const shop: ShopifyScriptsShop = {
Expand All @@ -82,10 +82,10 @@ const shop: ShopifyScriptsShop = {
myshopifyDomain: "example.myshopify.com", // permanent MyShopify domain
};

const i18n: ShopifyScriptsI18n = {
const i18n: ShopifyScriptsI18nWithCurrency = {
country: "US",
language: "EN", // sent as Monorail content language
currency: "USD", // optional; sets window.Shopify.currency.active
currency: "USD", // required while Shopify analytics is enabled
};

const consent: ConsentConfig = {
Expand All @@ -111,11 +111,31 @@ Resolve shop metadata on the server and pass it to ShopifyScripts. Shopify analy

### `i18n`

Pass the app's resolved `country` and `language` market values. Optional `currency` sets `window.Shopify.currency.active` for Shopify runtime scripts and Shopify analytics. Shopify analytics reads its content language from `window.Shopify.locale`.
Pass the app's resolved market values. `currency` is required by the ShopifyScripts types whenever Shopify analytics
is enabled (the default); it is only optional with `shopifyAnalytics: false`. It initializes
`window.Shopify.currency.active` before consent-gated events can replay. Use an authoritative configured market currency or select the active presentment currency from the
Storefront API under the same market context as the request:

```graphql
query AnalyticsCurrency($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
localization {
country {
currency {
isoCode
}
}
}
}
```

Keep an explicit configured fallback for API failures or mock mode. Do not use
`shop.paymentSettings.currencyCode`: that is the shop currency and may differ from the active market's presentment
currency. Shopify analytics reads its content language from `window.Shopify.locale`.

### `analytics`

The analytics bus is enabled by default. Pass `analytics` only when you need optional bus configuration such as `customData`, which is attached to bus-generated payloads. Shopify analytics reads currency from `window.Shopify.currency.active`, which is seeded by `i18n.currency` and updated from cart currency when available.
The analytics bus is enabled by default. Pass `analytics` only when you need optional bus configuration such as `customData`, which is attached to bus-generated payloads. Shopify analytics reads currency from `window.Shopify.currency.active`, which must be seeded by `i18n.currency`; cart currency is only later synchronization or fallback, not initial setup.

### `consent`

Expand Down Expand Up @@ -591,6 +611,8 @@ After wiring, smoke-test each event in the browser dev tools:
3. **Per-route navigation fires page_viewed** — click around. Each navigation should produce a fresh `page_viewed` event. If only the initial page load fires, the route-change hook is wired wrong (e.g. effect dependency missing in React, reactive read missing in Solid).
4. **Cart events fire** — add an item to the cart. You should see `cart_updated` followed by `product_added_to_cart`. If you see `cart_updated` repeating with the same payload, the dedupe key (`updatedAt`) is stale — confirm your cart query selects `updatedAt`.
5. **Privacy banner renders for EU/UK visitors** — if `mode: "default-banner"`, simulate a GDPR-protected region with browser dev-tools location override or VPN. The banner should render. If it does not, check that `cdn.shopify.com` is not blocked by your CSP.
6. **Currency exists across consent** — before and after granting consent, verify `window.Shopify.currency.active`
matches the resolved market currency without first adding an item to the cart.

For production, re-verify against the production bundle. Several gotchas only appear once the SSR/CSR boundary stabilizes.

Expand All @@ -601,6 +623,9 @@ For production, re-verify against the production bundle. Several gotchas only ap
- **Replay is destination-only.** Raw `analytics.subscribe()` listeners only receive live events. `analytics.addDestination()` callbacks receive consent-gated live events plus buffered replay after analytics consent is granted. If the visitor explicitly denies analytics consent, the buffer is cleared and those pre-denial events are never replayed.
- **The singleton must be lazy.** Reading the global bus at module top-level can run on the server during SSR and crash on `window` access. Always wrap in a `typeof window === 'undefined'` guard.
- **Use the right shop shape for each API.** `ShopifyScripts` accepts a numeric Shop ID or Shopify Shop GID plus `storefrontId` and the permanent `myshopifyDomain`; the analytics bus normalizes `shop.shopId` to a Shopify Shop GID before dispatch, while the bootstrap exposes the domain as `window.Shopify.shop`.
- **Currency only appears after a cart mutation.** The cart tracker can synchronize currency, which may hide missing
bootstrap data. Resolve the active market currency on the server and pass it as `i18n.currency` during initial
ShopifyScripts rendering.
- **Customer Privacy script blocked by CSP.** If your CSP does not allow `cdn.shopify.com`, the consent script never loads, `analyticsProcessingAllowed()` stays `false`, and destination events never deliver. Check Network tab for blocked requests; add `cdn.shopify.com` to `script-src`.
- **`mode: "no-banner"` is wrong for any storefront with EU/UK/CA visitors unless consent is handled elsewhere.** Without a hosted or custom banner, those visitors have no UI to grant consent — destination events never deliver. Default to `mode: "default-banner"` unless you have a custom consent UI that calls `setTrackingConsent()`.
- **Multiple bus instances on the same page conflict.** `window.Shopify.customerPrivacy.config` is global; the latest initialized config wins. Multi-store-per-page is not supported. Use one bus per active storefront shell.
Expand All @@ -622,3 +647,5 @@ For production, re-verify against the production bundle. Several gotchas only ap
- **Don't put per-route view events in a global subscriber.** A single subscriber that watches `page_viewed` and synthesizes `product_viewed` from URL parsing is brittle and loses payload context. Publish each view event from the route that has the data.
- **Don't construct multiple buses for "different consent contexts" on the same page.** Customer Privacy config is global; the latest initialized config takes effect. If you need conditional behavior, branch inside subscribers, not at construction.
- **Don't skip the request-handler prerequisite.** Without the SFAPI proxy, modern same-origin Shopify cookies cannot be set. Analytics may appear to work via deprecated JS-visible cookies, but session continuity into checkout breaks. Treat analytics as incomplete until the proxy is live in production.
- **Don't use `shop.paymentSettings.currencyCode` for market analytics.** It is the shop currency, not necessarily the
active market's presentment currency; use `localization.country.currency.isoCode` in the active market context.
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ Render Shopify runtime scripts once in the root document.
Build both in a server-only module, annotated with the types exported from `@shopify/hydrogen`, so TypeScript rejects wrong or missing fields at the declaration — not at the distant `ShopifyScripts` call site:

```ts
import type { ShopifyScriptsI18n, ShopifyScriptsShop } from "@shopify/hydrogen";
import type { ShopifyScriptsI18nWithCurrency, ShopifyScriptsShop } from "@shopify/hydrogen";

const shop: ShopifyScriptsShop = {
shopId: env.SHOP_ID,
storefrontId: env.PUBLIC_STOREFRONT_ID || "0",
myshopifyDomain: env.PUBLIC_STORE_DOMAIN,
};

const i18n: ShopifyScriptsI18n = {
const i18n: ShopifyScriptsI18nWithCurrency = {
country, // resolved market country, e.g. "US"
language, // resolved market language, e.g. "EN"
currency, // resolved market currency, e.g. "USD" — required while Shopify analytics is enabled
};
```

Expand All @@ -32,7 +33,7 @@ All three `shop` fields are required and identify different things:
- `storefrontId` — the specific **headless/Hydrogen storefront instance** attached to the shop. A shop can have several storefronts; analytics and PerfKit use this ID to attribute traffic to this one. Use `"0"` when the app has no provisioned storefront ID.
- `myshopifyDomain` — the shop's permanent `*.myshopify.com` domain, exposed as `window.Shopify.shop`.

`i18n` is the same resolved market used by Storefront API requests (`country` + `language`, optional `currency`) — not a locale string, and not the analytics consent config.
`i18n` is the same resolved market used by Storefront API requests (`country` + `language` + `currency`) — not a locale string, and not the analytics consent config. `currency` is required whenever Shopify analytics is enabled (the default); it is only optional when `shopifyAnalytics: false`.

For vanilla browser code that does not use SSR or a framework head API, render the HTML from core and include it in the document shell. If your app renders tags through core helpers instead of a framework binding, call `initializeShopifyScripts()` from bundled client code:

Expand Down
1 change: 1 addition & 0 deletions packages/hydrogen/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export type {
ShopifyScriptsShop,
ShopifyScriptTagsOptions,
ShopifyScriptsI18n,
ShopifyScriptsI18nWithCurrency,
} from "./shopify-scripts/index";

export { createCollectionReconciler, createCollectionStore } from "./collection";
Expand Down
Loading
Loading