vite-plugin-shopify-theme-islands
Version:
Vite plugin for island architecture in Shopify themes
322 lines (321 loc) • 11 kB
JavaScript
// 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
};