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

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
@@ -190,16 +190,51 @@ Loads the island after a fixed delay. The delay in milliseconds is read from the
190
190
 
191
191
  Unlike `client:idle`, which waits for genuine browser idle time, `client:defer` always waits exactly the specified number of milliseconds.
192
192
 
193
+ ### `client:interaction`
194
+
195
+ 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.
196
+
197
+ ```html
198
+ <cart-flyout client:interaction>
199
+ <!-- ... -->
200
+ </cart-flyout>
201
+ ```
202
+
203
+ The attribute value overrides the events for that element only (space-separated MDN event names):
204
+
205
+ ```html
206
+ <!-- only mouseenter — touchstart and focusin are excluded -->
207
+ <cart-flyout client:interaction="mouseenter">
208
+ <!-- ... -->
209
+ </cart-flyout>
210
+ ```
211
+
212
+ 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:
213
+
214
+ ```html
215
+ <mega-menu client:visible client:interaction>
216
+ <!-- loads when visible, then waits for hover/touch/focus -->
217
+ </mega-menu>
218
+ ```
219
+
193
220
  ### Combining directives
194
221
 
195
- Directives can be combined — the element waits for all conditions to be met before loading:
222
+ 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.
196
223
 
197
224
  ```html
225
+ <!-- must scroll into view, then wait for user interaction -->
226
+ <product-recommendations client:visible client:interaction>
227
+ <!-- ... -->
228
+ </product-recommendations>
229
+
230
+ <!-- must scroll into view, then wait for idle time -->
198
231
  <heavy-widget client:visible client:idle>
199
232
  <!-- ... -->
200
233
  </heavy-widget>
201
234
  ```
202
235
 
236
+ 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.
237
+
203
238
  ### Custom directives
204
239
 
205
240
  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.
@@ -207,24 +242,28 @@ Register your own loading conditions via `directives.custom`. A custom directive
207
242
  #### 1. Write the directive
208
243
 
209
244
  ```ts
210
- // src/directives/hover.ts
245
+ // src/directives/hash.ts
211
246
  import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
212
247
 
213
- const hoverDirective: ClientDirective = (load, _opts, el) => {
214
- el.addEventListener("mouseenter", load, { once: true });
248
+ const hashDirective: ClientDirective = (load, opts) => {
249
+ const target = opts.value;
250
+ if (location.hash === target) { load(); return; }
251
+ window.addEventListener("hashchange", () => {
252
+ if (location.hash === target) load();
253
+ });
215
254
  };
216
255
 
217
- export default hoverDirective;
256
+ export default hashDirective;
218
257
  ```
219
258
 
220
- `mouseenter` fires before `click`, so the module starts downloading the moment the user moves their cursor toward the element by the time they click it's already loaded.
259
+ 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.
221
260
 
222
261
  The function signature is `(load, options, el) => void | Promise<void>`:
223
262
 
224
263
  | Parameter | Type | Description |
225
264
  | --------------- | ---------------------- | ----------------------------------------------------- |
226
265
  | `load` | `() => Promise<void>` | Call this to trigger the island module load |
227
- | `options.name` | `string` | The matched attribute name, e.g. `'client:hover'` |
266
+ | `options.name` | `string` | The matched attribute name, e.g. `'client:hash'` |
228
267
  | `options.value` | `string` | The attribute value; empty string if no value was set |
229
268
  | `el` | `HTMLElement` | The island element |
230
269
 
@@ -238,7 +277,7 @@ export default defineConfig({
238
277
  plugins: [
239
278
  shopifyThemeIslands({
240
279
  directives: {
241
- custom: [{ name: "client:hover", entrypoint: "./src/directives/hover.ts" }],
280
+ custom: [{ name: "client:hash", entrypoint: "./src/directives/hash.ts" }],
242
281
  },
243
282
  }),
244
283
  ],
@@ -250,9 +289,9 @@ The `entrypoint` supports Vite aliases.
250
289
  #### 3. Use it in Liquid
251
290
 
252
291
  ```html
253
- <quick-add client:hover>
292
+ <product-reviews client:hash="#reviews">
254
293
  <!-- ... -->
255
- </quick-add>
294
+ </product-reviews>
256
295
  ```
257
296
 
258
297
  #### Ordering
@@ -260,21 +299,21 @@ The `entrypoint` supports Vite aliases.
260
299
  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:
261
300
 
262
301
  ```html
263
- <!-- element must enter the viewport before the hover handler is registered -->
264
- <quick-add client:visible client:hover>
302
+ <!-- element must enter the viewport before the hash handler is registered -->
303
+ <product-reviews client:visible client:hash="#reviews">
265
304
  <!-- ... -->
266
- </quick-add>
305
+ </product-reviews>
267
306
  ```
268
307
 
269
308
  The custom directive owns the `load()` call — the built-in chain never calls it directly when a custom directive is matched.
270
309
 
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`:
310
+ 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`:
272
311
 
273
312
  ```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>
313
+ <!-- client:visible runs first (built-in); then both client:hash and client:network must fire -->
314
+ <product-reviews client:visible client:hash="#reviews" client:network="4g">
276
315
  <!-- ... -->
277
- </quick-add>
316
+ </product-reviews>
278
317
  ```
279
318
 
280
319
  ## Configuration
@@ -307,6 +346,10 @@ shopifyThemeIslands({
307
346
  attribute: "client:defer", // HTML attribute name
308
347
  delay: 3000, // fallback delay (ms) when the attribute has no value
309
348
  },
349
+ interaction: {
350
+ attribute: "client:interaction", // HTML attribute name
351
+ events: ["mouseenter", "touchstart", "focusin"], // DOM events that trigger load
352
+ },
310
353
  custom: [], // custom directives — see Custom directives above
311
354
  },
312
355
  });
