Skip to content

Engineering stories · 02

Commerce state without a frontend framework

  • Vanilla JS
  • AJAX Cart

Keeping three DOM-disjoint buy-box components price-consistent, and keeping the cart alive while a third-party app rewrites the drawer, on a Shopify theme with no frontend framework.

Shopify OS 2.0 · Vanilla JavaScript · AJAX Cart + Sections Rendering API · Root-cause debugging · Puppeteer QA

Part of the Lumway storefront engineering series. The subscription model this buy box drives is covered in article 01; the performance work it borders is article 03. This one is about state and stability: how a framework-free buy box stays consistent, and how I traced three separate production cart failures to a single root cause.

The buy box is where a supplement brand makes or loses the sale. On Lumway, a US wellness store, the product page and the advertorial landings carry the same buying surface: pick a pack (1, 2, or 3 jars), choose one-time or subscribe, see the price and the savings, add to cart. It sits in a small vertical space on a phone, and every part has to agree. If a pack card says one price and the button says another, or the drawer shows a quantity the shopper did not choose, the store loses trust at the moment of decision.

I built that surface without a frontend framework. This article is about two related problems: keeping three separate components in a consistent state with only the platform's own primitives, and keeping the add-to-cart path alive on a page where a third-party app is free to rewrite the cart out from under it.

The buy box is a schema-driven block dispatcher

The product page is one Online Store 2.0 section, product-details-lum, replacing the theme's native product template. The layout is a two-column flex: gallery on the left, buying column on the right. The right column is not a fixed template but a dispatcher over merchant-defined blocks:

{% for block in section.blocks %}
  {% case block.type %}
    {% when 'bundle' %}       {% render 'bundle-selector-lum', block: block, product: pdl_product %}
    {% when 'purchase_type' %}{% render 'purchase-type-lum',   block: block, product: pdl_product %}
    {% when 'add_to_cart' %}  {% render 'add-to-cart-lum',     block: block, product: pdl_product %}
    ...
  {% endcase %}
{% endfor %}

The content team can reorder, add, or remove blocks (rating, title, labels, bundle, purchase_type, add_to_cart, info_cards, cross_sell, product_tabs, plus @app for app compatibility) from the Customizer. Nothing about the offer is hardcoded: every visible element maps to a schema setting or a block.

One line near the top of the section makes the whole thing portable:

assign pdl_product = product | default: section.settings.product

On a product template the page's own product drives the section. On an advertorial page, where Liquid has no product in scope, the section falls back to a product-picker setting the merchant chooses. Every child snippet renders against pdl_product, so the identical buy box drops onto any landing with no code duplication: a new advertorial is a template copy with the product bindings swapped. That portability also sets up the state problem, because it guarantees the buy box runs on pages that carry other product forms it does not own.

Three components, no shared DOM, one price

The bundle selector, the purchase-type toggle, and the add-to-cart button are three separate snippets, each with its own scoped {% style %}, its own markup, and its own self-contained IIFE. They share no DOM node and no JavaScript module. That is deliberate: a merchant can drop any one out of the buy box, or reorder them, without breaking the others.

The catch is that they still have to agree on a single number. When a shopper taps "3 jars," the pack selector, the button, and the mobile sticky bar all have to reflect the three-jar price, its struck compare-at total, and (if subscribe is selected) the per-jar subscription price. The plan-to-pack mapping itself, which reads the subscription interval from plan text rather than hardcoded IDs, is the subject of article 01; here the components just need to broadcast their choices to each other cheaply.

They do it through a small, explicit contract:

  • One marker class on the real form. The add-to-cart snippet renders the only genuine {% form 'product' %} on the buy box, tagged .product-buy-form-pdp. That class is the single source of truth for "the form that actually submits."
  • A window global for the current pack. The bundle selector publishes window.__lumBundlePack as { variantId, total, paid, free }, so any component can read which pack is active and how many jars it contains (paid plus free) without walking the DOM.
  • Two custom events. variant:change fires when the pack changes; lum:atc-price carries the resolved price and its type (one-time or subscribe) from the purchase-type logic to the button and the sticky bar.

That is the entire state layer. No store, no reducer, no virtual DOM. The button's price renderer just listens for lum:atc-price and re-renders; the sticky bar does the same. State flows one way, as events, and each consumer decides what to do with it.

