vite-plugin-shopify-theme-islands
Version:
Vite plugin for island architecture in Shopify themes
339 lines (256 loc) • 10.2 kB
Markdown
---
name: custom-directives
description: >
Custom client directives registered via directives.custom in vite.config.ts.
ClientDirective function signature (load, options, el, ctx). AND-latch: when
multiple custom directives match the same element, all must call load() before
the island activates. Error handling — thrown errors, rejected promises, and
directiveTimeout expiry fire islands:error. Custom directives run after all
built-in conditions resolve. Matched directives now receive teardown-aware
cleanup via ctx.signal and ctx.onCleanup(). Matching is resolved by
src/directive-spine.ts; cleanup, AND-latch, and timeout policy are owned by
src/activation-session.ts.
metadata:
type: core
library: vite-plugin-shopify-theme-islands
library_version: "2.0.1"
sources:
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
- Rees1993/vite-plugin-shopify-theme-islands:src/directive-spine.ts
- Rees1993/vite-plugin-shopify-theme-islands:src/activation-session.ts
- Rees1993/vite-plugin-shopify-theme-islands:src/resolved-config.ts
- Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
---
## Setup
```ts
// src/directives/hash.ts
import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
const hashDirective: ClientDirective = (load, opts, _el, ctx) => {
const target = opts.value;
if (location.hash === target) { load(); return; }
const onHashChange = () => {
if (location.hash === target) load();
};
window.addEventListener("hashchange", onHashChange);
ctx.onCleanup(() => window.removeEventListener("hashchange", onHashChange));
};
export default hashDirective;
```
```ts
// vite.config.ts
import { defineConfig } from "vite";
import shopifyThemeIslands from "vite-plugin-shopify-theme-islands";
export default defineConfig({
plugins: [
shopifyThemeIslands({
directives: {
custom: [
{
name: "client:hash",
entrypoint: "./src/directives/hash.ts",
},
],
},
}),
],
});
```
```html
<product-reviews client:hash="#reviews"></product-reviews>
```
## Core Patterns
### Directive signature
```ts
import type {
ClientDirective,
ClientDirectiveContext,
ClientDirectiveLoader,
ClientDirectiveOptions,
} from "vite-plugin-shopify-theme-islands";
const myDirective: ClientDirective = (
load: ClientDirectiveLoader, // call this to trigger the island load
options: ClientDirectiveOptions, // { name: "client:my-attr", value: "..." }
el: HTMLElement, // the island element
ctx: ClientDirectiveContext, // teardown-aware cleanup + cancellation
) => {
// Set up your condition, then call load() when ready
const onClick = () => void load();
el.addEventListener("click", onClick, { once: true });
ctx.onCleanup(() => el.removeEventListener("click", onClick));
};
```
### Read the attribute value
```ts
const timedDirective: ClientDirective = (load, options, _el, ctx) => {
const ms = parseInt(options.value, 10) || 2000;
const timer = setTimeout(() => void load(), ms);
ctx.onCleanup(() => clearTimeout(timer));
};
```
`options.value` is the attribute value, or `""` if the attribute has no value.
### Async directive
```ts
const networkDirective: ClientDirective = async (load, _opts, _el, ctx) => {
if (ctx.signal.aborted) return;
await fetch("/api/check-feature", { signal: ctx.signal });
await load();
};
```
The directive function can be async. Unhandled rejections fire the document-level `islands:error` event, so `onIslandError()` observers still see directive failures.
### Timeout guard for hung directives
```ts
shopifyThemeIslands({
directiveTimeout: 5000,
});
```
If a matched custom directive never calls `load()`, the runtime normally waits forever. Setting `directiveTimeout` turns that hang into an `islands:error` event and abandons the activation attempt after the configured delay.
### Cleanup-aware directives
```ts
const mediaDirective: ClientDirective = (load, _opts, el, ctx) => {
const onFocus = () => void load();
el.addEventListener("focusin", onFocus, { once: true });
ctx.onCleanup(() => el.removeEventListener("focusin", onFocus));
};
```
Use `ctx.onCleanup()` for any listener, timer, observer, or subscription the directive creates. The runtime calls those cleanups when the directive resolves, the subtree is `unobserve()`d, the shared runtime `disconnect()`s, or the element is removed before the directive resolves.
### AND-latch with multiple matching directives
```html
<product-form client:hash="#details" client:auth-check></product-form>
```
If both `client:hash` and `client:auth-check` are registered as custom directives and both match, **both** must call `load()` before the island activates. The runtime tracks a `remaining` counter; it reaches 0 only when every matched directive has called `load()`.
## Common Mistakes
### CRITICAL Directive never calls `load()` — island never activates
Wrong:
```ts
const myDirective: ClientDirective = (load, _opts, el) => {
el.addEventListener("click", () => {
console.log("clicked"); // forgot to call load
});
};
```
Correct:
```ts
const myDirective: ClientDirective = (load, _opts, el) => {
el.addEventListener("click", load, { once: true });
};
```
No immediate error is thrown by default, so the island is silently never loaded unless you configure `directiveTimeout`.
Source: src/directive-spine.ts and src/activation-session.ts — matched custom directives own the `run()` call path
### HIGH Directive creates side effects without cleanup
Wrong:
```ts
const myDirective: ClientDirective = (load) => {
window.addEventListener("resize", () => {
if (window.innerWidth > 1200) load();
});
};
```
Correct:
```ts
const myDirective: ClientDirective = (load, _opts, _el, ctx) => {
const onResize = () => {
if (window.innerWidth > 1200) void load();
};
window.addEventListener("resize", onResize);
ctx.onCleanup(() => window.removeEventListener("resize", onResize));
};
```
Without cleanup, the directive can keep listeners or timers alive after subtree teardown.
### HIGH Writing a custom directive for mouseenter/touchstart/focusin — use `client:interaction` instead
Wrong:
```ts
// Reimplementing what the built-in already does
const hoverDirective: ClientDirective = (load, _opts, el) => {
el.addEventListener("mouseenter", load, { once: true });
};
```
Correct:
```html
<!-- Use the built-in client:interaction directive -->
<cart-flyout client:interaction></cart-flyout>
<!-- Or with a specific event -->
<cart-flyout client:interaction="mouseenter"></cart-flyout>
```
`client:interaction` is a built-in directive that handles `mouseenter`, `touchstart`, and `focusin`. Custom directives are for conditions the built-ins cannot express (e.g. URL hash matching, network conditions, feature flags).
Source: src/activation-session.ts — built-in interaction handling covers the hover/touch/focus pattern
### HIGH AND-latch: both matched directives must call `load()`
Wrong assumption:
```html
<product-form client:hash="#details" client:auth-check></product-form>
```
```ts
// Expecting: loads as soon as either hash or auth-check calls load()
```
Correct:
```ts
// Both client:hash AND client:auth-check must call load() before activation.
// remaining starts at 2; island fires when it reaches 0.
```
With two matching custom directives, `remaining = 2`. Each `load()` call decrements it. The island activates only when `remaining === 0`.
Source: src/activation-session.ts — `let remaining = matched.length`
### HIGH Duplicate custom directive names or collisions with built-ins fail plugin setup
Wrong:
```ts
shopifyThemeIslands({
directives: {
visible: { attribute: "data:visible" },
custom: [
{ name: "client:hash", entrypoint: "./src/directives/hash.ts" },
{ name: "data:visible", entrypoint: "./src/directives/other.ts" },
{ name: "client:hash", entrypoint: "./src/directives/duplicate.ts" },
],
},
});
```
Correct:
```ts
shopifyThemeIslands({
directives: {
visible: { attribute: "data:visible" },
custom: [{ name: "client:hash", entrypoint: "./src/directives/hash.ts" }],
},
});
```
Custom directive names must be unique and must not collide with any built-in directive name, including renamed built-ins.
Source: src/resolved-config.ts — validateOptions() duplicate and built-in conflict checks
### HIGH Entrypoint path missing `./` prefix
Wrong:
```ts
{
name: "client:hash",
entrypoint: "src/directives/hash.ts", // ← no ./
}
```
Correct:
```ts
{
name: "client:hash",
entrypoint: "./src/directives/hash.ts",
}
```
Custom directive entrypoints are resolved through Vite. Relative local files should usually use `./...`; unresolved entrypoints fail the build.
Source: src/index.ts — `this.resolve(entrypoint)` throws on null during revive compile planning
### MEDIUM Custom directives run after all built-in directive awaits
Wrong expectation:
```html
<!-- Expecting custom directive to intercept before client:visible -->
<cart-drawer client:visible client:auth></cart-drawer>
```
The runtime awaits built-ins in order (`visible → media → idle → defer → interaction`) first, then passes control to matched custom directives. Custom directives cannot short-circuit or replace built-in awaits.
Source: src/activation-session.ts — `runBuiltInDirectives()` completes before `runCustomDirectives()`
### MEDIUM Calling `load()` multiple times has no effect after the first
Wrong:
```ts
const retryDirective: ClientDirective = (load, _opts, el) => {
setInterval(load, 1000); // calls load every second
};
```
Correct:
```ts
const retryDirective: ClientDirective = (load, _opts, el) => {
el.addEventListener("click", load, { once: true }); // fires once
};
```
The `loadOnce` wrapper ignores all calls after the first (`fired` guard). Use `{ once: true }` on event listeners to avoid unnecessary calls.
Source: src/activation-session.ts — `loadOnce` guard (`if (fired || aborted) return Promise.resolve()`)