@@ -372,12 +415,12 @@ The `/events` entry point provides typed helpers that unwrap `e.detail` for you
372
415
  ```ts
373
416
  import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";
374
417
 
375
- const offLoad = onIslandLoad(({ tag }) => {
376
- analytics.track("island_loaded", { tag });
418
+ const offLoad = onIslandLoad(({ tag, duration, attempt }) => {
419
+ analytics.track("island_loaded", { tag, duration, attempt });
377
420
  });
378
421
 
379
- const offError = onIslandError(({ tag, error }) => {
380
- errorReporter.capture(error, { context: tag });
422
+ const offError = onIslandError(({ tag, error, attempt }) => {
423
+ errorReporter.capture(error, { context: tag, attempt });
381
424
  });
382
425
 
383
426
  // Remove listeners when no longer needed (e.g. SPA teardown)
@@ -395,10 +438,10 @@ document.addEventListener("islands:load", (e) => {
395
438
  });
396
439
  ```
397
440
 
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`) |
441
+ | Event | Detail properties | When it fires |
442
+ | --------------- | ------------------------------ | ---------------------------------------------------------- |
443
+ | `islands:load` | `tag`, `duration`, `attempt` | Island module resolves successfully |
444
+ | `islands:error` | `tag`, `error`, `attempt` | Load or custom directive fails (alongside `console.error`) |
402
445
 
403
446
  `islands:error` fires on each retry attempt, not just the final failure. Multiple independent listeners are supported — each receives its own event.
404
447
 
package/dist/index.d.ts CHANGED
@@ -16,21 +16,25 @@ export interface ClientDirectiveOptions {
16
16
  *
17
17
  * @example
18
18
  * ```ts
19
- * // src/directives/hover.ts
19
+ * // src/directives/hash.ts
20
20
  * import type { ClientDirective } from 'vite-plugin-shopify-theme-islands';
21
21
  *
22
- * const hoverDirective: ClientDirective = (load, _opts, el) => {
23
- * el.addEventListener('mouseenter', load, { once: true });
22
+ * const hashDirective: ClientDirective = (load, opts) => {
23
+ * const target = opts.value;
24
+ * if (location.hash === target) { load(); return; }
25
+ * window.addEventListener('hashchange', () => {
26
+ * if (location.hash === target) load();
27
+ * });
24
28
  * };
25
29
  *
26
- * export default hoverDirective;
30
+ * export default hashDirective;
27
31
  * ```
28
32
  *
29
33
  * Register it in `vite.config.ts`:
30
34
  * ```ts
31
35
  * shopifyThemeIslands({
32
36
  * directives: {
33
- * custom: [{ name: 'client:hover', entrypoint: './src/directives/hover.ts' }],
37
+ * custom: [{ name: 'client:hash', entrypoint: './src/directives/hash.ts' }],
34
38
  * },
35
39
  * })
36
40
  * ```
@@ -73,6 +77,13 @@ export interface DirectivesConfig {
73
77
  /** Fallback delay (ms) when the attribute has no value. Default: `3000` */
74
78
  delay?: number;
75
79
  };
80
+ /** Configuration for the `client:interaction` directive (mouseenter/touchstart/focusin). */
81
+ interaction?: {
82
+ /** HTML attribute name. Default: `'client:interaction'` */
83
+ attribute?: string;
84
+ /** DOM event names to listen for. Default: `['mouseenter', 'touchstart', 'focusin']` */
85
+ events?: string[];
86
+ };
76
87
  /** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
77
88
  custom?: ClientDirectiveDefinition[];
78
89
  }
@@ -89,6 +100,10 @@ export interface RetryConfig {
89
100
  export interface IslandLoadDetail {
90
101
  /** The custom element tag name, e.g. `'product-form'` */
91
102
  tag: string;
103
+ /** Milliseconds from directive resolution to successful module load (chunk fetch time). */
104
+ duration: number;
105
+ /** Which attempt succeeded. 1 = first try, 2 = first retry, etc. */
106
+ attempt: number;
92
107
  }
93
108
  /** Event detail for the `islands:error` DOM event. */
94
109
  export interface IslandErrorDetail {
@@ -96,6 +111,8 @@ export interface IslandErrorDetail {
96
111
  tag: string;
97
112
  /** The error thrown by the loader or custom directive */
98
113
  error: unknown;
114
+ /** Which attempt failed. 1 = initial attempt, 2 = first retry, etc. */
115
+ attempt: number;
99
116
  }
100
117
  declare global {
101
118
  interface DocumentEventMap {
package/dist/index.js CHANGED
@@ -10,20 +10,61 @@ var islandPath = fileURLToPath(new URL("./island.js", import.meta.url));
10
10
  var ISLAND_IMPORT_RE = /from\s+['"]vite-plugin-shopify-theme-islands\/island['"]/;
11
11
  var TS_JS_RE = /\.(ts|js)$/;
12
12
  var SKIP_DIRS = new Set(["node_modules", "dist", "build", "public", "assets", ".cache"]);
13
+ var PREFIX = "[vite-plugin-shopify-theme-islands]";
14
+ function validateOptions(options, directives) {
15
+ const customDefs = options.directives?.custom ?? [];
16
+ if (Array.isArray(options.directories) && options.directories.length === 0) {
17
+ throw new Error(`${PREFIX} "directories" must not be empty`);
18
+ }
19
+ const threshold = options.directives?.visible?.threshold;
20
+ if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
21
+ throw new Error(`${PREFIX} "directives.visible.threshold" must be between 0 and 1, got ${threshold}`);
22
+ }
23
+ if (options.retry !== undefined) {
24
+ const { retries, delay } = options.retry;
25
+ if (retries !== undefined && retries < 0) {
26
+ throw new Error(`${PREFIX} "retry.retries" must be >= 0, got ${retries}`);
27
+ }
28
+ if (delay !== undefined && delay < 0) {
29
+ throw new Error(`${PREFIX} "retry.delay" must be >= 0, got ${delay}`);
30
+ }
31
+ }
32
+ const builtinAttributes = new Set([
33
+ directives.visible.attribute,
34
+ directives.idle.attribute,
35
+ directives.media.attribute,
36
+ directives.defer.attribute,
37
+ directives.interaction.attribute
38
+ ]);
39
+ const seen = new Set;
40
+ for (const def of customDefs) {
41
+ if (seen.has(def.name)) {
42
+ throw new Error(`${PREFIX} Duplicate custom directive name: "${def.name}"`);
43
+ }
44
+ if (builtinAttributes.has(def.name)) {
45
+ throw new Error(`${PREFIX} Custom directive "${def.name}" conflicts with a built-in directive`);
46
+ }
47
+ seen.add(def.name);
48
+ }
49
+ }
13
50
  var defaults = {
14
51
  directories: ["/frontend/js/islands/"],
15
52
  directives: {
16
53
  visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
17
54
  idle: { attribute: "client:idle", timeout: 500 },
18
55
  media: { attribute: "client:media" },
19
- defer: { attribute: "client:defer", delay: 3000 }
56
+ defer: { attribute: "client:defer", delay: 3000 },
57
+ interaction: {
58
+ attribute: "client:interaction",
59
+ events: ["mouseenter", "touchstart", "focusin"]
60
+ }
20
61
  }
21
62
  };
22
63
  function normalizeDir(dir) {
23
64
  return dir.endsWith("/") ? dir : dir + "/";
24
65
  }
25
66
  function resolveAliases(dirs, config) {
26
- const aliases = config.resolve.alias;
67
+ const aliases = [...config.resolve.alias].sort((a, b) => (typeof b.find === "string" ? b.find.length : 0) - (typeof a.find === "string" ? a.find.length : 0));
27
68
  return dirs.map((dir) => {
28
69
  for (const { find, replacement } of aliases) {
29
70
  if (typeof find === "string" && dir.startsWith(find))
@@ -68,9 +109,11 @@ function shopifyThemeIslands(options = {}) {
68
109
  visible: { ...defaults.directives.visible, ...options.directives?.visible },
69
110
  idle: { ...defaults.directives.idle, ...options.directives?.idle },
70
111
  media: { ...defaults.directives.media, ...options.directives?.media },
71
- defer: { ...defaults.directives.defer, ...options.directives?.defer }
112
+ defer: { ...defaults.directives.defer, ...options.directives?.defer },
113
+ interaction: { ...defaults.directives.interaction, ...options.directives?.interaction }
72
114
  };
73
115
  const clientDirectiveDefinitions = options.directives?.custom ?? [];
116
+ validateOptions(options, directives);
74
117
  const debug = options.debug ?? false;
75
118
  const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
76
119
  let resolvedDirs = rawDirs;
@@ -178,8 +221,10 @@ function shopifyThemeIslands(options = {}) {
178
221
  ${mapEntries.join(`,
179
222
  `)}
180
223
  ]);`);