Why message passing, and not a framework

Why not reach for a small framework, or at least a shared controller? The environment rules it out. This is a Liquid theme that a content team co-edits daily through the Customizer, sharing the page at runtime with Recharge, Shogun, ElevateAB, Klaviyo, and a stack of analytics and pixel apps. Three things follow from that.

First, third-party scripts mutate the DOM whenever they like. A cart progress-bar app inserts and removes nodes, including the drawer itself. A component that assumed it owned the page would be wrong within a second of load.

Second, the Theme Editor re-renders sections in isolation. When a merchant edits a block, Shopify fires shopify:section:load and swaps that section's HTML without a full reload. A framework mounted once at page load would lose its bindings, so each component listens for that event and re-initializes itself.

Third, timing is adversarial. App scripts arrive late and out of order. So each IIFE guards against double-binding with a marker property on its root node, and re-runs its init on a short stagger rather than trusting a single ready event:

var root = document.querySelector('.bsl-' + uid);
if (!root || root.__bslBound) return;
root.__bslBound = true;
// ...
[200, 600, 1200, 2500].forEach(function (d) { setTimeout(init, d); });
document.addEventListener('shopify:section:load', function () { userTouched = false; setTimeout(init, 60); });

Loosely-coupled message passing fits this world better than a framework would. Each component is independently reloadable, tolerant of a DOM it does not control, and cheap. The cost is discipline: the globals and events are an informal contract, not a typed one, kept consistent by hand. A framework would have fought the Theme Editor and the app scripts for control of the same nodes, and lost.

One request to add and re-render

The add-to-cart snippet renders the real product form and a sticky price bar, then adds via AJAX, combining the AJAX Cart API and the Sections Rendering API in a single round trip:

var fd = new FormData(atcForm);
fd.append('sections', 'cart-drawer,cart-icon-bubble');
fd.append('sections_url', window.location.pathname);
fetch('/cart/add.js', { method: 'POST', headers: {...}, body: fd })

One POST /cart/add.js returns both the cart JSON and the freshly rendered HTML for the drawer and the cart-icon bubble, with no second fetch to re-render. The handler opens the drawer straight from that response, with a native submit() in the .catch as a fallback. This is the happy path; the length in this system is in the failure modes.

Three bugs, one disease

While shipping the buy box, three cart failures surfaced. They looked unrelated: a wrong quantity added, a drawer that vanished mid-interaction, a dead add-to-cart button. All three traced to the same root cause.

The disease: document-wide DOM lookups on a page that carries more than one product form and more than one .shopify-section, while a third-party free-gift and progress-bar app mutates the drawer. On a plain Dawn-style theme with one product form per page, a document.querySelector('input[name="id"]') is fine. On this theme, with the buy box, a hidden free-gift form, and a custom header all coexisting, that same query is a latent bug. Each of the three fixes is the same idea applied in a different place: stop trusting the whole document, scope to the element you actually mean.

