中文
Shopify knowledge · Guide

Horizon's Web Components Base Class: Refs, on: Event Delegation, and the Upgrade Race

A Horizon 4.2.0 source walkthrough of the framework-free Component base class: automatic ref collection and ownership, global event delegation through on: attributes, the custom element upgrade race, declarative Shadow DOM hydration, and how updatedCallback is meant to work with morph, plus a doubt about whether it fires.
Historical material
Read it together with the applicable versions and sources stated in the article.

This article is based on Horizon 4.2.0, with source pinned to 5acd1b6 (verified 2026-10-07). Conclusions are limited to that commit; this is a static source analysis and nothing was tested in a store.

Horizon's front end uses no Alpine, no Stimulus and no framework, and the repository has no build configuration. Interactive components all extend a single base class, Component, defined in assets/component.js; the whole file is 392 lines.

What problem it solves

Liquid renders HTML on the server; JavaScript only "claims" existing DOM and attaches behavior. In that setting virtual DOM and reactive rendering go unused. Only three things are actually needed:

  1. Convenient references to child elements;
  2. Binding DOM events to component methods;
  3. Both of the above still holding after the server re-renders and replaces the DOM.

Component is a minimal implementation of exactly those three things.

1. ref: declarative child references

Simplified from snippets/quantity-selector.liquid:

<quantity-selector-component>
  <button ref="minusButton" on:click="/decreaseQuantity">-</button>
  <input ref="quantityInput">
  <button ref="plusButton" on:click="/increaseQuantity">+</button>
</quantity-selector-component>

In JS, just use this.refs.quantityInput. When the attribute value ends with [] (e.g. ref="quantitySelectors[]"), matches are collected into an array.

A few implementation details are worth noting:

  • Ownership check: when collecting [ref] elements, only those whose nearest Component ancestor is this component are kept, so refs from a nested component don't leak into the outer one. getClosestComponent uses getAncestor, which jumps from a Shadow Root to its host and keeps climbing.
  • Unupgraded components recognized by convention: an element whose tag name ends with -component counts as a component boundary even before its JS has run. Module load order therefore can't change which component a ref belongs to.
  • Fail fast with requiredRefs: a subclass declares requiredRefs = ['quantityInput', 'minusButton', 'plusButton'] (as component-quantity-selector.js does). A missing ref throws MissingRefError, with the component's tag name in the message, instead of surfacing later as undefined.
  • Deferred observer: the MutationObserver only starts observing inside requestIdleCallback, so first paint doesn't pay for it. It only watches the ref attribute and child additions and removals.

2. on:event: global event delegation

<button on:click="/decreaseQuantity">
<button on:click="#cart-drawer/close">
<img on:click="#zoom-dialog-{{ block_id }}/open/{{ forloop.index0 }}">
<button on:click="/removeFilter?form=">

The attribute syntax is [selector]/methodName[/data or ?query]:

FormTarget componentMethod arguments
/closeNearest Component ancestor(event)
#cart-drawer/closedocument.querySelector('#cart-drawer')(event)
.card/selectelement.closest('.card')(event)
/open/3Nearest ancestor(3, event), numbers converted automatically
/remove?form=a&x=trueNearest ancestor({form:'a', x:true}, event)

One listener per event type is registered on the whole document, in the capture phase. When an event arrives, the handler first checks whether the event target itself carries on:<type>, otherwise it uses closest('[on\\:click]') to find the declaring element, then parses the value, locates the component and calls the method.

Two details are worth learning from:

Rewriting event.target with a Proxy. The user may have clicked an <svg> inside the button, but the method usually wants event.target to be the button that declared on:click. When the two differ, Horizon doesn't create a new event object; it wraps the event in a Proxy that intercepts only target and passes everything else through. Function properties (preventDefault and friends) are bind-ed back to the original event, otherwise this would point at the Proxy and throw Illegal invocation:

new Proxy(event, {
  get(target, property) {
    if (property === 'target') return element;
    const value = Reflect.get(target, property);
    return typeof value === 'function' ? value.bind(target) : value;
  },
});

Bubbling versus non-bubbling events. focus and blur don't bubble, but they still reach document in the capture phase, so they are listed in shouldBubble and still resolved with closest. pointerenter and pointerleave are listed as expensiveEvents: only the event target itself is matched, with no closest lookup, to avoid walking the DOM on every pointer movement.