224
+ lines.push(`export const { disconnect } = _islands(islands, options, customDirectives);`);
225
+ } else {
226
+ lines.push(`export const { disconnect } = _islands(islands, options);`);
181
227
  }
182
- lines.push(`export const { disconnect } = _islands(islands, options${mapEntries.length ? ", customDirectives" : ""});`);
183
228
  return lines.join(`
184
229
  `);
185
230
  }
package/dist/runtime.d.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  * Walks the DOM for custom elements that match island files, then loads them
5
5
  * lazily based on client directives:
6
6
  *
7
- * client:visible — load when the element scrolls into view
8
- * client:media — load when a CSS media query matches
9
- * client:idle — load when the browser has idle time
10
- * client:defer — load after a fixed delay (ms value on the attribute)
7
+ * client:visible — load when the element scrolls into view
8
+ * client:media — load when a CSS media query matches
9
+ * client:idle — load when the browser has idle time
10
+ * client:defer — load after a fixed delay (ms value on the attribute)
11
+ * client:interaction — load on mouseenter / touchstart / focusin (or custom events)
11
12
  *
12
13
  * Directives can be combined; all conditions must be met before loading.
13
14
  * A MutationObserver re-runs the same logic for elements added dynamically.
package/dist/runtime.js CHANGED
@@ -25,6 +25,25 @@ function visible(element, rootMargin, threshold, pending) {
25
25
  });
26
26
  });
27
27
  }
28
+ function interaction(element, events, pending) {
29
+ return new Promise((resolve, reject) => {
30
+ const cleanup = () => {
31
+ for (const name of events)
32
+ element.removeEventListener(name, handler);
33
+ pending.delete(element);
34
+ };
35
+ const handler = () => {
36
+ cleanup();
37
+ resolve();
38
+ };
39
+ for (const name of events)
40
+ element.addEventListener(name, handler);
41
+ pending.set(element, () => {
42
+ cleanup();
43
+ reject();
44
+ });
45
+ });
46
+ }
28
47
  function defer(ms) {
29
48
  return new Promise((resolve) => setTimeout(resolve, ms));
30
49
  }
@@ -36,11 +55,18 @@ function idle(timeout) {
36
55
  setTimeout(resolve, timeout);
37
56
  });
38
57
  }
