vite-plugin-shopify-theme-islands 1.1.1 → 1.2.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 +21 -7
- package/dist/contract.d.ts +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -2
- package/dist/runtime.js +253 -142
- package/package.json +1 -1
- package/skills/custom-directives/SKILL.md +15 -4
- package/skills/directives/SKILL.md +11 -2
- package/skills/lifecycle/SKILL.md +10 -5
- package/skills/setup/SKILL.md +39 -4
- package/skills/writing-islands/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -306,6 +306,7 @@ Built-in directives always run first. A custom directive is only invoked after a
|
|
|
306
306
|
```
|
|
307
307
|
|
|
308
308
|
The custom directive owns the `load()` call — the built-in chain never calls it directly when a custom directive is matched.
|
|
309
|
+
If a custom directive throws or returns a rejected promise, the runtime dispatches `islands:error` and abandons that island activation attempt.
|
|
309
310
|
|
|
310
311
|
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`:
|
|
311
312
|
|
|
@@ -316,14 +317,27 @@ Multiple custom directives on the same element use AND semantics — the island
|
|
|
316
317
|
</product-reviews>
|
|
317
318
|
```
|
|
318
319
|
|
|
320
|
+
#### Timeout guard
|
|
321
|
+
|
|
322
|
+
By default, a custom directive that never calls `load()` silently keeps the island unloaded forever. Set `directiveTimeout` to fire `islands:error` and abandon the island if the directive hasn't resolved within the given window:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
shopifyThemeIslands({
|
|
326
|
+
directiveTimeout: 5000, // abandon after 5 seconds
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
This is useful during development to surface directives that hang due to bugs, or in production to ensure broken directives don't silently degrade the experience.
|
|
331
|
+
|
|
319
332
|
## Configuration
|
|
320
333
|
|
|
321
|
-
| Option
|
|
322
|
-
|
|
|
323
|
-
| `directories`
|
|
324
|
-
| `directives`
|
|
325
|
-
| `retry`
|
|
326
|
-
| `debug`
|
|
334
|
+
| Option | Type | Default | Description |
|
|
335
|
+
| ------------------ | -------------------- | --------------------------- | ---------------------------------------------------------------------------------- |
|
|
336
|
+
| `directories` | `string \| string[]` | `['/frontend/js/islands/']` | Directories to scan for island files. Accepts Vite aliases. |
|
|
337
|
+
| `directives` | `object` | see below | Per-directive configuration — attribute names, timing options, and custom entries. |
|
|
338
|
+
| `retry` | `object` | — | Automatic retry behaviour for failed island loads. See [Retries](#retries). |
|
|
339
|
+
| `debug` | `boolean` | `false` | Log discovered islands at build time and directive events in the browser console. |
|
|
340
|
+
| `directiveTimeout` | `number` | `0` (disabled) | Milliseconds before a custom directive that never calls `load()` is considered timed out. Fires `islands:error` and abandons the island. |
|
|
327
341
|
|
|
328
342
|
### Directive defaults
|
|
329
343
|
|
|
@@ -441,7 +455,7 @@ document.addEventListener("islands:load", (e) => {
|
|
|
441
455
|
| Event | Detail properties | When it fires |
|
|
442
456
|
| --------------- | ------------------------------ | ---------------------------------------------------------- |
|
|
443
457
|
| `islands:load` | `tag`, `duration`, `attempt` | Island module resolves successfully |
|
|
444
|
-
| `islands:error` | `tag`, `error`, `attempt` | Load
|
|
458
|
+
| `islands:error` | `tag`, `error`, `attempt` | Load fails, custom directive throws or rejects, or `directiveTimeout` expires (alongside `console.error`) |
|
|
445
459
|
|
|
446
460
|
`islands:error` fires on each retry attempt, not just the final failure. Multiple independent listeners are supported — each receives its own event.
|
|
447
461
|
|
package/dist/contract.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface ReviveOptions {
|
|
|
39
39
|
directives?: RuntimeDirectivesConfig;
|
|
40
40
|
debug?: boolean;
|
|
41
41
|
retry?: RetryConfig;
|
|
42
|
+
/**
|
|
43
|
+
* Milliseconds before a custom directive that never calls `load()` is considered timed out.
|
|
44
|
+
* When exceeded, `islands:error` is dispatched and the island is abandoned.
|
|
45
|
+
* Default: `0` (disabled).
|
|
46
|
+
*/
|
|
47
|
+
directiveTimeout?: number;
|
|
42
48
|
}
|
|
43
49
|
/** Options passed to a custom client directive function. */
|
|
44
50
|
export interface ClientDirectiveOptions {
|
|
@@ -113,6 +119,7 @@ export interface NormalizedReviveOptions {
|
|
|
113
119
|
retries: number;
|
|
114
120
|
delay: number;
|
|
115
121
|
};
|
|
122
|
+
directiveTimeout: number;
|
|
116
123
|
}
|
|
117
124
|
/** Default directive config. Single source of truth for plugin merge and runtime normalization. */
|
|
118
125
|
export declare const DEFAULT_DIRECTIVES: NormalizedReviveOptions["directives"];
|
package/dist/index.d.ts
CHANGED
|
@@ -61,5 +61,11 @@ export interface ShopifyThemeIslandsOptions {
|
|
|
61
61
|
directives?: DirectivesConfig;
|
|
62
62
|
/** Automatic retry behaviour for failed island loads. */
|
|
63
63
|
retry?: RetryConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Milliseconds before a custom directive that never calls `load()` is considered timed out.
|
|
66
|
+
* When exceeded, `islands:error` is dispatched and the island is abandoned.
|
|
67
|
+
* Default: `0` (disabled).
|
|
68
|
+
*/
|
|
69
|
+
directiveTimeout?: number;
|
|
64
70
|
}
|
|
65
71
|
export default function shopifyThemeIslands(options?: ShopifyThemeIslandsOptions): Plugin;
|
package/dist/index.js
CHANGED
|
@@ -112,7 +112,8 @@ function normalizeReviveOptions(options) {
|
|
|
112
112
|
interaction: { ...d.interaction, ...dir?.interaction }
|
|
113
113
|
},
|
|
114
114
|
debug: options?.debug ?? false,
|
|
115
|
-
retry: { ...r, ...options?.retry }
|
|
115
|
+
retry: { ...r, ...options?.retry },
|
|
116
|
+
directiveTimeout: options?.directiveTimeout ?? 0
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
var basename = (key) => key.split("/").pop() ?? key;
|
|
@@ -298,7 +299,12 @@ function shopifyThemeIslands(options = {}) {
|
|
|
298
299
|
directoryGlobs,
|
|
299
300
|
islandPaths,
|
|
300
301
|
customDirectives,
|
|
301
|
-
reviveOptions: {
|
|
302
|
+
reviveOptions: {
|
|
303
|
+
directives,
|
|
304
|
+
debug,
|
|
305
|
+
retry: options.retry,
|
|
306
|
+
directiveTimeout: options.directiveTimeout
|
|
307
|
+
}
|
|
302
308
|
});
|
|
303
309
|
}
|
|
304
310
|
};
|
package/dist/runtime.js
CHANGED
|
@@ -23,7 +23,8 @@ function normalizeReviveOptions(options) {
|
|
|
23
23
|
interaction: { ...d.interaction, ...dir?.interaction }
|
|
24
24
|
},
|
|
25
25
|
debug: options?.debug ?? false,
|
|
26
|
-
retry: { ...r, ...options?.retry }
|
|
26
|
+
retry: { ...r, ...options?.retry },
|
|
27
|
+
directiveTimeout: options?.directiveTimeout ?? 0
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
var basename = (key) => key.split("/").pop() ?? key;
|
|
@@ -58,39 +59,49 @@ function media(query) {
|
|
|
58
59
|
m.addEventListener("change", () => resolve(), { once: true });
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
function visible(element, rootMargin, threshold,
|
|
62
|
+
function visible(element, rootMargin, threshold, watch) {
|
|
62
63
|
return new Promise((resolve, reject) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
let unwatch = () => {};
|
|
66
|
+
const finish = (done) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
unwatch();
|
|
71
|
+
io.disconnect();
|
|
72
|
+
done();
|
|
73
|
+
};
|
|
63
74
|
const io = new IntersectionObserver(([entry]) => {
|
|
64
75
|
if (entry.isIntersecting) {
|
|
65
|
-
|
|
66
|
-
pending.delete(element);
|
|
67
|
-
resolve();
|
|
76
|
+
finish(resolve);
|
|
68
77
|
}
|
|
69
78
|
}, { rootMargin, threshold });
|
|
70
79
|
io.observe(element);
|
|
71
|
-
|
|
72
|
-
io.disconnect();
|
|
73
|
-
reject();
|
|
74
|
-
});
|
|
80
|
+
unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError)));
|
|
75
81
|
});
|
|
76
82
|
}
|
|
77
|
-
function interaction(element, events,
|
|
83
|
+
function interaction(element, events, watch) {
|
|
78
84
|
return new Promise((resolve, reject) => {
|
|
85
|
+
let settled = false;
|
|
86
|
+
let unwatch = () => {};
|
|
79
87
|
const cleanup = () => {
|
|
80
88
|
for (const name of events)
|
|
81
89
|
element.removeEventListener(name, handler);
|
|
82
|
-
pending.delete(element);
|
|
83
90
|
};
|
|
84
|
-
const
|
|
91
|
+
const finish = (done) => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
settled = true;
|
|
95
|
+
unwatch();
|
|
85
96
|
cleanup();
|
|
86
|
-
|
|
97
|
+
done();
|
|
98
|
+
};
|
|
99
|
+
const handler = () => {
|
|
100
|
+
finish(resolve);
|
|
87
101
|
};
|
|
88
102
|
for (const name of events)
|
|
89
103
|
element.addEventListener(name, handler);
|
|
90
|
-
|
|
91
|
-
cleanup();
|
|
92
|
-
reject();
|
|
93
|
-
});
|
|
104
|
+
unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError)));
|
|
94
105
|
});
|
|
95
106
|
}
|
|
96
107
|
function defer(ms) {
|
|
@@ -104,10 +115,103 @@ function idle(timeout) {
|
|
|
104
115
|
setTimeout(resolve, timeout);
|
|
105
116
|
});
|
|
106
117
|
}
|
|
107
|
-
var
|
|
118
|
+
var SILENT_LOGGER = {
|
|
119
|
+
note() {},
|
|
120
|
+
flush() {}
|
|
121
|
+
};
|
|
122
|
+
function createIslandLogger(tagName, debug) {
|
|
123
|
+
if (!debug)
|
|
124
|
+
return SILENT_LOGGER;
|
|
125
|
+
const msgs = [];
|
|
126
|
+
return {
|
|
127
|
+
note(msg) {
|
|
128
|
+
msgs.push(msg);
|
|
129
|
+
},
|
|
130
|
+
flush(summary) {
|
|
131
|
+
if (msgs.length === 0) {
|
|
132
|
+
console.log("[islands]", `<${tagName}> ${summary}`);
|
|
133
|
+
} else {
|
|
134
|
+
console.groupCollapsed(`[islands] <${tagName}> ${summary}`);
|
|
135
|
+
for (const m of msgs)
|
|
136
|
+
console.log(m);
|
|
137
|
+
console.groupEnd();
|
|
138
|
+
}
|
|
139
|
+
msgs.length = 0;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class DirectiveCancelledError extends Error {
|
|
145
|
+
constructor() {
|
|
146
|
+
super("[islands] directive cancelled: element removed from DOM");
|
|
147
|
+
this.name = "DirectiveCancelledError";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
108
150
|
function isRevivePayload(v) {
|
|
109
151
|
return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v);
|
|
110
152
|
}
|
|
153
|
+
function createIslandRegistry(opts) {
|
|
154
|
+
const queued = new Set;
|
|
155
|
+
const loaded = new Set;
|
|
156
|
+
const retryCount = new Map;
|
|
157
|
+
const cancellableElements = new Map;
|
|
158
|
+
let initialWalkComplete = false;
|
|
159
|
+
return {
|
|
160
|
+
queue(tag) {
|
|
161
|
+
if (queued.has(tag) || loaded.has(tag))
|
|
162
|
+
return false;
|
|
163
|
+
queued.add(tag);
|
|
164
|
+
return true;
|
|
165
|
+
},
|
|
166
|
+
settleSuccess(tag) {
|
|
167
|
+
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
168
|
+
queued.delete(tag);
|
|
169
|
+
loaded.add(tag);
|
|
170
|
+
retryCount.delete(tag);
|
|
171
|
+
return attempt;
|
|
172
|
+
},
|
|
173
|
+
settleFailure(tag) {
|
|
174
|
+
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
175
|
+
if (attempt <= opts.retries) {
|
|
176
|
+
retryCount.set(tag, attempt);
|
|
177
|
+
return { retryDelayMs: opts.retryDelay * 2 ** (attempt - 1), attempt };
|
|
178
|
+
} else {
|
|
179
|
+
retryCount.delete(tag);
|
|
180
|
+
queued.delete(tag);
|
|
181
|
+
return { retryDelayMs: null, attempt };
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
evict(tag) {
|
|
185
|
+
retryCount.delete(tag);
|
|
186
|
+
queued.delete(tag);
|
|
187
|
+
},
|
|
188
|
+
isQueued(tag) {
|
|
189
|
+
return queued.has(tag);
|
|
190
|
+
},
|
|
191
|
+
get initialWalkComplete() {
|
|
192
|
+
return initialWalkComplete;
|
|
193
|
+
},
|
|
194
|
+
markInitialWalkComplete() {
|
|
195
|
+
initialWalkComplete = true;
|
|
196
|
+
},
|
|
197
|
+
watchCancellable(el, cancel) {
|
|
198
|
+
cancellableElements.set(el, cancel);
|
|
199
|
+
return () => {
|
|
200
|
+
cancellableElements.delete(el);
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
cancelDetached() {
|
|
204
|
+
if (cancellableElements.size === 0)
|
|
205
|
+
return;
|
|
206
|
+
for (const [el, cancel] of cancellableElements) {
|
|
207
|
+
if (!el.isConnected) {
|
|
208
|
+
cancellableElements.delete(el);
|
|
209
|
+
cancel();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
111
215
|
function revive(islandsOrPayload, options, customDirectives) {
|
|
112
216
|
const payload = isRevivePayload(islandsOrPayload) ? islandsOrPayload : { islands: islandsOrPayload, options, customDirectives };
|
|
113
217
|
const opts = normalizeReviveOptions(payload.options);
|
|
@@ -124,27 +228,125 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
124
228
|
const idleTimeout = opts.directives.idle.timeout;
|
|
125
229
|
const deferDelay = opts.directives.defer.delay;
|
|
126
230
|
const debug = opts.debug;
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const pendingCancellable = new Map;
|
|
133
|
-
const retryCount = new Map;
|
|
134
|
-
const isUnloadedIsland = (tag) => queued.has(tag) && !loaded.has(tag);
|
|
231
|
+
const directiveTimeout = opts.directiveTimeout;
|
|
232
|
+
const registry = createIslandRegistry({
|
|
233
|
+
retries: opts.retry.retries,
|
|
234
|
+
retryDelay: opts.retry.delay
|
|
235
|
+
});
|
|
135
236
|
const customElementFilter = {
|
|
136
237
|
acceptNode: (node) => {
|
|
137
238
|
const tag = node.tagName;
|
|
138
239
|
if (!tag.includes("-"))
|
|
139
240
|
return NodeFilter.FILTER_SKIP;
|
|
140
241
|
const lowerTag = tag.toLowerCase();
|
|
141
|
-
if (
|
|
242
|
+
if (registry.isQueued(lowerTag))
|
|
142
243
|
return NodeFilter.FILTER_REJECT;
|
|
143
244
|
return NodeFilter.FILTER_ACCEPT;
|
|
144
245
|
}
|
|
145
246
|
};
|
|
247
|
+
function makeDirectiveOutcomeHandler(tagName) {
|
|
248
|
+
return (outcome) => {
|
|
249
|
+
if (outcome.kind === "builtin-catch" && outcome.err instanceof DirectiveCancelledError) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const err = outcome.err;
|
|
253
|
+
if (outcome.kind === "directive-error") {
|
|
254
|
+
console.error(`[islands] Custom directive ${outcome.attrName} failed for <${tagName}>:`, err);
|
|
255
|
+
} else {
|
|
256
|
+
console.error(`[islands] Built-in directive failed for <${tagName}>:`, err);
|
|
257
|
+
}
|
|
258
|
+
dispatch("islands:error", { tag: tagName, error: err, attempt: 1 });
|
|
259
|
+
registry.evict(tagName);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async function applyBuiltInDirectives(tagName, el, log) {
|
|
263
|
+
const visibleAttr = el.getAttribute(attrVisible);
|
|
264
|
+
if (visibleAttr !== null) {
|
|
265
|
+
log.note(`waiting for ${attrVisible}`);
|
|
266
|
+
await visible(el, visibleAttr || rootMargin, threshold, registry.watchCancellable);
|
|
267
|
+
}
|
|
268
|
+
const query = el.getAttribute(attrMedia);
|
|
269
|
+
if (query === "") {
|
|
270
|
+
console.warn(`[islands] <${tagName}> ${attrMedia} has no value — media check skipped, island will load immediately`);
|
|
271
|
+
} else if (query) {
|
|
272
|
+
log.note(`waiting for ${attrMedia}="${query}"`);
|
|
273
|
+
await media(query);
|
|
274
|
+
}
|
|
275
|
+
const idleAttr = el.getAttribute(attrIdle);
|
|
276
|
+
if (idleAttr !== null) {
|
|
277
|
+
const raw = parseInt(idleAttr, 10);
|
|
278
|
+
const elTimeout = Number.isNaN(raw) ? idleTimeout : raw;
|
|
279
|
+
log.note(`waiting for ${attrIdle} (${elTimeout}ms)`);
|
|
280
|
+
await idle(elTimeout);
|
|
281
|
+
}
|
|
282
|
+
const d = el.getAttribute(attrDefer);
|
|
283
|
+
if (d !== null) {
|
|
284
|
+
const dMs = parseInt(d, 10);
|
|
285
|
+
if (d !== "" && Number.isNaN(dMs)) {
|
|
286
|
+
console.warn(`[islands] <${tagName}> invalid ${attrDefer} value "${d}" — using default ${deferDelay}ms`);
|
|
287
|
+
}
|
|
288
|
+
const ms = Number.isNaN(dMs) ? deferDelay : dMs;
|
|
289
|
+
log.note(`waiting for ${attrDefer} (${ms}ms)`);
|
|
290
|
+
await defer(ms);
|
|
291
|
+
}
|
|
292
|
+
const interactionAttr = el.getAttribute(attrInteraction);
|
|
293
|
+
if (interactionAttr !== null) {
|
|
294
|
+
let events = interactionEvents;
|
|
295
|
+
if (interactionAttr) {
|
|
296
|
+
const tokens = interactionAttr.split(/\s+/).filter(Boolean);
|
|
297
|
+
if (tokens.length > 0)
|
|
298
|
+
events = tokens;
|
|
299
|
+
else
|
|
300
|
+
console.warn(`[islands] <${tagName}> ${attrInteraction} has no valid event tokens — using default events`);
|
|
301
|
+
}
|
|
302
|
+
log.note(`waiting for ${attrInteraction} (${events.join(", ")})`);
|
|
303
|
+
await interaction(el, events, registry.watchCancellable);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function applyCustomDirectives(tagName, el, matched, run, handleDirectiveError, log) {
|
|
307
|
+
if (matched.length === 0)
|
|
308
|
+
return false;
|
|
309
|
+
const attrNames = matched.map(([a]) => a).join(", ");
|
|
310
|
+
log.flush(`dispatching to custom directive${matched.length === 1 ? "" : "s"} ${attrNames}`);
|
|
311
|
+
let remaining = matched.length;
|
|
312
|
+
let fired = false;
|
|
313
|
+
let aborted = false;
|
|
314
|
+
const loadOnce = () => {
|
|
315
|
+
if (fired || aborted)
|
|
316
|
+
return Promise.resolve();
|
|
317
|
+
if (--remaining === 0) {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
fired = true;
|
|
320
|
+
return run();
|
|
321
|
+
}
|
|
322
|
+
return Promise.resolve();
|
|
323
|
+
};
|
|
324
|
+
let timer;
|
|
325
|
+
if (directiveTimeout > 0) {
|
|
326
|
+
timer = setTimeout(() => {
|
|
327
|
+
if (fired || aborted)
|
|
328
|
+
return;
|
|
329
|
+
aborted = true;
|
|
330
|
+
handleDirectiveError(attrNames, new Error(`[islands] Custom directive timed out after ${directiveTimeout}ms for <${tagName}>`));
|
|
331
|
+
}, directiveTimeout);
|
|
332
|
+
}
|
|
333
|
+
for (const [attrName, directiveFn, value] of matched) {
|
|
334
|
+
try {
|
|
335
|
+
Promise.resolve(directiveFn(loadOnce, { name: attrName, value }, el)).catch((err) => {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
aborted = true;
|
|
338
|
+
handleDirectiveError(attrName, err);
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
aborted = true;
|
|
343
|
+
handleDirectiveError(attrName, err);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
146
348
|
async function loadIsland(tagName, el, loader) {
|
|
147
|
-
if (debug && !
|
|
349
|
+
if (debug && !registry.initialWalkComplete) {
|
|
148
350
|
const parts = [];
|
|
149
351
|
const pushAttr = (attr, val) => {
|
|
150
352
|
if (val !== null)
|
|
@@ -166,63 +368,13 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
166
368
|
if (parts.length > 0)
|
|
167
369
|
console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`);
|
|
168
370
|
}
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const flush = msgs ? (final) => {
|
|
172
|
-
if (msgs.length === 0) {
|
|
173
|
-
console.log("[islands]", `<${tagName}> ${final}`);
|
|
174
|
-
} else {
|
|
175
|
-
console.groupCollapsed(`[islands] <${tagName}> ${final}`);
|
|
176
|
-
for (const m of msgs)
|
|
177
|
-
console.log(m);
|
|
178
|
-
console.groupEnd();
|
|
179
|
-
}
|
|
180
|
-
} : noop;
|
|
371
|
+
const log = createIslandLogger(tagName, debug);
|
|
372
|
+
const handleOutcome = makeDirectiveOutcomeHandler(tagName);
|
|
181
373
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
const query = el.getAttribute(attrMedia);
|
|
188
|
-
if (query === "") {
|
|
189
|
-
console.warn(`[islands] <${tagName}> ${attrMedia} has no value — media check skipped, island will load immediately`);
|
|
190
|
-
} else if (query) {
|
|
191
|
-
note(`waiting for ${attrMedia}="${query}"`);
|
|
192
|
-
await media(query);
|
|
193
|
-
}
|
|
194
|
-
const idleAttr = el.getAttribute(attrIdle);
|
|
195
|
-
if (idleAttr !== null) {
|
|
196
|
-
const raw = parseInt(idleAttr, 10);
|
|
197
|
-
const elTimeout = Number.isNaN(raw) ? idleTimeout : raw;
|
|
198
|
-
note(`waiting for ${attrIdle} (${elTimeout}ms)`);
|
|
199
|
-
await idle(elTimeout);
|
|
200
|
-
}
|
|
201
|
-
const d = el.getAttribute(attrDefer);
|
|
202
|
-
if (d !== null) {
|
|
203
|
-
const dMs = parseInt(d, 10);
|
|
204
|
-
if (d !== "" && Number.isNaN(dMs)) {
|
|
205
|
-
console.warn(`[islands] <${tagName}> invalid ${attrDefer} value "${d}" — using default ${deferDelay}ms`);
|
|
206
|
-
}
|
|
207
|
-
const ms = Number.isNaN(dMs) ? deferDelay : dMs;
|
|
208
|
-
note(`waiting for ${attrDefer} (${ms}ms)`);
|
|
209
|
-
await defer(ms);
|
|
210
|
-
}
|
|
211
|
-
const interactionAttr = el.getAttribute(attrInteraction);
|
|
212
|
-
if (interactionAttr !== null) {
|
|
213
|
-
let events = interactionEvents;
|
|
214
|
-
if (interactionAttr) {
|
|
215
|
-
const tokens = interactionAttr.split(/\s+/).filter(Boolean);
|
|
216
|
-
if (tokens.length > 0)
|
|
217
|
-
events = tokens;
|
|
218
|
-
else
|
|
219
|
-
console.warn(`[islands] <${tagName}> ${attrInteraction} has no valid event tokens — using default events`);
|
|
220
|
-
}
|
|
221
|
-
note(`waiting for ${attrInteraction} (${events.join(", ")})`);
|
|
222
|
-
await interaction(el, events, pendingCancellable);
|
|
223
|
-
}
|
|
224
|
-
} catch {
|
|
225
|
-
flush("aborted (element removed)");
|
|
374
|
+
await applyBuiltInDirectives(tagName, el, log);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
handleOutcome({ kind: "builtin-catch", err });
|
|
377
|
+
log.flush(err instanceof DirectiveCancelledError ? "aborted (element removed)" : "aborted (directive error)");
|
|
226
378
|
return;
|
|
227
379
|
}
|
|
228
380
|
const run = () => {
|
|
@@ -230,9 +382,7 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
230
382
|
return Promise.resolve();
|
|
231
383
|
const t0 = performance.now();
|
|
232
384
|
return loader().then(() => {
|
|
233
|
-
const attempt =
|
|
234
|
-
loaded.add(tagName);
|
|
235
|
-
retryCount.delete(tagName);
|
|
385
|
+
const attempt = registry.settleSuccess(tagName);
|
|
236
386
|
dispatch("islands:load", {
|
|
237
387
|
tag: tagName,
|
|
238
388
|
duration: performance.now() - t0,
|
|
@@ -242,23 +392,14 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
242
392
|
walk(el);
|
|
243
393
|
}).catch((err) => {
|
|
244
394
|
console.error(`[islands] Failed to load <${tagName}>:`, err);
|
|
245
|
-
const attempt =
|
|
246
|
-
dispatch("islands:error", { tag: tagName, error: err, attempt
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
setTimeout(run, retryDelay * 2 ** attempt);
|
|
250
|
-
} else {
|
|
251
|
-
retryCount.delete(tagName);
|
|
252
|
-
queued.delete(tagName);
|
|
395
|
+
const { retryDelayMs, attempt } = registry.settleFailure(tagName);
|
|
396
|
+
dispatch("islands:error", { tag: tagName, error: err, attempt });
|
|
397
|
+
if (retryDelayMs !== null) {
|
|
398
|
+
setTimeout(run, retryDelayMs);
|
|
253
399
|
}
|
|
254
400
|
});
|
|
255
401
|
};
|
|
256
|
-
const handleDirectiveError = (attrName, err) => {
|
|
257
|
-
console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, err);
|
|
258
|
-
dispatch("islands:error", { tag: tagName, error: err, attempt: 1 });
|
|
259
|
-
retryCount.delete(tagName);
|
|
260
|
-
queued.delete(tagName);
|
|
261
|
-
};
|
|
402
|
+
const handleDirectiveError = (attrName, err) => handleOutcome({ kind: "directive-error", attrName, err });
|
|
262
403
|
if (resolvedDirectives?.size) {
|
|
263
404
|
const matched = [];
|
|
264
405
|
for (const [attrName, directiveFn] of resolvedDirectives) {
|
|
@@ -266,51 +407,25 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
266
407
|
if (value !== null)
|
|
267
408
|
matched.push([attrName, directiveFn, value]);
|
|
268
409
|
}
|
|
269
|
-
if (matched
|
|
270
|
-
flush(`dispatching to custom directive${matched.length === 1 ? "" : "s"} ${matched.map(([a]) => a).join(", ")}`);
|
|
271
|
-
let remaining = matched.length;
|
|
272
|
-
let fired = false;
|
|
273
|
-
let aborted = false;
|
|
274
|
-
const loadOnce = () => {
|
|
275
|
-
if (fired || aborted)
|
|
276
|
-
return Promise.resolve();
|
|
277
|
-
if (--remaining === 0) {
|
|
278
|
-
fired = true;
|
|
279
|
-
return run();
|
|
280
|
-
}
|
|
281
|
-
return Promise.resolve();
|
|
282
|
-
};
|
|
283
|
-
for (const [attrName, directiveFn, value] of matched) {
|
|
284
|
-
try {
|
|
285
|
-
Promise.resolve(directiveFn(loadOnce, { name: attrName, value }, el)).catch((err) => {
|
|
286
|
-
aborted = true;
|
|
287
|
-
handleDirectiveError(attrName, err);
|
|
288
|
-
});
|
|
289
|
-
} catch (err) {
|
|
290
|
-
aborted = true;
|
|
291
|
-
handleDirectiveError(attrName, err);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
410
|
+
if (applyCustomDirectives(tagName, el, matched, run, handleDirectiveError, log))
|
|
294
411
|
return;
|
|
295
|
-
}
|
|
296
412
|
}
|
|
297
|
-
flush("triggered");
|
|
413
|
+
log.flush("triggered");
|
|
298
414
|
run();
|
|
299
415
|
}
|
|
300
416
|
function activate(el) {
|
|
301
417
|
const tagName = el.tagName.toLowerCase();
|
|
302
|
-
if (queued.has(tagName))
|
|
303
|
-
return;
|
|
304
418
|
const loader = islandMap.get(tagName);
|
|
305
419
|
if (!loader)
|
|
306
420
|
return;
|
|
307
421
|
let ancestor = el.parentElement;
|
|
308
422
|
while (ancestor) {
|
|
309
|
-
if (
|
|
423
|
+
if (registry.isQueued(ancestor.tagName.toLowerCase()))
|
|
310
424
|
return;
|
|
311
425
|
ancestor = ancestor.parentElement;
|
|
312
426
|
}
|
|
313
|
-
|
|
427
|
+
if (!registry.queue(tagName))
|
|
428
|
+
return;
|
|
314
429
|
loadIsland(tagName, el, loader);
|
|
315
430
|
}
|
|
316
431
|
function walk(el) {
|
|
@@ -320,27 +435,23 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
320
435
|
while (node = walker.nextNode())
|
|
321
436
|
activate(node);
|
|
322
437
|
}
|
|
323
|
-
|
|
324
|
-
if (pendingCancellable.size > 0 && mutations.some((m) => m.removedNodes.length > 0)) {
|
|
325
|
-
for (const [el, cancel] of pendingCancellable) {
|
|
326
|
-
if (!el.isConnected) {
|
|
327
|
-
pendingCancellable.delete(el);
|
|
328
|
-
cancel();
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
438
|
+
function handleAdditions(mutations) {
|
|
332
439
|
for (const { addedNodes } of mutations) {
|
|
333
440
|
for (const node of addedNodes) {
|
|
334
441
|
if (node.nodeType === Node.ELEMENT_NODE)
|
|
335
442
|
walk(node);
|
|
336
443
|
}
|
|
337
444
|
}
|
|
445
|
+
}
|
|
446
|
+
const observer = new MutationObserver((mutations) => {
|
|
447
|
+
registry.cancelDetached();
|
|
448
|
+
handleAdditions(mutations);
|
|
338
449
|
});
|
|
339
450
|
function init() {
|
|
340
451
|
if (debug)
|
|
341
452
|
console.groupCollapsed(`[islands] ready — ${islandMap.size} island(s)`);
|
|
342
453
|
walk(document.body);
|
|
343
|
-
|
|
454
|
+
registry.markInitialWalkComplete();
|
|
344
455
|
if (debug)
|
|
345
456
|
console.groupEnd();
|
|
346
457
|
observer.observe(document.body, { childList: true, subtree: true });
|
package/package.json
CHANGED
|
@@ -4,11 +4,12 @@ description: >
|
|
|
4
4
|
Custom client directives registered via directives.custom in vite.config.ts.
|
|
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
|
-
the island activates. Error handling — thrown errors
|
|
8
|
-
Custom directives run after all
|
|
7
|
+
the island activates. Error handling — thrown errors, rejected promises, and
|
|
8
|
+
directiveTimeout expiry fire islands:error. Custom directives run after all
|
|
9
|
+
built-in conditions resolve.
|
|
9
10
|
type: core
|
|
10
11
|
library: vite-plugin-shopify-theme-islands
|
|
11
|
-
library_version: "1.
|
|
12
|
+
library_version: "1.2.0"
|
|
12
13
|
sources:
|
|
13
14
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
14
15
|
- Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
|
|
@@ -99,6 +100,16 @@ const networkDirective: ClientDirective = async (load, _opts, el) => {
|
|
|
99
100
|
|
|
100
101
|
The directive function can be async. Unhandled rejections fire the document-level `islands:error` event, so `onIslandError()` observers still see directive failures.
|
|
101
102
|
|
|
103
|
+
### Timeout guard for hung directives
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
shopifyThemeIslands({
|
|
107
|
+
directiveTimeout: 5000,
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
If a matched custom directive never calls `load()`, the runtime normally waits forever. Setting `directiveTimeout` turns that hang into an `islands:error` event and abandons the activation attempt after the configured delay.
|
|
112
|
+
|
|
102
113
|
### AND-latch with multiple matching directives
|
|
103
114
|
|
|
104
115
|
```html
|
|
@@ -129,7 +140,7 @@ const myDirective: ClientDirective = (load, _opts, el) => {
|
|
|
129
140
|
};
|
|
130
141
|
```
|
|
131
142
|
|
|
132
|
-
No error is thrown
|
|
143
|
+
No immediate error is thrown by default, so the island is silently never loaded unless you configure `directiveTimeout`.
|
|
133
144
|
|
|
134
145
|
Source: src/runtime.ts — directive owns the `run()` call path
|
|
135
146
|
|
|
@@ -9,7 +9,7 @@ description: >
|
|
|
9
9
|
client:interaction values warn and fall back to default events.
|
|
10
10
|
type: core
|
|
11
11
|
library: vite-plugin-shopify-theme-islands
|
|
12
|
-
library_version: "1.
|
|
12
|
+
library_version: "1.2.0"
|
|
13
13
|
sources:
|
|
14
14
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
|
|
15
15
|
- Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
|
|
@@ -114,6 +114,15 @@ shopifyThemeIslands({
|
|
|
114
114
|
});
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
### Removed elements abort waiting directives silently
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<hero-banner client:visible></hero-banner>
|
|
121
|
+
<cart-flyout client:interaction></cart-flyout>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If either element is removed from the DOM before its directive resolves, the runtime cancels that activation attempt and does not dispatch `islands:error`. This is expected teardown behavior, not a load failure.
|
|
125
|
+
|
|
117
126
|
## Common Mistakes
|
|
118
127
|
|
|
119
128
|
### HIGH `client:media=""` skips the media check entirely
|
|
@@ -214,7 +223,7 @@ Correct:
|
|
|
214
223
|
|
|
215
224
|
The attribute value is passed directly to `IntersectionObserver` as `rootMargin`, fully replacing the global default.
|
|
216
225
|
|
|
217
|
-
Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold,
|
|
226
|
+
Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, registry.watchCancellable)`
|
|
218
227
|
|
|
219
228
|
### HIGH Directive attribute typo — island loads without condition
|
|
220
229
|
|
|
@@ -6,11 +6,12 @@ description: >
|
|
|
6
6
|
raw document.addEventListener for guaranteed type safety. Raw DOM events
|
|
7
7
|
islands:load and islands:error on document. islands:load detail includes tag,
|
|
8
8
|
duration (ms), and attempt (1-based). islands:error detail includes tag,
|
|
9
|
-
error, and attempt
|
|
10
|
-
navigation
|
|
9
|
+
error, and attempt, including custom directive failures and directiveTimeout
|
|
10
|
+
expiry. disconnect() from the virtual module revive for SPA navigation
|
|
11
|
+
teardown.
|
|
11
12
|
type: core
|
|
12
13
|
library: vite-plugin-shopify-theme-islands
|
|
13
|
-
library_version: "1.
|
|
14
|
+
library_version: "1.2.0"
|
|
14
15
|
sources:
|
|
15
16
|
- Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
|
|
16
17
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
@@ -73,7 +74,7 @@ onIslandError(({ tag, error, attempt }) => {
|
|
|
73
74
|
});
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
`onIslandError` fires on each retry attempt
|
|
77
|
+
`onIslandError` fires on each retry attempt, on custom directive failures, and when `directiveTimeout` expires. `attempt` tells you which attempt failed — 1 is the initial load, 2 is the first retry, etc.
|
|
77
78
|
|
|
78
79
|
### Teardown for SPA navigation
|
|
79
80
|
|
|
@@ -182,6 +183,10 @@ onIslandError(({ tag, error }) => {
|
|
|
182
183
|
});
|
|
183
184
|
```
|
|
184
185
|
|
|
185
|
-
`islands:error` fires when any custom directive throws or
|
|
186
|
+
`islands:error` fires when any custom directive throws, rejects, or times out, not only when the island module's `import()` fails. The `error` value may be a directive error rather than a network or chunk error.
|
|
186
187
|
|
|
187
188
|
Source: src/runtime.ts — handleDirectiveError dispatches `islands:error`
|
|
189
|
+
|
|
190
|
+
### LOW Removed elements waiting on `client:visible` / `client:interaction` do not emit `islands:error`
|
|
191
|
+
|
|
192
|
+
If an element is removed from the DOM before a cancellable built-in directive resolves, the runtime treats that as expected teardown and aborts silently. Use `onIslandError` for real failures, not DOM-removal cancellations.
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -4,12 +4,13 @@ description: >
|
|
|
4
4
|
Getting-started journey and plugin configuration. Covers the full path from
|
|
5
5
|
install to first working island. shopifyThemeIslands() options: directories
|
|
6
6
|
(string | string[]), debug, directives deep-merge (visible, idle, media,
|
|
7
|
-
defer, interaction, custom),
|
|
8
|
-
backoff)
|
|
9
|
-
directories, or enabling retry
|
|
7
|
+
defer, interaction, custom), retry (retries, delay with exponential
|
|
8
|
+
backoff), and directiveTimeout for hung custom directives. Load when setting
|
|
9
|
+
up the plugin, configuring island scan directories, or enabling retry /
|
|
10
|
+
directive timeout.
|
|
10
11
|
type: core
|
|
11
12
|
library: vite-plugin-shopify-theme-islands
|
|
12
|
-
library_version: "1.
|
|
13
|
+
library_version: "1.2.0"
|
|
13
14
|
sources:
|
|
14
15
|
- Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
|
|
15
16
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
@@ -91,6 +92,16 @@ shopifyThemeIslands({
|
|
|
91
92
|
|
|
92
93
|
`retries` is the number of attempts after the first failure. `delay` is the base ms — each subsequent retry doubles it (1000ms → 2000ms → 4000ms).
|
|
93
94
|
|
|
95
|
+
### Guard against hung custom directives
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
shopifyThemeIslands({
|
|
99
|
+
directiveTimeout: 5000,
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
When a custom directive never calls `load()`, the runtime normally waits forever. `directiveTimeout` turns that into an `islands:error` event and abandons the activation attempt after the configured number of milliseconds.
|
|
104
|
+
|
|
94
105
|
### Enable console debug output
|
|
95
106
|
|
|
96
107
|
```ts
|
|
@@ -216,3 +227,27 @@ shopifyThemeIslands({ retry: { retries: 3 } });
|
|
|
216
227
|
Unknown keys are silently ignored. The correct field is `retries`.
|
|
217
228
|
|
|
218
229
|
Source: src/contract.ts — RetryConfig
|
|
230
|
+
|
|
231
|
+
### HIGH `directiveTimeout` nested inside `directives` — timeout guard never applies
|
|
232
|
+
|
|
233
|
+
Wrong:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
shopifyThemeIslands({
|
|
237
|
+
directives: {
|
|
238
|
+
directiveTimeout: 5000,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Correct:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
shopifyThemeIslands({
|
|
247
|
+
directiveTimeout: 5000,
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`directiveTimeout` is a top-level plugin option, not part of the per-directive config object.
|
|
252
|
+
|
|
253
|
+
Source: src/index.ts — ShopifyThemeIslandsOptions
|
|
@@ -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.2.0"
|
|
12
12
|
sources:
|
|
13
13
|
- Rees1993/vite-plugin-shopify-theme-islands:src/island.ts
|
|
14
14
|
- Rees1993/vite-plugin-shopify-theme-islands:src/discovery.ts
|