3. The custom element upgrade race

<cart-drawer-component> is already on the page, but the module that defines it hasn't finished executing, and the user clicks a button. instance instanceof Component is false and the event is silently dropped. Users on slow connections see "I clicked and nothing happened."

Horizon's handling:

if (
  !(instance instanceof Component) &&
  instance instanceof HTMLElement &&
  instance.tagName.toLowerCase().endsWith('-component')
) {
  customElements.upgrade(instance);
  if (!(instance instanceof Component)) {
    console.warn(`[component] Dropped "${event.type}" on <...> — element not yet upgraded`);
    return;
  }
}

The source comment notes that customElements.upgrade is synchronous and idempotent: if the class is registered but the element isn't upgraded yet, it upgrades immediately; if the element is already upgraded or the class isn't registered yet, it does nothing. If the upgrade still fails, it logs a warning instead of swallowing the event. The comment also says this silent failure mode caused the bulk of their recent Playwright flakes.

Any architecture that combines event delegation with asynchronously loaded modules has to account for the window where the DOM has arrived and the behavior hasn't.

4. Hydrating declarative Shadow DOM

Component extends DeclarativeShadowElement, defined in the same file:

connectedCallback() {
  if (!this.shadowRoot) {
    const template = this.querySelector(':scope > template[shadowrootmode="open"]');
    if (!(template instanceof HTMLTemplateElement)) return;
    this.attachShadow({ mode: 'open' }).append(template.content.cloneNode(true));
  }
}

<template shadowrootmode="open"> is only turned into a Shadow Root by the browser during the initial HTML parse (the file's header comment says the same). Components inserted via innerHTML, DOMParser or morph aren't converted automatically and must be attached by hand. Since Horizon relies heavily on Section Rendering API partial refreshes, this step is essential. OverflowList in overflow-list.js extends DeclarativeShadowElement directly and depends on it.

5. Working with morph: updatedCallback

Horizon uses its own morph for partial updates (see section rendering and morph). By design, after morph changes a component's subtree it calls the component's updatedCallback() in a microtask:

updatedCallback() {
  this.#mutationObserver.takeRecords(); // drop queued mutations to avoid a redundant refresh
  this.#updateRefs();
}

When morph decides a subtree is unchanged (isEqualNode), it skips it entirely and updatedCallback doesn't fire. That's intentional: if the subtree didn't change, the refs don't need rebuilding.

From a static reading, though, this callback may never be called on the section-refresh path. walk in morph.js ends with options.onAfterUpdate?.(newNode), passing a node from the new tree, and MORPH_OPTIONS.onAfterUpdate only queues updatedCallback when node instanceof Component. morphSection in section-renderer.js parses the response with DOMParser; under the HTML spec, custom elements in such a document without a browsing context aren't upgraded, so nodes in the new tree aren't Component instances. In most cases the MutationObserver still keeps refs in sync (child additions and removals trigger #updateRefs); what's affected are subclasses that override updatedCallback, such as results-list.js and disclosures-summary-fit.js. This has not been verified in a browser.

Things worth questioning

  • The string DSL has no type checking. Misspell the method in on:click="/decreaseQuantity" and it just silently doesn't run (when typeof callback === 'function' is false it's skipped), without even a warning. After all the effort spent surfacing the upgrade race, the lack of a warning here is inconsistent.
  • The cost of convention over configuration. "A tag ending in -component is a component boundary" is an implicit convention you won't know without reading the source.
  • The globally delegated events are a hard-coded list of 13 (11 regular events plus pointerenter and pointerleave). To use dblclick, scroll and the like, you have to change the base class.

Summary

Component's trade-off is clear: HTML is the single source of state; JS only claims and binds. It gives up reactivity in exchange for zero dependencies, no build step, and natural compatibility with whole-section server re-rendering.

For a Liquid theme that's simpler than adopting Alpine and then dealing with "server HTML replacement wipes Alpine state." The cost: once you need client-side state to drive the UI, you're back to hand-written DOM manipulation.

Horizon's LICENSE.md forbids distributing themes derived from its code through the Theme Store or any other channel, allowing only direct delivery to a merchant for that merchant's own use in a services engagement. This article only analyzes the source; implement any borrowed ideas yourself.

Source baseline