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