Keep your theme's cart in sync
Refresh custom cart UI after Tab Promo or theme-owned mutations, without intercepting global storefront requests.
Shopify does not define one cart-change event that every theme must implement. After a gift, top-up item, or other Cart AJAX mutation succeeds, the server cart can be correct while a drawer, header badge, or promotion placement still shows old state.
Tab Promo supports two integration paths: the official Theme Extension’s public cart event and the Storefront SDK’s client lifecycle.
Official Theme Extension
After a cart mutation owned by the extension, Tab Promo refreshes its promotion facts, triggers its built-in adapters for supported theme families, and emits a documented event for custom theme UI. The extension fires it after adding a gift, top-up item, or progress item, after claiming gifts in the gift sheet, and after removing a gift.
document.addEventListener('tab-promo:cart:changed', (event) => {
const { cart, open } = event.detail
updateCartDrawer(cart)
updateHeaderCount(cart)
if (open) showCartDrawer()
})
The event is dispatched on document.documentElement and bubbles, so a
listener on either the root element or document receives it.
Event detail
| Field | Meaning |
|---|---|
open | true after an add, when opening the drawer is expected feedback; false after a gift removal and after gifts are claimed in the gift sheet, because the shopper is still in the sheet. |
cart | The fresh /cart.js payload, or null if the post-mutation fetch failed. Re-fetch when your UI requires a guaranteed current cart. |
The event fires after the mutation completes. It is additive: future fields may
appear in detail, and handlers should ignore what they do not understand.
Do not dispatch this event before your own Cart AJAX request succeeds.
Built-in theme adapters
Before the public event, the extension dispatches cart:updated,
cart:refresh, cart:change, cart:build, and theme:cart:refresh on the
root element for every theme, plus product:added-to-cart on window. Theme
families pick these up as follows:
| Theme family | Hook |
|---|---|
| Dawn and other Online Store 2.0 forks | cart-drawer, mini-cart, cart-notification, and ajax-cart elements: re-renders their sections through Shopify’s Section Rendering API (up to five sections), then calls renderContents, refetchContents, update, and open when the element provides them. |
| Horizon | cart:update with the fresh cart and rendered sections when a cart-items-component is present. Horizon handles section replacement, its cart icon, and its own drawer behavior. |
| Impulse, Expanse, Motion | Listen for the cart:build and cart:change events above. |
| Prestige, Impact, Focal | Listen for the cart:refresh event above. |
| Palo Alto, Pipeline | window.cart.getCart(). This always opens the drawer, even for a quiet removal, because the theme has no refresh-only call. |
Except on Palo Alto and Pipeline, the extension then updates count badges ([data-cart-count], .cart-count,
.cart-count-bubble, .cart-item-count) whose text is a bare number. Other
themes need their own listener for tab-promo:cart:changed.
The extension also refreshes its own promotion blocks after theme-owned cart
changes. It watches cart:updated, cart:refresh, cart:change,
cart-updated, cart:update, theme:cart:added, theme:cart:loaded,
tab-promo:cart:changed, and shopify:section:load, plus the theme’s own Cart
AJAX requests. It also refreshes when the browser comes back online, and on the
cart page when the tab becomes visible again or the page is restored from the
back-forward cache.
Cart drawer offers
Cart offers render through the Cart drawer offers app embed. There is no cart-page block. Enable the embed under App embeds and set the theme’s cart type to drawer. The embed works independently of the global offers embed.
By default the embed mounts in Horizon’s drawer, which is the only verified drawer. For another theme, enter the Drawer container CSS selector and, for before or after placement, the Position anchor CSS selector. The anchor must be inside the drawer container. If either selector matches nothing, the embed stays unmounted. Offers stay hidden while the cart is empty or cannot be read. Verify a custom selector on a development theme, from a product or home page.
Storefront SDK cart helpers
SDK cart helpers such as claimGift, removeGift, and addTopupItem fail
closed. They resolve only after Shopify returns a fresh cart confirming the
requested result, and they schedule the SDK’s mounted promotion facts to
refresh.
try {
const cart = await promo.addTopupItem({ variantId, quantity: 1 })
updateCartDrawer(cart)
updateHeaderCount(cart)
showCartDrawer()
} catch (error) {
showCartActionError(error)
}
Use the returned cart instead of immediately fetching /cart.js again. The SDK
does not provide a drawer renderer, markup, or focus management; those remain
the theme’s responsibility. SDK helpers do not run the Theme Extension’s theme
adapters or dispatch tab-promo:cart:changed.
Theme-owned cart mutations
The SDK deliberately does not monkey-patch global fetch or XMLHttpRequest.
When the theme performs a successful cart mutation and does not emit a cart
event the SDK already recognizes, notify the client explicitly:
const response = await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: lineKey, quantity }),
})
if (!response.ok) throw new Error('Cart update failed')
const cart = await response.json()
updateCartDrawer(cart)
promo.notifyCartChanged()
Call promo.notifyCartChanged() after success, not before. The notification is
for refreshing mounted promotion facts; it does not replace updating the
theme’s cart UI.
The client also listens on document for tab-promo:cart:changed,
cart:updated, cart:refresh, cart:change, cart-updated, and
shopify:section:load. Notifications within 100 ms are coalesced, so an
integration should use the signal it naturally owns rather than dispatching
several aliases.
Section lifecycle
Online Store sections can be replaced without a full page load. Dispose each mounted promotion surface when its section unloads, and destroy the shared client when the page or app shell ends.
const stop = await promo.mount({
request: { surface: 'cart' },
render: renderCartPromotions,
onEmpty: clearCartPromotions,
})
document.addEventListener('shopify:section:unload', stop, { once: true })
// At final page-shell teardown:
promo.destroy()
Disposing a mount cancels any read still in flight for it, and each refresh of a
mount cancels that mount’s previous read, so a slow earlier response never
replaces newer facts. If a refresh fails, mount calls onEmpty rather than
leaving the previous facts on screen.
Do not create a new long-lived client every time a drawer opens. One client can coordinate several mounted surfaces and one assignment lifecycle.
Custom-theme checklist
- Decide whether the official extension or custom SDK renderer owns each placement. Do not render both in one slot.
- Update drawer HTML, count badges, live regions, and focus from the confirmed cart returned by the owning mutation path.
- Test both an opening action such as a gift claim and a quiet action such as removal.
- Notify the SDK after successful theme-owned mutations.
- Dispose section mounts and destroy the shared client at final teardown.
- Verify rapid updates, failed Cart AJAX responses, unavailable variants, and an actual development-store checkout.