Skip to content
Draft
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/fix-cart-analytics-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

Fixed cart analytics events (`product_added_to_cart`, `cart_updated`) being silently dropped since 2025.7.1. A race condition between consent initialization and cart resolution caused events to be lost when the Customer Privacy SDK hadn't loaded yet. The internal event queue now correctly buffers all events until consent state is determined, preserving multiple events of the same type in FIFO order.
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,74 @@ describe('<Analytics.Provider />', () => {
expect(screen.getByText('child')).toBeInTheDocument();
});

describe('event queue behavior', () => {
it('delivers cart events even when canTrack is initially false', async () => {
const cartUpdatedEvent = vi.fn();
const productAddedToCartEvent = vi.fn();

// Simulate the real-world scenario: canTrack is initially false
// (privacy SDK not loaded), cart resolves, then canTrack becomes true.
const {rerender, AnalyticsProvider, getUpdatedAnalytics} =
await renderAnalyticsProvider({
initialCart: CART_DATA,
mockCanTrack: false,
registerCallback: (analytics, ready) => {
analytics.subscribe('cart_updated', cartUpdatedEvent);
analytics.subscribe(
'product_added_to_cart',
productAddedToCartEvent,
);
ready();
},
});

// Trigger cart update while canTrack is false
rerender(
<AnalyticsProvider
updateCart={CART_DATA_2}
updateMockCanTrack={true}
/>,
);
await act(async () => {});

// Cart events should eventually be delivered
expect(cartUpdatedEvent).toHaveBeenCalled();
expect(productAddedToCartEvent).toHaveBeenCalled();
});

it('delivers product_added_to_cart event when cart updates while canTrack transitions from false to true', async () => {
const productAddedToCartEvent = vi.fn();

// Start with canTrack: false (simulates privacy SDK not loaded yet)
const {rerender, AnalyticsProvider} = await renderAnalyticsProvider({
initialCart: CART_DATA,
mockCanTrack: false,
registerCallback: (analytics, ready) => {
analytics.subscribe('product_added_to_cart', productAddedToCartEvent);
ready();
},
});

// Cart update happens while canTrack is still false
// (simulates deferred cart resolving before privacy SDK loads)
rerender(<AnalyticsProvider updateCart={CART_DATA_2} />);
await act(async () => {});

// Now transition canTrack to true (simulates privacy SDK loaded)
rerender(
<AnalyticsProvider
updateCart={CART_DATA_2}
updateMockCanTrack={true}
/>,
);
await act(async () => {});

// The product_added_to_cart event should eventually be delivered
// even though the cart update happened while canTrack was false
expect(productAddedToCartEvent).toHaveBeenCalled();
});
});

