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 +68 -25
- package/dist/index.d.ts +22 -5
- package/dist/index.js +49 -4
- package/dist/runtime.d.ts +5 -4
- package/dist/runtime.js +69 -25
- package/package.json +1 -1
- package/skills/custom-directives/SKILL.md +54 -24
- package/skills/directives/SKILL.md +69 -6
- package/skills/lifecycle/SKILL.md +34 -19
- package/skills/setup/SKILL.md +86 -14
- package/skills/writing-islands/SKILL.md +1 -1
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
|
|
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/
|
|
245
|
+
// src/directives/hash.ts
|
|
211
246
|
import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
|
|
212
247
|
|
|
213
|
-
const
|
|
214
|
-
|
|
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
|
|
256
|
+
export default hashDirective;
|
|
218
257
|
```
|
|
219
258
|
|
|
220
|
-
|
|
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:
|
|
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:
|
|
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
|
-
<
|
|
292
|
+
<product-reviews client:hash="#reviews">
|
|
254
293
|
<!-- ... -->
|
|
255
|
-
</
|
|
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
|
|
264
|
-
<
|
|
302
|
+
<!-- element must enter the viewport before the hash handler is registered -->
|
|
303
|
+
<product-reviews client:visible client:hash="#reviews">
|
|
265
304
|
<!-- ... -->
|
|
266
|
-
</
|
|
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:
|
|
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:
|
|
275
|
-
<
|
|
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
|
-
</
|
|
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
|
|
399
|
-
| --------------- |
|
|
400
|
-
| `islands:load` | `tag`
|
|
401
|
-
| `islands:error` | `tag`, `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/
|
|
19
|
+
* // src/directives/hash.ts
|
|
20
20
|
* import type { ClientDirective } from 'vite-plugin-shopify-theme-islands';
|
|
21
21
|
*
|
|
22
|
-
* const
|
|
23
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
8
|
-
* client:media
|
|
9
|
-
* client:idle
|
|
10
|
-
* client:defer
|
|
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
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
105
|
-
const flush =
|
|
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,
|
|
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 —
|
|
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
|
|
138
|
-
|
|
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", {
|
|
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 (
|
|
240
|
-
for (const [el, cancel] of
|
|
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
|
-
|
|
286
|
+
pendingCancellable.delete(el);
|
|
243
287
|
cancel();
|
|
244
288
|
}
|
|
245
289
|
}
|
package/package.json
CHANGED
|
@@ -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.
|
|
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/
|
|
20
|
+
// src/directives/hash.ts
|
|
20
21
|
import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
|
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:
|
|
40
|
-
entrypoint: "./src/directives/
|
|
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
|
-
<
|
|
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:
|
|
104
|
+
<product-form client:hash="#details" client:auth-check></product-form>
|
|
100
105
|
```
|
|
101
106
|
|
|
102
|
-
If both `client:
|
|
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
|
|
112
|
-
el.addEventListener("
|
|
113
|
-
console.log("
|
|
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
|
|
122
|
-
el.addEventListener("
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
160
|
-
entrypoint: "src/directives/
|
|
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:
|
|
169
|
-
entrypoint: "./src/directives/
|
|
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 `
|
|
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)
|
|
7
|
-
|
|
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.
|
|
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 —
|
|
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
|
|
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,
|
|
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.
|
|
8
|
-
|
|
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.
|
|
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
|
-
`
|
|
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.
|
|
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);
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
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.
|
|
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
|
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -1,42 +1,60 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|