58
+ var noop = (..._) => {};
39
59
  function revive(islands, options, customDirectives) {
40
60
  const attrVisible = options?.directives?.visible?.attribute ?? "client:visible";
41
61
  const attrMedia = options?.directives?.media?.attribute ?? "client:media";
42
62
  const attrIdle = options?.directives?.idle?.attribute ?? "client:idle";
43
63
  const attrDefer = options?.directives?.defer?.attribute ?? "client:defer";
64
+ const attrInteraction = options?.directives?.interaction?.attribute ?? "client:interaction";
65
+ const interactionEvents = options?.directives?.interaction?.events ?? [
66
+ "mouseenter",
67
+ "touchstart",
68
+ "focusin"
69
+ ];
44
70
  const rootMargin = options?.directives?.visible?.rootMargin ?? "200px";
45
71
  const threshold = options?.directives?.visible?.threshold ?? 0;
46
72
  const idleTimeout = options?.directives?.idle?.timeout ?? 500;
@@ -62,7 +88,7 @@ function revive(islands, options, customDirectives) {
62
88
  const queued = new Set;
63
89
  let initDone = false;
64
90
  const loaded = new Set;
65
- const pendingVisible = new Map;
91
+ const pendingCancellable = new Map;
66
92
  const retryCount = new Map;
67
93
  const isUnloadedIsland = (tag) => queued.has(tag) && !loaded.has(tag);
68
94
  const customElementFilter = {
@@ -79,18 +105,17 @@ function revive(islands, options, customDirectives) {
79
105
  async function loadIsland(tagName, el, loader) {
80
106
  if (debug && !initDone) {
81
107
  const parts = [];
82
- const visibleVal = el.getAttribute(attrVisible);
83
- if (visibleVal !== null)
84
- parts.push(visibleVal ? `${attrVisible}="${visibleVal}"` : attrVisible);
108
+ const pushAttr = (attr, val) => {
109
+ if (val !== null)
110
+ parts.push(val ? `${attr}="${val}"` : attr);
111
+ };
112
+ pushAttr(attrVisible, el.getAttribute(attrVisible));
85
113
  const mediaVal = el.getAttribute(attrMedia);
86
114
  if (mediaVal)
87
115
  parts.push(`${attrMedia}="${mediaVal}"`);
88
- const idleVal = el.getAttribute(attrIdle);
89
- if (idleVal !== null)
90
- parts.push(idleVal ? `${attrIdle}="${idleVal}"` : attrIdle);
91
- const deferVal = el.getAttribute(attrDefer);
92
- if (deferVal !== null)
93
- parts.push(deferVal ? `${attrDefer}="${deferVal}"` : attrDefer);
116
+ pushAttr(attrIdle, el.getAttribute(attrIdle));
117
+ pushAttr(attrDefer, el.getAttribute(attrDefer));
118
+ pushAttr(attrInteraction, el.getAttribute(attrInteraction));
94
119
  if (customDirectives?.size) {
95
120
  for (const a of customDirectives.keys()) {
96
121
  if (el.hasAttribute(a))
@@ -100,9 +125,9 @@ function revive(islands, options, customDirectives) {
100
125
  if (parts.length > 0)
101
126
  console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`);
102
127
  }
103
- const msgs = [];
104
- const note = debug ? (msg) => msgs.push(msg) : () => {};
105
- const flush = debug ? (final) => {
128
+ const msgs = debug ? [] : null;
129
+ const note = msgs ? (msg) => msgs.push(msg) : noop;
130
+ const flush = msgs ? (final) => {
106
131
  if (msgs.length === 0) {
107
132
  console.log("[islands]", `<${tagName}> ${final}`);
108
133
  } else {
@@ -111,16 +136,16 @@ function revive(islands, options, customDirectives) {
111
136
  console.log(m);
112
137
  console.groupEnd();
113
138
  }
114
- } : () => {};
139
+ } : noop;
115
140
  try {
116
141
  const visibleAttr = el.getAttribute(attrVisible);
117
142
  if (visibleAttr !== null) {
118
143
  note(`waiting for ${attrVisible}`);
119
- await visible(el, visibleAttr || rootMargin, threshold, pendingVisible);
144
+ await visible(el, visibleAttr || rootMargin, threshold, pendingCancellable);
120
145
  }
121
146
  const query = el.getAttribute(attrMedia);
122
147
  if (query === "") {
123
- console.warn(`[islands] <${tagName}> ${attrMedia} has no value — skipping media check`);
148
+ console.warn(`[islands] <${tagName}> ${attrMedia} has no value — media check skipped, island will load immediately`);
124
149
  } else if (query) {
125
150
  note(`waiting for ${attrMedia}="${query}"`);
126
151
  await media(query);
@@ -134,14 +159,27 @@ function revive(islands, options, customDirectives) {
134
159
  }
135
160
  const d = el.getAttribute(attrDefer);
136
161
  if (d !== null) {
137
- const raw = parseInt(d, 10);
138
- const ms = Number.isNaN(raw) ? deferDelay : raw;
139
- if (d !== "" && Number.isNaN(raw)) {
162
+ const dMs = parseInt(d, 10);
163
+ if (d !== "" && Number.isNaN(dMs)) {
140
164
  console.warn(`[islands] <${tagName}> invalid ${attrDefer} value "${d}" — using default ${deferDelay}ms`);
141
165
  }
166
+ const ms = Number.isNaN(dMs) ? deferDelay : dMs;
142
167
  note(`waiting for ${attrDefer} (${ms}ms)`);
143
168
  await defer(ms);
144
169
  }
170
+ const interactionAttr = el.getAttribute(attrInteraction);
171
+ if (interactionAttr !== null) {
172
+ let events = interactionEvents;
173
+ if (interactionAttr) {
174
+ const tokens = interactionAttr.split(/\s+/).filter(Boolean);
175
+ if (tokens.length > 0)
176
+ events = tokens;
177
+ else
178
+ console.warn(`[islands] <${tagName}> ${attrInteraction} has no valid event tokens — using default events`);
179
+ }
180
+ note(`waiting for ${attrInteraction} (${events.join(", ")})`);
181
+ await interaction(el, events, pendingCancellable);
182
+ }
145
183
  } catch {
146
184
  flush("aborted (element removed)");
147
185
  return;
@@ -149,16 +187,22 @@ function revive(islands, options, customDirectives) {
149
187
  const run = () => {
150
188
  if (disconnected)
151
189
  return Promise.resolve();
190
+ const t0 = performance.now();
152
191
  return loader().then(() => {
192
+ const attempt = (retryCount.get(tagName) ?? 0) + 1;
153
193
  loaded.add(tagName);
154
194
  retryCount.delete(tagName);
155
- dispatch("islands:load", { tag: tagName });
195
+ dispatch("islands:load", {
196
+ tag: tagName,
197
+ duration: performance.now() - t0,
198
+ attempt
199
+ });
156
200
  if (el.children.length)
157
201
  walk(el);
158
202
  }).catch((err) => {
159
203
  console.error(`[islands] Failed to load <${tagName}>:`, err);
160
- dispatch("islands:error", { tag: tagName, error: err });
161
204
  const attempt = retryCount.get(tagName) ?? 0;
205
+ dispatch("islands:error", { tag: tagName, error: err, attempt: attempt + 1 });
162
206
  if (attempt < retries) {
163
207
  retryCount.set(tagName, attempt + 1);
164
208
  setTimeout(run, retryDelay * 2 ** attempt);
@@ -170,7 +214,7 @@ function revive(islands, options, customDirectives) {
170
214
  };
171
215
  const handleDirectiveError = (attrName, err) => {
172
216
  console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, err);
173
- dispatch("islands:error", { tag: tagName, error: err });
217
+ dispatch("islands:error", { tag: tagName, error: err, attempt: 1 });
174
218
  retryCount.delete(tagName);
175
219
  queued.delete(tagName);
176
220
  };
@@ -236,10 +280,10 @@ function revive(islands, options, customDirectives) {
236
280
  activate(node);
237
281
  }
238
282
  const observer = new MutationObserver((mutations) => {
239
- if (pendingVisible.size > 0 && mutations.some((m) => m.removedNodes.length > 0)) {
240
- for (const [el, cancel] of pendingVisible) {
283
+ if (pendingCancellable.size > 0 && mutations.some((m) => m.removedNodes.length > 0)) {
284
+ for (const [el, cancel] of pendingCancellable) {
241
285
  if (!el.isConnected) {
242
- pendingVisible.delete(el);
286
+ pendingCancellable.delete(el);
243
287
  cancel();
244
288
  }
245
289
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -5,9 +5,10 @@ description: >
5
5
  ClientDirective function signature (load, options, el). AND-latch: when
6
6
  multiple custom directives match the same element, all must call load() before
7
7
  the island activates. Error handling — thrown errors fire islands:error.
8
+ Custom directives run after all built-in conditions resolve.
8
9
  type: core
9
10
  library: vite-plugin-shopify-theme-islands
10
- library_version: "1.0.0"
11
+ library_version: "1.1.0"
11
12
  sources:
12
13
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
13
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
@@ -16,14 +17,18 @@ sources:
16
17
  ## Setup
17
18
 
18
19
  ```ts
19
- // src/directives/hover.ts
20
+ // src/directives/hash.ts
20
21
  import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
21
22
 
22
- const hoverDirective: ClientDirective = (load, _opts, el) => {
23
- el.addEventListener("mouseenter", load, { once: true });
23
+ const hashDirective: ClientDirective = (load, opts) => {
24
+ const target = opts.value;
25
+ if (location.hash === target) { load(); return; }
26
+ window.addEventListener("hashchange", () => {
27
+ if (location.hash === target) load();
28
+ });
24
29
  };
25
30
 
26
- export default hoverDirective;
31
+ export default hashDirective;
27
32
  ```
28
33
 
29
34
  ```ts
@@ -36,8 +41,8 @@ export default defineConfig({
36
41
  directives: {
37
42
  custom: [
38
43
  {
39
- name: "client:hover",
40
- entrypoint: "./src/directives/hover.ts",
44
+ name: "client:hash",
45
+ entrypoint: "./src/directives/hash.ts",
41
46
  },
42
47
  ],
43
48
  },
@@ -47,7 +52,7 @@ export default defineConfig({
47
52
  ```
48
53
 
49
54
  ```html
50
- <quick-add client:hover></quick-add>
55
+ <product-reviews client:hash="#reviews"></product-reviews>
51
56
  ```
52
57
 
53
58
  ## Core Patterns
@@ -96,10 +101,10 @@ The directive function can be async. Unhandled rejections fire `islands:error` o
96
101
  ### AND-latch with multiple matching directives
97
102
 
98
103
  ```html
99
- <product-form client:hover client:visible></product-form>
104
+ <product-form client:hash="#details" client:auth-check></product-form>
100
105
  ```
101
106
 
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()`.
107
+ 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()`.
103
108
 
104
109
  ## Common Mistakes
105
110
 
@@ -108,9 +113,9 @@ If both `client:hover` and `client:visible` are registered as custom directives
108
113
  Wrong:
109
114
 
110
115
  ```ts
111
- const hoverDirective: ClientDirective = (load, _opts, el) => {
112
- el.addEventListener("mouseenter", () => {
113
- console.log("hovered"); // forgot to call load
116
+ const myDirective: ClientDirective = (load, _opts, el) => {
117
+ el.addEventListener("click", () => {
118
+ console.log("clicked"); // forgot to call load
114
119
  });
115
120
  };
116
121
  ```
@@ -118,8 +123,8 @@ const hoverDirective: ClientDirective = (load, _opts, el) => {
118
123
  Correct:
119
124
 
120
125
  ```ts
121
- const hoverDirective: ClientDirective = (load, _opts, el) => {
122
- el.addEventListener("mouseenter", load, { once: true });
126
+ const myDirective: ClientDirective = (load, _opts, el) => {
127
+ el.addEventListener("click", load, { once: true });
123
128
  };
124
129
  ```
125
130
 
@@ -127,22 +132,47 @@ No error is thrown and no timeout fires — the island is silently never loaded.
127
132
 
128
133
  Source: src/runtime.ts — directive owns the `run()` call path
129
134
 
135
+ ### HIGH Writing a custom directive for mouseenter/touchstart/focusin — use `client:interaction` instead
136
+
137
+ Wrong:
138
+
139
+ ```ts
140
+ // Reimplementing what the built-in already does
141
+ const hoverDirective: ClientDirective = (load, _opts, el) => {
142
+ el.addEventListener("mouseenter", load, { once: true });
143
+ };
144
+ ```
145
+
146
+ Correct:
147
+
148
+ ```html
149
+ <!-- Use the built-in client:interaction directive -->
150
+ <cart-flyout client:interaction></cart-flyout>
151
+
152
+ <!-- Or with a specific event -->
153
+ <cart-flyout client:interaction="mouseenter"></cart-flyout>
154
+ ```
155
+
156
+ `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).
157
+
158
+ Source: src/runtime.ts — `interaction()` built-in handles the hover/touch/focus pattern
159
+
130
160
  ### HIGH AND-latch: both matched directives must call `load()`
131
161
 
132
162
  Wrong assumption:
133
163
 
134
164
  ```html
135
- <product-form client:hover client:auth-check></product-form>
165
+ <product-form client:hash="#details" client:auth-check></product-form>
136
166
  ```
137
167
 
138
168
  ```ts
139
- // Expecting: loads as soon as either hover or auth-check calls load()
169
+ // Expecting: loads as soon as either hash or auth-check calls load()
140
170
  ```
141
171
 
142
172
  Correct:
143
173
 
144
174
  ```ts
145
- // Both client:hover AND client:auth-check must call load() before activation.
175
+ // Both client:hash AND client:auth-check must call load() before activation.
146
176
  // remaining starts at 2; island fires when it reaches 0.
147
177
  ```
148
178
 
@@ -156,8 +186,8 @@ Wrong:
156
186
 
157
187
  ```ts
158
188
  {
159
- name: "client:hover",
160
- entrypoint: "src/directives/hover.ts", // ← no ./
189
+ name: "client:hash",
190
+ entrypoint: "src/directives/hash.ts", // ← no ./
161
191
  }
162
192
  ```
163
193
 
@@ -165,8 +195,8 @@ Correct:
165
195
 
166
196
  ```ts
167
197
  {
168
- name: "client:hover",
169
- entrypoint: "./src/directives/hover.ts",
198
+ name: "client:hash",
199
+ entrypoint: "./src/directives/hash.ts",
170
200
  }
171
201
  ```
172
202
 
@@ -174,7 +204,7 @@ Vite's resolver may fail to locate the file without the `./` relative prefix. Th
174
204
 
175
205
  Source: src/index.ts — `this.resolve(def.entrypoint)` throws on null
176
206
 
177
- ### MEDIUM Custom directives run after built-in directive awaits
207
+ ### MEDIUM Custom directives run after all built-in directive awaits
178
208
 
179
209
  Wrong expectation:
180
210
 
@@ -183,7 +213,7 @@ Wrong expectation:
183
213
  <cart-drawer client:visible client:auth></cart-drawer>
184
214
  ```
185
215
 
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.
216
+ 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.
187
217
 
188
218
  Source: src/runtime.ts — built-in awaits precede `if (customDirectives?.size)` block
189
219
 
@@ -3,11 +3,12 @@ name: directives
3
3
  description: >
4
4
  Built-in client directives: client:visible (IntersectionObserver, rootMargin),
5
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.
6
+ client:defer (setTimeout delay), client:interaction (mouseenter/touchstart/focusin).
7
+ Directives resolve sequentially visible media → idle → defer → interaction → custom.
8
+ Per-element value overrides. Empty client:media warning.
8
9
  type: core
9
10
  library: vite-plugin-shopify-theme-islands
10
- library_version: "1.0.0"
11
+ library_version: "1.1.0"
11
12
  sources:
12
13
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
13
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
@@ -29,16 +30,24 @@ Add one or more directives as HTML attributes on any custom element:
29
30
 
30
31
  <!-- Load after a fixed delay (ms) -->
31
32
  <chat-widget client:defer="5000"></chat-widget>
33
+
34
+ <!-- Load on mouseenter, touchstart, or focusin (hover/touch/keyboard intent) -->
35
+ <cart-flyout client:interaction></cart-flyout>
32
36
  ```
33
37
 
34
38
  No JS changes needed — the runtime reads these attributes during DOM walk.
35
39
 
36
40
  ## Core Patterns
37
41
 
38
- ### Combining directives — all conditions must pass
42
+ ### Combining directives — sequential resolution order
43
+
44
+ Directives resolve in a fixed order: `visible → media → idle → defer → interaction → custom`. Each condition is only evaluated after the previous one has passed.
39
45
 
40
46
  ```html
41
- <!-- Loads only when BOTH visible AND the media query match -->
47
+ <!-- Loads when visible AND on interaction interaction listeners only attach after scroll-in -->
48
+ <mega-menu client:visible client:interaction></mega-menu>
49
+
50
+ <!-- Loads when visible AND the media query matches -->
42
51
  <product-recommendations
43
52
  client:visible
44
53
  client:media="(min-width: 768px)"
@@ -58,6 +67,9 @@ Combined directives are AND-latched. The island loads only after every condition
58
67
 
59
68
  <!-- Fixed delay in ms; empty attribute uses the global default (3000ms) -->
60
69
  <chat-widget client:defer="8000"></chat-widget>
70
+
71
+ <!-- Override interaction events for this element only (space-separated MDN event names) -->
72
+ <cart-flyout client:interaction="mouseenter"></cart-flyout>
61
73
  ```
62
74
 
63
75
  The attribute value overrides the globally configured default for that element. Other elements are unaffected.
@@ -74,6 +86,18 @@ The attribute value overrides the globally configured default for that element.
74
86
 
75
87
  An empty `client:defer` attribute is NOT zero — it falls back to the configured `defer.delay` (default 3000ms).
76
88
 
89
+ ### `client:interaction` with no value uses the default events
90
+
91
+ ```html
92
+ <!-- Uses default events: mouseenter, touchstart, focusin -->
93
+ <cart-flyout client:interaction></cart-flyout>
94
+
95
+ <!-- Uses only mouseenter -->
96
+ <cart-flyout client:interaction="mouseenter"></cart-flyout>
97
+ ```
98
+
99
+ An empty `client:interaction` attribute silently uses the configured default events — no warning is emitted (unlike `client:media`).
100
+
77
101
  ### Changing built-in directive defaults globally
78
102
 
79
103
  ```ts
@@ -82,6 +106,7 @@ shopifyThemeIslands({
82
106
  directives: {
83
107
  visible: { rootMargin: "0px" },
84
108
  defer: { delay: 5000 },
109
+ interaction: { events: ["mouseenter"] },
85
110
  },
86
111
  });
87
112
  ```
@@ -164,4 +189,42 @@ Correct:
164
189
 
165
190
  The attribute value is passed directly to `IntersectionObserver` as `rootMargin`, fully replacing the global default.
166
191
 
167
- Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, pendingVisible)`
192
+ Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, pendingCancellable)`
193
+
194
+ ### HIGH Directive attribute typo — island loads without condition
195
+
196
+ Wrong:
197
+
198
+ ```html
199
+ <product-form client:visibled></product-form>
200
+ <product-form client:Visible></product-form>
201
+ ```
202
+
203
+ Correct:
204
+
205
+ ```html
206
+ <product-form client:visible></product-form>
207
+ ```
208
+
209
+ Directive attributes are case-sensitive. An unrecognised attribute is silently ignored — the island loads immediately as if no directive were set. No warning is emitted. Check for typos if an island activates earlier than expected.
210
+
211
+ Source: src/runtime.ts — runtime checks exact attribute names from plugin config
212
+
213
+ ### HIGH Agent uses default attribute name when developer has configured a custom one
214
+
215
+ Wrong:
216
+
217
+ ```html
218
+ <!-- developer has set visible.attribute: "data:visible" in vite.config.ts -->
219
+ <product-form client:visible></product-form>
220
+ ```
221
+
222
+ Correct:
223
+
224
+ ```html
225
+ <product-form data:visible></product-form>
226
+ ```
227
+
228
+ When `directives.visible.attribute` (or any directive's `attribute` option) is overridden in `vite.config.ts`, all Liquid templates must use the configured name. The default `client:*` names no longer apply. Always read `vite.config.ts` to check for overridden attribute names before writing directives in Liquid.
229
+
230
+ Source: src/index.ts:DirectivesConfig — `attribute` field per directive; src/runtime.ts reads configured attribute names at runtime
@@ -4,11 +4,13 @@ description: >
4
4
  Island lifecycle events and SPA teardown. onIslandLoad and onIslandError
5
5
  helpers from vite-plugin-shopify-theme-islands/events — prefer these over
6
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.
7
+ islands:load and islands:error on document. islands:load detail includes tag,
8
+ duration (ms), and attempt (1-based). islands:error detail includes tag,
9
+ error, and attempt. disconnect() from the virtual module revive for SPA
10
+ navigation teardown.
9
11
  type: core
10
12
  library: vite-plugin-shopify-theme-islands
11
- library_version: "1.0.0"
13
+ library_version: "1.1.0"
12
14
  sources:
13
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
14
16
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
@@ -20,12 +22,12 @@ sources:
20
22
  ```ts
21
23
  import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";
22
24
 
23
- const offLoad = onIslandLoad(({ tag }) => {
24
- console.log("loaded:", tag);
25
+ const offLoad = onIslandLoad(({ tag, duration, attempt }) => {
26
+ console.log("loaded:", tag, `${duration.toFixed(1)}ms`, `attempt ${attempt}`);
25
27
  });
26
28
 
27
- const offError = onIslandError(({ tag, error }) => {
28
- console.error("failed:", tag, error);
29
+ const offError = onIslandError(({ tag, error, attempt }) => {
30
+ console.error("failed:", tag, `attempt ${attempt}`, error);
29
31
  });
30
32
 
31
33
  // Remove listeners when no longer needed
@@ -40,24 +42,38 @@ offError();
40
42
  ```ts
41
43
  import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";
42
44
 
43
- onIslandLoad(({ tag }) => {
44
- analytics.track("island_loaded", { component: tag });
45
+ onIslandLoad(({ tag, duration, attempt }) => {
46
+ analytics.track("island_loaded", { component: tag, duration, attempt });
47
+ });
48
+ ```
49
+
50
+ `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.
51
+
52
+ ### Track load performance
53
+
54
+ ```ts
55
+ import { onIslandLoad } from "vite-plugin-shopify-theme-islands/events";
56
+
57
+ onIslandLoad(({ tag, duration }) => {
58
+ if (duration > 3000) {
59
+ performance.mark(`island-slow:${tag}`);
60
+ }
45
61
  });
46
62
  ```
47
63
 
48
- `tag` is the lowercased custom element tag name (e.g. `"product-form"`).
64
+ `duration` measures only the chunk fetch time time spent waiting on directives (e.g. `client:visible`) is not included.
49
65
 
50
66
  ### Report errors to a monitoring service
51
67
 
52
68
  ```ts
53
69
  import { onIslandError } from "vite-plugin-shopify-theme-islands/events";
54
70
 
55
- onIslandError(({ tag, error }) => {
56
- Sentry.captureException(error, { extra: { island: tag } });
71
+ onIslandError(({ tag, error, attempt }) => {
72
+ Sentry.captureException(error, { extra: { island: tag, attempt } });
57
73
  });
58
74
  ```
59
75
 
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.
76
+ `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.
61
77
 
62
78
  ### Teardown for SPA navigation
63
79
 
@@ -77,7 +93,7 @@ disconnect();
77
93
  import type {} from "vite-plugin-shopify-theme-islands";
78
94
 
79
95
  document.addEventListener("islands:load", (e) => {
80
- console.log(e.detail.tag); // typed as string
96
+ console.log(e.detail.tag, e.detail.duration, e.detail.attempt);
81
97
  });
82
98
  ```
83
99
 
@@ -143,16 +159,15 @@ onIslandError(({ tag }) => {
143
159
  Correct:
144
160
 
145
161
  ```ts
146
- const seen = new Set<string>();
147
- onIslandError(({ tag, error }) => {
148
- if (!seen.has(tag)) {
149
- seen.add(tag);
162
+ onIslandError(({ tag, error, attempt }) => {
163
+ // attempt === 1 is the first failure; higher values are retries
164
+ if (attempt === 1) {
150
165
  reportFirstFailure(tag, error);
151
166
  }
152
167
  });
153
168
  ```
154
169
 
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.
170
+ 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.
156
171
 
157
172
  Source: src/runtime.ts — `dispatch("islands:error", ...)` inside `.catch()` before retry check
158
173
 
@@ -1,42 +1,60 @@
1
1
  ---
2
2
  name: setup
3
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.
4
+ Getting-started journey and plugin configuration. Covers the full path from
5
+ install to first working island. shopifyThemeIslands() options: directories
6
+ (string | string[]), debug, directives deep-merge (visible, idle, media,
7
+ defer, interaction, custom), and retry (retries, delay with exponential
8
+ backoff). Load when setting up the plugin, configuring island scan
9
+ directories, or enabling retry.
8
10
  type: core
9
11
  library: vite-plugin-shopify-theme-islands
10
- library_version: "1.0.0"
12
+ library_version: "1.1.0"
11
13
  sources:
12
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
13
15
  ---
14
16
 
15
17
  ## Setup
16
18
 
19
+ This plugin is framework-agnostic but designed for Shopify themes. Most Shopify
20
+ projects also use
21
+ [vite-plugin-shopify](https://github.com/barrel/vite-plugin-shopify) to handle
22
+ Shopify-specific asset serving — if the project uses it, add this plugin
23
+ alongside it in the existing `plugins` array.
24
+
25
+ ### 1. Add the plugin to `vite.config.ts`
26
+
17
27
  ```ts
18
28
  // vite.config.ts
19
29
  import { defineConfig } from "vite";
20
30
  import shopifyThemeIslands from "vite-plugin-shopify-theme-islands";
21
31
 
22
32
  export default defineConfig({
23
- plugins: [
24
- shopifyThemeIslands({
25
- directories: ["/frontend/js/islands/"],
26
- debug: false,
27
- retry: { retries: 2, delay: 500 },
28
- }),
29
- ],
33
+ plugins: [shopifyThemeIslands()],
30
34
  });
31
35
  ```
32
36
 
33
- Import the virtual module in the theme JS entry point to activate islands:
37
+ All options are optional. The default islands directory is `/frontend/js/islands/`.
38
+
39
+ ### 2. Import the virtual module in the theme JS entry point
34
40
 
35
41
  ```ts
36
42
  // frontend/js/theme.ts
37
43
  import "vite-plugin-shopify-theme-islands/revive";
38
44
  ```
39
45
 
46
+ This activates the runtime — islands are never loaded without this import.
47
+
48
+ ### 3. Add directives to Liquid templates
49
+
50
+ ```html
51
+ <!-- sections/product.liquid -->
52
+ <product-form client:visible></product-form>
53
+ ```
54
+
55
+ That's a working setup. Islands in `/frontend/js/islands/` matching the tag
56
+ name are loaded lazily when the directive condition is met.
57
+
40
58
  ## Core Patterns
41
59
 
42
60
  ### Configure multiple island directories
@@ -55,6 +73,7 @@ shopifyThemeIslands({
55
73
  visible: { rootMargin: "0px", threshold: 0.5 },
56
74
  idle: { timeout: 2000 },
57
75
  defer: { delay: 5000 },
76
+ interaction: { events: ["mouseenter"] },
58
77
  },
59
78
  });
60
79
  ```
@@ -101,6 +120,59 @@ The plugin generates the virtual module but has no effect until it is imported i
101
120
 
102
121
  Source: src/index.ts — VIRTUAL_ID / RESOLVED_ID
103
122
 
123
+ ### HIGH Agent hardcodes default values — unnecessary noise
124
+
125
+ Wrong:
126
+
127
+ ```ts
128
+ shopifyThemeIslands({
129
+ directories: ["/frontend/js/islands/"],
130
+ debug: false,
131
+ directives: {
132
+ visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
133
+ idle: { attribute: "client:idle", timeout: 500 },
134
+ media: { attribute: "client:media" },
135
+ defer: { attribute: "client:defer", delay: 3000 },
136
+ interaction: { attribute: "client:interaction", events: ["mouseenter", "touchstart", "focusin"] },
137
+ },
138
+ });
139
+ ```
140
+
141
+ Correct:
142
+
143
+ ```ts
144
+ shopifyThemeIslands();
145
+ ```
146
+
147
+ All options are optional and default to sensible values. Only include options that differ from the defaults.
148
+
149
+ ### HIGH Agent overwrites existing `vite.config.ts` instead of appending
150
+
151
+ Before adding the plugin, read the existing `vite.config.ts`. Projects commonly
152
+ already have `vite-plugin-shopify` or other plugins — the island plugin must be
153
+ added to the existing `plugins` array, not replace it.
154
+
155
+ Wrong:
156
+
157
+ ```ts
158
+ // Replaces existing plugins
159
+ export default defineConfig({
160
+ plugins: [shopifyThemeIslands()],
161
+ });
162
+ ```
163
+
164
+ Correct:
165
+
166
+ ```ts
167
+ // Appends to existing plugins
168
+ export default defineConfig({
169
+ plugins: [
170
+ shopify(), // pre-existing plugin preserved
171
+ shopifyThemeIslands(),
172
+ ],
173
+ });
174
+ ```
175
+
104
176
  ### HIGH `retry` nested inside `directives` — no retries happen
105
177
 
106
178
  Wrong:
@@ -121,7 +193,7 @@ shopifyThemeIslands({
121
193
  });
122
194
  ```
123
195
 
124
- `directives` accepts only `visible`, `idle`, `media`, `defer`, and `custom`. `retry` at `directives.retry` is silently ignored.
196
+ `directives` accepts only `visible`, `idle`, `media`, `defer`, `interaction`, and `custom`. `retry` at `directives.retry` is silently ignored.
125
197
 
126
198
  Source: src/index.ts:ShopifyThemeIslandsOptions
127
199
 
@@ -8,7 +8,7 @@ description: >
8
8
  base class, and child island cascade behaviour.
9
9
  type: core
10
10
  library: vite-plugin-shopify-theme-islands
11
- library_version: "1.0.0"
11
+ library_version: "1.1.0"
12
12
  sources:
13
13
  - Rees1993/vite-plugin-shopify-theme-islands:src/island.ts
14
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts