vite-plugin-shopify-theme-islands

Vite plugin for island architecture in Shopify themes

lifecycle

core
188 linesSource

Island lifecycle events and SPA teardown. onIslandLoad and onIslandError helpers from vite-plugin-shopify-theme-islands/events — prefer these over raw document.addEventListener for guaranteed type safety. Raw DOM events islands:load and islands:error on document. islands:load detail includes tag, duration (ms), and attempt (1-based). islands:error detail includes tag, error, and attempt. disconnect() from the virtual module revive for SPA navigation teardown.

Setup

ts
import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";

const offLoad = onIslandLoad(({ tag, duration, attempt }) => {
  console.log("loaded:", tag, `${duration.toFixed(1)}ms`, `attempt ${attempt}`);
});

const offError = onIslandError(({ tag, error, attempt }) => {
  console.error("failed:", tag, `attempt ${attempt}`, error);
});

// Remove listeners when no longer needed
offLoad();
offError();

Core Patterns

Track island load for analytics

ts
import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";

onIslandLoad(({ tag, duration, attempt }) => {
  analytics.track("island_loaded", { component: tag, duration, attempt });
});

tag is the lowercased custom element tag name (e.g. "product-form"). duration is the time in milliseconds from when all directives resolved to when the module finished loading. attempt is 1 on the first successful load, 2 if it succeeded on the first retry, etc.

Track load performance

ts
import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";

onIslandLoad(({ tag, duration }) => {
  if (duration > 3000) {
    performance.mark(`island-slow:${tag}`);
  }
});

duration measures only the chunk fetch time — time spent waiting on directives (e.g. client:visible) is not included.

Report errors to a monitoring service

ts
import { onIslandError } from "vite-plugin-shopify-theme-islands/events";

onIslandError(({ tag, error, attempt }) => {
  Sentry.captureException(error, { extra: { island: tag, attempt } });
});

onIslandError fires on each retry attempt and on custom directive failures. attempt tells you which attempt failed — 1 is the initial load, 2 is the first retry, etc.

Teardown for SPA navigation

ts
import { disconnect } from "vite-plugin-shopify-theme-islands/revive";

// Before navigating away / unmounting the page
disconnect();

disconnect() stops the MutationObserver and prevents new islands from activating. Call it before SPA page transitions to avoid activating islands from the previous page's DOM.

Raw DOM events (when type augmentation is in scope)

ts
// DocumentEventMap augmentation is exported from the main package
import type {} from "vite-plugin-shopify-theme-islands";

document.addEventListener("islands:load", (e) => {
  console.log(e.detail.tag, e.detail.duration, e.detail.attempt);
});

The DocumentEventMap augmentation is declared in the main package's index.ts. It is only in scope when the import is present in the same tsconfig compilation.

Common Mistakes

HIGH Raw addEventListener without types — e.detail is untyped

Wrong:

ts
// No import from the package — e is Event, detail is unknown
document.addEventListener("islands:load", (e) => {
  console.log(e.detail.tag); // TypeScript error or any
});

Correct:

ts
import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";

onIslandLoad(({ tag }) => {
  console.log(tag); // string, always typed
});

onIslandLoad and onIslandError are typed unconditionally regardless of tsconfig setup. Use them instead of raw document.addEventListener unless the DocumentEventMap augmentation is confirmed to be in scope.

Source: src/events.ts

CRITICAL disconnect imported from wrong entry point

Wrong:

ts
import { disconnect } from "vite-plugin-shopify-theme-islands/runtime";
import { disconnect } from "vite-plugin-shopify-theme-islands/island";

Correct:

ts
import { disconnect } from "vite-plugin-shopify-theme-islands/revive";

Only the virtual module (/revive) exports the disconnect bound to the plugin-managed revive() instance. Importing from other entry points references a different or nonexistent instance.

Source: src/index.ts — virtual module export const { disconnect } = _islands(...)

MEDIUM onIslandError fires on every retry, not just final failure

Wrong:

ts
onIslandError(({ tag }) => {
  // Assuming this fires once when the island permanently fails
  markIslandBroken(tag);
});

Correct:

ts
onIslandError(({ tag, error, attempt }) => {
  // attempt === 1 is the first failure; higher values are retries
  if (attempt === 1) {
    reportFirstFailure(tag, error);
  }
});

With retry: { retries: 3 }, a single island can fire islands:error up to 4 times before exhausting retries. Use attempt to distinguish the initial failure from retries.

Source: src/runtime.ts — dispatch("islands:error", ...) inside .catch() before retry check

MEDIUM islands:error fires for custom directive failures too

Wrong assumption:

ts
onIslandError(({ tag, error }) => {
  // Assuming this only fires for failed dynamic import()
  reportChunkLoadFailure(tag);
});

islands:error fires when any custom directive throws or rejects, not only when the island module's import() fails. The error value may be a directive error rather than a network or chunk error.

Source: src/runtime.ts — handleDirectiveError dispatches islands:error