vite-plugin-shopify-theme-islands 0.7.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -221,12 +221,12 @@ export default hoverDirective;
221
221
 
222
222
  The function signature is `(load, options, el) => void | Promise<void>`:
223
223
 
224
- | Parameter | Type | Description |
225
- | --------------- | ------------------------ | ----------------------------------------------------- |
226
- | `load` | `() => Promise<unknown>` | Call this to trigger the island module load |
227
- | `options.name` | `string` | The matched attribute name, e.g. `'client:hover'` |
228
- | `options.value` | `string` | The attribute value; empty string if no value was set |
229
- | `el` | `HTMLElement` | The island element |
224
+ | Parameter | Type | Description |
225
+ | --------------- | ---------------------- | ----------------------------------------------------- |
226
+ | `load` | `() => Promise<void>` | Call this to trigger the island module load |
227
+ | `options.name` | `string` | The matched attribute name, e.g. `'client:hover'` |
228
+ | `options.value` | `string` | The attribute value; empty string if no value was set |
229
+ | `el` | `HTMLElement` | The island element |
230
230
 
231
231
  #### 2. Register it in the plugin config
232
232
 
@@ -268,7 +268,14 @@ Built-in directives always run first. A custom directive is only invoked after a
268
268
 
269
269
  The custom directive owns the `load()` call — the built-in chain never calls it directly when a custom directive is matched.
270
270
 
271
- > Only one custom directive can be active per element. If multiple custom directive attributes are present, the first registered one is used and a console warning is emitted. Combining multiple custom directives on one element is not yet supported.
271
+ 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:hover` and `client:focus`:
272
+
273
+ ```html
274
+ <!-- client:visible runs first (built-in); then both client:hover and client:focus must fire -->
275
+ <quick-add client:visible client:hover client:focus>
276
+ <!-- ... -->
277
+ </quick-add>
278
+ ```
272
279
 
273
280
  ## Configuration
274
281
 
@@ -276,6 +283,7 @@ The custom directive owns the `load()` call — the built-in chain never calls i
276
283
  | ------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------- |
277
284
  | `directories` | `string \| string[]` | `['/frontend/js/islands/']` | Directories to scan for island files. Accepts Vite aliases. |
278
285
  | `directives` | `object` | see below | Per-directive configuration — attribute names, timing options, and custom entries. |
286
+ | `retry` | `object` | — | Automatic retry behaviour for failed island loads. See [Retries](#retries). |
279
287
  | `debug` | `boolean` | `false` | Log discovered islands at build time and directive events in the browser console. |
280
288
 
281
289
  ### Directive defaults
@@ -338,6 +346,72 @@ export default defineConfig({
338
346
  });
339
347
  ```
340
348
 
349
+ ## Retries
350
+
351
+ Automatically retry failed island loads with exponential backoff:
352
+
353
+ ```ts
354
+ shopifyThemeIslands({
355
+ retry: {
356
+ retries: 2, // number of retries after the initial failure. Default: 0 (no retry)
357
+ delay: 1000, // base delay in ms; doubles each attempt (1s, 2s, 4s…). Default: 1000
358
+ },
359
+ });
360
+ ```
361
+
362
+ Once retries are exhausted the island is dequeued — a fresh activation requires a new element instance.
363
+
364
+ ## Lifecycle events
365
+
366
+ The runtime dispatches DOM events on `document` for observability use cases such as analytics and error reporting.
367
+
368
+ ### Typed helpers
369
+
370
+ The `/events` entry point provides typed helpers that unwrap `e.detail` for you and return a cleanup function:
371
+
372
+ ```ts
373
+ import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";
374
+
375
+ const offLoad = onIslandLoad(({ tag }) => {
376
+ analytics.track("island_loaded", { tag });
377
+ });
378
+
379
+ const offError = onIslandError(({ tag, error }) => {
380
+ errorReporter.capture(error, { context: tag });
381
+ });
382
+
383
+ // Remove listeners when no longer needed (e.g. SPA teardown)
384
+ offLoad();
385
+ offError();
386
+ ```
387
+
388
+ ### Raw DOM events
389
+
390
+ 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).
391
+
392
+ ```ts
393
+ document.addEventListener("islands:load", (e) => {
394
+ analytics.track("island_loaded", { tag: e.detail.tag });
395
+ });
396
+ ```
397
+
398
+ | Event | Detail properties | When it fires |
399
+ | --------------- | ----------------- | ---------------------------------------------------------- |
400
+ | `islands:load` | `tag` | Island module resolves successfully |
401
+ | `islands:error` | `tag`, `error` | Load or custom directive fails (alongside `console.error`) |
402
+
403
+ `islands:error` fires on each retry attempt, not just the final failure. Multiple independent listeners are supported — each receives its own event.
404
+
405
+ ## AI Agents
406
+
407
+ If you use an AI coding agent (Claude Code, Cursor, Copilot, etc.), run once after installing:
408
+
409
+ ```bash
410
+ npx @tanstack/intent@latest install
411
+ ```
412
+
413
+ 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.
414
+
341
415
  ## License
342
416
 
343
417
  MIT
@@ -0,0 +1,32 @@
1
+ import type { IslandLoadDetail, IslandErrorDetail } from "./index.js";
2
+ /**
3
+ * Listen for successful island module loads.
4
+ *
5
+ * Returns a cleanup function that removes the listener.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";
10
+ *
11
+ * const off = onIslandLoad(({ tag }) => {
12
+ * analytics.track("island_loaded", { tag });
13
+ * });
14
+ * ```
15
+ */
16
+ export declare function onIslandLoad(handler: (detail: IslandLoadDetail) => void): () => void;
17
+ /**
18
+ * Listen for island load or custom directive failures.
19
+ *
20
+ * Fires on each retry attempt, not just the final failure.
21
+ * Returns a cleanup function that removes the listener.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { onIslandError } from "vite-plugin-shopify-theme-islands/events";
26
+ *
27
+ * const off = onIslandError(({ tag, error }) => {
28
+ * errorReporter.capture(error, { context: tag });
29
+ * });
30
+ * ```
31
+ */
32
+ export declare function onIslandError(handler: (detail: IslandErrorDetail) => void): () => void;
package/dist/events.js ADDED
@@ -0,0 +1,15 @@
1
+ // src/events.ts
2
+ function onIslandLoad(handler) {
3
+ const listener = (e) => handler(e.detail);
4
+ document.addEventListener("islands:load", listener);
5
+ return () => document.removeEventListener("islands:load", listener);
6
+ }
7
+ function onIslandError(handler) {
8
+ const listener = (e) => handler(e.detail);
9
+ document.addEventListener("islands:error", listener);
10
+ return () => document.removeEventListener("islands:error", listener);
11
+ }
12
+ export {
13
+ onIslandLoad,
14
+ onIslandError
15
+ };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin } from "vite";
2
2
  /** A function that triggers the load of an island module. */
