Skip to content

Performance and architecture

This page is for the technically-curious merchant and for developers evaluating the theme. You don’t need any of it to run a store — but if you’re deciding whether Signal is something you’d want to maintain, this is what’s underneath.

No framework

No React, no Vue, no jQuery, no Alpine. No build step. The JavaScript is plain ES modules served as theme assets.

No dependencies

Nothing from npm, no CDN scripts. Every line ships from your own theme, so nothing can change under you and nothing is a third-party request.

Two stylesheets

A small critical.css and the main signal.css, both preloaded. Section-specific CSS lives in each section’s own {% stylesheet %} block.

Progressive by default

Carousels are scroll-snap strips before the script loads. Localization is a native form. Variant pickers have a <select> fallback. Accordions are <details>.

The theme’s JavaScript is split into real modules: an engine (signal.js), a component base class (component.js), a DOM morpher (morph.js), and one component so far (slideshow.js).

Shopify serves assets at fingerprinted URLs, which would normally force every import to be written as a Liquid-generated absolute URL. An importmap in the layout fixes that:

<script type="importmap">
{
"imports": {
"@theme/component": "/cdn/shop/t/1/assets/component.js?v=…",
"@theme/morph": "/cdn/shop/t/1/assets/morph.js?v=…",
"@theme/slideshow": "/cdn/shop/t/1/assets/slideshow.js?v=…"
}
}
</script>

So a module can say import { morph } from '@theme/morph' and stay readable, while still resolving to the cache-busted URL Shopify generates.

Modules load in parallel, are deferred by nature of type="module", and never block the first paint.

Custom elements extend a small base class that does two jobs, both of which used to be done by hand.

Any descendant carrying ref="name" is collected into this.refs.name. Repeat a name and you get an array.

<lab-slideshow>
<div ref="track">
<div ref="slide">…</div>
<div ref="slide">…</div>
</div>
<div ref="ui" hidden>
<button ref="dot"></button>
<button ref="dot"></button>
</div>
</lab-slideshow>

A MutationObserver keeps them in sync, so a component that re-renders its own contents — or is re-rendered by the theme editor — doesn’t have to re-query anything. Nested components own their own refs; a parent never steals a child’s.

requiredRefs lets a component declare what it can’t work without, and a missing one warns in the console rather than silently doing nothing.

on:click="/methodName" in markup calls methodName on the nearest component:

<button on:click="/next" ref="next">→</button>

No addEventListener, no delegation block elsewhere in the file having to re-derive what was clicked. The wiring sits next to the thing it wires, and it’s visible in the browser’s Elements panel — it’s ordinary DOM, not magic.

Components register themselves through a define() helper that checks first, so a section rendered twice on a page, or re-rendered through the Section Rendering API, can’t throw a duplicate-definition error.

When a list changes — cart lines, search results — the theme doesn’t rebuild it. It morphs the existing tree into the new one, applying only the differences.

innerHTML = … throws away and recreates every node, which destroys everything the browser was holding for the user:

  • keyboard focus
  • text selection and caret position in an input
  • the scroll offset of the container
  • any CSS transition mid-flight
  • every IntersectionObserver registration, which is why injected content used to have to be handed back to the reveal observer by hand

Morphing walks the new tree against the old one and touches only what differs, so untouched nodes are never replaced.

By data-key when present, otherwise by position. Give rows a stable key — a cart line key, a product handle, a result URL — and reordering moves nodes instead of rewriting them.

Where you can see the difference:

SurfaceWhat morphing preserves
Cart drawerThe quantity field’s caret, the focus ring, the list’s scroll position, the slide-in transition
Predictive searchResult thumbnails aren’t re-requested on every keystroke; the list doesn’t flash
Drawer re-renderThe drawer doesn’t have to be closed and re-opened around an update

value, checked and selected diverge from their attributes the moment someone types into a field or ticks a box. The morpher only follows the incoming markup when it actually disagrees with what’s on screen, and never while the field has focus — so an update arriving mid-typing can’t wipe what’s being typed.

An element can opt out entirely with data-morph-skip.

The layout declares a Speculation Rules policy:

{
"prefetch": [{
"where": { "and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": ["/cart*", "/checkout*", "/account*", …] } },
{ "not": { "selector_matches": "[data-no-prefetch]" } },
{ "not": { "selector_matches": "[rel~=nofollow]" } }
]},
"eagerness": "moderate"
}]
}

moderate eagerness starts the fetch on hover or pointerdown, so the page is usually in cache by the time the click lands. The browser honours Save-Data and low-memory conditions on its own, which is why this needs no motion gate and no setting.

Cart, checkout and account are excluded deliberately. They’re per-session and state-changing; prefetching them would waste the request and risk acting on a stale view. Any link can opt out with data-no-prefetch.

This is a browser-native feature with no JavaScript cost at all — it’s a declarative block the browser reads.

Shopify’s standard storefront actions work on every Liquid storefront whether or not a theme does anything. But the default updateCart action falls back to a full page reload when it can’t recognise how a theme renders its cart — throwing away scroll position and anything the buyer had typed.

Signal configures the action to point at its own drawer refresh instead:

Shopify.actions.updateCart.configure({
eventTarget: () => document.querySelector('[data-drawer="cart"]') || document,
handler: (defaultHandler) =>
defaultHandler().then((result) =>
window.SignalDrawer.refresh(false).then(() => result)
),
});

So when an app updates the cart, the drawer re-renders in place rather than reloading the page.

The configure call sits above content_for_header in the layout, because only the first configure call on an action takes effect and app code loads from there.

Everything goes through Shopify’s cart AJAX API — /cart/add.js, /cart/change.js, /cart/update.js — and then re-renders the drawer through the Section Rendering API (/?sections=cart-drawer).

That section request can hang behind a development proxy, so it’s wrapped in a 2.5-second AbortController timeout with a client-side render from /cart.js as the fallback. The UI always settles, either way.

Two details worth noting:

  • Errors show Shopify’s own reason. When an add fails, the toast carries Shopify’s human-readable description rather than a generic “something went wrong” — so “You can only add 2 of this item” reaches the shopper.
  • The cart note is debounced and flushed. It used to save on change alone, which only fires on blur: a shopper who typed a note and went straight for Checkout lost it silently. It now saves as they type and any pending save is flushed before the checkout submit is allowed through.

A small thing that was a real bug. body carries overflow-x: hidden, which makes BODY the scrolling element rather than <html>. Every overlay in the theme used to lock documentElement — so none of them actually stopped the page moving behind them.

The lock is now counted, not boolean, because overlays stack: quick view can open over a drawer, and the first one closing must not unlock while another is still open. The scrollbar’s width is added back as padding so the page doesn’t jump sideways when it disappears, and every <dialog> releases the lock on its close event however it was closed — Escape, backdrop or button.

All motion is gated twice: by Theme settings → Motion → Enable motion, and by the visitor’s own prefers-reduced-motion. Either one being off disables reveals, counters, the ticker, the fly-to-bag animation and smooth scrolling.

The flag is read in Liquid as an explicit false check rather than through the default filter — default replaces false as well as nil, so a merchant switching motion off was still getting data-motion="true" and every reduced-motion rule stayed unreachable.

  • Responsive srcset with explicit sizes on product media and cards.
  • The first gallery image loads eagerly; everything else is lazy.
  • Zoom paints the already-decoded thumbnail first, then swaps in the 2400 px file when it’s loaded — so the stage is never empty while a large image downloads, and a shopper who moves on before it lands doesn’t get a late swap.
  • The default favicon is an inline SVG data URI in your accent colour, so a store with no uploaded favicon still makes zero extra requests.
  • Favicon type is derived from the uploaded file rather than assumed, because image_url passes SVG through untransformed and declaring image/png for SVG bytes is wrong.

Anton and Inter are served by Shopify’s font CDN via the font picker, and both the heading and body faces are preloaded in the <head> with crossorigin. Changing fonts in theme settings changes what’s preloaded — there’s no hardcoded font URL to go stale.