UNPKG

vite-plugin-shopify-theme-islands

Version:
534 lines (530 loc) 17.1 kB
// src/contract.ts var DEFAULT_DIRECTIVES = { visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 }, idle: { attribute: "client:idle", timeout: 500 }, media: { attribute: "client:media" }, defer: { attribute: "client:defer", delay: 3000 }, interaction: { attribute: "client:interaction", events: ["mouseenter", "touchstart", "focusin"] } }; var DEFAULT_RETRY = { retries: 0, delay: 1000 }; function normalizeReviveOptions(options) { const d = DEFAULT_DIRECTIVES; const r = DEFAULT_RETRY; const dir = options?.directives; return { directives: { visible: { ...d.visible, ...dir?.visible }, idle: { ...d.idle, ...dir?.idle }, media: { ...d.media, ...dir?.media }, defer: { ...d.defer, ...dir?.defer }, interaction: { ...d.interaction, ...dir?.interaction } }, debug: options?.debug ?? false, retry: { ...r, ...options?.retry }, directiveTimeout: options?.directiveTimeout ?? 0 }; } var basename = (key) => key.split("/").pop() ?? key; function defaultKeyToTag(key) { const filename = basename(key); const tag = filename.replace(/\.(ts|js)$/, ""); const skip = !tag.includes("-"); if (skip && tag) console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag (e.g. rename to "${tag}-island.ts")`); return { tag, skip }; } function buildIslandMap(payload) { const map = new Map; for (const [key, loader] of Object.entries(payload.islands)) { const { tag, skip } = defaultKeyToTag(key); if (skip) continue; if (!map.has(tag)) map.set(tag, loader); } return map; } // src/directive-orchestration.ts class DirectiveCancelledError extends Error { constructor() { super("[islands] directive cancelled: element removed from DOM"); this.name = "DirectiveCancelledError"; } } function waitVisible(element, rootMargin, threshold, watch) { return new Promise((resolve, reject) => { let settled = false; let unwatch = () => {}; const finish = (done) => { if (settled) return; settled = true; unwatch(); io.disconnect(); done(); }; const io = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { finish(resolve); } }, { rootMargin, threshold }); io.observe(element); unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError))); }); } function waitInteraction(element, events, watch) { return new Promise((resolve, reject) => { let settled = false; let unwatch = () => {}; const cleanup = () => { for (const name of events) element.removeEventListener(name, handler); }; const finish = (done) => { if (settled) return; settled = true; unwatch(); cleanup(); done(); }; const handler = () => { finish(resolve); }; for (const name of events) element.addEventListener(name, handler); unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError))); }); } function waitDelay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function waitIdle(timeout) { return new Promise((resolve) => { if ("requestIdleCallback" in window) window.requestIdleCallback(() => resolve(), { timeout }); else setTimeout(resolve, timeout); }); } function waitMedia(query) { const m = window.matchMedia(query); return new Promise((resolve) => { if (m.matches) resolve(); else m.addEventListener("change", () => resolve(), { once: true }); }); } function createDirectiveOrchestrator(waiters = { waitVisible, waitMedia, waitIdle, waitDelay, waitInteraction }) { async function runBuiltIns(ctx) { const { tagName, element: el, directives, log, watchCancellable } = ctx; const visibleAttr = directives.visible.attribute; if (el.getAttribute(visibleAttr) !== null) { log.note(`waiting for ${visibleAttr}`); await waiters.waitVisible(el, el.getAttribute(visibleAttr) || directives.visible.rootMargin, directives.visible.threshold, watchCancellable); } const query = el.getAttribute(directives.media.attribute); if (query === "") { console.warn(`[islands] <${tagName}> ${directives.media.attribute} has no value — media check skipped, island will load immediately`); } else if (query) { log.note(`waiting for ${directives.media.attribute}="${query}"`); await waiters.waitMedia(query); } const idleAttr = el.getAttribute(directives.idle.attribute); if (idleAttr !== null) { const raw = parseInt(idleAttr, 10); const elTimeout = Number.isNaN(raw) ? directives.idle.timeout : raw; log.note(`waiting for ${directives.idle.attribute} (${elTimeout}ms)`); await waiters.waitIdle(elTimeout); } const deferAttr = el.getAttribute(directives.defer.attribute); if (deferAttr !== null) { const msParsed = parseInt(deferAttr, 10); if (deferAttr !== "" && Number.isNaN(msParsed)) { console.warn(`[islands] <${tagName}> invalid ${directives.defer.attribute} value "${deferAttr}" — using default ${directives.defer.delay}ms`); } const ms = Number.isNaN(msParsed) ? directives.defer.delay : msParsed; log.note(`waiting for ${directives.defer.attribute} (${ms}ms)`); await waiters.waitDelay(ms); } const interactionAttr = el.getAttribute(directives.interaction.attribute); if (interactionAttr !== null) { let events = directives.interaction.events; if (interactionAttr) { const tokens = interactionAttr.split(/\s+/).filter(Boolean); if (tokens.length > 0) events = tokens; else { console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} has no valid event tokens — using default events`); } } log.note(`waiting for ${directives.interaction.attribute} (${events.join(", ")})`); await waiters.waitInteraction(el, events, watchCancellable); } } function runCustomDirectives(ctx) { const matched = []; if (ctx.customDirectives) { for (const [attrName, directiveFn] of ctx.customDirectives) { const value = ctx.element.getAttribute(attrName); if (value !== null) matched.push([attrName, directiveFn, value]); } } if (matched.length === 0) return false; const attrNames = matched.map(([attrName]) => attrName).join(", "); ctx.log.flush(`dispatching to custom directive${matched.length === 1 ? "" : "s"} ${attrNames}`); let remaining = matched.length; let fired = false; let aborted = false; let timer; const loadOnce = () => { if (fired || aborted) return Promise.resolve(); if (--remaining === 0) { clearTimeout(timer); fired = true; return ctx.run(); } return Promise.resolve(); }; if (ctx.directiveTimeout > 0) { timer = setTimeout(() => { if (fired || aborted) return; aborted = true; ctx.onError(attrNames, new Error(`[islands] Custom directive timed out after ${ctx.directiveTimeout}ms for <${ctx.tagName}>`)); }, ctx.directiveTimeout); } for (const [attrName, directiveFn, value] of matched) { try { Promise.resolve(directiveFn(loadOnce, { name: attrName, value }, ctx.element)).catch((err) => { clearTimeout(timer); aborted = true; ctx.onError(attrName, err); }); } catch (err) { clearTimeout(timer); aborted = true; ctx.onError(attrName, err); } } return true; } return { async run(ctx) { await runBuiltIns(ctx); return runCustomDirectives(ctx); } }; } // src/runtime-surface.ts var SILENT_LOGGER = { note() {}, flush() {} }; function addListener(target, name, handler) { const listener = (event) => handler(event.detail); target.addEventListener(name, listener); return () => target.removeEventListener(name, listener); } function dispatch(target, name, detail) { target.dispatchEvent(new CustomEvent(name, { detail })); } function createRuntimeSurface(deps) { return { dispatchLoad(detail) { dispatch(deps.target, "islands:load", detail); }, dispatchError(detail) { dispatch(deps.target, "islands:error", detail); }, onLoad(handler) { return addListener(deps.target, "islands:load", handler); }, onError(handler) { return addListener(deps.target, "islands:error", handler); }, createLogger(tagName, debug) { if (!debug) return SILENT_LOGGER; const msgs = []; return { note(msg) { msgs.push(msg); }, flush(summary) { if (msgs.length === 0) { deps.console.log("[islands]", `<${tagName}> ${summary}`); } else { deps.console.groupCollapsed(`[islands] <${tagName}> ${summary}`); for (const msg of msgs) deps.console.log(msg); deps.console.groupEnd(); } msgs.length = 0; } }; }, beginReadyLog(islandCount, debug) { if (!debug) return () => {}; deps.console.groupCollapsed(`[islands] ready — ${islandCount} island(s)`); return () => deps.console.groupEnd(); } }; } var runtimeSurface; function getRuntimeSurface() { runtimeSurface ??= createRuntimeSurface({ target: document, console }); return runtimeSurface; } // src/runtime.ts function isRevivePayload(v) { return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v); } function createIslandRegistry(opts) { const queued = new Set; const loaded = new Set; const retryCount = new Map; const cancellableElements = new Map; let initialWalkComplete = false; return { queue(tag) { if (queued.has(tag) || loaded.has(tag)) return false; queued.add(tag); return true; }, settleSuccess(tag) { const attempt = (retryCount.get(tag) ?? 0) + 1; queued.delete(tag); loaded.add(tag); retryCount.delete(tag); return attempt; }, settleFailure(tag) { const attempt = (retryCount.get(tag) ?? 0) + 1; if (attempt <= opts.retries) { retryCount.set(tag, attempt); return { retryDelayMs: opts.retryDelay * 2 ** (attempt - 1), attempt }; } else { retryCount.delete(tag); queued.delete(tag); return { retryDelayMs: null, attempt }; } }, evict(tag) { retryCount.delete(tag); queued.delete(tag); }, isQueued(tag) { return queued.has(tag); }, get initialWalkComplete() { return initialWalkComplete; }, markInitialWalkComplete() { initialWalkComplete = true; }, watchCancellable(el, cancel) { cancellableElements.set(el, cancel); return () => { cancellableElements.delete(el); }; }, cancelDetached() { if (cancellableElements.size === 0) return; for (const [el, cancel] of cancellableElements) { if (!el.isConnected) { cancellableElements.delete(el); cancel(); } } } }; } function revive(islandsOrPayload, options, customDirectives) { const runtimeSurface2 = getRuntimeSurface(); const payload = isRevivePayload(islandsOrPayload) ? islandsOrPayload : { islands: islandsOrPayload, options, customDirectives }; const opts = normalizeReviveOptions(payload.options); const islandMap = buildIslandMap(payload); const resolvedDirectives = payload.customDirectives; const attrVisible = opts.directives.visible.attribute; const attrMedia = opts.directives.media.attribute; const attrIdle = opts.directives.idle.attribute; const attrDefer = opts.directives.defer.attribute; const attrInteraction = opts.directives.interaction.attribute; const debug = opts.debug; const directiveTimeout = opts.directiveTimeout; const registry = createIslandRegistry({ retries: opts.retry.retries, retryDelay: opts.retry.delay }); const directiveOrchestrator = createDirectiveOrchestrator(); const customElementFilter = { acceptNode: (node) => { const tag = node.tagName; if (!tag.includes("-")) return NodeFilter.FILTER_SKIP; const lowerTag = tag.toLowerCase(); if (registry.isQueued(lowerTag)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }; async function loadIsland(tagName, el, loader) { if (debug && !registry.initialWalkComplete) { 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 (resolvedDirectives?.size) { for (const a of resolvedDirectives.keys()) { if (el.hasAttribute(a)) parts.push(a); } } if (parts.length > 0) console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`); } const log = runtimeSurface2.createLogger(tagName, debug); const run = () => { if (disconnected) return Promise.resolve(); const t0 = performance.now(); return loader().then(() => { const attempt = registry.settleSuccess(tagName); runtimeSurface2.dispatchLoad({ tag: tagName, duration: performance.now() - t0, attempt }); if (el.children.length) walk(el); }).catch((err) => { console.error(`[islands] Failed to load <${tagName}>:`, err); const { retryDelayMs, attempt } = registry.settleFailure(tagName); runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt }); if (retryDelayMs !== null) { setTimeout(run, retryDelayMs); } }); }; const handleDirectiveError = (attrName, err) => { if (attrName === null && err instanceof DirectiveCancelledError) return; if (attrName !== null) { console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, err); } else { console.error(`[islands] Built-in directive failed for <${tagName}>:`, err); } runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt: 1 }); registry.evict(tagName); }; try { const matchedCustomDirectives = await directiveOrchestrator.run({ tagName, element: el, directives: opts.directives, customDirectives: resolvedDirectives, directiveTimeout, watchCancellable: registry.watchCancellable, log, run, onError: handleDirectiveError }); if (matchedCustomDirectives) return; } catch (err) { handleDirectiveError(null, err); log.flush(err instanceof DirectiveCancelledError ? "aborted (element removed)" : "aborted (directive error)"); return; } log.flush("triggered"); run(); } function activate(el) { const tagName = el.tagName.toLowerCase(); const loader = islandMap.get(tagName); if (!loader) return; let ancestor = el.parentElement; while (ancestor) { if (registry.isQueued(ancestor.tagName.toLowerCase())) return; ancestor = ancestor.parentElement; } if (!registry.queue(tagName)) return; 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); } function handleAdditions(mutations) { for (const { addedNodes } of mutations) { for (const node of addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) walk(node); } } } const observer = new MutationObserver((mutations) => { registry.cancelDetached(); handleAdditions(mutations); }); let disconnected = false; let initialized = false; function init() { if (disconnected || initialized) return; initialized = true; const endReadyLog = runtimeSurface2.beginReadyLog(islandMap.size, debug); walk(document.body); registry.markInitialWalkComplete(); endReadyLog(); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init, { once: true }); } else { init(); } const disconnect = () => { disconnected = true; document.removeEventListener("DOMContentLoaded", init); observer.disconnect(); }; return { disconnect }; } export { revive };