UNPKG

vite-plugin-shopify-theme-islands

Version:
1,295 lines (1,281 loc) 40.3 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 }; } function formatUnsupportedInteractionTokenWarning(params) { const { attribute, invalidTokens, usedDefaultEvents } = params; const countSuffix = invalidTokens.length === 1 ? "" : "s"; const invalidTokenList = invalidTokens.join(", "); if (!usedDefaultEvents) { return `${attribute} contains unsupported event token${countSuffix} (${invalidTokenList}) — ignoring invalid token${countSuffix}; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`; } return `${attribute} contains no supported event tokens (${invalidTokenList}) — using default events; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`; } // 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 deriveDefaultTag(key) { const filename = basename(key); return filename.replace(/\.(ts|js)$/, ""); } function defaultKeyToTag(key) { const filename = basename(key); const tag = deriveDefaultTag(key); 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 duplicateTagOwnershipError(tag, filePaths) { return new Error(`[islands] Multiple island entrypoints resolve to <${tag}>: - ${filePaths.join(` - `)} Tag ownership must be unique before calling revive(...). Remove one entry or disambiguate the final tag.`); } function compileResolvedTags(filePaths, resolveTag) { const entries = []; for (const filePath of filePaths) { const defaultTag = deriveDefaultTag(filePath); const resolvedTag = resolveTag({ filePath, defaultTag }); if (resolvedTag === defaultTag) continue; entries.push([filePath, resolvedTag]); } return entries.length > 0 ? Object.fromEntries(entries) : null; } function buildIslandMap(payload) { const map = new Map; const sourceKeys = new Map; for (const [key, loader] of Object.entries(payload.islands)) { const resolvedTag = payload.resolvedTags?.[key]; const { tag, skip } = resolvedTag !== undefined ? resolvedTag === false ? { tag: "", skip: true } : { tag: resolvedTag } : defaultKeyToTag(key); if (skip) continue; if (!map.has(tag)) { map.set(tag, loader); sourceKeys.set(tag, key); continue; } throw duplicateTagOwnershipError(tag, [sourceKeys.get(tag) ?? key, key]); } return map; } // src/directive-waiters.ts class DirectiveCancelledError extends Error { constructor() { super("[islands] directive cancelled: element removed from DOM"); this.name = "DirectiveCancelledError"; } } function abortableWait(signal, setup) { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new DirectiveCancelledError); return; } let settled = false; let cleanup = () => {}; const finish = (done) => { if (settled) return; settled = true; signal.removeEventListener("abort", abort); cleanup(); done(); }; const abort = () => finish(() => reject(new DirectiveCancelledError)); signal.addEventListener("abort", abort, { once: true }); try { const registeredCleanup = setup(() => finish(resolve)); cleanup = () => { registeredCleanup?.(); }; if (settled) cleanup(); } catch (err) { finish(() => reject(err)); } }); } function waitVisible(element, rootMargin, threshold, signal) { return abortableWait(signal, (finish) => { const io = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) finish(); }, { rootMargin, threshold }); io.observe(element); return () => io.disconnect(); }); } function waitInteraction(element, events, signal) { return abortableWait(signal, (finish) => { const handler = () => { finish(); }; for (const name of events) element.addEventListener(name, handler); return () => { for (const name of events) element.removeEventListener(name, handler); }; }); } function waitDelay(ms, signal) { return abortableWait(signal, (finish) => { const timer = setTimeout(finish, ms); return () => clearTimeout(timer); }); } function waitIdle(timeout, signal) { return abortableWait(signal, (finish) => { let idleId = null; let timer = null; if ("requestIdleCallback" in window) { idleId = window.requestIdleCallback(() => finish(), { timeout }); } else { timer = setTimeout(finish, timeout); } return () => { if ("cancelIdleCallback" in window && idleId !== null) { window.cancelIdleCallback(idleId); } else if (timer !== null) { clearTimeout(timer); } }; }); } function waitMedia(query, signal) { const m = window.matchMedia(query); return abortableWait(signal, (finish) => { if (m.matches) { finish(); return; } const onChange = () => finish(); m.addEventListener("change", onChange, { once: true }); return () => { m.removeEventListener("change", onChange); }; }); } var DEFAULT_DIRECTIVE_WAITERS = { waitVisible, waitMedia, waitIdle, waitDelay, waitInteraction }; // src/island-element-activation.ts function formatGateWarning(tagName, warning) { switch (warning.kind) { case "emptyMediaQuery": return `[islands] <${tagName}> ${warning.attribute} has no value — media check skipped, island will load immediately`; case "invalidIdleValue": case "invalidDeferValue": return `[islands] <${tagName}> invalid ${warning.attribute} value "${warning.rawValue}" — using default ${warning.defaultMs}ms`; case "emptyInteractionTokens": return `[islands] <${tagName}> ${warning.attribute} has no valid event tokens — using default events`; case "invalidInteractionTokens": return `[islands] <${tagName}> ${formatUnsupportedInteractionTokenWarning({ attribute: warning.attribute, invalidTokens: warning.invalidTokens, usedDefaultEvents: warning.usedDefaultEvents })}`; } } async function runBuiltInDirectives(ctx) { const { element, gates, waiters, watchCancellable, log } = ctx; const controller = new AbortController; const unwatch = watchCancellable(element, () => controller.abort()); try { for (const gate of gates) { switch (gate.kind) { case "visible": log.note(`waiting for ${gate.attribute}`); await waiters.waitVisible(element, gate.rootMargin, gate.threshold, controller.signal); break; case "media": if (gate.query === null) { break; } log.note(`waiting for ${gate.attribute}="${gate.query}"`); await waiters.waitMedia(gate.query, controller.signal); break; case "idle": log.note(`waiting for ${gate.attribute} (${gate.timeout}ms)`); await waiters.waitIdle(gate.timeout, controller.signal); break; case "defer": log.note(`waiting for ${gate.attribute} (${gate.delay}ms)`); await waiters.waitDelay(gate.delay, controller.signal); break; case "interaction": log.note(`waiting for ${gate.attribute} (${gate.events.join(", ")})`); await waiters.waitInteraction(element, gate.events, controller.signal); break; case "custom": break; } } } finally { unwatch(); } } function runCustomDirectives(ctx) { const matched = ctx.gates.map((gate) => [gate.attribute, gate.directive, gate.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; let cleanupRan = false; const cleanupFns = new Set; let unwatch = () => {}; const controller = new AbortController; const runCleanup = () => { if (cleanupRan) return; cleanupRan = true; unwatch(); clearTimeout(timer); for (const cleanup of cleanupFns) cleanup(); cleanupFns.clear(); }; const abort = () => { if (aborted) return; aborted = true; controller.abort(); runCleanup(); }; const directiveContext = { signal: controller.signal, onCleanup(cleanup) { if (controller.signal.aborted) { cleanup(); return; } cleanupFns.add(cleanup); } }; const loadOnce = () => { if (fired || aborted) return Promise.resolve(); if (--remaining === 0) { fired = true; controller.abort(); runCleanup(); return ctx.run(); } return Promise.resolve(); }; unwatch = ctx.watchCancellable(ctx.element, abort); if (ctx.directiveTimeout > 0) { timer = setTimeout(() => { if (fired || aborted) return; abort(); 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, directiveContext)).catch((err) => { if (fired) return; abort(); ctx.onError(attrName, err); }); } catch (err) { if (fired) continue; abort(); ctx.onError(attrName, err); } } return true; } function createLoaderRunner(ctx) { const { tagName, element, loader, ownership, surface, platform } = ctx; const abortIfInactive = () => { if (ownership.isObserved(element)) return false; ownership.evict(tagName); return true; }; const runLoader = () => { if (abortIfInactive()) return Promise.resolve(); const startedAt = platform.now(); return loader().then(() => { if (abortIfInactive()) return; const attempt = ownership.settleSuccess(tagName); surface.dispatchLoad({ tag: tagName, duration: platform.now() - startedAt, attempt }); if (element.children.length > 0) ownership.walk(element); }).catch((error) => { platform.console.error(`[islands] Failed to load <${tagName}>:`, error); const { willRetry, attempt } = ownership.settleFailure(tagName, () => { runLoader(); }); surface.dispatchError({ tag: tagName, error, attempt }); if (!willRetry) ownership.evict(tagName); }); }; return runLoader; } function handleDirectiveError(tagName, error, attrName, log, deps) { if (attrName === null && error instanceof DirectiveCancelledError) { log.flush("aborted (element removed)"); return; } if (attrName !== null) { deps.platform.console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, error); } else { deps.platform.console.error(`[islands] Built-in directive failed for <${tagName}>:`, error); } deps.surface.dispatchError({ tag: tagName, error, attempt: 1 }); deps.ownership.evict(tagName); log.flush(attrName === null ? "aborted (directive error)" : "aborted (custom directive error)"); } async function activateIslandElement(deps) { const { tagName, element, loader, plan, platform, ownership, surface } = deps; const waiters = deps.waiters ?? DEFAULT_DIRECTIVE_WAITERS; const log = surface.createLogger(tagName); for (const warning of plan.warnings) { platform.console.warn(formatGateWarning(tagName, warning)); } const runLoader = createLoaderRunner({ tagName, element, loader, ownership, surface, platform }); try { await runBuiltInDirectives({ tagName, element, gates: plan.gates, waiters, watchCancellable: ownership.watchCancellable, log }); } catch (error) { handleDirectiveError(tagName, error, null, log, deps); return; } const delegatedToCustomDirectives = runCustomDirectives({ tagName, element, gates: plan.customGates, directiveTimeout: deps.directiveTimeout, watchCancellable: ownership.watchCancellable, log, run: runLoader, onError: (attrName, error) => handleDirectiveError(tagName, error, attrName, log, deps) }); if (delegatedToCustomDirectives) return; log.flush("triggered"); await runLoader(); } // src/activation-session.ts function createActivationSession(deps) { const clear = (tagNames) => { if (tagNames) { const tags = [...tagNames]; deps.ownership.clear(tags); deps.observability.clear(tags); return; } deps.ownership.clear(); deps.observability.clear(); }; const discover = (tagName, element) => { const plan = deps.spine.planGates(element); deps.observability.warnOnConflictingLoadGate(tagName, element, plan.conflictSignature); }; const activate = async ({ tagName, element, loader }) => { const plan = deps.spine.planGates(element); deps.observability.noteInitialWaits(tagName, plan.initialDiagnosticParts, deps.ownership.initialWalkComplete); await activateIslandElement({ tagName, element, loader, plan, directiveTimeout: deps.directiveTimeout, waiters: deps.waiters, ownership: deps.ownership, surface: deps.surface, platform: deps.platform }); }; return { discover, activate, clear }; } // src/directive-spine.ts function parseStrictIntegerAttribute(value, fallback) { if (value === null) return { value: null, invalid: false }; if (value === "") return { value: fallback, invalid: false }; const trimmed = value.trim(); if (!/^-?\d+$/.test(trimmed)) return { value: fallback, invalid: true }; return { value: Number.parseInt(trimmed, 10), invalid: false }; } function buildGatePlan(gates) { const customGates = []; const warnings = []; const initialDiagnosticParts = []; for (const gate of gates) { switch (gate.kind) { case "visible": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); break; } case "media": { if (gate.rawValue) initialDiagnosticParts.push(`${gate.attribute}="${gate.rawValue}"`); if (gate.query === null) warnings.push({ kind: "emptyMediaQuery", attribute: gate.attribute }); break; } case "idle": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); if (gate.invalid) warnings.push({ kind: "invalidIdleValue", attribute: gate.attribute, rawValue: gate.rawValue, defaultMs: gate.timeout }); break; } case "defer": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); if (gate.invalid) warnings.push({ kind: "invalidDeferValue", attribute: gate.attribute, rawValue: gate.rawValue, defaultMs: gate.delay }); break; } case "interaction": { const part = gate.rawValue ? `${gate.attribute}="${gate.rawValue}"` : gate.attribute; initialDiagnosticParts.push(part); if (gate.emptyTokens) { warnings.push({ kind: "emptyInteractionTokens", attribute: gate.attribute }); } else if (gate.invalidTokens.length > 0) { warnings.push({ kind: "invalidInteractionTokens", attribute: gate.attribute, invalidTokens: gate.invalidTokens, usedDefaultEvents: gate.usedDefaultEvents }); } break; } case "custom": { const part = gate.value ? `${gate.attribute}="${gate.value}"` : gate.attribute; initialDiagnosticParts.push(part); customGates.push(gate); break; } } } return { gates, customGates, conflictSignature: describeGates(gates), initialDiagnosticParts, warnings }; } function formatEffectiveGate(gate) { switch (gate.kind) { case "visible": return `${gate.attribute}="${gate.rootMargin}"`; case "media": return gate.query ? `${gate.attribute}="${gate.query}"` : null; case "idle": return `${gate.attribute}="${gate.timeout}"`; case "defer": return `${gate.attribute}="${gate.delay}"`; case "interaction": return `${gate.attribute}="${gate.events.join(" ")}"`; case "custom": return gate.value ? `${gate.attribute}="${gate.value}"` : gate.attribute; } } function describeGates(gates) { if (gates.length === 0) return "immediate"; return gates.map(formatEffectiveGate).filter((part) => part !== null).join(", "); } function createDirectiveSpine(directives = DEFAULT_DIRECTIVES) { const attributeNames = new Set([ directives.visible.attribute, directives.idle.attribute, directives.media.attribute, directives.defer.attribute, directives.interaction.attribute ]); return { planGates(el) { return buildGatePlan(this.readGates(el)); }, readGates(el) { const gates = []; const visible = el.getAttribute(directives.visible.attribute); if (visible !== null) { gates.push({ kind: "visible", attribute: directives.visible.attribute, rawValue: visible, rootMargin: visible || directives.visible.rootMargin, threshold: directives.visible.threshold }); } const media = el.getAttribute(directives.media.attribute); if (media !== null) { gates.push({ kind: "media", attribute: directives.media.attribute, rawValue: media, query: media || null }); } const idle = parseStrictIntegerAttribute(el.getAttribute(directives.idle.attribute), directives.idle.timeout); if (idle.value !== null) { gates.push({ kind: "idle", attribute: directives.idle.attribute, timeout: idle.value, invalid: idle.invalid, rawValue: el.getAttribute(directives.idle.attribute) ?? "" }); } const defer = parseStrictIntegerAttribute(el.getAttribute(directives.defer.attribute), directives.defer.delay); if (defer.value !== null) { gates.push({ kind: "defer", attribute: directives.defer.attribute, delay: defer.value, invalid: defer.invalid, rawValue: el.getAttribute(directives.defer.attribute) ?? "" }); } const interaction = el.getAttribute(directives.interaction.attribute); if (interaction !== null) { let events = [...directives.interaction.events]; let invalidTokens = []; let emptyTokens = false; let usedDefaultEvents = interaction === ""; if (interaction) { const tokens = interaction.split(/\s+/).filter(Boolean); if (tokens.length === 0) { emptyTokens = true; usedDefaultEvents = true; } else { const partition = partitionInteractionEventTokens(tokens); invalidTokens = partition.invalid; if (partition.valid.length > 0) { events = partition.valid; usedDefaultEvents = false; } else { usedDefaultEvents = true; } } } gates.push({ kind: "interaction", attribute: directives.interaction.attribute, rawValue: interaction, events, invalidTokens, emptyTokens, usedDefaultEvents }); } return gates; }, describe(el) { return describeGates(this.readGates(el)); }, attributeNames }; } function extendDirectiveSpine(base, customDirectives) { if (!customDirectives?.size) return base; const attributeNames = new Set(base.attributeNames); for (const attrName of customDirectives.keys()) attributeNames.add(attrName); return { readGates(el) { const gates = [...base.readGates(el)]; for (const [attribute, directive] of customDirectives) { const value = el.getAttribute(attribute); if (value !== null) { gates.push({ kind: "custom", attribute, value, directive }); } } return gates; }, planGates(el) { return buildGatePlan(this.readGates(el)); }, describe(el) { return describeGates(this.readGates(el)); }, attributeNames }; } var DEFAULT_DIRECTIVE_SPINE = createDirectiveSpine(); // src/cancellable-watchers.ts function createCancellableWatchers() { const cancellableElements = new Map; return { watch(el, cancel) { const cancels = cancellableElements.get(el) ?? new Set; cancels.add(cancel); cancellableElements.set(el, cancels); return () => { const activeCancels = cancellableElements.get(el); if (!activeCancels) return; activeCancels.delete(cancel); if (activeCancels.size === 0) cancellableElements.delete(el); }; }, cancelDetached() { if (cancellableElements.size === 0) return; for (const [el, cancels] of cancellableElements) { if (!el.isConnected) { cancellableElements.delete(el); for (const cancel of cancels) cancel(); } } }, cancelInRoot(root) { for (const [el, cancels] of cancellableElements) { if (el === root || root.contains(el)) { cancellableElements.delete(el); for (const cancel of cancels) cancel(); } } } }; } // src/retry-scheduler.ts function createRetryScheduler(options) { const retryCount = new Map; const retryTimers = new Map; const platform = options.platform ?? globalThis; const clearRetryTimer = (tag) => { const timer = retryTimers.get(tag); if (timer === undefined) return; platform.clearTimeout(timer); retryTimers.delete(tag); }; return { attemptOf(tag) { return (retryCount.get(tag) ?? 0) + 1; }, scheduleRetry(tag, retry) { const attempt = (retryCount.get(tag) ?? 0) + 1; if (attempt <= options.retries) { retryCount.set(tag, attempt); clearRetryTimer(tag); const timer = platform.setTimeout(() => { retryTimers.delete(tag); retry(); }, options.retryDelay * 2 ** (attempt - 1)); retryTimers.set(tag, timer); return { willRetry: true, attempt }; } clearRetryTimer(tag); retryCount.delete(tag); return { willRetry: false, attempt }; }, cancel(tag) { clearRetryTimer(tag); retryCount.delete(tag); }, cancelAll() { for (const timer of retryTimers.values()) platform.clearTimeout(timer); retryTimers.clear(); retryCount.clear(); } }; } // src/lifecycle.ts function createIslandLifecycleCoordinator(opts) { const queued = new Set; const loaded = new Set; const retryScheduler = createRetryScheduler({ retries: opts.retries, retryDelay: opts.retryDelay, platform: opts.platform }); const cancellableWatchers = createCancellableWatchers(); const rootPolicies = new Map; let initialWalkComplete = false; let walkImpl; const isExcluded = (el) => { let current = el; while (current) { const policy = rootPolicies.get(current); if (policy) return policy === "exclude"; current = current.parentElement; } return false; }; const queue = (tag) => { if (queued.has(tag) || loaded.has(tag)) return false; queued.add(tag); return true; }; const settleSuccess = (tag) => { const attempt = retryScheduler.attemptOf(tag); retryScheduler.cancel(tag); queued.delete(tag); loaded.add(tag); return attempt; }; const settleFailure = (tag, retry) => { const result = retryScheduler.scheduleRetry(tag, retry); if (!result.willRetry) queued.delete(tag); return result; }; const evict = (tag) => { retryScheduler.cancel(tag); queued.delete(tag); }; const clear = (tags) => { if (tags) { for (const tag of tags) evict(tag); return; } retryScheduler.cancelAll(); queued.clear(); }; const isQueued = (tag) => queued.has(tag); const start = (input) => { let disconnected = false; let initialized = false; const customElementFilter = { acceptNode: (node) => { if (isExcluded(node)) return NodeFilter.FILTER_REJECT; const tag = node.tagName.toLowerCase(); if (!tag.includes("-")) return NodeFilter.FILTER_SKIP; if (!input.islandMap.has(tag)) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; } }; const activate = (el) => { if (isExcluded(el)) return; const tagName = el.tagName.toLowerCase(); const loader = input.islandMap.get(tagName); if (!loader) return; input.onDiscover?.(tagName, el); 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) => { cancellableWatchers.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 { excludeRoot(root) { rootPolicies.set(root, "exclude"); cancellableWatchers.cancelInRoot(root); }, includeRoot(root) { rootPolicies.set(root, "include"); }, isObserved(el) { return !isExcluded(el); }, settleSuccess, settleFailure, evict, clear, isQueued, get initialWalkComplete() { return initialWalkComplete; }, watchCancellable: cancellableWatchers.watch, 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-observability.ts function createRuntimeObservability(deps) { const discoveredElementsByTag = new Map; const warnedLoadGateSignatures = new Map; const clear = (tagNames) => { if (tagNames) { for (const tagName of tagNames) { discoveredElementsByTag.delete(tagName); warnedLoadGateSignatures.delete(tagName); } return; } discoveredElementsByTag.clear(); warnedLoadGateSignatures.clear(); }; return { noteInitialWaits(tagName, initialDiagnosticParts, initialWalkComplete) { if (!deps.debug || initialWalkComplete) return; if (initialDiagnosticParts.length > 0) deps.console.log("[islands]", `<${tagName}> waiting · ${initialDiagnosticParts.join(", ")}`); }, warnOnConflictingLoadGate(tagName, element, conflictSignature) { if (!deps.debug) return; const elementSignatures = discoveredElementsByTag.get(tagName) ?? new Map; elementSignatures.set(element, conflictSignature); discoveredElementsByTag.set(tagName, elementSignatures); const gates = new Set; for (const [candidate, sig] of elementSignatures) { if (!candidate.isConnected || !deps.isObserved(candidate)) { elementSignatures.delete(candidate); continue; } gates.add(sig); } if (elementSignatures.size === 0) { discoveredElementsByTag.delete(tagName); warnedLoadGateSignatures.delete(tagName); return; } if (gates.size <= 1) { warnedLoadGateSignatures.delete(tagName); return; } const signature = [...gates].sort().join(" vs "); if (warnedLoadGateSignatures.get(tagName) === signature) return; warnedLoadGateSignatures.set(tagName, signature); deps.console.warn(`[islands] Found same tag <${tagName}> with conflicting directive gates (${signature}). Directives load code at the tag level, so the first-resolved instance wins for this tag.`); }, clear }; } // src/shopify-lifecycle.ts var SHOPIFY_LIFECYCLE_ACTIONS = [ ["shopify:section:load", "observe"], ["shopify:section:unload", "unobserve"], ["shopify:section:reorder", "scan"], ["shopify:section:select", "scan"], ["shopify:section:deselect", "scan"], ["shopify:block:select", "scan"], ["shopify:block:deselect", "scan"] ]; var isBlockLifecycleEvent = (type) => type.startsWith("shopify:block:"); var isSectionLifecycleEvent = (type) => type.startsWith("shopify:section:"); function findClosestLifecycleRoot(target, selector) { if (!(target instanceof Element)) return null; const root = target.closest(selector); return root instanceof HTMLElement ? root : null; } function resolveLifecycleRoot(event) { if (!(event instanceof CustomEvent)) return null; const detail = event.detail; if (!detail || typeof detail !== "object") return null; if (isBlockLifecycleEvent(event.type)) { const blockId = "blockId" in detail && typeof detail.blockId === "string" ? detail.blockId : null; if (blockId) { const root = document.getElementById(`shopify-block-${blockId}`); if (root instanceof HTMLElement) return root; } return findClosestLifecycleRoot(event.target, '[id^="shopify-block-"]'); } if (isSectionLifecycleEvent(event.type)) { const sectionId = "sectionId" in detail && typeof detail.sectionId === "string" ? detail.sectionId : null; if (sectionId) { const root = document.getElementById(`shopify-section-${sectionId}`); if (root instanceof HTMLElement) return root; } return findClosestLifecycleRoot(event.target, '[id^="shopify-section-"]'); } return null; } function connectShopifyLifecycle(runtime, ports = {}) { const removers = SHOPIFY_LIFECYCLE_ACTIONS.map(([type, action]) => { const listener = (event) => { const root = ports.resolveRoot ? ports.resolveRoot(event) : resolveLifecycleRoot(event); if (!root) return; runtime[action](root); }; document.addEventListener(type, listener); return () => document.removeEventListener(type, listener); }); return () => { for (const remove of removers) remove(); }; } // src/runtime-ownership.ts function createObservedRootSession(deps) { let disconnected = false; let endReadyLog; const membershipByRoot = new Map; const tagsStillObservedOutside = (ignoredRoot) => { const tags = new Set; for (const [root, membership] of membershipByRoot) { if (root === ignoredRoot) continue; for (const tagName of membership.values()) tags.add(tagName); } return tags; }; const disconnectLifecycle = deps.lifecycle.start({ getRoot: () => document.body, islandMap: deps.islandMap, onDiscover: (tagName, element) => { for (const [root, membership] of membershipByRoot) { if (root.contains(element)) membership.set(element, tagName); } deps.session.discover(tagName, element); }, onActivate: (tagName, element, loader) => { deps.session.activate({ tagName, element, loader }); }, onBeforeInitialWalk: () => { endReadyLog = deps.surface.beginReadyLog(deps.islandMap.size); }, onInitialWalkComplete: () => { endReadyLog?.(); endReadyLog = undefined; } }); const disconnectRoot = (root = document.body) => { if (root !== document.body) return; deps.lifecycle.excludeRoot(document.body); disconnected = true; membershipByRoot.clear(); deps.session.clear(); endReadyLog?.(); endReadyLog = undefined; disconnectLifecycle.disconnect(); }; const runtime = { scan(root = document.body) { if (disconnected || !root) return; deps.lifecycle.walk(root); }, observe(root = document.body) { if (disconnected || !root) return; if (root !== document.body) { membershipByRoot.set(root, membershipByRoot.get(root) ?? new Map); deps.lifecycle.includeRoot(root); } deps.lifecycle.walk(root); }, unobserve(root = document.body) { if (root && root !== document.body) { const membership = membershipByRoot.get(root); const retainedTags = tagsStillObservedOutside(root); const tagsToClear = new Set; for (const tagName of membership?.values() ?? []) { if (!retainedTags.has(tagName)) tagsToClear.add(tagName); } membershipByRoot.delete(root); deps.lifecycle.excludeRoot(root); deps.session.clear(tagsToClear); return; } disconnectRoot(root); }, disconnect() { disconnectRoot(document.body); } }; const disconnectShopify = (deps.connectShopify ?? connectShopifyLifecycle)(runtime); return { ...runtime, disconnect() { disconnectShopify(); runtime.disconnect(); } }; } // src/runtime.ts function isRevivePayload(v) { return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v); } function revive(payload) { const runtimeSurface2 = getRuntimeSurface(); if (!isRevivePayload(payload)) { throw new TypeError("[islands] revive() now requires a RevivePayload object. Pass { islands, options?, customDirectives?, resolvedTags? }."); } const opts = normalizeReviveOptions(payload.options); const islandMap = buildIslandMap(payload); const baseSpine = payload.options?.directives ? createDirectiveSpine(opts.directives) : DEFAULT_DIRECTIVE_SPINE; const spine = extendDirectiveSpine(baseSpine, payload.customDirectives); const lifecycle = createIslandLifecycleCoordinator({ retries: opts.retry.retries, retryDelay: opts.retry.delay }); const observability = createRuntimeObservability({ debug: opts.debug, isObserved: (element) => lifecycle.isObserved(element), console }); const debugBoundSurface = { dispatchLoad: runtimeSurface2.dispatchLoad, dispatchError: runtimeSurface2.dispatchError, createLogger: (tagName) => runtimeSurface2.createLogger(tagName, opts.debug), beginReadyLog: (islandCount) => runtimeSurface2.beginReadyLog(islandCount, opts.debug) }; const session = createActivationSession({ spine, directiveTimeout: opts.directiveTimeout, ownership: lifecycle, surface: debugBoundSurface, observability, platform: { now: () => performance.now(), console: { error: console.error.bind(console), warn: console.warn.bind(console) } } }); return createObservedRootSession({ islandMap, lifecycle, session, surface: debugBoundSurface }); } export { revive };