← back to Dw Theme Boost Fix
snippets/product.hot-reload.liquid
280 lines
{% doc %}
Copyright © 2025 Archetype Themes LP. All rights reserved.
A component that hot reloads the product form when a variant is selected or all of it's
contents if a new product url is loaded via combined listings.
@param {string} slot - The slot to render the product form content in
@param {object} [section] - A section object
@param {object} [product] - A product object
@example Basic Example
{% capture product_form_content %}
{% for option in product.options_with_values %}
{% for value in option.values %}
{%- capture input_attributes -%}
{% render 'product.hot-reload.radio-attributes', value: value %}
{%- endcapture -%}
{%- render 'element.radio',
name: option.name,
value: value,
label: value,
checked: value.selected,
attributes: input_attributes
-%}
{% endfor %}
{% endfor %}
{% endcapture %}
{% capture product_form %}
{% render 'element.text', as: 'h1 ', variant: 'heading-xl', text: product.title %}
{% render 'form.product', product: product, slot: product_form_content %}
{% endcapture %}
{% render 'product.hot-reload', section: section, product: product, slot: product_form %}
{% enddoc %}
{% capture id %}
{% render 'utility.id', name: 'product-hot-reload' %}
{% endcapture %}
<product-hot-reload id="{{ id }}" data-section-id="{{ section.id }}" data-product-url="{{ product.url }}" data-variant-id="{{ product.selected_or_first_available_variant.id }}">
{{ slot }}
</product-hot-reload>
{% stylesheet %}
product-hot-reload {
contain: layout style paint;
}
.product-hot-reload--loading {
opacity: 0.6;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* View Transitions API support */
@supports (view-transition-name: none) {
product-hot-reload {
view-transition-name: product-content;
}
}
{% endstylesheet %}
{% javascript %}
class ProductHotReload extends HTMLElement {
constructor() {
super();
this.cache = new Map(); // Cache for pre-fetched content
this.activeRequests = new Map(); // Track active fetch requests
this.loadingTimeout = null; // Timeout for loading state
this.supportsViewTransitions = 'startViewTransition' in document;
this.addEventListener('change', (event) => this.onVariantChange(event));
this.addEventListener('mouseover', (event) => this.onMouseOver(event));
}
showLoadingState() {
// Start loading state (pulse) after 200ms
this.loadingTimeout = setTimeout(() => {
this.classList.add('product-hot-reload--loading');
}, 200);
}
hideLoadingState() {
// Clear the timeout if it hasn't triggered yet
if (this.loadingTimeout) {
clearTimeout(this.loadingTimeout);
this.loadingTimeout = null;
}
// Remove loading animation
this.classList.remove('product-hot-reload--loading');
}
buildRequestUrl(input) {
const productFormElement = input.closest('form');
const sectionId = this.dataset.sectionId;
// Get dataset values from the input or selected option
let newProductUrl, optionValueId;
if (input.type === 'radio') {
newProductUrl = input.dataset.productUrl;
optionValueId = input.dataset.optionValueId;
} else if (input.tagName === 'SELECT') {
const selectedOption = input.options[input.selectedIndex];
newProductUrl = selectedOption.dataset.productUrl;
optionValueId = selectedOption.dataset.optionValueId;
}
// Get all currently selected values from both radio inputs and select elements
let selectedOptionValues = [];
// Get values from checked radio inputs
const checkedRadios = Array.from(productFormElement.querySelectorAll('input[type="radio"]:checked'));
selectedOptionValues = checkedRadios.map(({ dataset }) => dataset.optionValueId).filter(Boolean);
// Get values from select elements
const selects = Array.from(productFormElement.querySelectorAll('select'));
selects.forEach(select => {
const selectedOption = select.options[select.selectedIndex];
if (selectedOption && selectedOption.dataset.optionValueId) {
selectedOptionValues.push(selectedOption.dataset.optionValueId);
}
});
// If we're building for a hover state, replace the current option's value
if (input.type === 'radio' && !input.checked) {
const currentOptionName = input.name;
// Remove any existing selection for this option
selectedOptionValues = selectedOptionValues.filter(id => {
const input = productFormElement.querySelector(`input[data-option-value-id="${id}"]`);
return input && input.name !== currentOptionName;
});
// Add the hovered option value
selectedOptionValues.push(optionValueId);
}
const params = selectedOptionValues.length > 0 ? `&option_values=${selectedOptionValues.join(',')}` : '';
return `${newProductUrl}?section_id=${sectionId}${params}`;
}
onMouseOver(event) {
const label = event.target.closest('label');
if (!label) return;
const input = label.querySelector('input[type="radio"]');
if (!input) return;
// Don't pre-fetch if already selected
if (input.checked) return;
const requestUrl = this.buildRequestUrl(input);
// Don't prefetch if we already have cached content or an active request
if (this.cache.has(requestUrl) || this.activeRequests.has(requestUrl)) return;
// Pre-fetch the content for this option
this.fetchContent(requestUrl);
}
fetchContent(requestUrl) {
// Create and store the fetch promise
const fetchPromise = fetch(requestUrl)
.then((response) => response.text())
.then((responseText) => {
const html = new DOMParser().parseFromString(responseText, 'text/html');
this.cache.set(requestUrl, html);
this.activeRequests.delete(requestUrl);
return html;
})
.catch((error) => {
this.activeRequests.delete(requestUrl);
throw error;
});
this.activeRequests.set(requestUrl, fetchPromise);
return fetchPromise;
}
async onVariantChange(event) {
if (!event.target.hasAttribute('data-product-hot-reload-trigger')) return;
const optionValueElement = event.target;
const requestUrl = this.buildRequestUrl(optionValueElement);
const oldProductUrl = this.dataset.productUrl;
// Get new product URL from the input or selected option
let newProductUrl;
if (optionValueElement.type === 'radio') {
newProductUrl = optionValueElement.dataset.productUrl || oldProductUrl;
} else if (optionValueElement.tagName === 'SELECT') {
const selectedOption = optionValueElement.options[optionValueElement.selectedIndex];
newProductUrl = selectedOption.dataset.productUrl || oldProductUrl;
} else {
newProductUrl = oldProductUrl;
}
// Start loading state immediately on change event
this.showLoadingState();
let html;
try {
// Use cached content if available
if (this.cache.has(requestUrl)) {
html = this.cache.get(requestUrl);
}
// Wait for active request if one exists
else if (this.activeRequests.has(requestUrl)) {
html = await this.activeRequests.get(requestUrl);
}
// Make new request if no cache or active request
else {
html = await this.fetchContent(requestUrl);
}
await this.updateProductWithTransition(html, newProductUrl, event.target.id);
} catch (error) {
console.error('Error updating product content:', error);
} finally {
// Always hide loading state
this.hideLoadingState();
}
}
async updateProductWithTransition(html, newProductUrl, targetId) {
const updatedHtml = html.getElementById(this.id);
if (this.supportsViewTransitions) {
// Use View Transitions API for smooth transitions
const transition = document.startViewTransition(() => {
this.innerHTML = updatedHtml.innerHTML;
this.dataset.productUrl = updatedHtml.dataset.productUrl;
this.dataset.variantId = updatedHtml.dataset.variantId;
});
// Wait for the transition to complete
await transition.finished;
} else {
// Fallback: Update content immediately without animations
this.innerHTML = updatedHtml.innerHTML;
this.dataset.productUrl = updatedHtml.dataset.productUrl;
this.dataset.variantId = updatedHtml.dataset.variantId;
}
// Update URL and history
let url = new URL(newProductUrl, window.location.origin);
// Preserve all existing URL parameters from the current page
const currentUrl = new URL(window.location.href);
for (const [key, value] of currentUrl.searchParams.entries()) {
// Don't override the variant parameter we're setting
if (key !== 'variant') {
url.searchParams.set(key, value);
}
}
// Add variant param if variantId exists
url.searchParams.set('variant', this.dataset.variantId);
// Push new state to browser history
window.history.pushState({}, '', url.toString());
// Focus the input for the last clicked option value
this.querySelector(`#${targetId}`).focus();
}
}
customElements.define('product-hot-reload', ProductHotReload);
{% endjavascript %}