UNPKG

vite-plugin-shopify-theme-islands

Version:
485 lines (354 loc) 17.8 kB
# vite-plugin-shopify-theme-islands [![npm version](https://img.shields.io/npm/v/vite-plugin-shopify-theme-islands)](https://www.npmjs.com/package/vite-plugin-shopify-theme-islands) [![npm downloads](https://img.shields.io/npm/dm/vite-plugin-shopify-theme-islands)](https://www.npmjs.com/package/vite-plugin-shopify-theme-islands) [![license](https://img.shields.io/npm/l/vite-plugin-shopify-theme-islands)](./LICENSE) Island architecture for Shopify themes. Lazily hydrate custom elements using loading directives — only load the JavaScript when it's actually needed. ## Installation ```bash bun add -d vite-plugin-shopify-theme-islands npm install -D vite-plugin-shopify-theme-islands pnpm add -D vite-plugin-shopify-theme-islands yarn add -D vite-plugin-shopify-theme-islands ``` ## Setup ### 1. Add the plugin to `vite.config.ts` ```ts import { defineConfig } from "vite"; import shopifyThemeIslands from "vite-plugin-shopify-theme-islands"; export default defineConfig({ plugins: [shopifyThemeIslands()], }); ``` ### 2. Import the runtime in your entrypoint ```ts import "vite-plugin-shopify-theme-islands/revive"; ``` That's it. The plugin automatically scans your islands directory and wires everything up. For SPA navigation teardown, import `disconnect` to stop the MutationObserver: ```ts import { disconnect } from "vite-plugin-shopify-theme-islands/revive"; // Call during SPA teardown: disconnect(); ``` If `disconnect()` is called before `DOMContentLoaded`, the runtime also cancels its pending startup listener so islands never initialize later against stale DOM. ## Writing islands Two approaches — use either or both. ### Directory scanning Drop files into your islands directory and they're automatically picked up. The filename (without extension) must match the custom element tag name used in your Liquid templates. ``` frontend/js/islands/ product-form.ts → <product-form> cart-drawer.ts → <cart-drawer> forms/checkout-form.ts → <checkout-form> ``` > Filenames must contain a hyphen (`product-form.ts` not `productform.ts`) — this is a Web Components requirement. Filenames must also be lowercase to match the tag name. Subdirectories are supported. Any file in the directory becomes an island automatically. ```ts // frontend/js/islands/product-form.ts class ProductForm extends HTMLElement { connectedCallback() { // ... } } if (!customElements.get("product-form")) { customElements.define("product-form", ProductForm); } ``` ### Island mixin Mark any file as an island with the `Island` mixin, regardless of where it lives. Import it and extend from `Island(HTMLElement)` instead of `HTMLElement` — everything else stays identical. ```ts // frontend/js/components/site-footer.ts import Island from "vite-plugin-shopify-theme-islands/island"; class SiteFooter extends Island(HTMLElement) { connectedCallback() { // ... } } if (!customElements.get("site-footer")) { customElements.define("site-footer", SiteFooter); } ``` The plugin detects the mixin import at build time and includes the file as a lazy island chunk — no directory config needed. ### Which to use | | Directory scanning | Island mixin | | ----------------- | ---------------------------------- | ------------------------------ | | File organisation | Dedicated islands directory | Co-located anywhere | | Opt-in style | Convention (everything in the dir) | Explicit (per file) | | Auditability | One directory to check | Search for `/island` import | | Build overhead | None | Filesystem scan at build start | Both can be used together — directory scanning for new islands, the mixin for existing components you want to adopt without moving. ### Child island cascade Child islands nested inside a parent island are automatically held until the parent's module has loaded. The runtime re-walks the parent's subtree on success, so child islands activate with their normal directives intact — no extra configuration needed. ```html <product-form client:visible> <!-- tab-switcher will not load until product-form has loaded --> <tab-switcher client:idle></tab-switcher> </product-form> ``` ## Directives Add these attributes to your custom elements in Liquid to control when the JavaScript loads. Without a directive, the island loads immediately. ### `client:visible` Loads the island when the element scrolls into view. ```html <product-recommendations client:visible> <!-- ... --> </product-recommendations> ``` The attribute value overrides the global `rootMargin` for that element only: ```html <!-- load only once fully visible (no pre-load margin) --> <product-recommendations client:visible="0px"> <!-- ... --> </product-recommendations> ``` ### `client:media` Loads the island when a CSS media query matches. ```html <mobile-menu client:media="(max-width: 768px)"> <!-- ... --> </mobile-menu> ``` An empty attribute (`client:media=""`) logs a console warning and skips the media check — the island still loads. ### `client:idle` Loads the island once the browser is idle (uses `requestIdleCallback` with a 500ms deadline, falls back to `setTimeout`). ```html <recently-viewed client:idle> <!-- ... --> </recently-viewed> ``` The attribute value overrides the global `timeout` for that element only: ```html <!-- wait up to 2 seconds for idle time before loading --> <recently-viewed client:idle="2000"> <!-- ... --> </recently-viewed> ``` ### `client:defer` Loads the island after a fixed delay. The delay in milliseconds is read from the attribute value. If no value is given, the configured default (3000ms) is used. ```html <chat-widget client:defer="3000"> <!-- ... --> </chat-widget> <!-- uses the default 3000ms delay --> <analytics-widget client:defer> <!-- ... --> </analytics-widget> ``` Unlike `client:idle`, which waits for genuine browser idle time, `client:defer` always waits exactly the specified number of milliseconds. ### `client:interaction` Loads the island when the user interacts with the element. Listens for `mouseenter`, `touchstart`, and `focusin` by default — the module starts downloading the moment the user moves their cursor toward or focuses the element. ```html <cart-flyout client:interaction> <!-- ... --> </cart-flyout> ``` The attribute value overrides the events for that element only: ```html <!-- only mouseenter — touchstart and focusin are excluded --> <cart-flyout client:interaction="mouseenter"> <!-- ... --> </cart-flyout> ``` In plugin config, `directives.interaction.events` is intentionally narrower than the raw HTML attribute surface. The typed config only accepts the curated package-owned set `mouseenter`, `touchstart`, and `focusin`, and rejects empty arrays. Per-element `client:interaction="..."` values are also validated at runtime against that same curated set. Unsupported tokens log a warning and are ignored. If no supported tokens remain, the runtime logs a warning and falls back to the configured default interaction events instead of attaching unsupported listeners. Combine with `client:visible` to avoid attaching listeners to off-screen elements. Because directives resolve sequentially, interaction listeners are only registered once the element has entered the viewport: ```html <mega-menu client:visible client:interaction> <!-- loads when visible, then waits for hover/touch/focus --> </mega-menu> ``` ### Combining directives Directives can be combined — the element works through each condition in sequence before loading. The resolution order is: `visible` → `media` → `idle` → `defer` → `interaction` → custom directives. ```html <!-- must scroll into view, then wait for user interaction --> <product-recommendations client:visible client:interaction> <!-- ... --> </product-recommendations> <!-- must scroll into view, then wait for idle time --> <heavy-widget client:visible client:idle> <!-- ... --> </heavy-widget> ``` Because conditions resolve sequentially, each directive is only evaluated after the previous one has passed. Interaction listeners, for example, are never attached to an element that isn't yet visible. ### Custom directives Register your own loading conditions via `directives.custom`. A custom directive is a function that receives a `load` callback and decides when to call it. #### 1. Write the directive ```ts // src/directives/hash.ts import type { ClientDirective } from "vite-plugin-shopify-theme-islands"; const hashDirective: ClientDirective = (load, opts) => { const target = opts.value; if (location.hash === target) { load(); return; } window.addEventListener("hashchange", () => { if (location.hash === target) load(); }); }; export default hashDirective; ``` Useful for anchor-linked sections — `<product-reviews client:hash="#reviews">` loads only when the URL fragment matches, so deep-links like `/products/shirt#reviews` activate the island immediately while other visitors never load it. The function signature is `(load, options, el) => void | Promise<void>`: | Parameter | Type | Description | | --------------- | ---------------------- | ----------------------------------------------------- | | `load` | `() => Promise<void>` | Call this to trigger the island module load | | `options.name` | `string` | The matched attribute name, e.g. `'client:hash'` | | `options.value` | `string` | The attribute value; empty string if no value was set | | `el` | `HTMLElement` | The island element | #### 2. Register it in the plugin config ```ts // vite.config.ts import shopifyThemeIslands from "vite-plugin-shopify-theme-islands"; export default defineConfig({ plugins: [ shopifyThemeIslands({ directives: { custom: [{ name: "client:hash", entrypoint: "./src/directives/hash.ts" }], }, }), ], }); ``` The `entrypoint` supports Vite aliases. #### 3. Use it in Liquid ```html <product-reviews client:hash="#reviews"> <!-- ... --> </product-reviews> ``` #### Ordering Built-in directives always run first. A custom directive is only invoked after all built-in conditions on the element have been met. This means you can gate a custom directive behind `client:visible` to avoid wiring event listeners for off-screen elements: ```html <!-- element must enter the viewport before the hash handler is registered --> <product-reviews client:visible client:hash="#reviews"> <!-- ... --> </product-reviews> ``` The custom directive owns the `load()` call — the built-in chain never calls it directly when a custom directive is matched. If a custom directive throws or returns a rejected promise, the runtime dispatches `islands:error` and abandons that island activation attempt. Multiple custom directives on the same element use AND semantics — the island loads only once all matched directives have called `load()`. For example, given two registered custom directives `client:hash` and `client:network`: ```html <!-- client:visible runs first (built-in); then both client:hash and client:network must fire --> <product-reviews client:visible client:hash="#reviews" client:network="4g"> <!-- ... --> </product-reviews> ``` #### Timeout guard By default, a custom directive that never calls `load()` silently keeps the island unloaded forever. Set `directiveTimeout` to fire `islands:error` and abandon the island if the directive hasn't resolved within the given window: ```ts shopifyThemeIslands({ directiveTimeout: 5000, // abandon after 5 seconds }); ``` This is useful during development to surface directives that hang due to bugs, or in production to ensure broken directives don't silently degrade the experience. ## Configuration | Option | Type | Default | Description | | ------------------ | -------------------- | --------------------------- | ---------------------------------------------------------------------------------- | | `directories` | `string \| string[]` | `['/frontend/js/islands/']` | Directories to scan for island files. Accepts Vite aliases. | | `directives` | `object` | see below | Per-directive configuration — attribute names, timing options, and custom entries. | | `retry` | `object` | — | Automatic retry behaviour for failed island loads. See [Retries](#retries). | | `debug` | `boolean` | `false` | Log discovered islands at build time and directive events in the browser console. | | `directiveTimeout` | `number` | `0` (disabled) | Milliseconds before a custom directive that never calls `load()` is considered timed out. Fires `islands:error` and abandons the island. | ### Directive defaults ```ts shopifyThemeIslands({ directives: { visible: { attribute: "client:visible", // HTML attribute name rootMargin: "200px", // passed to IntersectionObserver — pre-loads before scrolling into view threshold: 0, // passed to IntersectionObserver — ratio of element that must be visible }, idle: { attribute: "client:idle", // HTML attribute name timeout: 500, // deadline (ms) for requestIdleCallback; also the setTimeout fallback delay }, media: { attribute: "client:media", // HTML attribute name }, defer: { attribute: "client:defer", // HTML attribute name delay: 3000, // fallback delay (ms) when the attribute has no value }, interaction: { attribute: "client:interaction", // HTML attribute name events: ["mouseenter", "touchstart", "focusin"], // curated config events that trigger load }, custom: [], // custom directives — see Custom directives above }, }); ``` All options are optional — only override what you need. Partial overrides preserve the other defaults: ```ts // Only change rootMargin — attribute and threshold keep their defaults shopifyThemeIslands({ directives: { visible: { rootMargin: "400px" }, }, }); ``` For `directives.interaction.events`, supported config values are currently limited to `mouseenter`, `touchstart`, and `focusin`. Passing `[]` or unsupported names causes config resolution to fail. ### Multiple island directories ```ts shopifyThemeIslands({ directories: ["/frontend/js/islands/", "/frontend/js/components/"], }); ``` ### Using Vite aliases ```ts export default defineConfig({ resolve: { alias: { "@islands": "/frontend/js/islands" }, }, plugins: [ shopifyThemeIslands({ directories: ["@islands/"], }), ], }); ``` ## Retries Automatically retry failed island loads with exponential backoff: ```ts shopifyThemeIslands({ retry: { retries: 2, // number of retries after the initial failure. Default: 0 (no retry) delay: 1000, // base delay in ms; doubles each attempt (1s, 2s, 4s…). Default: 1000 }, }); ``` Once retries are exhausted the island is dequeued — a fresh activation requires a new element instance. ## Lifecycle events The runtime dispatches DOM events on `document` for observability use cases such as analytics and error reporting. ### Typed helpers The `/events` entry point provides typed helpers that unwrap `e.detail` for you and return a cleanup function: ```ts import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events"; const offLoad = onIslandLoad(({ tag, duration, attempt }) => { analytics.track("island_loaded", { tag, duration, attempt }); }); const offError = onIslandError(({ tag, error, attempt }) => { errorReporter.capture(error, { context: tag, attempt }); }); // Remove listeners when no longer needed (e.g. SPA teardown) offLoad(); offError(); ``` For SPA teardown, the virtual `/revive` module also exports `disconnect()`, which stops further lifecycle observation and cancels pending startup before init has run. ### Raw DOM events The events are also available via the standard `document.addEventListener` API. Event types are fully typed via `DocumentEventMap` augmentation — available automatically when `vite-plugin-shopify-theme-islands` is present in your TypeScript compilation (e.g. via `vite.config.ts` or a directive type import). ```ts document.addEventListener("islands:load", (e) => { analytics.track("island_loaded", { tag: e.detail.tag }); }); ``` | Event | Detail properties | When it fires | | --------------- | ------------------------------ | ---------------------------------------------------------- | | `islands:load` | `tag`, `duration`, `attempt` | Island module resolves successfully | | `islands:error` | `tag`, `error`, `attempt` | Load fails, custom directive throws or rejects, or `directiveTimeout` expires (alongside `console.error`) | `islands:error` fires on each retry attempt, not just the final failure. Multiple independent listeners are supported — each receives its own event. ## AI Agents If you use an AI coding agent (Claude Code, Cursor, Copilot, etc.), run once after installing: ```bash npx @tanstack/intent@latest install ``` This maps the bundled skills to your agent config so your agent gets accurate v1 API guidance. Skills update automatically with npm updates — no re-run needed. ## License MIT