Beauty Affairs HK

Mini-cart

How the cart drawer's custom elements share cart state through events, and how they detect cart writes made by other apps.

The cart drawer is the largest piece of custom code in the theme. It is a set of custom elements that never call each other. Each element reads the cart from events on a shared bus.

Mini-cart data flow: custom elements dispatch command events to a command controller, which calls CartService, which calls the Shopify Cart API and emits lifecycle events on CartEventBus. CartStore keeps the latest cart and elements re-render from bus events. A fetch interceptor catches cart writes by third-party apps and emits the same change event.

Why it is built this way

Many scripts on a Shopify storefront write to the same cart. The theme writes to it, and so do the wishlist app, the discount app, the upsell app and any quick-add button on the page. None of them tell the theme when they do.

A drawer that fetched the cart again on every interaction would be slow, and it would still miss writes made while it was closed. The mini-cart instead treats cart updates as a stream of events, and gives each job to exactly one component.

The parts

PartFileResponsibility
CartEventBusinternals/event-bus.tsWraps CustomEvent on window. Typed by event name
CartServiceinternals/cart-service.tsCalls the Shopify cart endpoints and emits lifecycle events
SimpleCartServiceinternals/simple-cart-service.tsLine-addressed changes, with recovery from stale line keys
CartStoreinternals/cart-store.tsHolds the last known cart. Loads once, then updates from events
CartCommandControllerinternals/command-controller.tsTurns mini-cart:* events from outside code into service calls
CartFetchInterceptorinternals/fetch-interceptor.tsPatches window.fetch and XMLHttpRequest to notice other writers
Elementselements/*.tsCustom elements that each render one part of the drawer

internals/core.ts defines every event name, and its CartEventDetailMap types each event's payload. A new event needs an entry in both.

The command interface

Any code on the page, including code the theme does not own, can change the cart by dispatching an event on window:

window.dispatchEvent(
  new CustomEvent('mini-cart:add', {
    detail: { items: [{ id: 123456789, quantity: 1 }] },
  }),
);

The command controller handles mini-cart:get, mini-cart:add, mini-cart:change, mini-cart:update and mini-cart:clear. Code that sends these events instead of calling /cart/*.js gets the same request queueing and stale-key recovery as the drawer's own controls.

Why the fetch interceptor exists

The drawer has to find out when an app writes to the cart. The interceptor patches window.fetch and XMLHttpRequest, watches for POST requests to URLs matching /cart/*.js, and emits cart:changed so the drawer renders again.

Two details matter.

The mini-cart's own requests come back with an x-source: mini-cart response header. The interceptor skips those responses, so the drawer does not react twice to its own write.

/cart/add.js returns only the added items, not the whole cart. After a successful add, the interceptor requests GET /cart.js and uses that response as the event payload.

The request and response events are off by default. A comment in the source says other apps send so many cart requests that these events are noise. Turn them on only while debugging.

Stale line item keys

Shopify builds a line item's key from its variant and its properties. Writing a property to a line gives it a new key, even though it is the same line.

This theme writes line item properties automatically, through the line item properties update chain, so keys change more often here than in most themes. DOM rendered before such a write holds a key the API no longer accepts, and a request using it fails with 400 no valid id or line parameter.

resolveLineItem in simple-cart-service.ts recovers by matching on the variant ID, which does not change. It only does so when exactly one line has that variant. If two lines share a variant, such as a purchased item and a free gift of the same product, it cannot tell them apart. It stops, and the caller renders the cart again instead of guessing.

The rule chains

Two extension points let other code change cart behaviour.

CheckoutEventChain runs a series of async functions over the cart when the shopper clicks checkout. Each function receives the cart the previous one returned. The footer element owns and runs the chain. Other code adds steps through window.addCheckoutEventChain, which finds the chain on the mini-cart-footer element, and the free gifts component registers this way. The footer handles its own shipping protection upsell directly, outside the chain. On a page without a footer block there is no chain, so nothing else can register a step.

The line item properties update chain collects proposed property changes across the cart and applies them in one pass. Two rules run today.

promotionLabelQuantityRule removes the Promotion property from any line whose quantity drops below 3.

remainingAmountRecalculationRule recalculates the remaining balance on Medispa deposit lines. It applies to any line whose vendor contains medispa, or that already has a remaining amount property.

A rule returns proposed changes and does not write to the cart. The chain collects the proposals, skips any that match the current values, and sends the rest together. This keeps the number of key-changing requests low.

Failure modes

The drawer shows a stale cart. An app wrote to the cart in a way the interceptor does not match. In the network tab, look for cart requests that are not a POST to /cart/*.js.

A quantity change returns a 400. The request used a stale line key that resolveLineItem could not match, usually because the same variant is in the cart twice. See Cart and line item errors.

An element renders nothing. The block is not enabled in the section, or its render location puts it in a different part of the drawer. See Mini-cart and gifts.

Nothing in the drawer works. mini-cart.ts imports the internals/ modules, but they also sit in src/entrypoints, so Vite emits each one as a separate chunk. If one chunk fails to load, the drawer mounts without its services.

Source map

ConcernFile
Section markup, blocks and settingssections/mini-cart.liquid
Per-block markupsnippets/mini-cart.*.liquid
Block dispatch by render locationsnippets/mini-cart.render-blocks.liquid
Event names and payload typessrc/entrypoints/mini-cart/internals/core.ts
Elementssrc/entrypoints/mini-cart/elements/
A worked example of a new elementsrc/entrypoints/mini-cart/elements/00-example.ts
Rulessrc/entrypoints/mini-cart/internals/line-item-rules/
Cart source taggingsrc/lib/cart-source.ts

00-example.ts is a template to copy when you add an element. It is not dead code.

On this page