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