describe('useAnalytics()', () => {
it('returns shop, cart, customData, privacyBanner and customerPrivacy', async () => {
const {analytics} = await renderAnalyticsProvider({
Expand Down Expand Up @@ -334,23 +402,27 @@ async function renderAnalyticsProvider({
const AnalyticsProvider = ({
updateCart,
updateCustomData,
updateMockCanTrack,
}: {
updateCart?: CartReturn;
updateCustomData?: Record<string, unknown>;
updateMockCanTrack?: boolean;
} = {}) => {
const effectiveCanTrack =
updateMockCanTrack !== undefined ? updateMockCanTrack : mockCanTrack;
return (
<Analytics.Provider
cart={updateCart || initialCart}
shop={SHOP_DATA}
consent={CONSENT_DATA}
customData={updateCustomData || customData}
{...(typeof mockCanTrack === 'boolean'
? {canTrack: () => mockCanTrack}
{...(typeof effectiveCanTrack === 'boolean'
? {canTrack: () => effectiveCanTrack}
: {})}
>
<LoopAnalytics
registerCallback={registerCallback}
mockCanTrack={mockCanTrack}
mockCanTrack={effectiveCanTrack}
>
{loopAnalyticsFn}
</LoopAnalytics>
Expand Down
35 changes: 22 additions & 13 deletions packages/hydrogen/src/analytics-manager/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ const AnalyticsContext = createContext<AnalyticsContextValue>(
defaultAnalyticsContext,
);

// TODO: These module-scoped singletons are shared across all AnalyticsProvider
// instances in the same JS context. This works because there is exactly one
// provider per page, but would need refactoring if multiple providers are ever needed.
const subscribers = new Map<
string,
Map<string, (payload: EventPayloads) => void>
Expand Down Expand Up @@ -194,7 +197,8 @@ function subscribe(event: any, callback: any) {
subscribers.get(event)?.set(callback.toString(), callback);
}

const waitForReadyQueue = new Map<any, any>();
const MAX_ANALYTICS_QUEUE_SIZE = 100;
const waitForReadyQueue: Array<{event: string; payload: any}> = [];

function publish(
event: typeof AnalyticsEvent.PAGE_VIEWED,
Expand Down Expand Up @@ -230,7 +234,14 @@ function publish(
): void;
function publish(event: any, payload: any): void {
if (!areRegistersReady()) {
waitForReadyQueue.set(event, payload);
if (waitForReadyQueue.length >= MAX_ANALYTICS_QUEUE_SIZE) {
console.warn(
'[h2:warn:Analytics] Event queue is full. Events are being dropped. ' +
'This usually means analytics registers never called ready().',
);
return;
}
waitForReadyQueue.push({event, payload});
return;
}

Expand Down Expand Up @@ -265,11 +276,14 @@ function register(key: string) {
ready: () => {
registers[key] = true;

if (areRegistersReady() && waitForReadyQueue.size > 0) {
waitForReadyQueue.forEach((queuePayload, queueEvent) => {
publishEvent(queueEvent, queuePayload);
});
waitForReadyQueue.clear();
if (areRegistersReady()) {
while (waitForReadyQueue.length > 0) {
const pending = [...waitForReadyQueue];
waitForReadyQueue.length = 0;
for (const {event: queueEvent, payload: queuePayload} of pending) {
publishEvent(queueEvent, queuePayload);
}
}
}
},
};
Expand Down Expand Up @@ -300,9 +314,6 @@ function AnalyticsProvider({
cookieDomain,
}: AnalyticsProviderProps): JSX.Element {
const {shop} = useShopAnalytics(shopProp);
const [analyticsLoaded, setAnalyticsLoaded] = useState(
customCanTrack ? true : false,
);
const [consentCollected, setConsentCollected] = useState(false);
const [carts, setCarts] = useState<Carts>({cart: null, prevCart: null});
const [canTrack, setCanTrack] = useState<() => boolean>(
Expand Down Expand Up @@ -354,15 +365,14 @@ function AnalyticsProvider({
canTrack,
...carts,
customData,
publish: canTrack() ? publish : () => {},
publish,
shop,
subscribe,
register,
customerPrivacy: getCustomerPrivacy(),
privacyBanner: getPrivacyBanner(),
};
}, [
analyticsLoaded,
canTrack,
carts,
carts.cart?.updatedAt,
Expand All @@ -388,7 +398,6 @@ function AnalyticsProvider({
<ShopifyAnalytics
consent={consent}
onReady={() => {
setAnalyticsLoaded(true);
setCanTrack(
customCanTrack ? () => customCanTrack : () => shopifyCanTrack,
);
Expand Down
5 changes: 3 additions & 2 deletions packages/hydrogen/src/analytics-manager/CartAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ export function CartAnalytics({
customData,
};

// prevent duplicate events
// TODO: add cart id check
// Dedup is safe: `publish` always enqueues for delivery (either immediately
// or via waitForReadyQueue when registers aren't ready yet). Events are
// never silently dropped, so marking an event as "sent" here is correct.
if (cart.updatedAt === lastEventId.current) return;
lastEventId.current = cart.updatedAt;

Expand Down
Loading