UNPKG

vite-plugin-shopify-theme-islands

Version:
322 lines (321 loc) 11 kB
// src/runtime.ts var dispatch = (name, detail) => document.dispatchEvent(new CustomEvent(name, { detail })); function media(query) { const m = window.matchMedia(query); return new Promise((resolve) => { if (m.matches) resolve(); else m.addEventListener("change", () => resolve(), { once: true }); }); } function visible(element, rootMargin, threshold, pending) { return new Promise((resolve, reject) => { const io = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { io.disconnect(); pending.delete(element); resolve(); } }, { rootMargin, threshold }); io.observe(element); pending.set(element, () => { io.disconnect(); reject(); }); }); } function interaction(element, events, pending) { return new Promise((resolve, reject) => { const cleanup = () => { for (const name of events) element.removeEventListener(name, handler); pending.delete(element); }; const handler = () => { cleanup(); resolve(); }; for (const name of events) element.addEventListener(name, handler); pending.set(element, () => { cleanup(); reject(); }); }); } function defer(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function idle(timeout) { return new Promise((resolve) => { if ("requestIdleCallback" in window) window.requestIdleCallback(() => resolve(), { timeout }); else setTimeout(resolve, timeout); }); } var noop = (..._) => {}; function revive(islands, options, customDirectives) { const attrVisible = options?.directives?.visible?.attribute ?? "client:visible"; const attrMedia = options?.directives?.media?.attribute ?? "client:media"; const attrIdle = options?.directives?.idle?.attribute ?? "client:idle"; const attrDefer = options?.directives?.defer?.attribute ?? "client:defer"; const attrInteraction = options?.directives?.interaction?.attribute ?? "client:interaction"; const interactionEvents = options?.directives?.interaction?.events ?? [ "mouseenter", "touchstart", "focusin" ]; const rootMargin = options?.directives?.visible?.rootMargin ?? "200px"; const threshold = options?.directives?.visible?.threshold ?? 0; const idleTimeout = options?.directives?.idle?.timeout ?? 500; const deferDelay = options?.directives?.defer?.delay ?? 3000; const debug = options?.debug ?? false; const retries = options?.retry?.retries ?? 0; const retryDelay = options?.retry?.delay ?? 1000; const islandMap = new Map; for (const [key, loader] of Object.entries(islands)) { const filename = key.split("/").pop(); const tagName = filename.replace(/\.(ts|js)$/, ""); if (!tagName.includes("-")) { console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag name (e.g. rename to "${tagName}-island.ts")`); continue; } if (!islandMap.has(tagName)) islandMap.set(tagName, loader); } const queued = new Set; let initDone = false; const loaded = new Set; const pendingCancellable = new Map; const retryCount = new Map; const isUnloadedIsland = (tag) => queued.has(tag) && !loaded.has(tag); const customElementFilter = { acceptNode: (node) => { const tag = node.tagName; if (!tag.includes("-")) return NodeFilter.FILTER_SKIP; const lowerTag = tag.toLowerCase(); if (isUnloadedIsland(lowerTag)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }; async function loadIsland(tagName, el, loader) { if (debug && !initDone) { const parts = []; const pushAttr = (attr, val) => { if (val !== null) parts.push(val ? `${attr}="${val}"` : attr); }; pushAttr(attrVisible, el.getAttribute(attrVisible)); const mediaVal = el.getAttribute(attrMedia); if (mediaVal) parts.push(`${attrMedia}="${mediaVal}"`); pushAttr(attrIdle, el.getAttribute(attrIdle)); pushAttr(attrDefer, el.getAttribute(attrDefer)); pushAttr(attrInteraction, el.getAttribute(attrInteraction)); if (customDirectives?.size) { for (const a of customDirectives.keys()) { if (el.hasAttribute(a)) parts.push(a); } } if (parts.length > 0) console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`); } const msgs = debug ? [] : null; const note = msgs ? (msg) => msgs.push(msg) : noop; const flush = msgs ? (final) => { if (msgs.length === 0) { console.log("[islands]", `<${tagName}> ${final}`); } else { console.groupCollapsed(`[islands] <${tagName}> ${final}`); for (const m of msgs) console.log(m); console.groupEnd(); } } : noop; try { const visibleAttr = el.getAttribute(attrVisible); if (visibleAttr !== null) { note(`waiting for ${attrVisible}`); await visible(el, visibleAttr || rootMargin, threshold, pendingCancellable); } const query = el.getAttribute(attrMedia); if (query === "") { console.warn(`[islands] <${tagName}> ${attrMedia} has no value — media check skipped, island will load immediately`); } else if (query) { note(`waiting for ${attrMedia}="${query}"`); await media(query); } const idleAttr = el.getAttribute(attrIdle); if (idleAttr !== null) { const raw = parseInt(idleAttr, 10); const elTimeout = Number.isNaN(raw) ? idleTimeout : raw; note(`waiting for ${attrIdle} (${elTimeout}ms)`); await idle(elTimeout); } const d = el.getAttribute(attrDefer); if (d !== null) { const dMs = parseInt(d, 10); if (d !== "" && Number.isNaN(dMs)) { console.warn(`[islands] <${tagName}> invalid ${attrDefer} value "${d}" — using default ${deferDelay}ms`); } const ms = Number.isNaN(dMs) ? deferDelay : dMs; note(`waiting for ${attrDefer} (${ms}ms)`); await defer(ms); } const interactionAttr = el.getAttribute(attrInteraction); if (interactionAttr !== null) { let events = interactionEvents; if (interactionAttr) { const tokens = interactionAttr.split(/\s+/).filter(Boolean); if (tokens.length > 0) events = tokens; else console.warn(`[islands] <${tagName}> ${attrInteraction} has no valid event tokens — using default events`); } note(`waiting for ${attrInteraction} (${events.join(", ")})`); await interaction(el, events, pendingCancellable); } } catch { flush("aborted (element removed)"); return; } const run = () => { if (disconnected) return Promise.resolve(); const t0 = performance.now(); return loader().then(() => { const attempt = (retryCount.get(tagName) ?? 0) + 1; loaded.add(tagName); retryCount.delete(tagName); dispatch("islands:load", { tag: tagName, duration: performance.now() - t0, attempt }); if (el.children.length) walk(el); }).catch((err) => { console.error(`[islands] Failed to load <${tagName}>:`, err); const attempt = retryCount.get(tagName) ?? 0; dispatch("islands:error", { tag: tagName, error: err, attempt: attempt + 1 }); if (attempt < retries) { retryCount.set(tagName, attempt + 1); setTimeout(run, retryDelay * 2 ** attempt); } else { retryCount.delete(tagName); queued.delete(tagName); } }); }; const handleDirectiveError = (attrName, err) => { console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, err); dispatch("islands:error", { tag: tagName, error: err, attempt: 1 }); retryCount.delete(tagName); queued.delete(tagName); }; if (customDirectives?.size) { const matched = []; for (const [attrName, directiveFn] of customDirectives) { const value = el.getAttribute(attrName); if (value !== null) matched.push([attrName, directiveFn, value]); } if (matched.length > 0) { flush(`dispatching to custom directive${matched.length === 1 ? "" : "s"} ${matched.map(([a]) => a).join(", ")}`); let remaining = matched.length; let fired = false; let aborted = false; const loadOnce = () => { if (fired || aborted) return Promise.resolve(); if (--remaining === 0) { fired = true; return run(); } return Promise.resolve(); }; for (const [attrName, directiveFn, value] of matched) { try { Promise.resolve(directiveFn(loadOnce, { name: attrName, value }, el)).catch((err) => { aborted = true; handleDirectiveError(attrName, err); }); } catch (err) { aborted = true; handleDirectiveError(attrName, err); } } return; } } flush("triggered"); run(); } function activate(el) { const tagName = el.tagName.toLowerCase(); if (queued.has(tagName)) return; const loader = islandMap.get(tagName); if (!loader) return; let ancestor = el.parentElement; while (ancestor) { if (isUnloadedIsland(ancestor.tagName.toLowerCase())) return; ancestor = ancestor.parentElement; } queued.add(tagName); loadIsland(tagName, el, loader); } function walk(el) { activate(el); const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, customElementFilter); let node; while (node = walker.nextNode()) activate(node); } const observer = new MutationObserver((mutations) => { if (pendingCancellable.size > 0 && mutations.some((m) => m.removedNodes.length > 0)) { for (const [el, cancel] of pendingCancellable) { if (!el.isConnected) { pendingCancellable.delete(el); cancel(); } } } for (const { addedNodes } of mutations) { for (const node of addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) walk(node); } } }); function init() { if (debug) console.groupCollapsed(`[islands] ready — ${islandMap.size} island(s)`); walk(document.body); initDone = true; if (debug) console.groupEnd(); observer.observe(document.body, { childList: true, subtree: true }); } let disconnected = false; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init, { once: true }); } else { init(); } const disconnect = () => { disconnected = true; observer.disconnect(); }; return { disconnect }; } export { revive };