vite-plugin-shopify-theme-islands 1.1.1 → 1.2.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 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
 
@@ -0,0 +1,11 @@
1
+ import { type ReviveOptions } from "./contract.js";
2
+ import type { ClientDirectiveDefinition, DirectivesConfig, ShopifyThemeIslandsOptions } from "./options.js";
3
+ export interface ResolvedThemeIslandsPolicy {
4
+ plugin: {
5
+ directives: DirectivesConfig;
6
+ customDirectives: ClientDirectiveDefinition[];
7
+ debug: boolean;
8
+ };
9
+ runtime: ReviveOptions;
10
+ }
11
+ export declare function resolveThemeIslandsPolicy(options?: ShopifyThemeIslandsOptions): ResolvedThemeIslandsPolicy;
@@ -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
@@ -1,65 +1,8 @@
1
+ import type { ShopifyThemeIslandsOptions } from "./options.js";
1
2
  import type { Plugin } from "vite";
2
3
  /** A function that triggers the load of an island module. */
3
4
  export type ClientDirectiveLoader = () => Promise<void>;
4
5
  export type { ClientDirective, ClientDirectiveOptions } from "./contract.js";
5
- /** Plugin option entry for registering a custom client directive. */
6
- export interface ClientDirectiveDefinition {
7
- /** HTML attribute name, e.g. `'client:on-click'` */
8
- name: string;
9
- /** Path to the directive module (supports Vite aliases) */
10
- entrypoint: string;
11
- }
12
- /** Shared directive configuration shape used by both the plugin and the runtime. */
13
- export interface DirectivesConfig {
14
- /** Configuration for the `client:visible` directive (IntersectionObserver). */
15
- visible?: {
16
- /** HTML attribute name. Default: `'client:visible'` */
17
- attribute?: string;
18
- /** Passed to IntersectionObserver — loads islands before they scroll into view. Default: `'200px'` */
19
- rootMargin?: string;
20
- /** Passed to IntersectionObserver — ratio of element that must be visible. Default: `0` */
21
- threshold?: number;
22
- };
23
- /** Configuration for the `client:idle` directive (requestIdleCallback). */
24
- idle?: {
25
- /** HTML attribute name. Default: `'client:idle'` */
26
- attribute?: string;
27
- /** Deadline (ms) passed to requestIdleCallback; also used as the setTimeout fallback delay. Default: `500` */
28
- timeout?: number;
29
- };
30
- /** Configuration for the `client:media` directive (matchMedia). */
31
- media?: {
32
- /** HTML attribute name. Default: `'client:media'` */
33
- attribute?: string;
34
- };
35
- /** Configuration for the `client:defer` directive (fixed setTimeout delay). */
36
- defer?: {
37
- /** HTML attribute name. Default: `'client:defer'` */
38
- attribute?: string;
39
- /** Fallback delay (ms) when the attribute has no value. Default: `3000` */
40
- delay?: number;
41
- };
42
- /** Configuration for the `client:interaction` directive (mouseenter/touchstart/focusin). */
43
- interaction?: {
44
- /** HTML attribute name. Default: `'client:interaction'` */
45
- attribute?: string;
46
- /** DOM event names to listen for. Default: `['mouseenter', 'touchstart', 'focusin']` */
47
- events?: string[];
48
- };
49
- /** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
50
- custom?: ClientDirectiveDefinition[];
51
- }
52
- /** Event detail and runtime options (single source of truth in contract). */
53
- import type { RetryConfig } from "./contract.js";
6
+ export type { ClientDirectiveDefinition, DirectivesConfig, ShopifyThemeIslandsOptions, } from "./options.js";
54
7
  export type { IslandLoadDetail, IslandErrorDetail, ReviveOptions, RetryConfig, RuntimeDirectivesConfig, } from "./contract.js";
55
- export interface ShopifyThemeIslandsOptions {
56
- /** Directories to scan for island files. Accepts paths or Vite aliases. Default: `['/frontend/js/islands/']` */
57
- directories?: string | string[];
58
- /** Log discovered islands and generated virtual module. Default: `false` */
59
- debug?: boolean;
60
- /** Per-directive configuration. */
61
- directives?: DirectivesConfig;
62
- /** Automatic retry behaviour for failed island loads. */
63
- retry?: RetryConfig;
64
- }
65
8
  export default function shopifyThemeIslands(options?: ShopifyThemeIslandsOptions): Plugin;
package/dist/index.js CHANGED
@@ -54,39 +54,6 @@ function collectTagNames(dir) {
54
54
  return names;
55
55
  }
56
56
 
57
- // src/revive-module.ts
58
- function buildReviveModuleSource(params) {
59
- const { runtimePath, directoryGlobs, islandPaths, customDirectives, reviveOptions } = params;
60
- const directiveImportLines = customDirectives?.map(({ entrypoint }, index) => `import _directive${index} from ${JSON.stringify(entrypoint)};`) ?? [];
61
- const globEntries = [
62
- `{ ${directoryGlobs.map((glob) => `...import.meta.glob(${JSON.stringify(glob)})`).join(", ")} }`
63
- ];
64
- if (islandPaths?.length)
65
- globEntries.push(`import.meta.glob(${JSON.stringify(islandPaths)})`);
66
- const lines = [
67
- ...directiveImportLines,
68
- `import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
69
- `const islands = Object.assign({}, ${globEntries.join(", ")});`,
70
- `const options = ${JSON.stringify(reviveOptions)};`
71
- ];
72
- if (customDirectives?.length) {
73
- const customDirectivesMapLines = customDirectives.map(({ name }, index) => ` [${JSON.stringify(name)}, _directive${index}]`);
74
- lines.push(`const customDirectives = new Map([
75
- ${customDirectivesMapLines.join(`,
76
- `)}
77
- ]);`);
78
- lines.push(`const payload = { islands, options, customDirectives };`);
79
- } else {
80
- lines.push(`const payload = { islands, options };`);
81
- }
82
- lines.push(`export const { disconnect } = _islands(payload);`);
83
- return lines.join(`
84
- `);
85
- }
86
-
87
- // src/index.ts
88
- import { fileURLToPath } from "node:url";
89
-
90
57
  // src/contract.ts
91
58
  var DEFAULT_DIRECTIVES = {
92
59
  visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
@@ -112,7 +79,8 @@ function normalizeReviveOptions(options) {
112
79
  interaction: { ...d.interaction, ...dir?.interaction }
113
80
  },
114
81
  debug: options?.debug ?? false,
115
- retry: { ...r, ...options?.retry }
82
+ retry: { ...r, ...options?.retry },
83
+ directiveTimeout: options?.directiveTimeout ?? 0
116
84
  };
117
85
  }
118
86
  var basename = (key) => key.split("/").pop() ?? key;
@@ -136,13 +104,17 @@ function buildIslandMap(payload) {
136
104
  return map;
137
105
  }
138
106
 
139
- // src/index.ts
140
- var VIRTUAL_ID = "vite-plugin-shopify-theme-islands/revive";
141
- var RESOLVED_ID = "\x00" + VIRTUAL_ID;
142
- var ISLAND_ID = "vite-plugin-shopify-theme-islands/island";
143
- var runtimePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
144
- var islandPath = fileURLToPath(new URL("./island.js", import.meta.url));
107
+ // src/config-policy.ts
145
108
  var PREFIX = "[vite-plugin-shopify-theme-islands]";
109
+ function mergeDirectives(directives) {
110
+ return {
111
+ visible: { ...DEFAULT_DIRECTIVES.visible, ...directives?.visible },
112
+ idle: { ...DEFAULT_DIRECTIVES.idle, ...directives?.idle },
113
+ media: { ...DEFAULT_DIRECTIVES.media, ...directives?.media },
114
+ defer: { ...DEFAULT_DIRECTIVES.defer, ...directives?.defer },
115
+ interaction: { ...DEFAULT_DIRECTIVES.interaction, ...directives?.interaction }
116
+ };
117
+ }
146
118
  function validateOptions(options, directives) {
147
119
  const customDefs = options.directives?.custom ?? [];
148
120
  if (Array.isArray(options.directories) && options.directories.length === 0) {
@@ -179,6 +151,93 @@ function validateOptions(options, directives) {
179
151
  seen.add(def.name);
180
152
  }
181
153
  }
154
+ function resolveThemeIslandsPolicy(options = {}) {
155
+ const directives = mergeDirectives(options.directives);
156
+ validateOptions(options, directives);
157
+ const customDirectives = options.directives?.custom ?? [];
158
+ const debug = options.debug ?? false;
159
+ const runtime = {
160
+ directives,
161
+ debug,
162
+ ...options.retry !== undefined ? { retry: options.retry } : {},
163
+ ...options.directiveTimeout !== undefined ? { directiveTimeout: options.directiveTimeout } : {}
164
+ };
165
+ return {
166
+ plugin: {
167
+ directives,
168
+ customDirectives,
169
+ debug
170
+ },
171
+ runtime
172
+ };
173
+ }
174
+
175
+ // src/revive-module.ts
176
+ function buildReviveModuleSource(params) {
177
+ const { runtimePath, directoryGlobs, islandPaths, customDirectives, reviveOptions } = params;
178
+ const directiveImportLines = customDirectives?.map(({ entrypoint }, index) => `import _directive${index} from ${JSON.stringify(entrypoint)};`) ?? [];
179
+ const globEntries = [
180
+ `{ ${directoryGlobs.map((glob) => `...import.meta.glob(${JSON.stringify(glob)})`).join(", ")} }`
181
+ ];
182
+ if (islandPaths?.length)
183
+ globEntries.push(`import.meta.glob(${JSON.stringify(islandPaths)})`);
184
+ const lines = [
185
+ ...directiveImportLines,
186
+ `import { revive as _islands } from ${JSON.stringify(runtimePath)};`,
187
+ `const islands = Object.assign({}, ${globEntries.join(", ")});`,
188
+ `const options = ${JSON.stringify(reviveOptions)};`
189
+ ];
190
+ if (customDirectives?.length) {
191
+ const customDirectivesMapLines = customDirectives.map(({ name }, index) => ` [${JSON.stringify(name)}, _directive${index}]`);
192
+ lines.push(`const customDirectives = new Map([
193
+ ${customDirectivesMapLines.join(`,
194
+ `)}
195
+ ]);`);
196
+ lines.push(`const payload = { islands, options, customDirectives };`);
197
+ } else {
198
+ lines.push(`const payload = { islands, options };`);
199
+ }
200
+ lines.push(`export const { disconnect } = _islands(payload);`);
201
+ return lines.join(`
202
+ `);
203
+ }
204
+
205
+ // src/revive-bootstrap.ts
206
+ function createReviveBootstrapCompiler(ports, runtimePath) {
207
+ return {
208
+ async plan(input) {
209
+ const islandPaths = input.islandFiles.size > 0 ? ports.toLoadPaths(input.islandFiles, input.root) : null;
210
+ const customDirectives = input.customDirectives?.length ? await Promise.all(input.customDirectives.map(async ({ name, entrypoint }) => ({
211
+ name,
212
+ entrypoint: await ports.resolveEntrypoint(entrypoint)
213
+ }))) : null;
214
+ return {
215
+ runtimePath,
216
+ directoryGlobs: input.directories.map((dir) => dir + "**/*.{ts,js}"),
217
+ islandPaths,
218
+ customDirectives,
219
+ reviveOptions: input.reviveOptions
220
+ };
221
+ },
222
+ emit(plan) {
223
+ return buildReviveModuleSource({
224
+ runtimePath: plan.runtimePath,
225
+ directoryGlobs: plan.directoryGlobs,
226
+ islandPaths: plan.islandPaths,
227
+ customDirectives: plan.customDirectives?.length ? plan.customDirectives : undefined,
228
+ reviveOptions: plan.reviveOptions
229
+ });
230
+ }
231
+ };
232
+ }
233
+
234
+ // src/index.ts
235
+ import { fileURLToPath } from "node:url";
236
+ var VIRTUAL_ID = "vite-plugin-shopify-theme-islands/revive";
237
+ var RESOLVED_ID = "\x00" + VIRTUAL_ID;
238
+ var ISLAND_ID = "vite-plugin-shopify-theme-islands/island";
239
+ var runtimePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
240
+ var islandPath = fileURLToPath(new URL("./island.js", import.meta.url));
182
241
  var defaultDirectories = ["/frontend/js/islands/"];
183
242
  function normalizeDir(dir) {
184
243
  return dir.endsWith("/") ? dir : dir + "/";
@@ -197,16 +256,9 @@ function resolveAliases(dirs, config) {
197
256
  }
198
257
  function shopifyThemeIslands(options = {}) {
199
258
  const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaultDirectories[0]]).map(normalizeDir);
200
- const directives = {
201
- visible: { ...DEFAULT_DIRECTIVES.visible, ...options.directives?.visible },
202
- idle: { ...DEFAULT_DIRECTIVES.idle, ...options.directives?.idle },
203
- media: { ...DEFAULT_DIRECTIVES.media, ...options.directives?.media },
204
- defer: { ...DEFAULT_DIRECTIVES.defer, ...options.directives?.defer },
205
- interaction: { ...DEFAULT_DIRECTIVES.interaction, ...options.directives?.interaction }
206
- };
207
- const clientDirectiveDefinitions = options.directives?.custom ?? [];
208
- validateOptions(options, directives);
209
- const debug = options.debug ?? false;
259
+ const policy = resolveThemeIslandsPolicy(options);
260
+ const { directives, customDirectives: clientDirectiveDefinitions, debug } = policy.plugin;
261
+ const { runtime: reviveOptions } = policy;
210
262
  const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
211
263
  let resolvedDirs = rawDirs;
212
264
  let root = process.cwd();
@@ -283,23 +335,24 @@ function shopifyThemeIslands(options = {}) {
283
335
  async load(id) {
284
336
  if (id !== RESOLVED_ID)
285
337
  return;
286
- const directoryGlobs = resolvedDirs.map((dir) => dir + "**/*.{ts,js}");
287
- const islandPaths = islandFiles.size > 0 ? getIslandPathsForLoad(islandFiles, root) : null;
288
- const customDirectives = [];
289
- for (const def of clientDirectiveDefinitions) {
290
- const resolved = await this.resolve(def.entrypoint);
291
- if (!resolved) {
292
- throw new Error(`[vite-plugin-shopify-theme-islands] Cannot resolve custom directive entrypoint: "${def.entrypoint}"`);
293
- }
294
- customDirectives.push({ name: def.name, entrypoint: resolved.id });
295
- }
296
- return buildReviveModuleSource({
297
- runtimePath,
298
- directoryGlobs,
299
- islandPaths,
300
- customDirectives,
301
- reviveOptions: { directives, debug, retry: options.retry }
338
+ const compiler = createReviveBootstrapCompiler({
339
+ resolveEntrypoint: async (entrypoint) => {
340
+ const resolved = await this.resolve(entrypoint);
341
+ if (!resolved) {
342
+ throw new Error(`[vite-plugin-shopify-theme-islands] Cannot resolve custom directive entrypoint: "${entrypoint}"`);
343
+ }
344
+ return resolved.id;
345
+ },
346
+ toLoadPaths: getIslandPathsForLoad
347
+ }, runtimePath);
348
+ const plan = await compiler.plan({
349
+ root,
350
+ directories: resolvedDirs,
351
+ islandFiles,
352
+ customDirectives: clientDirectiveDefinitions,
353
+ reviveOptions
302
354
  });
355
+ return compiler.emit(plan);
303
356
  }
304
357
  };
305
358
  }
@@ -0,0 +1,64 @@
1
+ import type { RetryConfig } from "./contract.js";
2
+ /** Plugin option entry for registering a custom client directive. */
3
+ export interface ClientDirectiveDefinition {
4
+ /** HTML attribute name, e.g. `'client:on-click'` */
5
+ name: string;
6
+ /** Path to the directive module (supports Vite aliases) */
7
+ entrypoint: string;
8
+ }
9
+ /** Shared directive configuration shape used by both the plugin and the runtime. */
10
+ export interface DirectivesConfig {
11
+ /** Configuration for the `client:visible` directive (IntersectionObserver). */
12
+ visible?: {
13
+ /** HTML attribute name. Default: `'client:visible'` */
14
+ attribute?: string;
15
+ /** Passed to IntersectionObserver — loads islands before they scroll into view. Default: `'200px'` */
16
+ rootMargin?: string;
17
+ /** Passed to IntersectionObserver — ratio of element that must be visible. Default: `0` */
18
+ threshold?: number;
19
+ };
20
+ /** Configuration for the `client:idle` directive (requestIdleCallback). */
21
+ idle?: {
22
+ /** HTML attribute name. Default: `'client:idle'` */
23
+ attribute?: string;
24
+ /** Deadline (ms) passed to requestIdleCallback; also used as the setTimeout fallback delay. Default: `500` */
25
+ timeout?: number;
26
+ };
27
+ /** Configuration for the `client:media` directive (matchMedia). */
28
+ media?: {
29
+ /** HTML attribute name. Default: `'client:media'` */
30
+ attribute?: string;
31
+ };
32
+ /** Configuration for the `client:defer` directive (fixed setTimeout delay). */
33
+ defer?: {
34
+ /** HTML attribute name. Default: `'client:defer'` */
35
+ attribute?: string;
36
+ /** Fallback delay (ms) when the attribute has no value. Default: `3000` */
37
+ delay?: number;
38
+ };
39
+ /** Configuration for the `client:interaction` directive (mouseenter/touchstart/focusin). */
40
+ interaction?: {
41
+ /** HTML attribute name. Default: `'client:interaction'` */
42
+ attribute?: string;
43
+ /** DOM event names to listen for. Default: `['mouseenter', 'touchstart', 'focusin']` */
44
+ events?: string[];
45
+ };
46
+ /** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
47
+ custom?: ClientDirectiveDefinition[];
48
+ }
49
+ export interface ShopifyThemeIslandsOptions {
50
+ /** Directories to scan for island files. Accepts paths or Vite aliases. Default: `['/frontend/js/islands/']` */
51
+ directories?: string | string[];
52
+ /** Log discovered islands and generated virtual module. Default: `false` */
53
+ debug?: boolean;
54
+ /** Per-directive configuration. */
55
+ directives?: DirectivesConfig;
56
+ /** Automatic retry behaviour for failed island loads. */
57
+ retry?: RetryConfig;
58
+ /**
59
+ * Milliseconds before a custom directive that never calls `load()` is considered timed out.
60
+ * When exceeded, `islands:error` is dispatched and the island is abandoned.
61
+ * Default: `0` (disabled).
62
+ */
63
+ directiveTimeout?: number;
64
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReviveOptions } from "./contract.js";
2
+ export interface ResolvedCustomDirective {
3
+ name: string;
4
+ entrypoint: string;
5
+ }
6
+ export interface ReviveBootstrapInputs {
7
+ root: string;
8
+ directories: string[];
9
+ islandFiles: Set<string>;
10
+ customDirectives?: Array<{
11
+ name: string;
12
+ entrypoint: string;
13
+ }>;
14
+ reviveOptions: ReviveOptions;
15
+ }
16
+ export interface ReviveBootstrapPlan {
17
+ runtimePath: string;
18
+ directoryGlobs: string[];
19
+ islandPaths: string[] | null;
20
+ customDirectives: ResolvedCustomDirective[] | null;
21
+ reviveOptions: ReviveOptions;
22
+ }
23
+ export interface ReviveBootstrapCompilerPorts {
24
+ resolveEntrypoint(entrypoint: string): Promise<string>;
25
+ toLoadPaths(islandFiles: Set<string>, root: string): string[];
26
+ }
27
+ export interface ReviveBootstrapCompiler {
28
+ plan(input: ReviveBootstrapInputs): Promise<ReviveBootstrapPlan>;
29
+ emit(plan: ReviveBootstrapPlan): string;
30
+ }
31
+ export declare function createReviveBootstrapCompiler(ports: ReviveBootstrapCompilerPorts, runtimePath: string): ReviveBootstrapCompiler;
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.1",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -4,15 +4,17 @@ 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.1"
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
15
16
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
17
+ - Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
16
18
  ---
17
19
 
18
20
  ## Setup
@@ -99,6 +101,16 @@ const networkDirective: ClientDirective = async (load, _opts, el) => {
99
101
 
100
102
  The directive function can be async. Unhandled rejections fire the document-level `islands:error` event, so `onIslandError()` observers still see directive failures.
101
103
 
104
+ ### Timeout guard for hung directives
105
+
106
+ ```ts
107
+ shopifyThemeIslands({
108
+ directiveTimeout: 5000,
109
+ });
110
+ ```
111
+
112
+ 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.
113
+
102
114
  ### AND-latch with multiple matching directives
103
115
 
104
116
  ```html
@@ -129,7 +141,7 @@ const myDirective: ClientDirective = (load, _opts, el) => {
129
141
  };
130
142
  ```
131
143
 
132
- No error is thrown and no timeout fires — the island is silently never loaded.
144
+ No immediate error is thrown by default, so the island is silently never loaded unless you configure `directiveTimeout`.
133
145
 
134
146
  Source: src/runtime.ts — directive owns the `run()` call path
135
147
 
@@ -211,7 +223,7 @@ shopifyThemeIslands({
211
223
 
212
224
  Custom directive names must be unique and must not collide with any built-in directive name, including renamed built-ins.
213
225
 
214
- Source: src/index.ts — validateOptions duplicate and built-in conflict checks
226
+ Source: src/config-policy.ts — validateOptions() duplicate and built-in conflict checks
215
227
 
216
228
  ### HIGH Entrypoint path missing `./` prefix
217
229
 
@@ -9,10 +9,11 @@ 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.1"
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
16
+ - Rees1993/vite-plugin-shopify-theme-islands:src/options.ts
16
17
  ---
17
18
 
18
19
  ## Setup
@@ -114,6 +115,15 @@ shopifyThemeIslands({
114
115
  });
115
116
  ```
116
117
 
118
+ ### Removed elements abort waiting directives silently
119
+
120
+ ```html
121
+ <hero-banner client:visible></hero-banner>
122
+ <cart-flyout client:interaction></cart-flyout>
123
+ ```
124
+
125
+ 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.
126
+
117
127
  ## Common Mistakes
118
128
 
119
129
  ### HIGH `client:media=""` skips the media check entirely
@@ -214,7 +224,7 @@ Correct:
214
224
 
215
225
  The attribute value is passed directly to `IntersectionObserver` as `rootMargin`, fully replacing the global default.
216
226
 
217
- Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, pendingCancellable)`
227
+ Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, registry.watchCancellable)`
218
228
 
219
229
  ### HIGH Directive attribute typo — island loads without condition
220
230
 
@@ -252,4 +262,4 @@ Correct:
252
262
 
253
263
  When `directives.visible.attribute` (or any directive's `attribute` option) is overridden in `vite.config.ts`, all Liquid templates must use the configured name. The default `client:*` names no longer apply. Always read `vite.config.ts` to check for overridden attribute names before writing directives in Liquid.
254
264
 
255
- Source: src/index.ts:DirectivesConfig — `attribute` field per directive; src/runtime.ts reads configured attribute names at runtime
265
+ Source: src/options.ts:DirectivesConfig — `attribute` field per directive; src/runtime.ts reads configured attribute names at runtime
@@ -6,15 +6,17 @@ 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.1"
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
17
18
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
19
+ - Rees1993/vite-plugin-shopify-theme-islands:src/revive-module.ts
18
20
  ---
19
21
 
20
22
  ## Setup
@@ -73,7 +75,7 @@ onIslandError(({ tag, error, attempt }) => {
73
75
  });
74
76
  ```
75
77
 
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.
78
+ `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
79
 
78
80
  ### Teardown for SPA navigation
79
81
 
@@ -143,7 +145,7 @@ import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
143
145
 
144
146
  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.
145
147
 
146
- Source: src/index.ts — virtual module `export const { disconnect } = _islands(...)`
148
+ Source: src/revive-module.ts — buildReviveModuleSource() emits `export const { disconnect } = _islands(payload)`
147
149
 
148
150
  ### MEDIUM `onIslandError` fires on every retry, not just final failure
149
151
 
@@ -182,6 +184,10 @@ onIslandError(({ tag, error }) => {
182
184
  });
183
185
  ```
184
186
 
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.
187
+ `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
188
 
187
189
  Source: src/runtime.ts — handleDirectiveError dispatches `islands:error`
190
+
191
+ ### LOW Removed elements waiting on `client:visible` / `client:interaction` do not emit `islands:error`
192
+
193
+ 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,15 +4,18 @@ 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.1"
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
17
+ - Rees1993/vite-plugin-shopify-theme-islands:src/options.ts
18
+ - Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
16
19
  ---
17
20
 
18
21
  ## Setup
@@ -91,6 +94,16 @@ shopifyThemeIslands({
91
94
 
92
95
  `retries` is the number of attempts after the first failure. `delay` is the base ms — each subsequent retry doubles it (1000ms → 2000ms → 4000ms).
93
96
 
97
+ ### Guard against hung custom directives
98
+
99
+ ```ts
100
+ shopifyThemeIslands({
101
+ directiveTimeout: 5000,
102
+ });
103
+ ```
104
+
105
+ 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.
106
+
94
107
  ### Enable console debug output
95
108
 
96
109
  ```ts
@@ -196,7 +209,7 @@ shopifyThemeIslands({
196
209
 
197
210
  `directives` accepts only `visible`, `idle`, `media`, `defer`, `interaction`, and `custom`. `retry` at `directives.retry` is silently ignored.
198
211
 
199
- Source: src/index.ts — ShopifyThemeIslandsOptions
212
+ Source: src/options.ts — ShopifyThemeIslandsOptions
200
213
 
201
214
  ### HIGH Wrong key name for retry count
202
215
 
@@ -216,3 +229,27 @@ shopifyThemeIslands({ retry: { retries: 3 } });
216
229
  Unknown keys are silently ignored. The correct field is `retries`.
217
230
 
218
231
  Source: src/contract.ts — RetryConfig
232
+
233
+ ### HIGH `directiveTimeout` nested inside `directives` — timeout guard never applies
234
+
235
+ Wrong:
236
+
237
+ ```ts
238
+ shopifyThemeIslands({
239
+ directives: {
240
+ directiveTimeout: 5000,
241
+ },
242
+ });
243
+ ```
244
+
245
+ Correct:
246
+
247
+ ```ts
248
+ shopifyThemeIslands({
249
+ directiveTimeout: 5000,
250
+ });
251
+ ```
252
+
253
+ `directiveTimeout` is a top-level plugin option, not part of the per-directive config object.
254
+
255
+ Source: src/options.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.1"
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