UNPKG

vite-plugin-shopify-theme-islands

Version:
608 lines (602 loc) 19.9 kB
// src/interaction-events.ts var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"]; var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES]; var INTERACTION_EVENT_NAMES_LABEL = INTERACTION_EVENT_NAMES.join(", "); var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES); var PREFIX = "[vite-plugin-shopify-theme-islands]"; function isInteractionEventName(value) { return INTERACTION_EVENT_NAME_SET.has(value); } function validateInteractionEvents(events) { if (events === undefined) return; if (events.length === 0) { throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`); } const { invalid } = partitionInteractionEventTokens(events); const invalidEvent = invalid[0]; if (invalidEvent) { throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`); } } function partitionInteractionEventTokens(tokens) { const valid = []; const invalid = []; for (const token of tokens) { if (isInteractionEventName(token)) valid.push(token); else invalid.push(token); } return { valid, invalid }; } // 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: [...DEFAULT_INTERACTION_EVENTS] } }; var DEFAULT_RETRY = { retries: 0, delay: 1000 }; function normalizeReviveOptions(options) { const d = DEFAULT_DIRECTIVES; const r = DEFAULT_RETRY; const dir = options?.directives; validateInteractionEvents(dir?.interaction?.events); 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) { console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} has no valid event tokens — using default events`); } else { const { valid, invalid } = partitionInteractionEventTokens(tokens); if (invalid.length > 0) { if (valid.length > 0) { console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} contains unsupported event token${invalid.length === 1 ? "" : "s"} (${invalid.join(", ")}) — ignoring invalid token${invalid.length === 1 ? "" : "s"}; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`); } else { console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} contains no supported event tokens (${invalid.join(", ")}) — using default events; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`); } } if (valid.length > 0) events = valid; } } 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/lifecycle.ts function createIslandLifecycleCoordinator(opts) { const queued = new Set; const loaded = new Set; const retryCount = new Map; const cancellableElements = new Map; let initialWalkComplete = false; let walkImpl; const queue = (tag) => { if (queued.has(tag) || loaded.has(tag)) return false; queued.add(tag); return true; }; const settleSuccess = (tag) => { const attempt = (retryCount.get(tag) ?? 0) + 1; queued.delete(tag); loaded.add(tag); retryCount.delete(tag); return attempt; }; const 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 }; } retryCount.delete(tag); queued.delete(tag); return { retryDelayMs: null, attempt }; }; const evict = (tag) => { retryCount.delete(tag); queued.delete(tag); }; const isQueued = (tag) => queued.has(tag); const watchCancellable = (el, cancel) => { cancellableElements.set(el, cancel); return () => { cancellableElements.delete(el); }; }; const cancelDetached = () => { if (cancellableElements.size === 0) return; for (const [el, cancel] of cancellableElements) { if (!el.isConnected) { cancellableElements.delete(el); cancel(); } } }; const start = (input) => { let disconnected = false; let initialized = false; const customElementFilter = { acceptNode: (node) => { const tag = node.tagName; if (!tag.includes("-")) return NodeFilter.FILTER_SKIP; return isQueued(tag.toLowerCase()) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } }; const activate = (el) => { const tagName = el.tagName.toLowerCase(); const loader = input.islandMap.get(tagName); if (!loader) return; let ancestor = el.parentElement; while (ancestor) { if (isQueued(ancestor.tagName.toLowerCase())) return; ancestor = ancestor.parentElement; } if (!queue(tagName)) return; input.onActivate(tagName, el, loader); }; const walk = (el) => { activate(el); const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, customElementFilter); let node; while (node = walker.nextNode()) activate(node); }; walkImpl = walk; const 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) => { cancelDetached(); handleAdditions(mutations); }); const init = () => { if (disconnected || initialized) return; const root = input.getRoot(); if (!root) return; initialized = true; input.onBeforeInitialWalk?.(); walk(root); initialWalkComplete = true; input.onInitialWalkComplete?.(); observer.observe(root, { 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 }; }; return { settleSuccess, settleFailure, evict, isQueued, get initialWalkComplete() { return initialWalkComplete; }, watchCancellable, walk(root) { walkImpl?.(root); }, start }; } // 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 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 lifecycle = createIslandLifecycleCoordinator({ retries: opts.retry.retries, retryDelay: opts.retry.delay }); const directiveOrchestrator = createDirectiveOrchestrator(); let disconnected = false; async function loadIsland(tagName, el, loader) { if (debug && !lifecycle.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 = lifecycle.settleSuccess(tagName); runtimeSurface2.dispatchLoad({ tag: tagName, duration: performance.now() - t0, attempt }); if (!disconnected && el.children.length) lifecycle.walk(el); }).catch((err) => { console.error(`[islands] Failed to load <${tagName}>:`, err); const { retryDelayMs, attempt } = lifecycle.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 }); lifecycle.evict(tagName); }; try { const matchedCustomDirectives = await directiveOrchestrator.run({ tagName, element: el, directives: opts.directives, customDirectives: resolvedDirectives, directiveTimeout, watchCancellable: lifecycle.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(); } let endReadyLog; const disconnectLifecycle = lifecycle.start({ getRoot: () => document.body, islandMap, onActivate: loadIsland, onBeforeInitialWalk: () => { endReadyLog = runtimeSurface2.beginReadyLog(islandMap.size, debug); }, onInitialWalkComplete: () => { endReadyLog?.(); endReadyLog = undefined; } }); return { disconnect() { disconnected = true; endReadyLog?.(); endReadyLog = undefined; disconnectLifecycle.disconnect(); } }; } export { revive };