3
- export type ClientDirectiveLoader = () => Promise<unknown>;
3
+ export type ClientDirectiveLoader = () => Promise<void>;
4
4
  /** Options passed to a custom client directive function. */
5
5
  export interface ClientDirectiveOptions {
6
6
  /** The matched attribute name, e.g. `'client:on-click'` */
@@ -76,6 +76,35 @@ export interface DirectivesConfig {
76
76
  /** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
77
77
  custom?: ClientDirectiveDefinition[];
78
78
  }
79
+ /** Runtime-facing directive configuration — omits plugin-only `custom` directives. */
80
+ export type RuntimeDirectivesConfig = Omit<DirectivesConfig, "custom">;
81
+ /** Retry configuration for failed island loads. */
82
+ export interface RetryConfig {
83
+ /** Number of times to retry after the initial failure. Default: `0` (no auto-retry) */
84
+ retries?: number;
85
+ /** Base delay in ms between retries; doubles each attempt. Default: `1000` */
86
+ delay?: number;
87
+ }
88
+ /** Event detail for the `islands:load` DOM event. */
89
+ export interface IslandLoadDetail {
90
+ /** The custom element tag name, e.g. `'product-form'` */
91
+ tag: string;
92
+ }
93
+ /** Event detail for the `islands:error` DOM event. */
94
+ export interface IslandErrorDetail {
95
+ /** The custom element tag name, e.g. `'product-form'` */
96
+ tag: string;
97
+ /** The error thrown by the loader or custom directive */
98
+ error: unknown;
99
+ }
100
+ declare global {
101
+ interface DocumentEventMap {
102
+ /** Fired after an island module resolves successfully. */
103
+ "islands:load": CustomEvent<IslandLoadDetail>;
104
+ /** Fired when an island load or custom directive fails. Fired on each retry attempt. */
105
+ "islands:error": CustomEvent<IslandErrorDetail>;
106
+ }
107
+ }
79
108
  export interface ShopifyThemeIslandsOptions {
80
109
  /** Directories to scan for island files. Accepts paths or Vite aliases. Default: `['/frontend/js/islands/']` */
81
110
  directories?: string | string[];
@@ -83,10 +112,14 @@ export interface ShopifyThemeIslandsOptions {
83
112
  debug?: boolean;
84
113
  /** Per-directive configuration. */
85
114
  directives?: DirectivesConfig;
115
+ /** Automatic retry behaviour for failed island loads. */
116
+ retry?: RetryConfig;
86
117
  }
87
118
  export interface ReviveOptions {
88
- directives?: DirectivesConfig;
119
+ directives?: RuntimeDirectivesConfig;
89
120
  /** Log island activation and directive events to the console. Default: `false` */
90
121
  debug?: boolean;
122
+ /** Automatic retry behaviour for failed island loads. */
123
+ retry?: RetryConfig;
91
124
  }
92
125
  export default function shopifyThemeIslands(options?: ShopifyThemeIslandsOptions): Plugin;
package/dist/index.js CHANGED
@@ -171,7 +171,7 @@ function shopifyThemeIslands(options = {}) {
171
171
  ...directiveImports,
172
172
  `import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
173
173
  `const islands = Object.assign({}, ${islandsEntries.join(", ")});`,
174
- `const options = ${JSON.stringify({ directives, debug })};`
174
+ `const options = ${JSON.stringify({ directives, debug, retry: options.retry })};`
175
175
  ];
176
176
  if (mapEntries.length) {
177
177
  lines.push(`const customDirectives = new Map([
@@ -179,7 +179,7 @@ ${mapEntries.join(`,
179
179
  `)}
180
180
  ]);`);
181
181
  }
182
- lines.push(`export const disconnect = _islands(islands, options${mapEntries.length ? ", customDirectives" : ""});`);
182
+ lines.push(`export const { disconnect } = _islands(islands, options${mapEntries.length ? ", customDirectives" : ""});`);
183
183
  return lines.join(`
184
184
  `);
185
185
  }
package/dist/runtime.d.ts CHANGED
@@ -13,4 +13,6 @@
13
13
  * A MutationObserver re-runs the same logic for elements added dynamically.
14
14
  */
15
15
  import type { ClientDirective, ReviveOptions } from "./index.js";
16
- export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): () => void;
16
+ export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): {
17
+ disconnect: () => void;
18
+ };
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/runtime.ts
2
+ var dispatch = (name, detail) => document.dispatchEvent(new CustomEvent(name, { detail }));
2
3
  function media(query) {
3
4
  const m = window.matchMedia(query);
4
5
  return new Promise((resolve) => {
@@ -45,6 +46,8 @@ function revive(islands, options, customDirectives) {
45
46
  const idleTimeout = options?.directives?.idle?.timeout ?? 500;
46
47
  const deferDelay = options?.directives?.defer?.delay ?? 3000;
47
48
  const debug = options?.debug ?? false;
49
+ const retries = options?.retry?.retries ?? 0;
50
+ const retryDelay = options?.retry?.delay ?? 1000;
48
51
  const islandMap = new Map;
49
52
  for (const [key, loader] of Object.entries(islands)) {
50
53
  const filename = key.split("/").pop();
@@ -60,6 +63,7 @@ function revive(islands, options, customDirectives) {
60
63
  let initDone = false;
61
64
  const loaded = new Set;
62
65
  const pendingVisible = new Map;
66
+ const retryCount = new Map;
63
67
  const isUnloadedIsland = (tag) => queued.has(tag) && !loaded.has(tag);
64
68
  const customElementFilter = {
65
69
  acceptNode: (node) => {
@@ -142,34 +146,65 @@ function revive(islands, options, customDirectives) {
142
146
  flush("aborted (element removed)");
143
147
  return;
144
148
  }
145
- const run = () => loader().then(() => {
146
- loaded.add(tagName);
147
- if (el.children.length)
148
- walk(el);
149
- }).catch((err) => {
150
- console.error(`[islands] Failed to load <${tagName}>:`, err);
151
- queued.delete(tagName);
152
- });
149
+ const run = () => {
150
+ if (disconnected)
151
+ return Promise.resolve();
152
+ return loader().then(() => {
153
+ loaded.add(tagName);
154
+ retryCount.delete(tagName);
155
+ dispatch("islands:load", { tag: tagName });
156
+ if (el.children.length)
157
+ walk(el);
158
+ }).catch((err) => {
159
+ console.error(`[islands] Failed to load <${tagName}>:`, err);
160
+ dispatch("islands:error", { tag: tagName, error: err });
161
+ const attempt = retryCount.get(tagName) ?? 0;
162
+ if (attempt < retries) {
163
+ retryCount.set(tagName, attempt + 1);
164
+ setTimeout(run, retryDelay * 2 ** attempt);
165
+ } else {
166
+ retryCount.delete(tagName);
167
+ queued.delete(tagName);
168
+ }
169
+ });
170
+ };
153
171
  const handleDirectiveError = (attrName, err) => {
154
172
  console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, err);
173
+ dispatch("islands:error", { tag: tagName, error: err });
174
+ retryCount.delete(tagName);
155
175
  queued.delete(tagName);
156
176
  };
157
177
  if (customDirectives?.size) {
158
178
  const matched = [];
159
179
  for (const [attrName, directiveFn] of customDirectives) {
160
- if (el.hasAttribute(attrName))
161
- matched.push([attrName, directiveFn]);
162
- }
163
- if (matched.length > 1) {
164
- console.warn(`[islands] <${tagName}> has multiple custom directives (${matched.map(([a]) => a).join(", ")}) — only "${matched[0][0]}" will be used. Combining custom directives is not yet supported.`);
180
+ const value = el.getAttribute(attrName);
181
+ if (value !== null)
182
+ matched.push([attrName, directiveFn, value]);
165
183
  }
166
184
  if (matched.length > 0) {
167
- const [attrName, directiveFn] = matched[0];
168
- flush(`dispatching to custom directive ${attrName}`);
169
- try {
170
- Promise.resolve(directiveFn(run, { name: attrName, value: el.getAttribute(attrName) }, el)).catch((err) => handleDirectiveError(attrName, err));
171
- } catch (err) {
172
- handleDirectiveError(attrName, err);
185
+ flush(`dispatching to custom directive${matched.length === 1 ? "" : "s"} ${matched.map(([a]) => a).join(", ")}`);
186
+ let remaining = matched.length;
187
+ let fired = false;
188
+ let aborted = false;
189
+ const loadOnce = () => {
190
+ if (fired || aborted)
191
+ return Promise.resolve();
192
+ if (--remaining === 0) {
193
+ fired = true;
194
+ return run();
195
+ }
196
+ return Promise.resolve();
197
+ };
198
+ for (const [attrName, directiveFn, value] of matched) {
199
+ try {
200
+ Promise.resolve(directiveFn(loadOnce, { name: attrName, value }, el)).catch((err) => {
201
+ aborted = true;
202
+ handleDirectiveError(attrName, err);
203
+ });
204
+ } catch (err) {
205
+ aborted = true;
206
+ handleDirectiveError(attrName, err);
207
+ }
173
208
  }
174
209
  return;
175
210
  }
@@ -201,10 +236,12 @@ function revive(islands, options, customDirectives) {
201
236
  activate(node);
202
237
  }
203
238
  const observer = new MutationObserver((mutations) => {
204
- for (const [el, cancel] of pendingVisible) {
205
- if (!el.isConnected) {
206
- pendingVisible.delete(el);
207
- cancel();
239
+ if (pendingVisible.size > 0 && mutations.some((m) => m.removedNodes.length > 0)) {
240
+ for (const [el, cancel] of pendingVisible) {
241
+ if (!el.isConnected) {
242
+ pendingVisible.delete(el);
243
+ cancel();
244
+ }
208
245
  }
209
246
  }
210
247
  for (const { addedNodes } of mutations) {
@@ -223,12 +260,17 @@ function revive(islands, options, customDirectives) {
223
260
  console.groupEnd();
224
261
  observer.observe(document.body, { childList: true, subtree: true });
225
262
  }
263
+ let disconnected = false;
226
264
  if (document.readyState === "loading") {
227
265
  document.addEventListener("DOMContentLoaded", init, { once: true });
228
266
  } else {
229
267
  init();
230
268
  }
231
- return () => observer.disconnect();
269
+ const disconnect = () => {
270
+ disconnected = true;
271
+ observer.disconnect();
272
+ };
273
+ return { disconnect };
232
274
  }
233
275
  export {
234
276
  revive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "0.7.2",
3
+ "version": "1.0.1",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -14,14 +14,24 @@
14
14
  "./revive": {
15
15
  "types": "./revive.d.ts"
16
16
  },
17
+ "./runtime": {
18
+ "types": "./dist/runtime.d.ts",
19
+ "import": "./dist/runtime.js"
20
+ },
17
21
  "./island": {
18
22
  "types": "./dist/island.d.ts",
19
23
  "import": "./dist/island.js"
24
+ },
25
+ "./events": {
26
+ "types": "./dist/events.d.ts",
27
+ "import": "./dist/events.js"
20
28
  }
21
29
  },
22
30
  "files": [
23
31
  "dist",
24
- "revive.d.ts"
32
+ "revive.d.ts",
33
+ "skills",
34
+ "!skills/_artifacts"
25
35
  ],
26
36
  "sideEffects": [
27
37
  "./dist/runtime.js"
@@ -30,7 +40,8 @@
30
40
  "vite",
31
41
  "shopify",
32
42
  "islands",
33
- "vite-plugin"
43
+ "vite-plugin",
44
+ "tanstack-intent"
34
45
  ],
35
46
  "license": "MIT",
36
47
  "author": {
@@ -47,10 +58,10 @@
47
58
  "url": "https://github.com/Rees1993/vite-plugin-shopify-theme-islands/issues"
48
59
  },
49
60
  "engines": {
50
- "node": ">=24"
61
+ "node": ">=22"
51
62
  },
52
63
  "scripts": {
53
- "build:js": "bun build src/index.ts src/runtime.ts src/island.ts --outdir dist --format esm --target node",
64
+ "build:js": "bun build src/index.ts src/runtime.ts src/island.ts src/events.ts --outdir dist --format esm --target node",
54
65
  "build:types": "tsc -p tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
55
66
  "build": "rm -rf dist && bun run build:js && bun run build:types",
56
67
  "check": "tsc --noEmit",
@@ -60,18 +71,18 @@
60
71
  "test": "bun test",
61
72
  "test:watch": "bun test --watch",
62
73
  "lint": "oxlint src/",
63
- "format": "oxfmt src/",
64
- "release:version": "node ./scripts/update-release-package-version.mjs"
74
+ "format": "oxfmt src/"
65
75
  },
66
76
  "peerDependencies": {
67
77
  "vite": ">=6"
68
78
  },
69
79
  "devDependencies": {
70
80
  "@happy-dom/global-registrator": "^20.8.4",
81
+ "@tanstack/intent": "0.0.21",
71
82
  "@types/bun": "^1.3.10",
72
- "@types/node": "^24.0.0",
73
- "oxfmt": "^0.40.0",
74
- "oxlint": "^1.55.0",
83
+ "@types/node": "^22.0.0",
84
+ "oxfmt": "0.41.0",
85
+ "oxlint": "1.56.0",
75
86
  "typescript": "^5.0.0",
76
87
  "vite": "^8.0.0"
77
88
  }
package/revive.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  declare module "vite-plugin-shopify-theme-islands/revive" {
2
- /** Stops the island MutationObserver. Call during SPA navigation teardown. */
2
+ /** Stops the island MutationObserver. */
3
3
  export const disconnect: () => void;
4
4
  }
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: custom-directives
3
+ description: >
4
+ Custom client directives registered via directives.custom in vite.config.ts.
5
+ ClientDirective function signature (load, options, el). AND-latch: when
6
+ multiple custom directives match the same element, all must call load() before
7
+ the island activates. Error handling — thrown errors fire islands:error.
8
+ type: core
9
+ library: vite-plugin-shopify-theme-islands
10
+ library_version: "1.0.0"
11
+ sources:
12
+ - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
13
+ - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
14
+ ---
15
+
16
+ ## Setup
17
+
18
+ ```ts
19
+ // src/directives/hover.ts
20
+ import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
21
+
22
+ const hoverDirective: ClientDirective = (load, _opts, el) => {
23
+ el.addEventListener("mouseenter", load, { once: true });
24
+ };
25
+
26
+ export default hoverDirective;
27
+ ```
28
+
29
+ ```ts
30
+ // vite.config.ts
31
+ import shopifyThemeIslands from "vite-plugin-shopify-theme-islands";
32
+
33
+ export default defineConfig({
34
+ plugins: [
35
+ shopifyThemeIslands({
36
+ directives: {
37
+ custom: [
38
+ {
39
+ name: "client:hover",
40
+ entrypoint: "./src/directives/hover.ts",
41
+ },
42
+ ],
43
+ },
44
+ }),
45
+ ],
46
+ });
47
+ ```
48
+
49
+ ```html
50
+ <quick-add client:hover></quick-add>
51
+ ```
52
+
53
+ ## Core Patterns
54
+
55
+ ### Directive signature
56
+
57
+ ```ts
58
+ import type {
59
+ ClientDirective,
60
+ ClientDirectiveLoader,
61
+ ClientDirectiveOptions,
62
+ } from "vite-plugin-shopify-theme-islands";
63
+
64
+ const myDirective: ClientDirective = (
65
+ load: ClientDirectiveLoader, // call this to trigger the island load
66
+ options: ClientDirectiveOptions, // { name: "client:my-attr", value: "..." }
67
+ el: HTMLElement, // the island element
68
+ ) => {
69
+ // Set up your condition, then call load() when ready
70
+ el.addEventListener("click", load, { once: true });
71
+ };
72
+ ```
73
+
74
+ ### Read the attribute value
75
+
76
+ ```ts
77
+ const timedDirective: ClientDirective = (load, options, el) => {
78
+ const ms = parseInt(options.value, 10) || 2000;
79
+ setTimeout(load, ms);
80
+ };
81
+ ```
82
+
83
+ `options.value` is the attribute value, or `""` if the attribute has no value.
84
+
85
+ ### Async directive
86
+
87
+ ```ts
88
+ const networkDirective: ClientDirective = async (load, _opts, el) => {
89
+ await fetch("/api/check-feature");
90
+ load();
91
+ };
92
+ ```
93
+
94
+ The directive function can be async. Unhandled rejections fire `islands:error` on the element.
95
+
96
+ ### AND-latch with multiple matching directives
97
+
98
+ ```html
99
+ <product-form client:hover client:visible></product-form>
100
+ ```
101
+
102
+ If both `client:hover` and `client:visible` 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()`.
103
+
104
+ ## Common Mistakes
105
+
106
+ ### CRITICAL Directive never calls `load()` — island never activates
107
+
108
+ Wrong:
109
+
110
+ ```ts
111
+ const hoverDirective: ClientDirective = (load, _opts, el) => {
112
+ el.addEventListener("mouseenter", () => {
113
+ console.log("hovered"); // forgot to call load
114
+ });
115
+ };
116
+ ```
117
+
118
+ Correct:
119
+
120
+ ```ts
121
+ const hoverDirective: ClientDirective = (load, _opts, el) => {
122
+ el.addEventListener("mouseenter", load, { once: true });
123
+ };
124
+ ```
125
+
126
+ No error is thrown and no timeout fires — the island is silently never loaded.
127
+
128
+ Source: src/runtime.ts — directive owns the `run()` call path
129
+
130
+ ### HIGH AND-latch: both matched directives must call `load()`
131
+
132
+ Wrong assumption:
133
+
134
+ ```html
135
+ <product-form client:hover client:auth-check></product-form>
136
+ ```
137
+
138
+ ```ts
139
+ // Expecting: loads as soon as either hover or auth-check calls load()
140
+ ```
141
+
142
+ Correct:
143
+
144
+ ```ts
145
+ // Both client:hover AND client:auth-check must call load() before activation.
146
+ // remaining starts at 2; island fires when it reaches 0.
147
+ ```
148
+
149
+ With two matching custom directives, `remaining = 2`. Each `load()` call decrements it. The island activates only when `remaining === 0`.
150
+
151
+ Source: src/runtime.ts — `let remaining = matched.length`
152
+
153
+ ### HIGH Entrypoint path missing `./` prefix
154
+
155
+ Wrong:
156
+
157
+ ```ts
158
+ {
159
+ name: "client:hover",
160
+ entrypoint: "src/directives/hover.ts", // ← no ./
161
+ }
162
+ ```
163
+
164
+ Correct:
165
+
166
+ ```ts
167
+ {
168
+ name: "client:hover",
169
+ entrypoint: "./src/directives/hover.ts",
170
+ }
171
+ ```
172
+
173
+ Vite's resolver may fail to locate the file without the `./` relative prefix. The plugin throws a build error if the entrypoint cannot be resolved.
174
+
175
+ Source: src/index.ts — `this.resolve(def.entrypoint)` throws on null
176
+
177
+ ### MEDIUM Custom directives run after built-in directive awaits
178
+
179
+ Wrong expectation:
180
+
181
+ ```html
182
+ <!-- Expecting custom directive to intercept before client:visible -->
183
+ <cart-drawer client:visible client:auth></cart-drawer>
184
+ ```
185
+
186
+ The runtime awaits `client:visible` first, then passes control to the `client:auth` custom directive. Custom directives cannot short-circuit or replace built-in awaits.
187
+
188
+ Source: src/runtime.ts — built-in awaits precede `if (customDirectives?.size)` block
189
+
190
+ ### MEDIUM Calling `load()` multiple times has no effect after the first
191
+
192
+ Wrong:
193
+
194
+ ```ts
195
+ const retryDirective: ClientDirective = (load, _opts, el) => {
196
+ setInterval(load, 1000); // calls load every second
197
+ };
198
+ ```
199
+
200
+ Correct:
201
+
202
+ ```ts
203
+ const retryDirective: ClientDirective = (load, _opts, el) => {
204
+ el.addEventListener("click", load, { once: true }); // fires once
205
+ };
206
+ ```
207
+
208
+ The `loadOnce` wrapper ignores all calls after the first (`fired` guard). Use `{ once: true }` on event listeners to avoid unnecessary calls.
209
+
210
+ Source: src/runtime.ts — `if (fired || aborted) return Promise.resolve()`
@@ -0,0 +1,167 @@
1
+ ---
2
+ name: directives
3
+ description: >
4
+ Built-in client directives: client:visible (IntersectionObserver, rootMargin),
5
+ client:media (matchMedia query), client:idle (requestIdleCallback),
6
+ client:defer (setTimeout delay). Combining directives uses AND semantics —
7
+ all must resolve. Per-element value overrides. Empty client:media warning.
8
+ type: core
9
+ library: vite-plugin-shopify-theme-islands
10
+ library_version: "1.0.0"
11
+ sources:
12
+ - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
13
+ - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
14
+ ---
15
+
16
+ ## Setup
17
+
18
+ Add one or more directives as HTML attributes on any custom element:
19
+
20
+ ```html
21
+ <!-- Load when element scrolls into view (200px pre-load margin by default) -->
22
+ <product-form client:visible></product-form>
23
+
24
+ <!-- Load when CSS media query matches -->
25
+ <mobile-nav client:media="(max-width: 768px)"></mobile-nav>
26
+
27
+ <!-- Load during browser idle time -->
28
+ <site-footer client:idle></site-footer>
29
+
30
+ <!-- Load after a fixed delay (ms) -->
31
+ <chat-widget client:defer="5000"></chat-widget>
32
+ ```
33
+
34
+ No JS changes needed — the runtime reads these attributes during DOM walk.
35
+
36
+ ## Core Patterns
37
+
38
+ ### Combining directives — all conditions must pass
39
+
40
+ ```html
41
+ <!-- Loads only when BOTH visible AND the media query match -->
42
+ <product-recommendations
43
+ client:visible
44
+ client:media="(min-width: 768px)"
45
+ ></product-recommendations>
46
+ ```
47
+
48
+ Combined directives are AND-latched. The island loads only after every condition resolves. There is no OR mode.
49
+
50
+ ### Per-element value overrides
51
+
52
+ ```html
53
+ <!-- Override global rootMargin for this element only -->
54
+ <hero-banner client:visible="0px"></hero-banner>
55
+
56
+ <!-- Override global idle timeout for this element (ms) -->
57
+ <analytics-widget client:idle="2000"></analytics-widget>
58
+
59
+ <!-- Fixed delay in ms; empty attribute uses the global default (3000ms) -->
60
+ <chat-widget client:defer="8000"></chat-widget>
61
+ ```
62
+
63
+ The attribute value overrides the globally configured default for that element. Other elements are unaffected.
64
+
65
+ ### `client:defer` without a value uses the global default
66
+
67
+ ```html
68
+ <!-- Uses global defer.delay (default 3000ms) -->
69
+ <chat-widget client:defer></chat-widget>
70
+
71
+ <!-- Uses 0ms delay — loads on next tick -->
72
+ <chat-widget client:defer="0"></chat-widget>
73
+ ```
74
+
75
+ An empty `client:defer` attribute is NOT zero — it falls back to the configured `defer.delay` (default 3000ms).
76
+
77
+ ### Changing built-in directive defaults globally
78
+
79
+ ```ts
80
+ // vite.config.ts
81
+ shopifyThemeIslands({
82
+ directives: {
83
+ visible: { rootMargin: "0px" },
84
+ defer: { delay: 5000 },
85
+ },
86
+ });
87
+ ```
88
+
89
+ ## Common Mistakes
90
+
91
+ ### HIGH `client:media=""` skips the media check entirely
92
+
93
+ Wrong:
94
+
95
+ ```html
96
+ <mobile-nav client:media=""></mobile-nav>
97
+ ```
98
+
99
+ Correct:
100
+
101
+ ```html
102
+ <mobile-nav client:media="(max-width: 768px)"></mobile-nav>
103
+ ```
104
+
105
+ An empty `client:media` value emits a console warning and skips the media check — the island loads immediately. Provide a valid media query string.
106
+
107
+ Source: src/runtime.ts — `if (query === "")` branch
108
+
109
+ ### HIGH Multiple directives are AND, not OR
110
+
111
+ Wrong assumption:
112
+
113
+ ```html
114
+ <!-- Expecting: load when visible OR when media matches -->
115
+ <product-recs client:visible client:media="(min-width: 768px)"></product-recs>
116
+ ```
117
+
118
+ Correct understanding:
119
+
120
+ ```html
121
+ <!-- Loads only when BOTH visible AND media match -->
122
+ <product-recs client:visible client:media="(min-width: 768px)"></product-recs>
123
+ ```
124
+
125
+ The runtime awaits each directive sequentially. There is no way to express OR semantics with built-in directives — use a custom directive for that.
126
+
127
+ Source: src/runtime.ts — loadIsland sequential awaits
128
+
129
+ ### MEDIUM `client:defer` without value ≠ immediate load
130
+
131
+ Wrong:
132
+
133
+ ```html
134
+ <!-- Expecting 0ms or immediate load -->
135
+ <chat-widget client:defer></chat-widget>
136
+ ```
137
+
138
+ Correct:
139
+
140
+ ```html
141
+ <!-- Explicit 0ms for immediate load after current call stack -->
142
+ <chat-widget client:defer="0"></chat-widget>
143
+ ```
144
+
145
+ `client:defer` with no value uses the global `defer.delay` default (3000ms). `parseInt("", 10)` produces `NaN`, which the runtime replaces with the configured default.
146
+
147
+ Source: src/runtime.ts — `const ms = Number.isNaN(raw) ? deferDelay : raw`
148
+
149
+ ### MEDIUM Per-element visible value replaces rootMargin, not adds to it
150
+
151
+ Wrong:
152
+
153
+ ```html
154
+ <!-- Expecting 200px (global) + 100px = 300px effective margin -->
155
+ <hero-banner client:visible="100px"></hero-banner>
156
+ ```
157
+
158
+ Correct:
159
+
160
+ ```html
161
+ <!-- "100px" replaces the global rootMargin entirely -->
162
+ <hero-banner client:visible="100px"></hero-banner>
163
+ ```
164
+
165
+ The attribute value is passed directly to `IntersectionObserver` as `rootMargin`, fully replacing the global default.
166
+
167
+ Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, pendingVisible)`
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: lifecycle
3
+ description: >
4
+ Island lifecycle events and SPA teardown. onIslandLoad and onIslandError
5
+ helpers from vite-plugin-shopify-theme-islands/events — prefer these over
6
+ raw document.addEventListener for guaranteed type safety. Raw DOM events
7
+ islands:load and islands:error on document. disconnect() from the virtual
8
+ module revive for SPA navigation teardown.
9
+ type: core
10
+ library: vite-plugin-shopify-theme-islands
11
+ library_version: "1.0.0"
12
+ sources:
13
+ - Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
14
+ - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
15
+ - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
16
+ ---
17
+
18
+ ## Setup
19
+
20
+ ```ts
21
+ import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";
22
+
23
+ const offLoad = onIslandLoad(({ tag }) => {
24
+ console.log("loaded:", tag);
25
+ });
26
+
27
+ const offError = onIslandError(({ tag, error }) => {
28
+ console.error("failed:", tag, error);
29
+ });
30
+
31
+ // Remove listeners when no longer needed
32
+ offLoad();
33
+ offError();
34
+ ```
35
+
36
+ ## Core Patterns
37
+
38
+ ### Track island load for analytics
39
+
40
+ ```ts
41
+ import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";
42
+
43
+ onIslandLoad(({ tag }) => {
44
+ analytics.track("island_loaded", { component: tag });
45
+ });
46
+ ```
47
+
48
+ `tag` is the lowercased custom element tag name (e.g. `"product-form"`).
49
+
50
+ ### Report errors to a monitoring service
51
+
52
+ ```ts
53
+ import { onIslandError } from "vite-plugin-shopify-theme-islands/events";
54
+
55
+ onIslandError(({ tag, error }) => {
56
+ Sentry.captureException(error, { extra: { island: tag } });
57
+ });
58
+ ```
59
+
60
+ `onIslandError` fires on each retry attempt and on custom directive failures. If retry is enabled, a single island may produce multiple error events before succeeding or exhausting retries.
61
+
62
+ ### Teardown for SPA navigation
63
+
64
+ ```ts
65
+ import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
66
+
67
+ // Before navigating away / unmounting the page
68
+ disconnect();
69
+ ```
70
+
71
+ `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.
72
+
73
+ ### Raw DOM events (when type augmentation is in scope)
74
+
75
+ ```ts
76
+ // DocumentEventMap augmentation is exported from the main package
77
+ import type {} from "vite-plugin-shopify-theme-islands";
78
+
79
+ document.addEventListener("islands:load", (e) => {
80
+ console.log(e.detail.tag); // typed as string
81
+ });
82
+ ```
83
+
84
+ 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.
85
+
86
+ ## Common Mistakes
87
+
88
+ ### HIGH Raw `addEventListener` without types — `e.detail` is untyped
89
+
90
+ Wrong:
91
+
92
+ ```ts
93
+ // No import from the package — e is Event, detail is unknown
94
+ document.addEventListener("islands:load", (e) => {
95
+ console.log(e.detail.tag); // TypeScript error or any
96
+ });
97
+ ```
98
+
99
+ Correct:
100
+
101
+ ```ts
102
+ import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";
103
+
104
+ onIslandLoad(({ tag }) => {
105
+ console.log(tag); // string, always typed
106
+ });
107
+ ```
108
+
109
+ `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.
110
+
111
+ Source: src/events.ts
112
+
113
+ ### CRITICAL `disconnect` imported from wrong entry point
114
+
115
+ Wrong:
116
+
117
+ ```ts
118
+ import { disconnect } from "vite-plugin-shopify-theme-islands/runtime";
119
+ import { disconnect } from "vite-plugin-shopify-theme-islands/island";
120
+ ```
121
+
122
+ Correct:
123
+
124
+ ```ts
125
+ import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
126
+ ```
127
+
128
+ 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.
129
+
130
+ Source: src/index.ts — virtual module `export const { disconnect } = _islands(...)`
131
+
132
+ ### MEDIUM `onIslandError` fires on every retry, not just final failure
133
+
134
+ Wrong:
135
+
136
+ ```ts
137
+ onIslandError(({ tag }) => {
138
+ // Assuming this fires once when the island permanently fails
139
+ markIslandBroken(tag);
140
+ });
141
+ ```
142
+
143
+ Correct:
144
+
145
+ ```ts
146
+ const seen = new Set<string>();
147
+ onIslandError(({ tag, error }) => {
148
+ if (!seen.has(tag)) {
149
+ seen.add(tag);
150
+ reportFirstFailure(tag, error);
151
+ }
152
+ });
153
+ ```
154
+
155
+ With `retry: { retries: 3 }`, a single island can fire `islands:error` up to 4 times before exhausting retries. Deduplicate by `tag` if only the first failure matters.
156
+
157
+ Source: src/runtime.ts — `dispatch("islands:error", ...)` inside `.catch()` before retry check
158
+
159
+ ### MEDIUM `islands:error` fires for custom directive failures too
160
+
161
+ Wrong assumption:
162
+
163
+ ```ts
164
+ onIslandError(({ tag, error }) => {
165
+ // Assuming this only fires for failed dynamic import()
166
+ reportChunkLoadFailure(tag);
167
+ });
168
+ ```
169
+
170
+ `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.
171
+
172
+ Source: src/runtime.ts — handleDirectiveError dispatches `islands:error`
@@ -0,0 +1,145 @@
1
+ ---
2
+ name: setup
3
+ description: >
4
+ Plugin install and vite.config.ts configuration. Covers shopifyThemeIslands()
5
+ options: directories (string | string[]), debug, directives deep-merge, and
6
+ retry (retries, delay with exponential backoff). Load when configuring the
7
+ plugin, setting island scan directories, or enabling retry.
8
+ type: core
9
+ library: vite-plugin-shopify-theme-islands
10
+ library_version: "1.0.0"
11
+ sources:
12
+ - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
13
+ ---
14
+
15
+ ## Setup
16
+
17
+ ```ts
18
+ // vite.config.ts
19
+ import { defineConfig } from "vite";
20
+ import shopifyThemeIslands from "vite-plugin-shopify-theme-islands";
21
+
22
+ export default defineConfig({
23
+ plugins: [
24
+ shopifyThemeIslands({
25
+ directories: ["/frontend/js/islands/"],
26
+ debug: false,
27
+ retry: { retries: 2, delay: 500 },
28
+ }),
29
+ ],
30
+ });
31
+ ```
32
+
33
+ Import the virtual module in the theme JS entry point to activate islands:
34
+
35
+ ```ts
36
+ // frontend/js/theme.ts
37
+ import "vite-plugin-shopify-theme-islands/revive";
38
+ ```
39
+
40
+ ## Core Patterns
41
+
42
+ ### Configure multiple island directories
43
+
44
+ ```ts
45
+ shopifyThemeIslands({
46
+ directories: ["/frontend/js/islands/", "/frontend/js/components/"],
47
+ });
48
+ ```
49
+
50
+ ### Override built-in directive defaults
51
+
52
+ ```ts
53
+ shopifyThemeIslands({
54
+ directives: {
55
+ visible: { rootMargin: "0px", threshold: 0.5 },
56
+ idle: { timeout: 2000 },
57
+ defer: { delay: 5000 },
58
+ },
59
+ });
60
+ ```
61
+
62
+ Per-directive options are deep-merged — overriding `visible.rootMargin` preserves `visible.threshold` at its default of `0`.
63
+
64
+ ### Enable automatic retry with exponential backoff
65
+
66
+ ```ts
67
+ shopifyThemeIslands({
68
+ retry: { retries: 3, delay: 1000 },
69
+ });
70
+ ```
71
+
72
+ `retries` is the number of attempts after the first failure. `delay` is the base ms — each subsequent retry doubles it (1000ms → 2000ms → 4000ms).
73
+
74
+ ### Enable console debug output
75
+
76
+ ```ts
77
+ shopifyThemeIslands({ debug: true });
78
+ ```
79
+
80
+ Logs discovered islands, active directives per element, and load/error events at startup.
81
+
82
+ ## Common Mistakes
83
+
84
+ ### CRITICAL Virtual module not imported — islands never activate
85
+
86
+ Wrong:
87
+
88
+ ```ts
89
+ // vite.config.ts — plugin configured but virtual module never imported
90
+ shopifyThemeIslands({ directories: ["/frontend/js/islands/"] });
91
+ ```
92
+
93
+ Correct:
94
+
95
+ ```ts
96
+ // frontend/js/theme.ts
97
+ import "vite-plugin-shopify-theme-islands/revive";
98
+ ```
99
+
100
+ The plugin generates the virtual module but has no effect until it is imported in the browser entry point. Islands are silently never activated.
101
+
102
+ Source: src/index.ts — VIRTUAL_ID / RESOLVED_ID
103
+
104
+ ### HIGH `retry` nested inside `directives` — no retries happen
105
+
106
+ Wrong:
107
+
108
+ ```ts
109
+ shopifyThemeIslands({
110
+ directives: {
111
+ retry: { retries: 2 }, // ← wrong nesting
112
+ },
113
+ });
114
+ ```
115
+
116
+ Correct:
117
+
118
+ ```ts
119
+ shopifyThemeIslands({
120
+ retry: { retries: 2 }, // ← top-level option
121
+ });
122
+ ```
123
+
124
+ `directives` accepts only `visible`, `idle`, `media`, `defer`, and `custom`. `retry` at `directives.retry` is silently ignored.
125
+
126
+ Source: src/index.ts:ShopifyThemeIslandsOptions
127
+
128
+ ### HIGH Wrong key name for retry count
129
+
130
+ Wrong:
131
+
132
+ ```ts
133
+ shopifyThemeIslands({ retry: { count: 3 } });
134
+ shopifyThemeIslands({ retry: { attempts: 3 } });
135
+ ```
136
+
137
+ Correct:
138
+
139
+ ```ts
140
+ shopifyThemeIslands({ retry: { retries: 3 } });
141
+ ```
142
+
143
+ Unknown keys are silently ignored. The correct field is `retries`.
144
+
145
+ Source: src/index.ts:RetryConfig
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: writing-islands
3
+ description: >
4
+ Writing island files. Two discovery modes: directory scanning (files in
5
+ configured directories auto-discovered by tag name = filename) and Island
6
+ mixin (import Island from vite-plugin-shopify-theme-islands/island to mark
7
+ files anywhere in the project). Covers customElements.define, the Island
8
+ base class, and child island cascade behaviour.
9
+ type: core
10
+ library: vite-plugin-shopify-theme-islands
11
+ library_version: "1.0.0"
12
+ sources:
13
+ - Rees1993/vite-plugin-shopify-theme-islands:src/island.ts
14
+ - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
15
+ ---
16
+
17
+ ## Setup
18
+
19
+ ### Directory-based island (simplest)
20
+
21
+ Place the file in a configured island directory. The filename (minus extension) becomes the tag name.
22
+
23
+ ```ts
24
+ // frontend/js/islands/product-form.ts
25
+ class ProductForm extends HTMLElement {
26
+ connectedCallback() {
27
+ this.innerHTML = "<p>Loaded</p>";
28
+ }
29
+ }
30
+
31
+ if (!customElements.get("product-form")) {
32
+ customElements.define("product-form", ProductForm);
33
+ }
34
+ ```
35
+
36
+ ```html
37
+ <!-- In Shopify theme template -->
38
+ <product-form client:visible></product-form>
39
+ ```
40
+
41
+ ### Island mixin (file outside islands directory)
42
+
43
+ Use the `Island` mixin to mark a component for auto-discovery without moving it.
44
+
45
+ ```ts
46
+ // frontend/js/components/cart-drawer.ts
47
+ import Island from "vite-plugin-shopify-theme-islands/island";
48
+
49
+ class CartDrawer extends Island(HTMLElement) {
50
+ connectedCallback() {
51
+ this.innerHTML = "<p>Cart loaded</p>";
52
+ }
53
+ }
54
+
55
+ if (!customElements.get("cart-drawer")) {
56
+ customElements.define("cart-drawer", CartDrawer);
57
+ }
58
+ ```
59
+
60
+ The plugin scans all TS/JS files for the `Island` import at build time and includes matches as lazy chunks.
61
+
62
+ ## Core Patterns
63
+
64
+ ### Guard against duplicate registration
65
+
66
+ ```ts
67
+ if (!customElements.get("product-form")) {
68
+ customElements.define("product-form", ProductForm);
69
+ }
70
+ ```
71
+
72
+ Required when multiple entry points might import the same island file.
73
+
74
+ ### Child islands activate after their parent
75
+
76
+ ```html
77
+ <cart-drawer client:visible>
78
+ <cart-line-item client:idle></cart-line-item>
79
+ </cart-drawer>
80
+ ```
81
+
82
+ `cart-line-item` is not activated until `cart-drawer`'s module has resolved. The runtime's TreeWalker rejects subtrees of unloaded parent islands and re-walks them after the parent loads.
83
+
84
+ ### Vite alias in directories
85
+
86
+ ```ts
87
+ // vite.config.ts
88
+ export default defineConfig({
89
+ resolve: { alias: { "@islands": "/frontend/js/islands" } },
90
+ plugins: [
91
+ shopifyThemeIslands({ directories: ["@islands/"] }),
92
+ ],
93
+ });
94
+ ```
95
+
96
+ The plugin resolves Vite aliases in `directories` during `configResolved`.
97
+
98
+ ## Common Mistakes
99
+
100
+ ### HIGH Island file outside directories without Island mixin
101
+
102
+ Wrong:
103
+
104
+ ```ts
105
+ // frontend/js/components/search-bar.ts — not in islands directory
106
+ class SearchBar extends HTMLElement {}
107
+ customElements.define("search-bar", SearchBar);
108
+ ```
109
+
110
+ Correct:
111
+
112
+ ```ts
113
+ // frontend/js/components/search-bar.ts
114
+ import Island from "vite-plugin-shopify-theme-islands/island";
115
+
116
+ class SearchBar extends Island(HTMLElement) {}
117
+ customElements.define("search-bar", SearchBar);
118
+ ```
119
+
120
+ Without the `Island` import the plugin cannot detect the file. The element appears in the DOM but the module is never lazy-loaded.
121
+
122
+ Source: src/index.ts — ISLAND_IMPORT_RE, scanForIslandFiles
123
+
124
+ ### HIGH Missing `customElements.define` call
125
+
126
+ Wrong:
127
+
128
+ ```ts
129
+ // frontend/js/islands/mini-cart.ts
130
+ export class MiniCart extends HTMLElement {
131
+ connectedCallback() {}
132
+ }
133
+ ```
134
+
135
+ Correct:
136
+
137
+ ```ts
138
+ export class MiniCart extends HTMLElement {
139
+ connectedCallback() {}
140
+ }
141
+
142
+ if (!customElements.get("mini-cart")) {
143
+ customElements.define("mini-cart", MiniCart);
144
+ }
145
+ ```
146
+
147
+ The plugin loads the module but the custom element never upgrades without `customElements.define`.
148
+
149
+ Source: src/runtime.ts — loader() is called but registration is the file's responsibility
150
+
151
+ ### MEDIUM Child island activates before parent is ready
152
+
153
+ Wrong assumption:
154
+
155
+ ```html
156
+ <!-- Expecting cart-line-item to start its own directive wait immediately -->
157
+ <cart-drawer client:visible>
158
+ <cart-line-item client:idle></cart-line-item>
159
+ </cart-drawer>
160
+ ```
161
+
162
+ `cart-line-item`'s `client:idle` wait does **not** begin until `cart-drawer` has finished loading. The cascade is sequential, not parallel.
163
+
164
+ Source: src/runtime.ts — customElementFilter NodeFilter.FILTER_REJECT, walk() after parent loads