vite-plugin-shopify-theme-islands 1.2.1 → 1.2.2

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.
@@ -0,0 +1,27 @@
1
+ import type { ClientDirective, NormalizedReviveOptions } from "./contract.js";
2
+ import type { RuntimeLogger } from "./runtime-surface.js";
3
+ export interface DirectiveWaiters {
4
+ waitVisible(element: Element, rootMargin: string, threshold: number, watch: (el: Element, cancel: () => void) => () => void): Promise<void>;
5
+ waitMedia(query: string): Promise<void>;
6
+ waitIdle(timeout: number): Promise<void>;
7
+ waitDelay(ms: number): Promise<void>;
8
+ waitInteraction(element: Element, events: string[], watch: (el: Element, cancel: () => void) => () => void): Promise<void>;
9
+ }
10
+ export interface DirectiveRunContext {
11
+ tagName: string;
12
+ element: HTMLElement;
13
+ directives: NormalizedReviveOptions["directives"];
14
+ customDirectives?: Map<string, ClientDirective>;
15
+ directiveTimeout: number;
16
+ watchCancellable: (el: Element, cancel: () => void) => () => void;
17
+ log: RuntimeLogger;
18
+ run: () => Promise<void>;
19
+ onError(attrName: string, err: unknown): void;
20
+ }
21
+ export interface DirectiveOrchestrator {
22
+ run(ctx: DirectiveRunContext): Promise<boolean>;
23
+ }
24
+ export declare class DirectiveCancelledError extends Error {
25
+ constructor();
26
+ }
27
+ export declare function createDirectiveOrchestrator(waiters?: DirectiveWaiters): DirectiveOrchestrator;
package/dist/events.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { IslandLoadDetail, IslandErrorDetail } from "./index.js";
1
+ import type { IslandLoadDetail, IslandErrorDetail } from "./contract.js";
2
2
  /**
3
3
  * Listen for successful island module loads.
4
4
  *
package/dist/events.js CHANGED
@@ -1,13 +1,74 @@
1
+ // src/runtime-surface.ts
2
+ var SILENT_LOGGER = {
3
+ note() {},
4
+ flush() {}
5
+ };
6
+ function addListener(target, name, handler) {
7
+ const listener = (event) => handler(event.detail);
8
+ target.addEventListener(name, listener);
9
+ return () => target.removeEventListener(name, listener);
10
+ }
11
+ function dispatch(target, name, detail) {
12
+ target.dispatchEvent(new CustomEvent(name, { detail }));
13
+ }
14
+ function createRuntimeSurface(deps) {
15
+ return {
16
+ dispatchLoad(detail) {
17
+ dispatch(deps.target, "islands:load", detail);
18
+ },
19
+ dispatchError(detail) {
20
+ dispatch(deps.target, "islands:error", detail);
21
+ },
22
+ onLoad(handler) {
23
+ return addListener(deps.target, "islands:load", handler);
24
+ },
25
+ onError(handler) {
26
+ return addListener(deps.target, "islands:error", handler);
27
+ },
28
+ createLogger(tagName, debug) {
29
+ if (!debug)
30
+ return SILENT_LOGGER;
31
+ const msgs = [];
32
+ return {
33
+ note(msg) {
34
+ msgs.push(msg);
35
+ },
36
+ flush(summary) {
37
+ if (msgs.length === 0) {
38
+ deps.console.log("[islands]", `<${tagName}> ${summary}`);
39
+ } else {
40
+ deps.console.groupCollapsed(`[islands] <${tagName}> ${summary}`);
41
+ for (const msg of msgs)
42
+ deps.console.log(msg);
43
+ deps.console.groupEnd();
44
+ }
45
+ msgs.length = 0;
46
+ }
47
+ };
48
+ },
49
+ beginReadyLog(islandCount, debug) {
50
+ if (!debug)
51
+ return () => {};
52
+ deps.console.groupCollapsed(`[islands] ready — ${islandCount} island(s)`);
53
+ return () => deps.console.groupEnd();
54
+ }
55
+ };
56
+ }
57
+ var runtimeSurface;
58
+ function getRuntimeSurface() {
59
+ runtimeSurface ??= createRuntimeSurface({
60
+ target: document,
61
+ console
62
+ });
63
+ return runtimeSurface;
64
+ }
65
+
1
66
  // src/events.ts
2
67
  function onIslandLoad(handler) {
3
- const listener = (e) => handler(e.detail);
4
- document.addEventListener("islands:load", listener);
5
- return () => document.removeEventListener("islands:load", listener);
68
+ return getRuntimeSurface().onLoad(handler);
6
69
  }
7
70
  function onIslandError(handler) {
8
- const listener = (e) => handler(e.detail);
9
- document.addEventListener("islands:error", listener);
10
- return () => document.removeEventListener("islands:error", listener);
71
+ return getRuntimeSurface().onError(handler);
11
72
  }
12
73
  export {
13
74
  onIslandLoad,
@@ -0,0 +1,20 @@
1
+ import type { IslandErrorDetail, IslandLoadDetail } from "./contract.js";
2
+ export interface RuntimeLogger {
3
+ note(msg: string): void;
4
+ flush(summary: string): void;
5
+ }
6
+ export interface RuntimeSurface {
7
+ dispatchLoad(detail: IslandLoadDetail): void;
8
+ dispatchError(detail: IslandErrorDetail): void;
9
+ onLoad(handler: (detail: IslandLoadDetail) => void): () => void;
10
+ onError(handler: (detail: IslandErrorDetail) => void): () => void;
11
+ createLogger(tagName: string, debug: boolean): RuntimeLogger;
12
+ beginReadyLog(islandCount: number, debug: boolean): () => void;
13
+ }
14
+ interface RuntimeSurfaceDeps {
15
+ target: Document;
16
+ console: Pick<Console, "log" | "groupCollapsed" | "groupEnd">;
17
+ }
18
+ export declare function createRuntimeSurface(deps: RuntimeSurfaceDeps): RuntimeSurface;
19
+ export declare function getRuntimeSurface(): RuntimeSurface;
20
+ export {};
package/dist/runtime.js CHANGED
@@ -48,18 +48,14 @@ function buildIslandMap(payload) {
48
48
  return map;
49
49
  }
50
50
 
51
- // src/runtime.ts
52
- var dispatch = (name, detail) => document.dispatchEvent(new CustomEvent(name, { detail }));
53
- function media(query) {
54
- const m = window.matchMedia(query);
55
- return new Promise((resolve) => {
56
- if (m.matches)
57
- resolve();
58
- else
59
- m.addEventListener("change", () => resolve(), { once: true });
60
- });
51
+ // src/directive-orchestration.ts
52
+ class DirectiveCancelledError extends Error {
53
+ constructor() {
54
+ super("[islands] directive cancelled: element removed from DOM");
55
+ this.name = "DirectiveCancelledError";
56
+ }
61
57
  }
62
- function visible(element, rootMargin, threshold, watch) {
58
+ function waitVisible(element, rootMargin, threshold, watch) {
63
59
  return new Promise((resolve, reject) => {
64
60
  let settled = false;
65
61
  let unwatch = () => {};
@@ -80,7 +76,7 @@ function visible(element, rootMargin, threshold, watch) {
80
76
  unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError)));
81
77
  });
82
78
  }
83
- function interaction(element, events, watch) {
79
+ function waitInteraction(element, events, watch) {
84
80
  return new Promise((resolve, reject) => {
85
81
  let settled = false;
86
82
  let unwatch = () => {};
@@ -104,10 +100,10 @@ function interaction(element, events, watch) {
104
100
  unwatch = watch(element, () => finish(() => reject(new DirectiveCancelledError)));
105
101
  });
106
102
  }
107
- function defer(ms) {
103
+ function waitDelay(ms) {
108
104
  return new Promise((resolve) => setTimeout(resolve, ms));
109
105
  }
110
- function idle(timeout) {
106
+ function waitIdle(timeout) {
111
107
  return new Promise((resolve) => {
112
108
  if ("requestIdleCallback" in window)
113
109
  window.requestIdleCallback(() => resolve(), { timeout });
@@ -115,38 +111,192 @@ function idle(timeout) {
115
111
  setTimeout(resolve, timeout);
116
112
  });
117
113
  }
114
+ function waitMedia(query) {
115
+ const m = window.matchMedia(query);
116
+ return new Promise((resolve) => {
117
+ if (m.matches)
118
+ resolve();
119
+ else
120
+ m.addEventListener("change", () => resolve(), { once: true });
121
+ });
122
+ }
123
+ function createDirectiveOrchestrator(waiters = {
124
+ waitVisible,
125
+ waitMedia,
126
+ waitIdle,
127
+ waitDelay,
128
+ waitInteraction
129
+ }) {
130
+ async function runBuiltIns(ctx) {
131
+ const { tagName, element: el, directives, log, watchCancellable } = ctx;
132
+ const visibleAttr = directives.visible.attribute;
133
+ if (el.getAttribute(visibleAttr) !== null) {
134
+ log.note(`waiting for ${visibleAttr}`);
135
+ await waiters.waitVisible(el, el.getAttribute(visibleAttr) || directives.visible.rootMargin, directives.visible.threshold, watchCancellable);
136
+ }
137
+ const query = el.getAttribute(directives.media.attribute);
138
+ if (query === "") {
139
+ console.warn(`[islands] <${tagName}> ${directives.media.attribute} has no value — media check skipped, island will load immediately`);
140
+ } else if (query) {
141
+ log.note(`waiting for ${directives.media.attribute}="${query}"`);
142
+ await waiters.waitMedia(query);
143
+ }
144
+ const idleAttr = el.getAttribute(directives.idle.attribute);
145
+ if (idleAttr !== null) {
146
+ const raw = parseInt(idleAttr, 10);
147
+ const elTimeout = Number.isNaN(raw) ? directives.idle.timeout : raw;
148
+ log.note(`waiting for ${directives.idle.attribute} (${elTimeout}ms)`);
149
+ await waiters.waitIdle(elTimeout);
150
+ }
151
+ const deferAttr = el.getAttribute(directives.defer.attribute);
152
+ if (deferAttr !== null) {
153
+ const msParsed = parseInt(deferAttr, 10);
154
+ if (deferAttr !== "" && Number.isNaN(msParsed)) {
155
+ console.warn(`[islands] <${tagName}> invalid ${directives.defer.attribute} value "${deferAttr}" — using default ${directives.defer.delay}ms`);
156
+ }
157
+ const ms = Number.isNaN(msParsed) ? directives.defer.delay : msParsed;
158
+ log.note(`waiting for ${directives.defer.attribute} (${ms}ms)`);
159
+ await waiters.waitDelay(ms);
160
+ }
161
+ const interactionAttr = el.getAttribute(directives.interaction.attribute);
162
+ if (interactionAttr !== null) {
163
+ let events = directives.interaction.events;
164
+ if (interactionAttr) {
165
+ const tokens = interactionAttr.split(/\s+/).filter(Boolean);
166
+ if (tokens.length > 0)
167
+ events = tokens;
168
+ else {
169
+ console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} has no valid event tokens — using default events`);
170
+ }
171
+ }
172
+ log.note(`waiting for ${directives.interaction.attribute} (${events.join(", ")})`);
173
+ await waiters.waitInteraction(el, events, watchCancellable);
174
+ }
175
+ }
176
+ function runCustomDirectives(ctx) {
177
+ const matched = [];
178
+ if (ctx.customDirectives) {
179
+ for (const [attrName, directiveFn] of ctx.customDirectives) {
180
+ const value = ctx.element.getAttribute(attrName);
181
+ if (value !== null)
182
+ matched.push([attrName, directiveFn, value]);
183
+ }
184
+ }
185
+ if (matched.length === 0)
186
+ return false;
187
+ const attrNames = matched.map(([attrName]) => attrName).join(", ");
188
+ ctx.log.flush(`dispatching to custom directive${matched.length === 1 ? "" : "s"} ${attrNames}`);
189
+ let remaining = matched.length;
190
+ let fired = false;
191
+ let aborted = false;
192
+ let timer;
193
+ const loadOnce = () => {
194
+ if (fired || aborted)
195
+ return Promise.resolve();
196
+ if (--remaining === 0) {
197
+ clearTimeout(timer);
198
+ fired = true;
199
+ return ctx.run();
200
+ }
201
+ return Promise.resolve();
202
+ };
203
+ if (ctx.directiveTimeout > 0) {
204
+ timer = setTimeout(() => {
205
+ if (fired || aborted)
206
+ return;
207
+ aborted = true;
208
+ ctx.onError(attrNames, new Error(`[islands] Custom directive timed out after ${ctx.directiveTimeout}ms for <${ctx.tagName}>`));
209
+ }, ctx.directiveTimeout);
210
+ }
211
+ for (const [attrName, directiveFn, value] of matched) {
212
+ try {
213
+ Promise.resolve(directiveFn(loadOnce, { name: attrName, value }, ctx.element)).catch((err) => {
214
+ clearTimeout(timer);
215
+ aborted = true;
216
+ ctx.onError(attrName, err);
217
+ });
218
+ } catch (err) {
219
+ clearTimeout(timer);
220
+ aborted = true;
221
+ ctx.onError(attrName, err);
222
+ }
223
+ }
224
+ return true;
225
+ }
226
+ return {
227
+ async run(ctx) {
228
+ await runBuiltIns(ctx);
229
+ return runCustomDirectives(ctx);
230
+ }
231
+ };
232
+ }
233
+
234
+ // src/runtime-surface.ts
118
235
  var SILENT_LOGGER = {
119
236
  note() {},
120
237
  flush() {}
121
238
  };
122
- function createIslandLogger(tagName, debug) {
123
- if (!debug)
124
- return SILENT_LOGGER;
125
- const msgs = [];
239
+ function addListener(target, name, handler) {
240
+ const listener = (event) => handler(event.detail);
241
+ target.addEventListener(name, listener);
242
+ return () => target.removeEventListener(name, listener);
243
+ }
244
+ function dispatch(target, name, detail) {
245
+ target.dispatchEvent(new CustomEvent(name, { detail }));
246
+ }
247
+ function createRuntimeSurface(deps) {
126
248
  return {
127
- note(msg) {
128
- msgs.push(msg);
249
+ dispatchLoad(detail) {
250
+ dispatch(deps.target, "islands:load", detail);
129
251
  },
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;
252
+ dispatchError(detail) {
253
+ dispatch(deps.target, "islands:error", detail);
254
+ },
255
+ onLoad(handler) {
256
+ return addListener(deps.target, "islands:load", handler);
257
+ },
258
+ onError(handler) {
259
+ return addListener(deps.target, "islands:error", handler);
260
+ },
261
+ createLogger(tagName, debug) {
262
+ if (!debug)
263
+ return SILENT_LOGGER;
264
+ const msgs = [];
265
+ return {
266
+ note(msg) {
267
+ msgs.push(msg);
268
+ },
269
+ flush(summary) {
270
+ if (msgs.length === 0) {
271
+ deps.console.log("[islands]", `<${tagName}> ${summary}`);
272
+ } else {
273
+ deps.console.groupCollapsed(`[islands] <${tagName}> ${summary}`);
274
+ for (const msg of msgs)
275
+ deps.console.log(msg);
276
+ deps.console.groupEnd();
277
+ }
278
+ msgs.length = 0;
279
+ }
280
+ };
281
+ },
282
+ beginReadyLog(islandCount, debug) {
283
+ if (!debug)
284
+ return () => {};
285
+ deps.console.groupCollapsed(`[islands] ready — ${islandCount} island(s)`);
286
+ return () => deps.console.groupEnd();
140
287
  }
141
288
  };
142
289
  }
143
-
144
- class DirectiveCancelledError extends Error {
145
- constructor() {
146
- super("[islands] directive cancelled: element removed from DOM");
147
- this.name = "DirectiveCancelledError";
148
- }
290
+ var runtimeSurface;
291
+ function getRuntimeSurface() {
292
+ runtimeSurface ??= createRuntimeSurface({
293
+ target: document,
294
+ console
295
+ });
296
+ return runtimeSurface;
149
297
  }
298
+
299
+ // src/runtime.ts
150
300
  function isRevivePayload(v) {
151
301
  return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v);
152
302
  }
@@ -213,6 +363,7 @@ function createIslandRegistry(opts) {
213
363
  };
214
364
  }
215
365
  function revive(islandsOrPayload, options, customDirectives) {
366
+ const runtimeSurface2 = getRuntimeSurface();
216
367
  const payload = isRevivePayload(islandsOrPayload) ? islandsOrPayload : { islands: islandsOrPayload, options, customDirectives };
217
368
  const opts = normalizeReviveOptions(payload.options);
218
369
  const islandMap = buildIslandMap(payload);
@@ -222,17 +373,13 @@ function revive(islandsOrPayload, options, customDirectives) {
222
373
  const attrIdle = opts.directives.idle.attribute;
223
374
  const attrDefer = opts.directives.defer.attribute;
224
375
  const attrInteraction = opts.directives.interaction.attribute;
225
- const interactionEvents = opts.directives.interaction.events;
226
- const rootMargin = opts.directives.visible.rootMargin;
227
- const threshold = opts.directives.visible.threshold;
228
- const idleTimeout = opts.directives.idle.timeout;
229
- const deferDelay = opts.directives.defer.delay;
230
376
  const debug = opts.debug;
231
377
  const directiveTimeout = opts.directiveTimeout;
232
378
  const registry = createIslandRegistry({
233
379
  retries: opts.retry.retries,
234
380
  retryDelay: opts.retry.delay
235
381
  });
382
+ const directiveOrchestrator = createDirectiveOrchestrator();
236
383
  const customElementFilter = {
237
384
  acceptNode: (node) => {
238
385
  const tag = node.tagName;
@@ -244,107 +391,6 @@ function revive(islandsOrPayload, options, customDirectives) {
244
391
  return NodeFilter.FILTER_ACCEPT;
245
392
  }
246
393
  };
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
- }
348
394
  async function loadIsland(tagName, el, loader) {
349
395
  if (debug && !registry.initialWalkComplete) {
350
396
  const parts = [];
@@ -368,22 +414,14 @@ function revive(islandsOrPayload, options, customDirectives) {
368
414
  if (parts.length > 0)
369
415
  console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`);
370
416
  }
371
- const log = createIslandLogger(tagName, debug);
372
- const handleOutcome = makeDirectiveOutcomeHandler(tagName);
373
- try {
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)");
378
- return;
379
- }
417
+ const log = runtimeSurface2.createLogger(tagName, debug);
380
418
  const run = () => {
381
419
  if (disconnected)
382
420
  return Promise.resolve();
383
421
  const t0 = performance.now();
384
422
  return loader().then(() => {
385
423
  const attempt = registry.settleSuccess(tagName);
386
- dispatch("islands:load", {
424
+ runtimeSurface2.dispatchLoad({
387
425
  tag: tagName,
388
426
  duration: performance.now() - t0,
389
427
  attempt
@@ -393,22 +431,41 @@ function revive(islandsOrPayload, options, customDirectives) {
393
431
  }).catch((err) => {
394
432
  console.error(`[islands] Failed to load <${tagName}>:`, err);
395
433
  const { retryDelayMs, attempt } = registry.settleFailure(tagName);
396
- dispatch("islands:error", { tag: tagName, error: err, attempt });
434
+ runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt });
397
435
  if (retryDelayMs !== null) {
398
436
  setTimeout(run, retryDelayMs);
399
437
  }
400
438
  });
401
439
  };
402
- const handleDirectiveError = (attrName, err) => handleOutcome({ kind: "directive-error", attrName, err });
403
- if (resolvedDirectives?.size) {
404
- const matched = [];
405
- for (const [attrName, directiveFn] of resolvedDirectives) {
406
- const value = el.getAttribute(attrName);
407
- if (value !== null)
408
- matched.push([attrName, directiveFn, value]);
440
+ const handleDirectiveError = (attrName, err) => {
441
+ if (attrName === null && err instanceof DirectiveCancelledError)
442
+ return;
443
+ if (attrName !== null) {
444
+ console.error(`[islands] Custom directive ${attrName} failed for <${tagName}>:`, err);
445
+ } else {
446
+ console.error(`[islands] Built-in directive failed for <${tagName}>:`, err);
409
447
  }
410
- if (applyCustomDirectives(tagName, el, matched, run, handleDirectiveError, log))
448
+ runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt: 1 });
449
+ registry.evict(tagName);
450
+ };
451
+ try {
452
+ const matchedCustomDirectives = await directiveOrchestrator.run({
453
+ tagName,
454
+ element: el,
455
+ directives: opts.directives,
456
+ customDirectives: resolvedDirectives,
457
+ directiveTimeout,
458
+ watchCancellable: registry.watchCancellable,
459
+ log,
460
+ run,
461
+ onError: handleDirectiveError
462
+ });
463
+ if (matchedCustomDirectives)
411
464
  return;
465
+ } catch (err) {
466
+ handleDirectiveError(null, err);
467
+ log.flush(err instanceof DirectiveCancelledError ? "aborted (element removed)" : "aborted (directive error)");
468
+ return;
412
469
  }
413
470
  log.flush("triggered");
414
471
  run();
@@ -447,16 +504,18 @@ function revive(islandsOrPayload, options, customDirectives) {
447
504
  registry.cancelDetached();
448
505
  handleAdditions(mutations);
449
506
  });
507
+ let disconnected = false;
508
+ let initialized = false;
450
509
  function init() {
451
- if (debug)
452
- console.groupCollapsed(`[islands] ready — ${islandMap.size} island(s)`);
510
+ if (disconnected || initialized)
511
+ return;
512
+ initialized = true;
513
+ const endReadyLog = runtimeSurface2.beginReadyLog(islandMap.size, debug);
453
514
  walk(document.body);
454
515
  registry.markInitialWalkComplete();
455
- if (debug)
456
- console.groupEnd();
516
+ endReadyLog();
457
517
  observer.observe(document.body, { childList: true, subtree: true });
458
518
  }
459
- let disconnected = false;
460
519
  if (document.readyState === "loading") {
461
520
  document.addEventListener("DOMContentLoaded", init, { once: true });
462
521
  } else {
@@ -464,6 +523,7 @@ function revive(islandsOrPayload, options, customDirectives) {
464
523
  }
465
524
  const disconnect = () => {
466
525
  disconnected = true;
526
+ document.removeEventListener("DOMContentLoaded", init);
467
527
  observer.disconnect();
468
528
  };
469
529
  return { disconnect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -6,15 +6,17 @@ description: >
6
6
  multiple custom directives match the same element, all must call load() before
7
7
  the island activates. Error handling — thrown errors, rejected promises, and
8
8
  directiveTimeout expiry fire islands:error. Custom directives run after all
9
- built-in conditions resolve.
9
+ built-in conditions resolve. Current matching, AND-latch, and timeout policy
10
+ are owned by src/directive-orchestration.ts.
10
11
  type: core
11
12
  library: vite-plugin-shopify-theme-islands
12
- library_version: "1.2.1"
13
+ library_version: "1.2.2"
13
14
  sources:
14
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
16
+ - Rees1993/vite-plugin-shopify-theme-islands:src/directive-orchestration.ts
17
+ - Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
15
18
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
16
19
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
17
- - Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
18
20
  ---
19
21
 
20
22
  ## Setup
@@ -143,7 +145,7 @@ const myDirective: ClientDirective = (load, _opts, el) => {
143
145
 
144
146
  No immediate error is thrown by default, so the island is silently never loaded unless you configure `directiveTimeout`.
145
147
 
146
- Source: src/runtime.ts — directive owns the `run()` call path
148
+ Source: src/directive-orchestration.ts — matched custom directives own the `run()` call path
147
149
 
148
150
  ### HIGH Writing a custom directive for mouseenter/touchstart/focusin — use `client:interaction` instead
149
151
 
@@ -168,7 +170,7 @@ Correct:
168
170
 
169
171
  `client:interaction` is a built-in directive that handles `mouseenter`, `touchstart`, and `focusin`. Custom directives are for conditions the built-ins cannot express (e.g. URL hash matching, network conditions, feature flags).
170
172
 
171
- Source: src/runtime.ts — `interaction()` built-in handles the hover/touch/focus pattern
173
+ Source: src/directive-orchestration.ts — built-in interaction handling covers the hover/touch/focus pattern
172
174
 
173
175
  ### HIGH AND-latch: both matched directives must call `load()`
174
176
 
@@ -191,7 +193,7 @@ Correct:
191
193
 
192
194
  With two matching custom directives, `remaining = 2`. Each `load()` call decrements it. The island activates only when `remaining === 0`.
193
195
 
194
- Source: src/runtime.ts — `let remaining = matched.length`
196
+ Source: src/directive-orchestration.ts — `let remaining = matched.length`
195
197
 
196
198
  ### HIGH Duplicate custom directive names or collisions with built-ins fail plugin setup
197
199
 
@@ -247,7 +249,7 @@ Correct:
247
249
 
248
250
  Custom directive entrypoints are resolved through Vite. Relative local files should usually use `./...`; unresolved entrypoints fail the build.
249
251
 
250
- Source: src/index.ts — `this.resolve(def.entrypoint)` throws on null
252
+ Source: src/index.ts — `this.resolve(entrypoint)` throws on null during revive bootstrap planning
251
253
 
252
254
  ### MEDIUM Custom directives run after all built-in directive awaits
253
255
 
@@ -260,7 +262,7 @@ Wrong expectation:
260
262
 
261
263
  The runtime awaits built-ins in order (`visible → media → idle → defer → interaction`) first, then passes control to matched custom directives. Custom directives cannot short-circuit or replace built-in awaits.
262
264
 
263
- Source: src/runtime.ts — built-in awaits precede `if (customDirectives?.size)` block
265
+ Source: src/directive-orchestration.ts — runBuiltIns() completes before runCustomDirectives()
264
266
 
265
267
  ### MEDIUM Calling `load()` multiple times has no effect after the first
266
268
 
@@ -282,4 +284,4 @@ const retryDirective: ClientDirective = (load, _opts, el) => {
282
284
 
283
285
  The `loadOnce` wrapper ignores all calls after the first (`fired` guard). Use `{ once: true }` on event listeners to avoid unnecessary calls.
284
286
 
285
- Source: src/runtime.ts — `if (fired || aborted) return Promise.resolve()`
287
+ Source: src/directive-orchestration.ts — `if (fired || aborted) return Promise.resolve()`
@@ -4,16 +4,19 @@ description: >
4
4
  Built-in client directives: client:visible (IntersectionObserver, rootMargin),
5
5
  client:media (matchMedia query), client:idle (requestIdleCallback),
6
6
  client:defer (setTimeout delay), client:interaction (mouseenter/touchstart/focusin).
7
- Directives resolve sequentially — visible → media → idle → defer → interaction → custom.
8
- Per-element value overrides. Empty client:media warning. Whitespace-only
9
- client:interaction values warn and fall back to default events.
7
+ Directives resolve sequentially — visible → media → idle → defer →
8
+ interaction → custom. Per-element value overrides. Empty client:media
9
+ warning. Whitespace-only client:interaction values warn and fall back to
10
+ default events. Current directive sequencing and custom-directive latching
11
+ are owned by src/directive-orchestration.ts.
10
12
  type: core
11
13
  library: vite-plugin-shopify-theme-islands
12
- library_version: "1.2.1"
14
+ library_version: "1.2.2"
13
15
  sources:
16
+ - Rees1993/vite-plugin-shopify-theme-islands:src/directive-orchestration.ts
14
17
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
15
- - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
16
- - Rees1993/vite-plugin-shopify-theme-islands:src/options.ts
18
+ - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
19
+ - Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
17
20
  ---
18
21
 
19
22
  ## Setup
@@ -50,10 +53,7 @@ Directives resolve in a fixed order: `visible → media → idle → defer → i
50
53
  <mega-menu client:visible client:interaction></mega-menu>
51
54
 
52
55
  <!-- Loads when visible AND the media query matches -->
53
- <product-recommendations
54
- client:visible
55
- client:media="(min-width: 768px)"
56
- ></product-recommendations>
56
+ <product-recommendations client:visible client:media="(min-width: 768px)"></product-recommendations>
57
57
  ```
58
58
 
59
59
  Combined directives are AND-latched. The island loads only after every condition resolves. There is no OR mode.
@@ -100,7 +100,7 @@ An empty `client:defer` attribute is NOT zero — it falls back to the configure
100
100
 
101
101
  An empty `client:interaction` attribute uses the configured default events with no warning. A whitespace-only value such as `client:interaction=" "` emits a warning and still falls back to the default events.
102
102
 
103
- Source: src/runtime.ts — interaction token parsing and fallback warning
103
+ Source: src/directive-orchestration.ts — interaction token parsing and fallback warning
104
104
 
105
105
  ### Changing built-in directive defaults globally
106
106
 
@@ -118,8 +118,7 @@ shopifyThemeIslands({
118
118
  ### Removed elements abort waiting directives silently
119
119
 
120
120
  ```html
121
- <hero-banner client:visible></hero-banner>
122
- <cart-flyout client:interaction></cart-flyout>
121
+ <hero-banner client:visible></hero-banner> <cart-flyout client:interaction></cart-flyout>
123
122
  ```
124
123
 
125
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.
@@ -142,7 +141,7 @@ Correct:
142
141
 
143
142
  An empty `client:media` value emits a console warning and skips the media check — the island loads immediately. Provide a valid media query string.
144
143
 
145
- Source: src/runtime.ts — `if (query === "")` branch
144
+ Source: src/directive-orchestration.ts — `if (query === "")` branch
146
145
 
147
146
  ### MEDIUM Whitespace-only `client:interaction` value warns and falls back
148
147
 
@@ -164,7 +163,7 @@ Correct:
164
163
 
165
164
  Whitespace-only values are not treated the same as an empty attribute. The runtime warns and falls back to the configured default events.
166
165
 
167
- Source: src/runtime.ts — `interactionAttr.split(/\s+/).filter(Boolean)`
166
+ Source: src/directive-orchestration.ts — `interactionAttr.split(/\s+/).filter(Boolean)`
168
167
 
169
168
  ### HIGH Multiple directives are AND, not OR
170
169
 
@@ -184,7 +183,7 @@ Correct understanding:
184
183
 
185
184
  The runtime awaits each directive sequentially. There is no way to express OR semantics with built-in directives — use a custom directive for that.
186
185
 
187
- Source: src/runtime.ts — loadIsland sequential awaits
186
+ Source: src/directive-orchestration.ts — runBuiltIns() sequential awaits
188
187
 
189
188
  ### MEDIUM `client:defer` without value ≠ immediate load
190
189
 
@@ -204,7 +203,7 @@ Correct:
204
203
 
205
204
  `client:defer` with no value uses the global `defer.delay` default (3000ms). `parseInt("", 10)` produces `NaN`, which the runtime replaces with the configured default.
206
205
 
207
- Source: src/runtime.ts — `const ms = Number.isNaN(raw) ? deferDelay : raw`
206
+ Source: src/directive-orchestration.ts — defer parsing and fallback to directives.defer.delay
208
207
 
209
208
  ### MEDIUM Per-element visible value replaces rootMargin, not adds to it
210
209
 
@@ -224,15 +223,14 @@ Correct:
224
223
 
225
224
  The attribute value is passed directly to `IntersectionObserver` as `rootMargin`, fully replacing the global default.
226
225
 
227
- Source: src/runtime.ts — `await visible(el, visibleAttr || rootMargin, threshold, registry.watchCancellable)`
226
+ Source: src/directive-orchestration.ts — visible attribute value replaces directives.visible.rootMargin
228
227
 
229
228
  ### HIGH Directive attribute typo — island loads without condition
230
229
 
231
230
  Wrong:
232
231
 
233
232
  ```html
234
- <product-form client:visibled></product-form>
235
- <product-form client:Visible></product-form>
233
+ <product-form client:visibled></product-form> <product-form client:Visible></product-form>
236
234
  ```
237
235
 
238
236
  Correct:
@@ -243,7 +241,7 @@ Correct:
243
241
 
244
242
  Directive attributes are case-sensitive. An unrecognised attribute is silently ignored — the island loads immediately as if no directive were set. No warning is emitted. Check for typos if an island activates earlier than expected.
245
243
 
246
- Source: src/runtime.ts — runtime checks exact attribute names from plugin config
244
+ Source: src/directive-orchestration.ts — built-ins read exact configured attribute names
247
245
 
248
246
  ### HIGH Agent uses default attribute name when developer has configured a custom one
249
247
 
@@ -8,12 +8,14 @@ description: >
8
8
  duration (ms), and attempt (1-based). islands:error detail includes tag,
9
9
  error, and attempt, including custom directive failures and directiveTimeout
10
10
  expiry. disconnect() from the virtual module revive for SPA navigation
11
- teardown.
11
+ teardown, including before DOMContentLoaded — it now prevents init from ever
12
+ starting if called early.
12
13
  type: core
13
14
  library: vite-plugin-shopify-theme-islands
14
- library_version: "1.2.1"
15
+ library_version: "1.2.2"
15
16
  sources:
16
17
  - Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
18
+ - Rees1993/vite-plugin-shopify-theme-islands:src/runtime-surface.ts
17
19
  - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
18
20
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
19
21
  - Rees1993/vite-plugin-shopify-theme-islands:src/revive-module.ts
@@ -86,7 +88,7 @@ import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
86
88
  disconnect();
87
89
  ```
88
90
 
89
- `disconnect()` stops the MutationObserver and prevents new islands from activating. Call it before SPA page transitions to avoid activating islands from the previous page's DOM.
91
+ `disconnect()` stops the MutationObserver and prevents new islands from activating. If the runtime has not initialized yet because the document is still loading, `disconnect()` also unregisters the pending DOMContentLoaded startup listener so init never runs later. Call it before SPA page transitions to avoid activating islands from the previous page's DOM.
90
92
 
91
93
  ### Raw DOM events (when type augmentation is in scope)
92
94
 
@@ -171,7 +173,7 @@ onIslandError(({ tag, error, attempt }) => {
171
173
 
172
174
  With `retry: { retries: 3 }`, a single island can fire `islands:error` up to 4 times before exhausting retries. Use `attempt` to distinguish the initial failure from retries.
173
175
 
174
- Source: src/runtime.ts — `dispatch("islands:error", ...)` inside `.catch()` before retry check
176
+ Source: src/runtime.ts — runtimeSurface.dispatchError(...) inside the loader failure path before retry check
175
177
 
176
178
  ### MEDIUM `islands:error` fires for custom directive failures too
177
179
 
@@ -10,7 +10,7 @@ description: >
10
10
  directive timeout.
11
11
  type: core
12
12
  library: vite-plugin-shopify-theme-islands
13
- library_version: "1.2.1"
13
+ library_version: "1.2.2"
14
14
  sources:
15
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
16
16
  - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
@@ -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.2.1"
11
+ library_version: "1.2.2"
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