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 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 | Type | Default | Description |
322
- | ------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------- |
323
- | `directories` | `string \| string[]` | `['/frontend/js/islands/']` | Directories to scan for island files. Accepts Vite aliases. |
324
- | `directives` | `object` | see below | Per-directive configuration — attribute names, timing options, and custom entries. |
325
- | `retry` | `object` | — | Automatic retry behaviour for failed island loads. See [Retries](#retries). |
326
- | `debug` | `boolean` | `false` | Log discovered islands at build time and directive events in the browser console. |
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 or custom directive fails (alongside `console.error`) |
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
 
@@ -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: { directives, debug, retry: options.retry }
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, pending) {
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
- io.disconnect();
66
- pending.delete(element);
67
- resolve();
76
+ finish(resolve);
68
77
  }
69
78
  }, { rootMargin, threshold });
70
79
  io.observe(element);
71
- pending.set(element, () => {
72
- io.disconnect();
73
- reject();
74
- });
80
+ unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError)));
75
81
  });
76
82
  }
77
- function interaction(element, events, pending) {
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 handler = () => {
91
+ const finish = (done) => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ unwatch();
85
96
  cleanup();
86
- resolve();
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
- pending.set(element, () => {
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 noop = (..._) => {};
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 retries = opts.retry.retries;
128
- const retryDelay = opts.retry.delay;
129
- const queued = new Set;
130
- let initDone = false;
131
- const loaded = new Set;
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 (isUnloadedIsland(lowerTag))
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 && !initDone) {
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 msgs = debug ? [] : null;
170
- const note = msgs ? (msg) => msgs.push(msg) : noop;
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
- const visibleAttr = el.getAttribute(attrVisible);
183
- if (visibleAttr !== null) {
184
- note(`waiting for ${attrVisible}`);
185
- await visible(el, visibleAttr || rootMargin, threshold, pendingCancellable);
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 = (retryCount.get(tagName) ?? 0) + 1;
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 = retryCount.get(tagName) ?? 0;
246
- dispatch("islands:error", { tag: tagName, error: err, attempt: attempt + 1 });
247
- if (attempt < retries) {
248
- retryCount.set(tagName, attempt + 1);
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.length > 0) {
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 (isUnloadedIsland(ancestor.tagName.toLowerCase()))
423
+ if (registry.isQueued(ancestor.tagName.toLowerCase()))
310
424
  return;
311
425
  ancestor = ancestor.parentElement;
312
426
  }
313
- queued.add(tagName);
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
- const observer = new MutationObserver((mutations) => {
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
- initDone = true;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -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 fire islands:error.
8
- Custom directives run after all built-in conditions resolve.
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.1.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 and no timeout fires — the island is silently never loaded.
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.1.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, pendingCancellable)`
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. disconnect() from the virtual module revive for SPA
10
- navigation teardown.
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.1.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 and on custom directive failures. `attempt` tells you which attempt failed — 1 is the initial load, 2 is the first retry, etc.
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 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.
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.
@@ -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), and retry (retries, delay with exponential
8
- backoff). Load when setting up the plugin, configuring island scan
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.1.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.1.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