doc-site
Architecture

Mini-cart

How the cart drawer stays consistent when several apps write to the same Shopify cart.

Mini-cart

The cart drawer is a set of custom elements around one event bus. Several installed apps write to the Shopify cart without telling the theme. A drawer that redraws only after its own actions would show out-of-date contents, so this one also listens for cart writes it did not make.

Three kinds of trigger reach the cart. Mini-cart controls call CartService directly, mini-cart command events go through CartCommandController into CartService, and cart writes made by third-party apps are caught by CartFetchInterceptor. Both CartService and CartFetchInterceptor emit the same events onto CartEventBus, which feeds CartStore and every mini-cart element.

Ownership

ConcernOwner
Cart contents and pricesShopify
Drawer markup and blockssections/mini-cart.liquid and its snippets
Network calls and event emissionCartService
Observing other people's writesCartFetchInterceptor
Latest known cartCartStore
RenderingThe mini-cart-* custom elements

The theme never treats its own copy of the cart as authoritative. Every mutation returns a fresh cart from Shopify, and the theme broadcasts that response.

The pieces

ModuleRole
internals/core.tsCart and line types, and the CartEvents name constants
internals/event-bus.tsThin wrapper over CustomEvent on window
internals/cart-service.tsCalls the Shopify Ajax Cart API and emits lifecycle events
internals/simple-cart-service.tsFriendlier API over CartService, plus stale-key recovery
internals/fetch-interceptor.tsPatches window.fetch to observe cart writes from anywhere
internals/command-controller.tsTurns mini-cart:* window events into service calls
internals/cart-store.tsHolds the latest cart, updated from success events
elements/mini-cart.tsThe root custom element that wires the rest together
elements/mini-cart-*.tsOne element per drawer block

The bus is plain CustomEvent on window, so any script on the page can listen or dispatch without importing anything. This is deliberate, because it lets a Liquid snippet or an app trigger cart behaviour.

Event flow

A mutation goes through four stages. An add runs like this.

  1. cart:add:start fires with the payload.
  2. CartService posts to /cart/add.js.
  3. On success, cart:add:success fires with the payload and the resulting cart.
  4. cart:changed fires with the cart and a reason of add.

cart:changed is the event most code should listen to. It fires for every kind of change and carries the authoritative cart.

The root element also emits cart:changed:debounced 250 milliseconds after the last change. Elements that re-render expensively, including the progress bar, listen to that instead so a rapid sequence of quantity clicks produces one render.

The full list of names is in Cart events.

Why fetch is patched

Third-party apps post to /cart/add.js and /cart/change.js directly. Without interception the drawer would show a total that no longer matches the cart.

CartFetchInterceptor replaces window.fetch, matches URLs against /cart(.js|/*.js), and emits the same success and change events for a write it did not make.

When the mini-cart makes the call itself, the interceptor would fire every event a second time. A header prevents this. CartService sends X-Source: mini-cart on all its requests, and the interceptor skips any response carrying it.

/cart/add.js does not return the cart, so after an add the interceptor issues its own GET /cart.js and broadcasts that result instead.

Why writes are serialised

Shopify derives a line item key from a hash of the line's properties. Writing a property creates a new key for the same line.

That makes read-modify-write racy. A concurrent write can invalidate the key between a GET /cart.js and the POST /cart/change.js that follows it, and Shopify answers with 400 no valid id or line parameter.

CartService.runExclusive holds a promise chain so mutations run one at a time. Every task queues behind the previous one, including failed ones, so a rejection does not stall the queue.

A task inside runExclusive must call only the low-level methods on CartService. Calling a SimpleCartService method re-enters the queue and deadlocks. This is the easiest way to break the cart. Check for it whenever you add cart code.

Stale key recovery

Even with serialisation, DOM rendered before a property write holds an old key.

resolveLineItem in simple-cart-service.ts recovers by matching on the variant id, which is the stable half of the key. It only recovers when exactly one line matches. Two lines of the same variant, such as a purchased item plus its free gift, are ambiguous, so it gives up and the caller re-renders from the authoritative cart rather than guessing.

Line item property rules

Some cart lines carry properties the theme maintains, such as a promotion label or the balance owing on a MediSpa deposit. A chain of rules in internals/line-item-rules/ keeps them correct.

RuleWhat it maintains
promotion-messaging-ruleAdds a promotion label when a line meets the gift manager's price and quantity requirements
promotion-label-quantity-ruleRemoves that label when the line drops below three units
remaining-amount-recalculation-ruleRecalculates the balance owing on MediSpa lines

Each rule proposes property changes rather than writing them, and the chain applies the merged result in one pass. That keeps the number of key-changing writes to a minimum, which matters because every one of them invalidates a key.

Cart source tagging

Every add carries a _source line item property recording where it came from, in the form surface:function, for example quick-add-modal:addToCart.

Shopify hides properties whose name starts with an underscore from the storefront and checkout, so customers never see it. It stays visible in /cart.js, the order in Shopify Admin and webhooks, so you can check there which code added a line.

withCartSource in src/lib/cart-source.ts keeps an existing _source rather than overwriting it, so a more specific tag set earlier by the caller survives.

Failure modes

SymptomLikely cause
Drawer total disagrees with the cart pageAn event was missed. Check whether the writing code sets X-Source
400 no valid id or line parameterStale line item key, usually a write that bypassed runExclusive
Every cart event fires twiceSomething copied the X-Source header, or a second interceptor was installed
Cart hangs after one actionA task inside runExclusive called SimpleCartService and deadlocked the queue
Progress bar does not moveIt listens to the debounced event. Confirm cart:changed fired at all

Diagnosis steps are in Cart total or gift is wrong.

Source map

ConcernFile
Event names and cart typessrc/entrypoints/mini-cart/internals/core.ts
Network calls and serialisationsrc/entrypoints/mini-cart/internals/cart-service.ts
Stale key recoverysrc/entrypoints/mini-cart/internals/simple-cart-service.ts
Fetch patchingsrc/entrypoints/mini-cart/internals/fetch-interceptor.ts
Command eventssrc/entrypoints/mini-cart/internals/command-controller.ts
Property rulessrc/entrypoints/mini-cart/internals/line-item-rules/
Checkout interceptionsrc/entrypoints/mini-cart/internals/checkout-chain.ts
Free gift logicsrc/entrypoints/mini-cart/components/free-gifts.ts
Drawer section and blockssections/mini-cart.liquid
Cart source propertysrc/lib/cart-source.ts

Operator instructions are in Cart rewards and free gifts.

On this page