vite-plugin-shopify-theme-islands 1.2.2 → 1.3.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/dist/contract.d.ts +3 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +35 -9
- package/dist/interaction-events.d.ts +12 -0
- package/dist/lifecycle.d.ts +27 -0
- package/dist/options.d.ts +3 -2
- package/dist/runtime.js +189 -137
- package/package.json +1 -1
- package/skills/custom-directives/SKILL.md +1 -1
- package/skills/directives/SKILL.md +34 -4
- package/skills/lifecycle/SKILL.md +9 -3
- package/skills/setup/SKILL.md +39 -4
- package/skills/writing-islands/SKILL.md +6 -3
package/dist/contract.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Single source of truth for the payload shape, key→tag derivation, and defaults.
|
|
5
5
|
* Plugin and runtime both depend on this module in-process.
|
|
6
6
|
*/
|
|
7
|
+
import type { InteractionEventName } from "./interaction-events.js";
|
|
7
8
|
/** Loader function for one island chunk. */
|
|
8
9
|
export type IslandLoader = () => Promise<unknown>;
|
|
9
10
|
/** Directive config for the runtime (built-in + no plugin-only `custom` entrypoints). */
|
|
@@ -26,7 +27,7 @@ export interface RuntimeDirectivesConfig {
|
|
|
26
27
|
};
|
|
27
28
|
interaction?: {
|
|
28
29
|
attribute?: string;
|
|
29
|
-
events?:
|
|
30
|
+
events?: readonly InteractionEventName[];
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
/** Retry configuration. */
|
|
@@ -111,7 +112,7 @@ export interface NormalizedReviveOptions {
|
|
|
111
112
|
};
|
|
112
113
|
interaction: {
|
|
113
114
|
attribute: string;
|
|
114
|
-
events:
|
|
115
|
+
events: readonly InteractionEventName[];
|
|
115
116
|
};
|
|
116
117
|
};
|
|
117
118
|
debug: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,6 @@ export type ClientDirectiveLoader = () => Promise<void>;
|
|
|
5
5
|
export type { ClientDirective, ClientDirectiveOptions } from "./contract.js";
|
|
6
6
|
export type { ClientDirectiveDefinition, DirectivesConfig, ShopifyThemeIslandsOptions, } from "./options.js";
|
|
7
7
|
export type { IslandLoadDetail, IslandErrorDetail, ReviveOptions, RetryConfig, RuntimeDirectivesConfig, } from "./contract.js";
|
|
8
|
+
export type { InteractionEventName } from "./interaction-events.js";
|
|
9
|
+
export { DEFAULT_INTERACTION_EVENTS, INTERACTION_EVENT_NAMES, isInteractionEventName, } from "./interaction-events.js";
|
|
8
10
|
export default function shopifyThemeIslands(options?: ShopifyThemeIslandsOptions): Plugin;
|
package/dist/index.js
CHANGED
|
@@ -54,6 +54,26 @@ function collectTagNames(dir) {
|
|
|
54
54
|
return names;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// src/interaction-events.ts
|
|
58
|
+
var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"];
|
|
59
|
+
var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES];
|
|
60
|
+
var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
|
|
61
|
+
var PREFIX = "[vite-plugin-shopify-theme-islands]";
|
|
62
|
+
function isInteractionEventName(value) {
|
|
63
|
+
return INTERACTION_EVENT_NAME_SET.has(value);
|
|
64
|
+
}
|
|
65
|
+
function validateInteractionEvents(events) {
|
|
66
|
+
if (events === undefined)
|
|
67
|
+
return;
|
|
68
|
+
if (events.length === 0) {
|
|
69
|
+
throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
|
|
70
|
+
}
|
|
71
|
+
const invalidEvent = events.find((eventName) => !isInteractionEventName(eventName));
|
|
72
|
+
if (invalidEvent) {
|
|
73
|
+
throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
// src/contract.ts
|
|
58
78
|
var DEFAULT_DIRECTIVES = {
|
|
59
79
|
visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
|
|
@@ -62,7 +82,7 @@ var DEFAULT_DIRECTIVES = {
|
|
|
62
82
|
defer: { attribute: "client:defer", delay: 3000 },
|
|
63
83
|
interaction: {
|
|
64
84
|
attribute: "client:interaction",
|
|
65
|
-
events: [
|
|
85
|
+
events: [...DEFAULT_INTERACTION_EVENTS]
|
|
66
86
|
}
|
|
67
87
|
};
|
|
68
88
|
var DEFAULT_RETRY = { retries: 0, delay: 1000 };
|
|
@@ -70,6 +90,7 @@ function normalizeReviveOptions(options) {
|
|
|
70
90
|
const d = DEFAULT_DIRECTIVES;
|
|
71
91
|
const r = DEFAULT_RETRY;
|
|
72
92
|
const dir = options?.directives;
|
|
93
|
+
validateInteractionEvents(dir?.interaction?.events);
|
|
73
94
|
return {
|
|
74
95
|
directives: {
|
|
75
96
|
visible: { ...d.visible, ...dir?.visible },
|
|
@@ -105,7 +126,7 @@ function buildIslandMap(payload) {
|
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
// src/config-policy.ts
|
|
108
|
-
var
|
|
129
|
+
var PREFIX2 = "[vite-plugin-shopify-theme-islands]";
|
|
109
130
|
function mergeDirectives(directives) {
|
|
110
131
|
return {
|
|
111
132
|
visible: { ...DEFAULT_DIRECTIVES.visible, ...directives?.visible },
|
|
@@ -118,19 +139,21 @@ function mergeDirectives(directives) {
|
|
|
118
139
|
function validateOptions(options, directives) {
|
|
119
140
|
const customDefs = options.directives?.custom ?? [];
|
|
120
141
|
if (Array.isArray(options.directories) && options.directories.length === 0) {
|
|
121
|
-
throw new Error(`${
|
|
142
|
+
throw new Error(`${PREFIX2} "directories" must not be empty`);
|
|
122
143
|
}
|
|
123
144
|
const threshold = options.directives?.visible?.threshold;
|
|
124
145
|
if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
|
|
125
|
-
throw new Error(`${
|
|
146
|
+
throw new Error(`${PREFIX2} "directives.visible.threshold" must be between 0 and 1, got ${threshold}`);
|
|
126
147
|
}
|
|
148
|
+
const interactionEvents = options.directives?.interaction?.events;
|
|
149
|
+
validateInteractionEvents(interactionEvents);
|
|
127
150
|
if (options.retry !== undefined) {
|
|
128
151
|
const { retries, delay } = options.retry;
|
|
129
152
|
if (retries !== undefined && retries < 0) {
|
|
130
|
-
throw new Error(`${
|
|
153
|
+
throw new Error(`${PREFIX2} "retry.retries" must be >= 0, got ${retries}`);
|
|
131
154
|
}
|
|
132
155
|
if (delay !== undefined && delay < 0) {
|
|
133
|
-
throw new Error(`${
|
|
156
|
+
throw new Error(`${PREFIX2} "retry.delay" must be >= 0, got ${delay}`);
|
|
134
157
|
}
|
|
135
158
|
}
|
|
136
159
|
const builtinAttributes = new Set([
|
|
@@ -143,10 +166,10 @@ function validateOptions(options, directives) {
|
|
|
143
166
|
const seen = new Set;
|
|
144
167
|
for (const def of customDefs) {
|
|
145
168
|
if (seen.has(def.name)) {
|
|
146
|
-
throw new Error(`${
|
|
169
|
+
throw new Error(`${PREFIX2} Duplicate custom directive name: "${def.name}"`);
|
|
147
170
|
}
|
|
148
171
|
if (builtinAttributes.has(def.name)) {
|
|
149
|
-
throw new Error(`${
|
|
172
|
+
throw new Error(`${PREFIX2} Custom directive "${def.name}" conflicts with a built-in directive`);
|
|
150
173
|
}
|
|
151
174
|
seen.add(def.name);
|
|
152
175
|
}
|
|
@@ -357,5 +380,8 @@ function shopifyThemeIslands(options = {}) {
|
|
|
357
380
|
};
|
|
358
381
|
}
|
|
359
382
|
export {
|
|
360
|
-
|
|
383
|
+
isInteractionEventName,
|
|
384
|
+
shopifyThemeIslands as default,
|
|
385
|
+
INTERACTION_EVENT_NAMES,
|
|
386
|
+
DEFAULT_INTERACTION_EVENTS
|
|
361
387
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated interaction events accepted by `client:interaction`.
|
|
3
|
+
*
|
|
4
|
+
* The list is intentionally narrow: these are the user-intent signals the
|
|
5
|
+
* plugin supports for now, and they are exposed as a package-owned union so
|
|
6
|
+
* config can be type-checked without relying on the DOM lib surface.
|
|
7
|
+
*/
|
|
8
|
+
export declare const INTERACTION_EVENT_NAMES: readonly ["mouseenter", "touchstart", "focusin"];
|
|
9
|
+
export type InteractionEventName = (typeof INTERACTION_EVENT_NAMES)[number];
|
|
10
|
+
export declare const DEFAULT_INTERACTION_EVENTS: readonly ["mouseenter", "touchstart", "focusin"];
|
|
11
|
+
export declare function isInteractionEventName(value: string): value is InteractionEventName;
|
|
12
|
+
export declare function validateInteractionEvents(events: readonly string[] | undefined): asserts events is readonly InteractionEventName[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IslandLoader } from "./contract.js";
|
|
2
|
+
export interface IslandLifecycleStartInput {
|
|
3
|
+
getRoot(): HTMLElement | null;
|
|
4
|
+
islandMap: Map<string, IslandLoader>;
|
|
5
|
+
onActivate(tagName: string, el: HTMLElement, loader: () => Promise<unknown>): void;
|
|
6
|
+
onBeforeInitialWalk?: () => void;
|
|
7
|
+
onInitialWalkComplete?: () => void;
|
|
8
|
+
}
|
|
9
|
+
export interface IslandLifecycle {
|
|
10
|
+
settleSuccess(tag: string): number;
|
|
11
|
+
settleFailure(tag: string): {
|
|
12
|
+
retryDelayMs: number | null;
|
|
13
|
+
attempt: number;
|
|
14
|
+
};
|
|
15
|
+
evict(tag: string): void;
|
|
16
|
+
isQueued(tag: string): boolean;
|
|
17
|
+
readonly initialWalkComplete: boolean;
|
|
18
|
+
watchCancellable(el: Element, cancel: () => void): () => void;
|
|
19
|
+
walk(root: HTMLElement): void;
|
|
20
|
+
start(input: IslandLifecycleStartInput): {
|
|
21
|
+
disconnect: () => void;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export declare function createIslandLifecycleCoordinator(opts: {
|
|
25
|
+
retries: number;
|
|
26
|
+
retryDelay: number;
|
|
27
|
+
}): IslandLifecycle;
|
package/dist/options.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RetryConfig } from "./contract.js";
|
|
2
|
+
import type { InteractionEventName } from "./interaction-events.js";
|
|
2
3
|
/** Plugin option entry for registering a custom client directive. */
|
|
3
4
|
export interface ClientDirectiveDefinition {
|
|
4
5
|
/** HTML attribute name, e.g. `'client:on-click'` */
|
|
@@ -40,8 +41,8 @@ export interface DirectivesConfig {
|
|
|
40
41
|
interaction?: {
|
|
41
42
|
/** HTML attribute name. Default: `'client:interaction'` */
|
|
42
43
|
attribute?: string;
|
|
43
|
-
/**
|
|
44
|
-
events?:
|
|
44
|
+
/** Curated intent events to listen for. Default: `['mouseenter', 'touchstart', 'focusin']` */
|
|
45
|
+
events?: readonly InteractionEventName[];
|
|
45
46
|
};
|
|
46
47
|
/** Custom client directives to register. Each entry maps an attribute name to a module entrypoint. */
|
|
47
48
|
custom?: ClientDirectiveDefinition[];
|
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
// src/interaction-events.ts
|
|
2
|
+
var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"];
|
|
3
|
+
var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES];
|
|
4
|
+
var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
|
|
5
|
+
var PREFIX = "[vite-plugin-shopify-theme-islands]";
|
|
6
|
+
function isInteractionEventName(value) {
|
|
7
|
+
return INTERACTION_EVENT_NAME_SET.has(value);
|
|
8
|
+
}
|
|
9
|
+
function validateInteractionEvents(events) {
|
|
10
|
+
if (events === undefined)
|
|
11
|
+
return;
|
|
12
|
+
if (events.length === 0) {
|
|
13
|
+
throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
|
|
14
|
+
}
|
|
15
|
+
const invalidEvent = events.find((eventName) => !isInteractionEventName(eventName));
|
|
16
|
+
if (invalidEvent) {
|
|
17
|
+
throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
1
21
|
// src/contract.ts
|
|
2
22
|
var DEFAULT_DIRECTIVES = {
|
|
3
23
|
visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
|
|
@@ -6,7 +26,7 @@ var DEFAULT_DIRECTIVES = {
|
|
|
6
26
|
defer: { attribute: "client:defer", delay: 3000 },
|
|
7
27
|
interaction: {
|
|
8
28
|
attribute: "client:interaction",
|
|
9
|
-
events: [
|
|
29
|
+
events: [...DEFAULT_INTERACTION_EVENTS]
|
|
10
30
|
}
|
|
11
31
|
};
|
|
12
32
|
var DEFAULT_RETRY = { retries: 0, delay: 1000 };
|
|
@@ -14,6 +34,7 @@ function normalizeReviveOptions(options) {
|
|
|
14
34
|
const d = DEFAULT_DIRECTIVES;
|
|
15
35
|
const r = DEFAULT_RETRY;
|
|
16
36
|
const dir = options?.directives;
|
|
37
|
+
validateInteractionEvents(dir?.interaction?.events);
|
|
17
38
|
return {
|
|
18
39
|
directives: {
|
|
19
40
|
visible: { ...d.visible, ...dir?.visible },
|
|
@@ -160,7 +181,7 @@ function createDirectiveOrchestrator(waiters = {
|
|
|
160
181
|
}
|
|
161
182
|
const interactionAttr = el.getAttribute(directives.interaction.attribute);
|
|
162
183
|
if (interactionAttr !== null) {
|
|
163
|
-
let events = directives.interaction.events;
|
|
184
|
+
let events = [...directives.interaction.events];
|
|
164
185
|
if (interactionAttr) {
|
|
165
186
|
const tokens = interactionAttr.split(/\s+/).filter(Boolean);
|
|
166
187
|
if (tokens.length > 0)
|
|
@@ -231,6 +252,145 @@ function createDirectiveOrchestrator(waiters = {
|
|
|
231
252
|
};
|
|
232
253
|
}
|
|
233
254
|
|
|
255
|
+
// src/lifecycle.ts
|
|
256
|
+
function createIslandLifecycleCoordinator(opts) {
|
|
257
|
+
const queued = new Set;
|
|
258
|
+
const loaded = new Set;
|
|
259
|
+
const retryCount = new Map;
|
|
260
|
+
const cancellableElements = new Map;
|
|
261
|
+
let initialWalkComplete = false;
|
|
262
|
+
let walkImpl;
|
|
263
|
+
const queue = (tag) => {
|
|
264
|
+
if (queued.has(tag) || loaded.has(tag))
|
|
265
|
+
return false;
|
|
266
|
+
queued.add(tag);
|
|
267
|
+
return true;
|
|
268
|
+
};
|
|
269
|
+
const settleSuccess = (tag) => {
|
|
270
|
+
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
271
|
+
queued.delete(tag);
|
|
272
|
+
loaded.add(tag);
|
|
273
|
+
retryCount.delete(tag);
|
|
274
|
+
return attempt;
|
|
275
|
+
};
|
|
276
|
+
const settleFailure = (tag) => {
|
|
277
|
+
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
278
|
+
if (attempt <= opts.retries) {
|
|
279
|
+
retryCount.set(tag, attempt);
|
|
280
|
+
return { retryDelayMs: opts.retryDelay * 2 ** (attempt - 1), attempt };
|
|
281
|
+
}
|
|
282
|
+
retryCount.delete(tag);
|
|
283
|
+
queued.delete(tag);
|
|
284
|
+
return { retryDelayMs: null, attempt };
|
|
285
|
+
};
|
|
286
|
+
const evict = (tag) => {
|
|
287
|
+
retryCount.delete(tag);
|
|
288
|
+
queued.delete(tag);
|
|
289
|
+
};
|
|
290
|
+
const isQueued = (tag) => queued.has(tag);
|
|
291
|
+
const watchCancellable = (el, cancel) => {
|
|
292
|
+
cancellableElements.set(el, cancel);
|
|
293
|
+
return () => {
|
|
294
|
+
cancellableElements.delete(el);
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
const cancelDetached = () => {
|
|
298
|
+
if (cancellableElements.size === 0)
|
|
299
|
+
return;
|
|
300
|
+
for (const [el, cancel] of cancellableElements) {
|
|
301
|
+
if (!el.isConnected) {
|
|
302
|
+
cancellableElements.delete(el);
|
|
303
|
+
cancel();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const start = (input) => {
|
|
308
|
+
let disconnected = false;
|
|
309
|
+
let initialized = false;
|
|
310
|
+
const customElementFilter = {
|
|
311
|
+
acceptNode: (node) => {
|
|
312
|
+
const tag = node.tagName;
|
|
313
|
+
if (!tag.includes("-"))
|
|
314
|
+
return NodeFilter.FILTER_SKIP;
|
|
315
|
+
return isQueued(tag.toLowerCase()) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const activate = (el) => {
|
|
319
|
+
const tagName = el.tagName.toLowerCase();
|
|
320
|
+
const loader = input.islandMap.get(tagName);
|
|
321
|
+
if (!loader)
|
|
322
|
+
return;
|
|
323
|
+
let ancestor = el.parentElement;
|
|
324
|
+
while (ancestor) {
|
|
325
|
+
if (isQueued(ancestor.tagName.toLowerCase()))
|
|
326
|
+
return;
|
|
327
|
+
ancestor = ancestor.parentElement;
|
|
328
|
+
}
|
|
329
|
+
if (!queue(tagName))
|
|
330
|
+
return;
|
|
331
|
+
input.onActivate(tagName, el, loader);
|
|
332
|
+
};
|
|
333
|
+
const walk = (el) => {
|
|
334
|
+
activate(el);
|
|
335
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, customElementFilter);
|
|
336
|
+
let node;
|
|
337
|
+
while (node = walker.nextNode())
|
|
338
|
+
activate(node);
|
|
339
|
+
};
|
|
340
|
+
walkImpl = walk;
|
|
341
|
+
const handleAdditions = (mutations) => {
|
|
342
|
+
for (const { addedNodes } of mutations) {
|
|
343
|
+
for (const node of addedNodes) {
|
|
344
|
+
if (node.nodeType === Node.ELEMENT_NODE)
|
|
345
|
+
walk(node);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const observer = new MutationObserver((mutations) => {
|
|
350
|
+
cancelDetached();
|
|
351
|
+
handleAdditions(mutations);
|
|
352
|
+
});
|
|
353
|
+
const init = () => {
|
|
354
|
+
if (disconnected || initialized)
|
|
355
|
+
return;
|
|
356
|
+
const root = input.getRoot();
|
|
357
|
+
if (!root)
|
|
358
|
+
return;
|
|
359
|
+
initialized = true;
|
|
360
|
+
input.onBeforeInitialWalk?.();
|
|
361
|
+
walk(root);
|
|
362
|
+
initialWalkComplete = true;
|
|
363
|
+
input.onInitialWalkComplete?.();
|
|
364
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
365
|
+
};
|
|
366
|
+
if (document.readyState === "loading") {
|
|
367
|
+
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
368
|
+
} else {
|
|
369
|
+
init();
|
|
370
|
+
}
|
|
371
|
+
const disconnect = () => {
|
|
372
|
+
disconnected = true;
|
|
373
|
+
document.removeEventListener("DOMContentLoaded", init);
|
|
374
|
+
observer.disconnect();
|
|
375
|
+
};
|
|
376
|
+
return { disconnect };
|
|
377
|
+
};
|
|
378
|
+
return {
|
|
379
|
+
settleSuccess,
|
|
380
|
+
settleFailure,
|
|
381
|
+
evict,
|
|
382
|
+
isQueued,
|
|
383
|
+
get initialWalkComplete() {
|
|
384
|
+
return initialWalkComplete;
|
|
385
|
+
},
|
|
386
|
+
watchCancellable,
|
|
387
|
+
walk(root) {
|
|
388
|
+
walkImpl?.(root);
|
|
389
|
+
},
|
|
390
|
+
start
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
234
394
|
// src/runtime-surface.ts
|
|
235
395
|
var SILENT_LOGGER = {
|
|
236
396
|
note() {},
|
|
@@ -300,68 +460,6 @@ function getRuntimeSurface() {
|
|
|
300
460
|
function isRevivePayload(v) {
|
|
301
461
|
return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v);
|
|
302
462
|
}
|
|
303
|
-
function createIslandRegistry(opts) {
|
|
304
|
-
const queued = new Set;
|
|
305
|
-
const loaded = new Set;
|
|
306
|
-
const retryCount = new Map;
|
|
307
|
-
const cancellableElements = new Map;
|
|
308
|
-
let initialWalkComplete = false;
|
|
309
|
-
return {
|
|
310
|
-
queue(tag) {
|
|
311
|
-
if (queued.has(tag) || loaded.has(tag))
|
|
312
|
-
return false;
|
|
313
|
-
queued.add(tag);
|
|
314
|
-
return true;
|
|
315
|
-
},
|
|
316
|
-
settleSuccess(tag) {
|
|
317
|
-
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
318
|
-
queued.delete(tag);
|
|
319
|
-
loaded.add(tag);
|
|
320
|
-
retryCount.delete(tag);
|
|
321
|
-
return attempt;
|
|
322
|
-
},
|
|
323
|
-
settleFailure(tag) {
|
|
324
|
-
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
325
|
-
if (attempt <= opts.retries) {
|
|
326
|
-
retryCount.set(tag, attempt);
|
|
327
|
-
return { retryDelayMs: opts.retryDelay * 2 ** (attempt - 1), attempt };
|
|
328
|
-
} else {
|
|
329
|
-
retryCount.delete(tag);
|
|
330
|
-
queued.delete(tag);
|
|
331
|
-
return { retryDelayMs: null, attempt };
|
|
332
|
-
}
|
|
333
|
-
},
|
|
334
|
-
evict(tag) {
|
|
335
|
-
retryCount.delete(tag);
|
|
336
|
-
queued.delete(tag);
|
|
337
|
-
},
|
|
338
|
-
isQueued(tag) {
|
|
339
|
-
return queued.has(tag);
|
|
340
|
-
},
|
|
341
|
-
get initialWalkComplete() {
|
|
342
|
-
return initialWalkComplete;
|
|
343
|
-
},
|
|
344
|
-
markInitialWalkComplete() {
|
|
345
|
-
initialWalkComplete = true;
|
|
346
|
-
},
|
|
347
|
-
watchCancellable(el, cancel) {
|
|
348
|
-
cancellableElements.set(el, cancel);
|
|
349
|
-
return () => {
|
|
350
|
-
cancellableElements.delete(el);
|
|
351
|
-
};
|
|
352
|
-
},
|
|
353
|
-
cancelDetached() {
|
|
354
|
-
if (cancellableElements.size === 0)
|
|
355
|
-
return;
|
|
356
|
-
for (const [el, cancel] of cancellableElements) {
|
|
357
|
-
if (!el.isConnected) {
|
|
358
|
-
cancellableElements.delete(el);
|
|
359
|
-
cancel();
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
463
|
function revive(islandsOrPayload, options, customDirectives) {
|
|
366
464
|
const runtimeSurface2 = getRuntimeSurface();
|
|
367
465
|
const payload = isRevivePayload(islandsOrPayload) ? islandsOrPayload : { islands: islandsOrPayload, options, customDirectives };
|
|
@@ -375,24 +473,14 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
375
473
|
const attrInteraction = opts.directives.interaction.attribute;
|
|
376
474
|
const debug = opts.debug;
|
|
377
475
|
const directiveTimeout = opts.directiveTimeout;
|
|
378
|
-
const
|
|
476
|
+
const lifecycle = createIslandLifecycleCoordinator({
|
|
379
477
|
retries: opts.retry.retries,
|
|
380
478
|
retryDelay: opts.retry.delay
|
|
381
479
|
});
|
|
382
480
|
const directiveOrchestrator = createDirectiveOrchestrator();
|
|
383
|
-
|
|
384
|
-
acceptNode: (node) => {
|
|
385
|
-
const tag = node.tagName;
|
|
386
|
-
if (!tag.includes("-"))
|
|
387
|
-
return NodeFilter.FILTER_SKIP;
|
|
388
|
-
const lowerTag = tag.toLowerCase();
|
|
389
|
-
if (registry.isQueued(lowerTag))
|
|
390
|
-
return NodeFilter.FILTER_REJECT;
|
|
391
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
392
|
-
}
|
|
393
|
-
};
|
|
481
|
+
let disconnected = false;
|
|
394
482
|
async function loadIsland(tagName, el, loader) {
|
|
395
|
-
if (debug && !
|
|
483
|
+
if (debug && !lifecycle.initialWalkComplete) {
|
|
396
484
|
const parts = [];
|
|
397
485
|
const pushAttr = (attr, val) => {
|
|
398
486
|
if (val !== null)
|
|
@@ -420,17 +508,17 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
420
508
|
return Promise.resolve();
|
|
421
509
|
const t0 = performance.now();
|
|
422
510
|
return loader().then(() => {
|
|
423
|
-
const attempt =
|
|
511
|
+
const attempt = lifecycle.settleSuccess(tagName);
|
|
424
512
|
runtimeSurface2.dispatchLoad({
|
|
425
513
|
tag: tagName,
|
|
426
514
|
duration: performance.now() - t0,
|
|
427
515
|
attempt
|
|
428
516
|
});
|
|
429
|
-
if (el.children.length)
|
|
430
|
-
walk(el);
|
|
517
|
+
if (!disconnected && el.children.length)
|
|
518
|
+
lifecycle.walk(el);
|
|
431
519
|
}).catch((err) => {
|
|
432
520
|
console.error(`[islands] Failed to load <${tagName}>:`, err);
|
|
433
|
-
const { retryDelayMs, attempt } =
|
|
521
|
+
const { retryDelayMs, attempt } = lifecycle.settleFailure(tagName);
|
|
434
522
|
runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt });
|
|
435
523
|
if (retryDelayMs !== null) {
|
|
436
524
|
setTimeout(run, retryDelayMs);
|
|
@@ -446,7 +534,7 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
446
534
|
console.error(`[islands] Built-in directive failed for <${tagName}>:`, err);
|
|
447
535
|
}
|
|
448
536
|
runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt: 1 });
|
|
449
|
-
|
|
537
|
+
lifecycle.evict(tagName);
|
|
450
538
|
};
|
|
451
539
|
try {
|
|
452
540
|
const matchedCustomDirectives = await directiveOrchestrator.run({
|
|
@@ -455,7 +543,7 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
455
543
|
directives: opts.directives,
|
|
456
544
|
customDirectives: resolvedDirectives,
|
|
457
545
|
directiveTimeout,
|
|
458
|
-
watchCancellable:
|
|
546
|
+
watchCancellable: lifecycle.watchCancellable,
|
|
459
547
|
log,
|
|
460
548
|
run,
|
|
461
549
|
onError: handleDirectiveError
|
|
@@ -470,63 +558,27 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
470
558
|
log.flush("triggered");
|
|
471
559
|
run();
|
|
472
560
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
if (!registry.queue(tagName))
|
|
485
|
-
return;
|
|
486
|
-
loadIsland(tagName, el, loader);
|
|
487
|
-
}
|
|
488
|
-
function walk(el) {
|
|
489
|
-
activate(el);
|
|
490
|
-
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, customElementFilter);
|
|
491
|
-
let node;
|
|
492
|
-
while (node = walker.nextNode())
|
|
493
|
-
activate(node);
|
|
494
|
-
}
|
|
495
|
-
function handleAdditions(mutations) {
|
|
496
|
-
for (const { addedNodes } of mutations) {
|
|
497
|
-
for (const node of addedNodes) {
|
|
498
|
-
if (node.nodeType === Node.ELEMENT_NODE)
|
|
499
|
-
walk(node);
|
|
500
|
-
}
|
|
561
|
+
let endReadyLog;
|
|
562
|
+
const disconnectLifecycle = lifecycle.start({
|
|
563
|
+
getRoot: () => document.body,
|
|
564
|
+
islandMap,
|
|
565
|
+
onActivate: loadIsland,
|
|
566
|
+
onBeforeInitialWalk: () => {
|
|
567
|
+
endReadyLog = runtimeSurface2.beginReadyLog(islandMap.size, debug);
|
|
568
|
+
},
|
|
569
|
+
onInitialWalkComplete: () => {
|
|
570
|
+
endReadyLog?.();
|
|
571
|
+
endReadyLog = undefined;
|
|
501
572
|
}
|
|
502
|
-
}
|
|
503
|
-
const observer = new MutationObserver((mutations) => {
|
|
504
|
-
registry.cancelDetached();
|
|
505
|
-
handleAdditions(mutations);
|
|
506
573
|
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
walk(document.body);
|
|
515
|
-
registry.markInitialWalkComplete();
|
|
516
|
-
endReadyLog();
|
|
517
|
-
observer.observe(document.body, { childList: true, subtree: true });
|
|
518
|
-
}
|
|
519
|
-
if (document.readyState === "loading") {
|
|
520
|
-
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
521
|
-
} else {
|
|
522
|
-
init();
|
|
523
|
-
}
|
|
524
|
-
const disconnect = () => {
|
|
525
|
-
disconnected = true;
|
|
526
|
-
document.removeEventListener("DOMContentLoaded", init);
|
|
527
|
-
observer.disconnect();
|
|
574
|
+
return {
|
|
575
|
+
disconnect() {
|
|
576
|
+
disconnected = true;
|
|
577
|
+
endReadyLog?.();
|
|
578
|
+
endReadyLog = undefined;
|
|
579
|
+
disconnectLifecycle.disconnect();
|
|
580
|
+
}
|
|
528
581
|
};
|
|
529
|
-
return { disconnect };
|
|
530
582
|
}
|
|
531
583
|
export {
|
|
532
584
|
revive
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ description: >
|
|
|
10
10
|
are owned by src/directive-orchestration.ts.
|
|
11
11
|
type: core
|
|
12
12
|
library: vite-plugin-shopify-theme-islands
|
|
13
|
-
library_version: "1.
|
|
13
|
+
library_version: "1.3.0"
|
|
14
14
|
sources:
|
|
15
15
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
16
16
|
- Rees1993/vite-plugin-shopify-theme-islands:src/directive-orchestration.ts
|
|
@@ -7,16 +7,19 @@ description: >
|
|
|
7
7
|
Directives resolve sequentially — visible → media → idle → defer →
|
|
8
8
|
interaction → custom. Per-element value overrides. Empty client:media
|
|
9
9
|
warning. Whitespace-only client:interaction values warn and fall back to
|
|
10
|
-
default events.
|
|
11
|
-
|
|
10
|
+
default events. Global `directives.interaction.events` config is intentionally
|
|
11
|
+
narrowed to the curated set `mouseenter`, `touchstart`, and `focusin`.
|
|
12
|
+
Current directive sequencing and custom-directive latching are owned by
|
|
13
|
+
src/directive-orchestration.ts.
|
|
12
14
|
type: core
|
|
13
15
|
library: vite-plugin-shopify-theme-islands
|
|
14
|
-
library_version: "1.
|
|
16
|
+
library_version: "1.3.0"
|
|
15
17
|
sources:
|
|
16
18
|
- Rees1993/vite-plugin-shopify-theme-islands:src/directive-orchestration.ts
|
|
17
19
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
|
|
18
20
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
19
21
|
- Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
|
|
22
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/interaction-events.ts
|
|
20
23
|
---
|
|
21
24
|
|
|
22
25
|
## Setup
|
|
@@ -70,11 +73,12 @@ Combined directives are AND-latched. The island loads only after every condition
|
|
|
70
73
|
<!-- Fixed delay in ms; empty attribute uses the global default (3000ms) -->
|
|
71
74
|
<chat-widget client:defer="8000"></chat-widget>
|
|
72
75
|
|
|
73
|
-
<!-- Override interaction events for this element only
|
|
76
|
+
<!-- Override interaction events for this element only -->
|
|
74
77
|
<cart-flyout client:interaction="mouseenter"></cart-flyout>
|
|
75
78
|
```
|
|
76
79
|
|
|
77
80
|
The attribute value overrides the globally configured default for that element. Other elements are unaffected.
|
|
81
|
+
In config, `directives.interaction.events` is stricter and only accepts the curated package-owned list: `mouseenter`, `touchstart`, and `focusin`.
|
|
78
82
|
|
|
79
83
|
### `client:defer` without a value uses the global default
|
|
80
84
|
|
|
@@ -243,6 +247,32 @@ Directive attributes are case-sensitive. An unrecognised attribute is silently i
|
|
|
243
247
|
|
|
244
248
|
Source: src/directive-orchestration.ts — built-ins read exact configured attribute names
|
|
245
249
|
|
|
250
|
+
### HIGH Unsupported interaction events in config fail plugin setup
|
|
251
|
+
|
|
252
|
+
Wrong:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
shopifyThemeIslands({
|
|
256
|
+
directives: {
|
|
257
|
+
interaction: { events: ["click"] as never[] },
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Correct:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
shopifyThemeIslands({
|
|
266
|
+
directives: {
|
|
267
|
+
interaction: { events: ["mouseenter", "focusin"] },
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The package now owns a curated interaction-event vocabulary for config. Supported values are `mouseenter`, `touchstart`, and `focusin`; unsupported names and empty arrays are rejected during config resolution.
|
|
273
|
+
|
|
274
|
+
Source: src/interaction-events.ts — validateInteractionEvents()
|
|
275
|
+
|
|
246
276
|
### HIGH Agent uses default attribute name when developer has configured a custom one
|
|
247
277
|
|
|
248
278
|
Wrong:
|
|
@@ -9,13 +9,15 @@ description: >
|
|
|
9
9
|
error, and attempt, including custom directive failures and directiveTimeout
|
|
10
10
|
expiry. disconnect() from the virtual module revive for SPA navigation
|
|
11
11
|
teardown, including before DOMContentLoaded — it now prevents init from ever
|
|
12
|
-
starting if called early.
|
|
12
|
+
starting if called early. Startup, DOM walking, mutation observation, and
|
|
13
|
+
parent/child activation gating are now owned by src/lifecycle.ts.
|
|
13
14
|
type: core
|
|
14
15
|
library: vite-plugin-shopify-theme-islands
|
|
15
|
-
library_version: "1.
|
|
16
|
+
library_version: "1.3.0"
|
|
16
17
|
sources:
|
|
17
18
|
- Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
|
|
18
19
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime-surface.ts
|
|
20
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/lifecycle.ts
|
|
19
21
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
20
22
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
|
|
21
23
|
- Rees1993/vite-plugin-shopify-theme-islands:src/revive-module.ts
|
|
@@ -90,6 +92,8 @@ disconnect();
|
|
|
90
92
|
|
|
91
93
|
`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.
|
|
92
94
|
|
|
95
|
+
The startup walk itself is now lifecycle-owned. The runtime resolves the root lazily at init time, then the lifecycle coordinator performs the initial walk, begins observing subtree additions, and keeps child islands gated behind queued parents until the parent resolves.
|
|
96
|
+
|
|
93
97
|
### Raw DOM events (when type augmentation is in scope)
|
|
94
98
|
|
|
95
99
|
```ts
|
|
@@ -192,4 +196,6 @@ Source: src/runtime.ts — handleDirectiveError dispatches `islands:error`
|
|
|
192
196
|
|
|
193
197
|
### LOW Removed elements waiting on `client:visible` / `client:interaction` do not emit `islands:error`
|
|
194
198
|
|
|
195
|
-
If an element is removed from the DOM before a cancellable built-in directive resolves, the runtime treats
|
|
199
|
+
If an element is removed from the DOM before a cancellable built-in directive resolves, the lifecycle coordinator cancels that activation attempt and the runtime treats it as expected teardown. No `islands:error` event is dispatched.
|
|
200
|
+
|
|
201
|
+
Source: src/lifecycle.ts — cancelDetached() with watchCancellable() ownership
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -5,17 +5,19 @@ description: >
|
|
|
5
5
|
install to first working island. shopifyThemeIslands() options: directories
|
|
6
6
|
(string | string[]), debug, directives deep-merge (visible, idle, media,
|
|
7
7
|
defer, interaction, custom), retry (retries, delay with exponential
|
|
8
|
-
backoff),
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
backoff), directiveTimeout for hung custom directives, and the curated
|
|
9
|
+
interaction-event config policy (`mouseenter`, `touchstart`, `focusin`; empty
|
|
10
|
+
arrays rejected). Load when setting up the plugin, configuring island scan
|
|
11
|
+
directories, or enabling retry / directive timeout.
|
|
11
12
|
type: core
|
|
12
13
|
library: vite-plugin-shopify-theme-islands
|
|
13
|
-
library_version: "1.
|
|
14
|
+
library_version: "1.3.0"
|
|
14
15
|
sources:
|
|
15
16
|
- Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
|
|
16
17
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
17
18
|
- Rees1993/vite-plugin-shopify-theme-islands:src/options.ts
|
|
18
19
|
- Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
|
|
20
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/interaction-events.ts
|
|
19
21
|
---
|
|
20
22
|
|
|
21
23
|
## Setup
|
|
@@ -83,6 +85,7 @@ shopifyThemeIslands({
|
|
|
83
85
|
```
|
|
84
86
|
|
|
85
87
|
Per-directive options are deep-merged — overriding `visible.rootMargin` preserves `visible.threshold` at its default of `0`.
|
|
88
|
+
For config, `directives.interaction.events` is intentionally narrow and only accepts `mouseenter`, `touchstart`, and `focusin`.
|
|
86
89
|
|
|
87
90
|
### Enable automatic retry with exponential backoff
|
|
88
91
|
|
|
@@ -253,3 +256,35 @@ shopifyThemeIslands({
|
|
|
253
256
|
`directiveTimeout` is a top-level plugin option, not part of the per-directive config object.
|
|
254
257
|
|
|
255
258
|
Source: src/options.ts — ShopifyThemeIslandsOptions
|
|
259
|
+
|
|
260
|
+
### HIGH Empty or unsupported `directives.interaction.events` values fail config resolution
|
|
261
|
+
|
|
262
|
+
Wrong:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
shopifyThemeIslands({
|
|
266
|
+
directives: {
|
|
267
|
+
interaction: { events: [] },
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
shopifyThemeIslands({
|
|
272
|
+
directives: {
|
|
273
|
+
interaction: { events: ["click"] as never[] },
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Correct:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
shopifyThemeIslands({
|
|
282
|
+
directives: {
|
|
283
|
+
interaction: { events: ["mouseenter", "focusin"] },
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The typed config surface only supports the package-owned interaction events `mouseenter`, `touchstart`, and `focusin`. An empty array is rejected because it would otherwise create an interaction gate that never resolves.
|
|
289
|
+
|
|
290
|
+
Source: src/interaction-events.ts — validateInteractionEvents()
|
|
@@ -5,14 +5,16 @@ description: >
|
|
|
5
5
|
configured directories auto-discovered by tag name = filename) and Island
|
|
6
6
|
mixin (import Island from vite-plugin-shopify-theme-islands/island to mark
|
|
7
7
|
files anywhere in the project). Covers customElements.define, the Island
|
|
8
|
-
base class, and child island cascade behaviour
|
|
8
|
+
base class, and child island cascade behaviour now owned by
|
|
9
|
+
src/lifecycle.ts.
|
|
9
10
|
type: core
|
|
10
11
|
library: vite-plugin-shopify-theme-islands
|
|
11
|
-
library_version: "1.
|
|
12
|
+
library_version: "1.3.0"
|
|
12
13
|
sources:
|
|
13
14
|
- Rees1993/vite-plugin-shopify-theme-islands:src/island.ts
|
|
14
15
|
- Rees1993/vite-plugin-shopify-theme-islands:src/discovery.ts
|
|
15
16
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
17
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/lifecycle.ts
|
|
16
18
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
|
|
17
19
|
---
|
|
18
20
|
|
|
@@ -82,6 +84,7 @@ Required when multiple entry points might import the same island file.
|
|
|
82
84
|
```
|
|
83
85
|
|
|
84
86
|
`cart-line-item` is not activated until `cart-drawer`'s module has resolved. The runtime's TreeWalker rejects subtrees of unloaded parent islands and re-walks them after the parent loads.
|
|
87
|
+
That parent/child gating now lives in the lifecycle coordinator, but the user-facing behavior is the same: nested islands wait for the queued parent to settle before their own activation starts.
|
|
85
88
|
|
|
86
89
|
### Vite alias in directories
|
|
87
90
|
|
|
@@ -188,4 +191,4 @@ Wrong assumption:
|
|
|
188
191
|
|
|
189
192
|
`cart-line-item`'s `client:idle` wait does **not** begin until `cart-drawer` has finished loading. The cascade is sequential, not parallel.
|
|
190
193
|
|
|
191
|
-
Source: src/
|
|
194
|
+
Source: src/lifecycle.ts — customElementFilter NodeFilter.FILTER_REJECT, walk() after parent loads
|