Horizon's Section Refresh and Morph: SectionRenderer Concurrency and DOM Merging
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.
Filtering products, switching variants, changing cart quantities: Horizon does them all the same way. It requests fresh section HTML from the Section Rendering API, then uses morph to apply the differences to the existing DOM. There are no client-side templates and no JSON-to-DOM mapping code. Two files are involved:
- assets/section-renderer.js: requests, concurrency and caching
- assets/morph.js: merging DOM differences
This article looks only at those two files, not at the individual features that call them.
Why not just innerHTML
Replacing with innerHTML loses focus, the value a user is typing, the open state of <details>, initialized custom element instances and their listeners, and it re-requests images. Morph's goal is to change only what changed and leave every other node as it was.
SectionRenderer: one render per section at a time
sectionRenderer.renderSection(sectionId, { cache, mode, url, shouldRender, injectStylesheet })
1. A new request cancels the old one
Each sectionId has an AbortController. When a new renderSection arrives, it first aborts the previous request for the same section, and signal.aborted is checked once more before morphing. If a user clicks filters in quick succession, only the last result is applied; a late old response can't overwrite a newer result.
Cancelled callers usually don't get an empty result either. In the catch branch of #renderSection, if the same section already has a newer pending render, that promise is returned directly:
if (abortController.signal.aborted) {
const pendingRender = this.#pendingRendersBySectionId.get(sectionId);
if (pendingRender && pendingRender.abortController !== abortController) {
return pendingRender.promise;
}
return sectionHTML;
}
So a caller of await renderSection() that was superseded by a newer request receives the HTML of the latest render and doesn't need to care that it was cancelled. Only a plain cancellation through abortRender, with no follow-up render, returns an empty string.
2. A stable cache key
url.searchParams.set('section_id', normalizeSectionId(sectionId));
url.searchParams.sort();
sort() makes ?a=1&b=2 and ?b=2&a=1 produce the same cache key. Filter parameter order depends on the order the user clicked, so this small detail lets the same set of conditions hit the same cache entry.
3. First-paint HTML pre-stored in the cache
window.addEventListener('load', this.#cachePageSections.bind(this));
After the page loads, the outerHTML of every .shopify-section is stored in the cache, keyed by the section-rendering URL for the current page. When a user filters and then returns to the initial URL, the cache hits and no request is sent. Sections containing a Shadow Root aren't cached, because outerHTML doesn't serialize Shadow DOM.
The default for renderSection's cache is !Shopify.designMode: in the theme editor the cache is off by default, so merchants don't see stale content after changing settings.
4. Request deduplication (limited)
getSectionHTML uses #pendingPromises to merge concurrent requests for the same URL, but only calls made without a signal register themselves there. renderSection always passes a signal, so its own request can't be reused by others. Conversely, before fetching it checks #pendingPromises, and if a signal-less request for the same URL is already in flight, it reuses that promise.
The typical beneficiary is filter prefetching: facets.js calls getSectionHTML (without a signal) after a 200ms debounce on pointerenter, or immediately on pointerdown. By the time the user actually clicks, the response is either already cached or still in flight and picked up by renderSection. The cost: a prefetch picked up this way isn't covered by abort; its result just won't be morphed if the render was cancelled.
morph: merging rather than replacing
Morph's core is the usual child-node comparison: two nodes are "the same" if their tagName and key (by default id) match. Same nodes are updated recursively; otherwise later siblings are searched for a match before deciding to move, insert or replace. This part is similar to morphdom and nanomorph and is not covered here. What's worth writing about is how it is specialized for e-commerce themes.
1. Skip identical subtrees, but sync form state
if (oldNode.isEqualNode(newNode)) {
syncFormControlsInSubtree(newEl, oldEl);
return oldNode;
}
isEqualNode lets most unchanged subtrees be skipped outright. But it compares serialized markup: the server outputs value="1" both times, the user changed the input to 3, and the two trees are still judged "equal." So before skipping, it walks the input, option and textarea elements in the subtree and syncs live state such as value, checked and selected.
The source comment spells this out: identical structure means the controls line up one-to-one, so they can be paired by index.
2. Protecting "the user is editing"
In the cart, a user is typing a quantity while another request returns and triggers a cart-items morph. Overwriting directly would wipe the half-typed number.
Horizon solves it with a client-only marker, data-skip-value-update, but the condition isn't just "is the marker present":
function skipsValueUpdate(newNode, oldNode) {
return (
oldNode.matches(':focus') &&
!newNode.matches(':disabled') &&
newNode.hasAttribute('data-skip-value-update') &&
oldNode.hasAttribute('data-skip-value-update')
);
}
Why also require :focus? The comment lays out the full reasoning: the server may render the input as disabled; copying disabled onto it blurs the input and the blur handler clears the marker; but onBeforeUpdate has already preserved the marker onto the new node, and the same copyAttributes pass writes it back. Checking the marker alone, it could never be cleared and the input would be frozen for good. Using live focus as the authoritative signal for "editing in progress" downgrades that leak from a permanent failure to a harmless one.
That comment is worth reading in full; it's a model of how to document the lifecycle of a state marker.
There's a subtler point too: while an input's dirty value flag is false, changes to the value attribute are reflected directly in the value property, which is what the user sees. The comment notes that focus plus select() doesn't set that flag, so copyAttributes applies the same skip when copying or removing the value attribute.
3. Preserving runtime state
Before merging, morph copies state the server doesn't know about from the old node onto the new one, in two places.
MORPH_OPTIONS.onBeforeUpdate:
- A list of attributes:
product-grid-view,data-current-checked,data-previous-checked,cart-summary-sticky, plusdata-skip-value-updatefrom above; - Runtime inline
styleonfloating-panel-componentandfieldset.variant-option; - A temporary
style.viewTransitionName.
updateNode (only when old and new attributes differ):
openon<details>and<dialog>, unless the new node carriesdeclarative-open, meaning the server states it explicitly;slotandsizes:slotis assigned at runtime by overflow-list.js, andsizesis rewritten by results-list.js according to the layout.
Two more are about performance:
src,href,srcsetandposterare not rewritten when unchanged, avoiding duplicate network requests;attributesEqualcompares attributes shallowly by position. Re-renders of the same template emit attributes in the same order; a different order only causes a false "unequal" and one extra copy, never a false "equal." The comment explicitly calls out this asymmetric safety.
4. Three skip switches
| Attribute | Effect |
|---|---|
data-skip-node-update | When on both old and new nodes, the node itself isn't changed, but its children are processed |
data-skip-subtree-update | When on both old and new nodes, the node's own attributes are updated as usual and its children are left alone |
data-skip-value-update | Only the value of an input being edited is kept; everything else updates; conditions above |
The first two require the marker on both old and new nodes, which means the server template must output it explicitly: a contract in which the server declares which regions belong to the client. data-skip-value-update is the opposite: only the client sets it, and onBeforeUpdate copies it onto the new node.
5. Platform special cases
<shopify-accelerated-checkout-cart>is skipped entirely: Shopify's own scripts manage the express checkout buttons;- The
<!--shopify:rendered_by_section_api-->comment the theme editor injects into section renders is discarded; - For components that already have a Shadow Root attached, the new HTML's
<template shadowrootmode="open">is rejected; - After morphing,
<script>elements with asrcinside.shopify-app-blockare recreated. The comment explains that browsers don't re-execute morphed scripts, and app scripts often cache DOM references at initialization.
Things worth questioning
- Business components are hard-coded into a generic morph.
onBeforeUpdatementionsFLOATING-PANEL-COMPONENT,.variant-optionand a list of preserved attributes. Each new component that needs runtime state preserved means editing morph's default options again. A better approach would let components declare the attributes to preserve, for example through a shareddata-preserve-attrsconvention. #cachePageSectionsbails out of the whole loop. When it hits an already-cached section or one containing a Shadow Root, it usesreturnrather thancontinue, so no later section gets cached. Given the function's intent this looks like an oversight rather than a design choice, but the source doesn't say.onAfterUpdatereceives the new-tree node.walkcallsonAfterUpdate(newNode), and the new tree in a section refresh comes fromDOMParser, where custom elements aren't upgraded, soupdatedCallbackvery likely doesn't fire; see the Web Components base class. Not verified in a browser.- Morph diffs everything. When a long list re-renders and only one product changed, the whole subtree is still walked (except parts that hit
isEqualNode). Fine at a theme's data sizes, but not suited to very long lists.
Summary
The essence of this approach is "the server is the only template; the client only merges differences":
- Liquid is written once, with no duplicate front-end and back-end templates;
- Any setting a merchant changes in the theme editor takes effect automatically after a partial refresh;
- The cost is a network round trip per interaction, plus a morph clever enough to preserve runtime state.
The amount of edge-case handling Horizon puts into morph (form state, focus, open, slot, app scripts) shows that the approach works, but a "smart morph" is itself a subsystem that needs ongoing maintenance. Another pattern built on top of morph is covered in keyed lazy hydration.
Horizon's LICENSE.md forbids distributing themes derived from its code; this article only analyzes the source, so implement any borrowed ideas yourself.