UNPKG

vite-plugin-shopify-theme-islands

Version:
339 lines (256 loc) 10.2 kB
--- 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 handlingthrown 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()`)