Bug 1: the wrong form (PR #62)

On landing pages the theme renders a second, hidden free-product-form for its gift mechanic. That form carries its own input[name="id"] with a placeholder value="0". The bundle selector wrote the chosen variant into the form via a document-wide querySelector('input[name="id"]'), which grabbed whichever form came first in the DOM. Sometimes that was the hidden gift form. The shopper picked "3 jars," the variant went into the wrong form, and the real buy box submitted its initial single-jar variant. The add either put one jar in the cart instead of three, or failed and fell back to the native submit, which is the lag and the lost button styling the client had reported.

It looks like a code bug, a stray selector; commercially it is a conversion bug. A shopper who intends to buy three jars and silently gets one is a smaller order, a confused customer, and probably a support ticket. The fix scopes every read and write to the buy box's own form first, with fallbacks that degrade in a safe order:

function variantInput() {
  return document.querySelector('.product-buy-form-pdp input[name="id"]')
      || document.querySelector('product-form input[name="id"]')
      || document.querySelector('form[action*="/cart/add"] input[name="id"]')
      || document.querySelector('input[name="id"]');
}

The marker class comes first; the document-wide query survives only as a last resort the specific selectors have almost certainly pre-empted.

Bug 2: the drawer that destroyed itself (PR #64)

This one was the actual root, and it took the longest to find because the symptom was so strange. Changing a line-item quantity in the cart drawer, a plus or a minus, would sometimes wipe the drawer entirely.

The cart JavaScript, after a /cart/change, re-renders a list of sections, resolving each target as "find by id, else find by selector." For the cart-icon-bubble section the selector was the generic .shopify-section. On a stock theme the header has a #cart-icon-bubble, so the id branch wins and the selector is never used. But Lumway's custom header, lhead, has no such node. The id lookup missed, the fallback ran, and document.querySelector('.shopify-section') returned the first .shopify-section on the page: the cart drawer's own host. The code overwrote the drawer with cart-icon markup. Every plus or minus was quietly destroying the drawer and replacing it with a shopping-cart glyph.

The fix is to never let a generic selector stand in for a specific target:

const elementToReplace =
  document.getElementById(section.id) ||
  (section.selector && section.selector !== ".shopify-section"
    ? document.querySelector(section.selector)
    : null);
if (!elementToReplace) return;

Resolve strictly by id, then by a specific selector; if that selector is the generic .shopify-section, treat the target as missing and skip it. A section with no home on this custom header simply does not render, which is correct. I applied the same guard in the quantity-update and free-product-removal paths, since both walked the same list.

Bug 3: the dead button (PR #63)

The third failure was a safety net for the second. When a shopper empties the cart from inside the drawer, the third-party free-gift and progress-bar app removes the <cart-drawer> element from the DOM entirely. After that, the add-to-cart button appeared dead. Clicking it did nothing visible.

The reason was subtle. The submit handler looked up the drawer at click time. Finding none, it returned early, before calling preventDefault(), and the button looked frozen. The shopper could not add to cart on a page where the cart had just been emptied. On a store where re-adding after an empty cart is a completely normal path, that is lost orders.

The fix changes when the decision is made. Page capability is captured once, at bind time, not re-checked at click time when an app may have altered the DOM:

var pageHasDrawer = !!document.querySelector('cart-drawer');
atcForm.addEventListener('submit', function (e) {
  if (!pageHasDrawer) return; // page genuinely never had a drawer -> native POST
  e.preventDefault();
  // ... always AJAX-add ...
});

If the page had a drawer when the script bound, the handler always prevents default and AJAX-adds. If the drawer is gone by the time the response returns, the code rebuilds it from the /cart/add.js section payload rather than assuming it still exists:

function openDrawerWith(data) {
  var drawer = document.querySelector('cart-drawer');
  if (drawer && typeof drawer.renderContents === 'function') { drawer.renderContents(data); return; }
  var host = document.getElementById('shopify-section-cart-drawer');
  var html = data && data.sections && data.sections['cart-drawer'];
  if (host && html) { host.innerHTML = html; /* re-open the fresh drawer */ }
}

The Sections Rendering API makes the recovery possible: because the add response already carries the rendered drawer HTML, I can re-materialize a drawer another app deleted, without a page reload.

The free gift, added defensively

The free digital guide is a small illustration of the same defensive posture. The theme's built-in gift mechanism cannot fire for this buy box: its gift form is only rendered on non-product templates, and the custom buy box uses its own AJAX path. So the add-to-cart snippet attaches the gift itself.

ensureFreeGift() reads the configured gift variant and dollar threshold from the app's own DOM nodes, checks the cart via /cart.js, and adds exactly one gift only if a paid item exists, the threshold is met, and the gift is not already present. Two details matter. It adds the gift as a one-time line with no selling_plan, so Recharge never turns a freebie into a recurring charge. And it is best-effort, wrapped so a failure resolves to null and never blocks the primary add:

return fetch('/cart/add.js', { method: 'POST', /* ... */
  body: JSON.stringify({ items: [{ id: Number(giftId), quantity: 1 }],
    sections: 'cart-drawer,cart-icon-bubble', sections_url: window.location.pathname })
}).then(r => r.ok ? r.json() : null).catch(() => null);

The gift is a nice-to-have; the paid add is the revenue. The code reflects that priority: the gift can fail silently, the product add cannot. (The $1-to-$0 pricing and fulfillment-invisibility are operational work that was agreed but not fully shipped, so this is a resilience pattern, not a finished feature.)

Proving it in a clean room

State and cart bugs are exactly the kind a quick manual check misses and a real shopper hits. So the buy box has a headless test harness: puppeteer-core scripts driving a clean system Chrome with no extensions. audit.js runs the full buy-box matrix: every pack against subscribe and one-time, form synchronization, plan mapping, prices checked to the cent, add to cart, remove and re-add, and the button styling. qty.js, removebug.js, and cartpage.js cover the drawer paths the three bugs lived in: quantity changes must not kill the drawer, an emptied cart must accept a re-add.

Three verification gotchas from this store are worth passing on, because each one produces a test that lies:

  • A dirty browser profile hides the bug. My own Chrome profile has an extension that forces the cart drawer to visibility:hidden. Functional tests run there pass while the real drawer is broken. The harness uses a clean headless Chrome for exactly this reason.
  • The CDN minifies theme JS, so comments are not proof of a deploy. The live asset is smaller than the repo source because the CDN strips comments and whitespace. To confirm a fix shipped, I grep the live file for a string literal that must survive minification, for example the !== ".shopify-section" guard, not the comment explaining it.
  • Heavy cart-API testing trips the rate limiter. Hammering /cart/add and /cart/change from one IP triggers a Shopify 429 with a roughly 15 to 30 minute cooldown. The test cadence has to respect that, which in turn shapes how the matrix is batched.

Outcome

Each fix shipped as an isolated, revertable pull request, re-verified in clean headless Chrome. The verified technical outcomes:

  • The wrong-form quantity desync is closed. Selecting a pack writes the correct variant into the buy box's own form, confirmed across the pack cards, the button, and the sticky bar by audit.js on the PDP and the landings.
  • The drawer no longer self-destructs on quantity changes, because no section render can fall back to the generic .shopify-section target.
  • Add-to-cart survives an emptied cart. Capability is decided once at bind time, the add always goes through AJAX, and a drawer an app removed is rebuilt from the add response.

The store's conversion and revenue figures belong to the brand and sit under NDA, so here I keep to the engineering: failures reproduced, root-caused, fixed, and re-verified.

Why this matters commercially

The cart path is the revenue path, and these failures were the worst kind: silent. None threw an obvious error. A shopper got one jar instead of three, watched the drawer blink away, or gave up on a button that looked frozen, and left. There is no log line for a customer who quietly abandons, and on a store with mostly paid mobile traffic, that silent breakage is money. Scoping, it turns out, is a commercial decision as much as a code-quality one: the most valuable changes here are one or two lines each, and their worth is entirely in the failure they prevent.

What I would carry to the next theme

  • Never gate preventDefault on a live DOM lookup a cart app can remove. Capture page capability once, at bind time, then commit to the AJAX path and rebuild what went missing.
  • Any document-wide querySelector fallback in this theme is a suspect. On a page with multiple product forms and multiple sections, "find the first one" is usually the wrong intent. Scope to a marker class or an id, and let a generic match be a last resort that rarely runs.
  • Message passing beats a framework when you do not own the page. Window globals, custom events, and a marker class kept three independent components consistent while the Theme Editor and app scripts rewrote the DOM around them.
  • Test in a clean room, and verify a deploy by what survives minification. A dirty browser profile and a comment-stripping CDN will each tell you a lie if you let them.

Technical references

  • sections/product-details-lum.liquid: the block-dispatcher buy box and the pdl_product portability line.
  • snippets/bundle-selector-lum.liquid: pack selector, window.__lumBundlePack, the form-scoped variantInput() fix (PR #62).
  • snippets/add-to-cart-lum.liquid: the real product form (.product-buy-form-pdp), AJAX add plus Sections Rendering, pageHasDrawer capture and drawer rebuild (PR #63), ensureFreeGift().
  • assets/cart.js: the guarded section resolution in updateQuantity and removeFreeProduct (PR #64).
  • snippets/free-product-form.liquid: the hidden second form (value="0") that PR #62 stopped writing into.
  • ~/Desktop/lumway-qa: the puppeteer-core harness (audit.js, qty.js, removebug.js, cartpage.js) and its verification notes.

Related: article 01 covers the native selling-plan subscription model and the plan-to-pack mapping this buy box drives. Article 03 covers the real-user monitoring and layout-shift work on the same pages.

Back to the caseLumway

Need a Shopify engineer who can work across the full commerce path?

I work where storefront UX, product logic, platform constraints, measurement, and production delivery meet.

Start a conversation