vite-plugin-shopify-theme-islands 1.2.2 → 1.3.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 +12 -2
- package/dist/contract.d.ts +3 -2
- package/dist/discovery.d.ts +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +155 -73
- package/dist/interaction-events.d.ts +18 -0
- package/dist/lifecycle.d.ts +27 -0
- package/dist/options.d.ts +3 -2
- package/dist/runtime.js +214 -140
- package/package.json +1 -1
- package/skills/custom-directives/SKILL.md +1 -1
- package/skills/directives/SKILL.md +69 -5
- package/skills/lifecycle/SKILL.md +12 -3
- package/skills/setup/SKILL.md +47 -4
- package/skills/writing-islands/SKILL.md +6 -3
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
|
|
|
44
44
|
disconnect();
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
If `disconnect()` is called before `DOMContentLoaded`, the runtime also cancels its pending startup listener so islands never initialize later against stale DOM.
|
|
48
|
+
|
|
47
49
|
## Writing islands
|
|
48
50
|
|
|
49
51
|
Two approaches — use either or both.
|
|
@@ -200,7 +202,7 @@ Loads the island when the user interacts with the element. Listens for `mouseent
|
|
|
200
202
|
</cart-flyout>
|
|
201
203
|
```
|
|
202
204
|
|
|
203
|
-
The attribute value overrides the events for that element only
|
|
205
|
+
The attribute value overrides the events for that element only:
|
|
204
206
|
|
|
205
207
|
```html
|
|
206
208
|
<!-- only mouseenter — touchstart and focusin are excluded -->
|
|
@@ -209,6 +211,10 @@ The attribute value overrides the events for that element only (space-separated
|
|
|
209
211
|
</cart-flyout>
|
|
210
212
|
```
|
|
211
213
|
|
|
214
|
+
In plugin config, `directives.interaction.events` is intentionally narrower than the raw HTML attribute surface. The typed config only accepts the curated package-owned set `mouseenter`, `touchstart`, and `focusin`, and rejects empty arrays.
|
|
215
|
+
|
|
216
|
+
Per-element `client:interaction="..."` values are also validated at runtime against that same curated set. Unsupported tokens log a warning and are ignored. If no supported tokens remain, the runtime logs a warning and falls back to the configured default interaction events instead of attaching unsupported listeners.
|
|
217
|
+
|
|
212
218
|
Combine with `client:visible` to avoid attaching listeners to off-screen elements. Because directives resolve sequentially, interaction listeners are only registered once the element has entered the viewport:
|
|
213
219
|
|
|
214
220
|
```html
|
|
@@ -362,7 +368,7 @@ shopifyThemeIslands({
|
|
|
362
368
|
},
|
|
363
369
|
interaction: {
|
|
364
370
|
attribute: "client:interaction", // HTML attribute name
|
|
365
|
-
events: ["mouseenter", "touchstart", "focusin"], //
|
|
371
|
+
events: ["mouseenter", "touchstart", "focusin"], // curated config events that trigger load
|
|
366
372
|
},
|
|
367
373
|
custom: [], // custom directives — see Custom directives above
|
|
368
374
|
},
|
|
@@ -380,6 +386,8 @@ shopifyThemeIslands({
|
|
|
380
386
|
});
|
|
381
387
|
```
|
|
382
388
|
|
|
389
|
+
For `directives.interaction.events`, supported config values are currently limited to `mouseenter`, `touchstart`, and `focusin`. Passing `[]` or unsupported names causes config resolution to fail.
|
|
390
|
+
|
|
383
391
|
### Multiple island directories
|
|
384
392
|
|
|
385
393
|
```ts
|
|
@@ -442,6 +450,8 @@ offLoad();
|
|
|
442
450
|
offError();
|
|
443
451
|
```
|
|
444
452
|
|
|
453
|
+
For SPA teardown, the virtual `/revive` module also exports `disconnect()`, which stops further lifecycle observation and cancels pending startup before init has run.
|
|
454
|
+
|
|
445
455
|
### Raw DOM events
|
|
446
456
|
|
|
447
457
|
The events are also available via the standard `document.addEventListener` API. Event types are fully typed via `DocumentEventMap` augmentation — available automatically when `vite-plugin-shopify-theme-islands` is present in your TypeScript compilation (e.g. via `vite.config.ts` or a directive type import).
|
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/discovery.d.ts
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
export declare const TS_JS_RE: RegExp;
|
|
3
3
|
/** Matches the island mixin import. Exported for plugin transform/watch detection. */
|
|
4
4
|
export declare const ISLAND_IMPORT_RE: RegExp;
|
|
5
|
+
export interface AliasLike {
|
|
6
|
+
find: string | RegExp;
|
|
7
|
+
replacement: string;
|
|
8
|
+
}
|
|
9
|
+
export interface IslandInventoryConfig {
|
|
10
|
+
root: string;
|
|
11
|
+
aliases: readonly AliasLike[];
|
|
12
|
+
}
|
|
13
|
+
export interface IslandInventorySnapshot {
|
|
14
|
+
resolvedDirectories: string[];
|
|
15
|
+
islandFiles: string[];
|
|
16
|
+
directoryTagNames: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface IslandInventoryChange {
|
|
19
|
+
type: "detected" | "removed";
|
|
20
|
+
file: string;
|
|
21
|
+
}
|
|
22
|
+
export interface IslandInventoryBootstrapState {
|
|
23
|
+
root: string;
|
|
24
|
+
directories: string[];
|
|
25
|
+
islandFiles: Set<string>;
|
|
26
|
+
}
|
|
5
27
|
/** True if file is under any of the given absolute directory paths. */
|
|
6
28
|
export declare function inDirectory(file: string, absDirs: string[]): boolean;
|
|
7
29
|
/** Paths for load() virtual module: "/relative/to/root" form, forward slashes. */
|
|
@@ -10,3 +32,11 @@ export declare function getIslandPathsForLoad(islandFiles: Set<string>, root: st
|
|
|
10
32
|
export declare function discoverIslandFiles(root: string, absDirs: string[]): Set<string>;
|
|
11
33
|
/** Tag names (filename without extension) for TS/JS files in a directory. Used for debug logging. */
|
|
12
34
|
export declare function collectTagNames(dir: string): string[];
|
|
35
|
+
export declare function createIslandInventory(rawDirectories: string[]): {
|
|
36
|
+
configure(config: IslandInventoryConfig): void;
|
|
37
|
+
scan(): IslandInventorySnapshot | null;
|
|
38
|
+
applyTransform(id: string, code: string): IslandInventoryChange | null;
|
|
39
|
+
applyWatchChange(id: string, event: string): IslandInventoryChange | null;
|
|
40
|
+
getBootstrapState(): IslandInventoryBootstrapState;
|
|
41
|
+
getRoot(): string;
|
|
42
|
+
};
|
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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
3
|
-
import { join as join2, relative as relative2 } from "node:path";
|
|
2
|
+
import { relative as relative2 } from "node:path";
|
|
4
3
|
|
|
5
4
|
// src/discovery.ts
|
|
6
5
|
import { readFileSync, readdirSync } from "node:fs";
|
|
@@ -35,6 +34,21 @@ function walkDir(dir, visitor) {
|
|
|
35
34
|
visitor(entry.name, full);
|
|
36
35
|
}
|
|
37
36
|
}
|
|
37
|
+
function resolveAliases(dirs, aliasesInput) {
|
|
38
|
+
const aliases = [...aliasesInput].sort((a, b) => (typeof b.find === "string" ? b.find.length : 0) - (typeof a.find === "string" ? a.find.length : 0));
|
|
39
|
+
return dirs.map((dir) => {
|
|
40
|
+
for (const { find, replacement } of aliases) {
|
|
41
|
+
if (typeof find === "string" && dir.startsWith(find))
|
|
42
|
+
return dir.replace(find, replacement);
|
|
43
|
+
if (find instanceof RegExp && find.test(dir))
|
|
44
|
+
return dir.replace(find, replacement);
|
|
45
|
+
}
|
|
46
|
+
return dir;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function toAbsoluteDirs(root, resolvedDirs) {
|
|
50
|
+
return resolvedDirs.map((dir) => dir.startsWith(root) ? dir : join(root, dir.replace(/^\//, "")));
|
|
51
|
+
}
|
|
38
52
|
function discoverIslandFiles(root, absDirs) {
|
|
39
53
|
const found = new Set;
|
|
40
54
|
walkDir(root, (_, full) => {
|
|
@@ -53,6 +67,101 @@ function collectTagNames(dir) {
|
|
|
53
67
|
walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
|
|
54
68
|
return names;
|
|
55
69
|
}
|
|
70
|
+
function createIslandInventory(rawDirectories) {
|
|
71
|
+
let root = process.cwd();
|
|
72
|
+
let resolvedDirs = [...rawDirectories];
|
|
73
|
+
let absDirs = [...rawDirectories];
|
|
74
|
+
const islandFiles = new Set;
|
|
75
|
+
let scanned = false;
|
|
76
|
+
const buildSnapshot = () => ({
|
|
77
|
+
resolvedDirectories: [...resolvedDirs],
|
|
78
|
+
islandFiles: [...islandFiles],
|
|
79
|
+
directoryTagNames: absDirs.flatMap((dir) => collectTagNames(dir))
|
|
80
|
+
});
|
|
81
|
+
const updateIslandFile = (id, code) => {
|
|
82
|
+
if (!TS_JS_RE.test(id))
|
|
83
|
+
return null;
|
|
84
|
+
if (code.includes("shopify-theme-islands/island") && ISLAND_IMPORT_RE.test(code) && !inDirectory(id, absDirs)) {
|
|
85
|
+
const sizeBefore = islandFiles.size;
|
|
86
|
+
islandFiles.add(id);
|
|
87
|
+
return islandFiles.size !== sizeBefore ? { type: "detected", file: id } : null;
|
|
88
|
+
}
|
|
89
|
+
return islandFiles.delete(id) ? { type: "removed", file: id } : null;
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
configure(config) {
|
|
93
|
+
root = config.root;
|
|
94
|
+
resolvedDirs = resolveAliases(rawDirectories, config.aliases);
|
|
95
|
+
absDirs = toAbsoluteDirs(root, resolvedDirs);
|
|
96
|
+
},
|
|
97
|
+
scan() {
|
|
98
|
+
if (scanned)
|
|
99
|
+
return null;
|
|
100
|
+
scanned = true;
|
|
101
|
+
islandFiles.clear();
|
|
102
|
+
discoverIslandFiles(root, absDirs).forEach((file) => islandFiles.add(file));
|
|
103
|
+
return buildSnapshot();
|
|
104
|
+
},
|
|
105
|
+
applyTransform(id, code) {
|
|
106
|
+
return updateIslandFile(id, code);
|
|
107
|
+
},
|
|
108
|
+
applyWatchChange(id, event) {
|
|
109
|
+
if (!TS_JS_RE.test(id))
|
|
110
|
+
return null;
|
|
111
|
+
if (event === "delete") {
|
|
112
|
+
return islandFiles.delete(id) ? { type: "removed", file: id } : null;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return updateIslandFile(id, readFileSync(id, "utf-8"));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
getBootstrapState() {
|
|
121
|
+
return {
|
|
122
|
+
root,
|
|
123
|
+
directories: [...resolvedDirs],
|
|
124
|
+
islandFiles: new Set(islandFiles)
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
getRoot() {
|
|
128
|
+
return root;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/interaction-events.ts
|
|
134
|
+
var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"];
|
|
135
|
+
var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES];
|
|
136
|
+
var INTERACTION_EVENT_NAMES_LABEL = INTERACTION_EVENT_NAMES.join(", ");
|
|
137
|
+
var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
|
|
138
|
+
var PREFIX = "[vite-plugin-shopify-theme-islands]";
|
|
139
|
+
function isInteractionEventName(value) {
|
|
140
|
+
return INTERACTION_EVENT_NAME_SET.has(value);
|
|
141
|
+
}
|
|
142
|
+
function validateInteractionEvents(events) {
|
|
143
|
+
if (events === undefined)
|
|
144
|
+
return;
|
|
145
|
+
if (events.length === 0) {
|
|
146
|
+
throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
|
|
147
|
+
}
|
|
148
|
+
const { invalid } = partitionInteractionEventTokens(events);
|
|
149
|
+
const invalidEvent = invalid[0];
|
|
150
|
+
if (invalidEvent) {
|
|
151
|
+
throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function partitionInteractionEventTokens(tokens) {
|
|
155
|
+
const valid = [];
|
|
156
|
+
const invalid = [];
|
|
157
|
+
for (const token of tokens) {
|
|
158
|
+
if (isInteractionEventName(token))
|
|
159
|
+
valid.push(token);
|
|
160
|
+
else
|
|
161
|
+
invalid.push(token);
|
|
162
|
+
}
|
|
163
|
+
return { valid, invalid };
|
|
164
|
+
}
|
|
56
165
|
|
|
57
166
|
// src/contract.ts
|
|
58
167
|
var DEFAULT_DIRECTIVES = {
|
|
@@ -62,7 +171,7 @@ var DEFAULT_DIRECTIVES = {
|
|
|
62
171
|
defer: { attribute: "client:defer", delay: 3000 },
|
|
63
172
|
interaction: {
|
|
64
173
|
attribute: "client:interaction",
|
|
65
|
-
events: [
|
|
174
|
+
events: [...DEFAULT_INTERACTION_EVENTS]
|
|
66
175
|
}
|
|
67
176
|
};
|
|
68
177
|
var DEFAULT_RETRY = { retries: 0, delay: 1000 };
|
|
@@ -70,6 +179,7 @@ function normalizeReviveOptions(options) {
|
|
|
70
179
|
const d = DEFAULT_DIRECTIVES;
|
|
71
180
|
const r = DEFAULT_RETRY;
|
|
72
181
|
const dir = options?.directives;
|
|
182
|
+
validateInteractionEvents(dir?.interaction?.events);
|
|
73
183
|
return {
|
|
74
184
|
directives: {
|
|
75
185
|
visible: { ...d.visible, ...dir?.visible },
|
|
@@ -105,7 +215,7 @@ function buildIslandMap(payload) {
|
|
|
105
215
|
}
|
|
106
216
|
|
|
107
217
|
// src/config-policy.ts
|
|
108
|
-
var
|
|
218
|
+
var PREFIX2 = "[vite-plugin-shopify-theme-islands]";
|
|
109
219
|
function mergeDirectives(directives) {
|
|
110
220
|
return {
|
|
111
221
|
visible: { ...DEFAULT_DIRECTIVES.visible, ...directives?.visible },
|
|
@@ -118,19 +228,21 @@ function mergeDirectives(directives) {
|
|
|
118
228
|
function validateOptions(options, directives) {
|
|
119
229
|
const customDefs = options.directives?.custom ?? [];
|
|
120
230
|
if (Array.isArray(options.directories) && options.directories.length === 0) {
|
|
121
|
-
throw new Error(`${
|
|
231
|
+
throw new Error(`${PREFIX2} "directories" must not be empty`);
|
|
122
232
|
}
|
|
123
233
|
const threshold = options.directives?.visible?.threshold;
|
|
124
234
|
if (threshold !== undefined && (threshold < 0 || threshold > 1)) {
|
|
125
|
-
throw new Error(`${
|
|
235
|
+
throw new Error(`${PREFIX2} "directives.visible.threshold" must be between 0 and 1, got ${threshold}`);
|
|
126
236
|
}
|
|
237
|
+
const interactionEvents = options.directives?.interaction?.events;
|
|
238
|
+
validateInteractionEvents(interactionEvents);
|
|
127
239
|
if (options.retry !== undefined) {
|
|
128
240
|
const { retries, delay } = options.retry;
|
|
129
241
|
if (retries !== undefined && retries < 0) {
|
|
130
|
-
throw new Error(`${
|
|
242
|
+
throw new Error(`${PREFIX2} "retry.retries" must be >= 0, got ${retries}`);
|
|
131
243
|
}
|
|
132
244
|
if (delay !== undefined && delay < 0) {
|
|
133
|
-
throw new Error(`${
|
|
245
|
+
throw new Error(`${PREFIX2} "retry.delay" must be >= 0, got ${delay}`);
|
|
134
246
|
}
|
|
135
247
|
}
|
|
136
248
|
const builtinAttributes = new Set([
|
|
@@ -143,10 +255,10 @@ function validateOptions(options, directives) {
|
|
|
143
255
|
const seen = new Set;
|
|
144
256
|
for (const def of customDefs) {
|
|
145
257
|
if (seen.has(def.name)) {
|
|
146
|
-
throw new Error(`${
|
|
258
|
+
throw new Error(`${PREFIX2} Duplicate custom directive name: "${def.name}"`);
|
|
147
259
|
}
|
|
148
260
|
if (builtinAttributes.has(def.name)) {
|
|
149
|
-
throw new Error(`${
|
|
261
|
+
throw new Error(`${PREFIX2} Custom directive "${def.name}" conflicts with a built-in directive`);
|
|
150
262
|
}
|
|
151
263
|
seen.add(def.name);
|
|
152
264
|
}
|
|
@@ -211,9 +323,10 @@ function createReviveBootstrapCompiler(ports, runtimePath) {
|
|
|
211
323
|
name,
|
|
212
324
|
entrypoint: await ports.resolveEntrypoint(entrypoint)
|
|
213
325
|
}))) : null;
|
|
326
|
+
const directoryGlobs = input.directories.map((dir) => dir + "**/*.{ts,js}");
|
|
214
327
|
return {
|
|
215
328
|
runtimePath,
|
|
216
|
-
directoryGlobs
|
|
329
|
+
directoryGlobs,
|
|
217
330
|
islandPaths,
|
|
218
331
|
customDirectives,
|
|
219
332
|
reviveOptions: input.reviveOptions
|
|
@@ -242,89 +355,57 @@ var defaultDirectories = ["/frontend/js/islands/"];
|
|
|
242
355
|
function normalizeDir(dir) {
|
|
243
356
|
return dir.endsWith("/") ? dir : dir + "/";
|
|
244
357
|
}
|
|
245
|
-
function resolveAliases(dirs, config) {
|
|
246
|
-
const aliases = [...config.resolve.alias].sort((a, b) => (typeof b.find === "string" ? b.find.length : 0) - (typeof a.find === "string" ? a.find.length : 0));
|
|
247
|
-
return dirs.map((dir) => {
|
|
248
|
-
for (const { find, replacement } of aliases) {
|
|
249
|
-
if (typeof find === "string" && dir.startsWith(find))
|
|
250
|
-
return dir.replace(find, replacement);
|
|
251
|
-
if (find instanceof RegExp && find.test(dir))
|
|
252
|
-
return dir.replace(find, replacement);
|
|
253
|
-
}
|
|
254
|
-
return dir;
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
358
|
function shopifyThemeIslands(options = {}) {
|
|
258
359
|
const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaultDirectories[0]]).map(normalizeDir);
|
|
259
360
|
const policy = resolveThemeIslandsPolicy(options);
|
|
260
361
|
const { directives, customDirectives: clientDirectiveDefinitions, debug } = policy.plugin;
|
|
261
362
|
const { runtime: reviveOptions } = policy;
|
|
262
363
|
const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
|
|
263
|
-
|
|
264
|
-
let root = process.cwd();
|
|
265
|
-
let absDirs = rawDirs;
|
|
266
|
-
const islandFiles = new Set;
|
|
267
|
-
let scanned = false;
|
|
364
|
+
const inventory = createIslandInventory(rawDirs);
|
|
268
365
|
return {
|
|
269
366
|
name: "vite-plugin-shopify-theme-islands",
|
|
270
367
|
enforce: "pre",
|
|
271
368
|
configResolved(config) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
369
|
+
inventory.configure({
|
|
370
|
+
root: config.root,
|
|
371
|
+
aliases: config.resolve.alias
|
|
372
|
+
});
|
|
275
373
|
},
|
|
276
374
|
buildStart() {
|
|
277
|
-
if (scanned)
|
|
278
|
-
return;
|
|
279
|
-
scanned = true;
|
|
280
375
|
const t0 = performance.now();
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
376
|
+
const snapshot = inventory.scan();
|
|
377
|
+
if (!snapshot)
|
|
378
|
+
return;
|
|
284
379
|
if (debug) {
|
|
285
380
|
const scanMs = (performance.now() - t0).toFixed(1);
|
|
286
381
|
log(`Scanned in ${scanMs}ms`);
|
|
287
|
-
log("Scanning directories:",
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (islandFiles.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
382
|
+
log("Scanning directories:", snapshot.resolvedDirectories.map((dir) => dir + "**/*.{ts,js}").join(", "));
|
|
383
|
+
if (snapshot.directoryTagNames.length) {
|
|
384
|
+
log(`Found ${snapshot.directoryTagNames.length} directory island(s): [${snapshot.directoryTagNames.join(", ")}]`);
|
|
385
|
+
}
|
|
386
|
+
if (snapshot.islandFiles.length) {
|
|
387
|
+
const root = inventory.getRoot();
|
|
388
|
+
log(`Found ${snapshot.islandFiles.length} island file(s) via mixin import:`);
|
|
389
|
+
for (const file of snapshot.islandFiles)
|
|
390
|
+
log(" ", relative2(root, file));
|
|
295
391
|
}
|
|
296
392
|
log("Directives:", directives);
|
|
297
393
|
}
|
|
298
394
|
},
|
|
299
395
|
transform(code, id) {
|
|
300
|
-
|
|
396
|
+
const change = inventory.applyTransform(id, code);
|
|
397
|
+
if (!change)
|
|
301
398
|
return;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
log("Detected island:", relative2(root, id));
|
|
305
|
-
} else {
|
|
306
|
-
if (islandFiles.delete(id))
|
|
307
|
-
log("Removed island:", relative2(root, id));
|
|
308
|
-
}
|
|
399
|
+
const root = inventory.getRoot();
|
|
400
|
+
log(change.type === "detected" ? "Detected island:" : "Removed island:", relative2(root, change.file));
|
|
309
401
|
},
|
|
310
402
|
watchChange(id, { event }) {
|
|
311
|
-
|
|
403
|
+
const change = inventory.applyWatchChange(id, event);
|
|
404
|
+
if (!change)
|
|
312
405
|
return;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
} else {
|
|
317
|
-
try {
|
|
318
|
-
const content = readFileSync2(id, "utf-8");
|
|
319
|
-
if (ISLAND_IMPORT_RE.test(content) && !inDirectory(id, absDirs)) {
|
|
320
|
-
islandFiles.add(id);
|
|
321
|
-
log("Detected island (watchChange):", relative2(root, id));
|
|
322
|
-
} else {
|
|
323
|
-
if (islandFiles.delete(id))
|
|
324
|
-
log("Removed island (watchChange):", relative2(root, id));
|
|
325
|
-
}
|
|
326
|
-
} catch {}
|
|
327
|
-
}
|
|
406
|
+
const root = inventory.getRoot();
|
|
407
|
+
const prefix = event === "delete" ? "Removed island (deleted):" : change.type === "detected" ? "Detected island (watchChange):" : "Removed island (watchChange):";
|
|
408
|
+
log(prefix, relative2(root, change.file));
|
|
328
409
|
},
|
|
329
410
|
resolveId(id) {
|
|
330
411
|
if (id === VIRTUAL_ID)
|
|
@@ -346,9 +427,7 @@ function shopifyThemeIslands(options = {}) {
|
|
|
346
427
|
toLoadPaths: getIslandPathsForLoad
|
|
347
428
|
}, runtimePath);
|
|
348
429
|
const plan = await compiler.plan({
|
|
349
|
-
|
|
350
|
-
directories: resolvedDirs,
|
|
351
|
-
islandFiles,
|
|
430
|
+
...inventory.getBootstrapState(),
|
|
352
431
|
customDirectives: clientDirectiveDefinitions,
|
|
353
432
|
reviveOptions
|
|
354
433
|
});
|
|
@@ -357,5 +436,8 @@ function shopifyThemeIslands(options = {}) {
|
|
|
357
436
|
};
|
|
358
437
|
}
|
|
359
438
|
export {
|
|
360
|
-
|
|
439
|
+
isInteractionEventName,
|
|
440
|
+
shopifyThemeIslands as default,
|
|
441
|
+
INTERACTION_EVENT_NAMES,
|
|
442
|
+
DEFAULT_INTERACTION_EVENTS
|
|
361
443
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
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 const INTERACTION_EVENT_NAMES_LABEL: string;
|
|
12
|
+
export interface InteractionEventTokenPartition {
|
|
13
|
+
valid: InteractionEventName[];
|
|
14
|
+
invalid: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function isInteractionEventName(value: string): value is InteractionEventName;
|
|
17
|
+
export declare function validateInteractionEvents(events: readonly string[] | undefined): asserts events is readonly InteractionEventName[];
|
|
18
|
+
export declare function partitionInteractionEventTokens(tokens: readonly string[]): InteractionEventTokenPartition;
|
|
@@ -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,36 @@
|
|
|
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_NAMES_LABEL = INTERACTION_EVENT_NAMES.join(", ");
|
|
5
|
+
var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
|
|
6
|
+
var PREFIX = "[vite-plugin-shopify-theme-islands]";
|
|
7
|
+
function isInteractionEventName(value) {
|
|
8
|
+
return INTERACTION_EVENT_NAME_SET.has(value);
|
|
9
|
+
}
|
|
10
|
+
function validateInteractionEvents(events) {
|
|
11
|
+
if (events === undefined)
|
|
12
|
+
return;
|
|
13
|
+
if (events.length === 0) {
|
|
14
|
+
throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
|
|
15
|
+
}
|
|
16
|
+
const { invalid } = partitionInteractionEventTokens(events);
|
|
17
|
+
const invalidEvent = invalid[0];
|
|
18
|
+
if (invalidEvent) {
|
|
19
|
+
throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function partitionInteractionEventTokens(tokens) {
|
|
23
|
+
const valid = [];
|
|
24
|
+
const invalid = [];
|
|
25
|
+
for (const token of tokens) {
|
|
26
|
+
if (isInteractionEventName(token))
|
|
27
|
+
valid.push(token);
|
|
28
|
+
else
|
|
29
|
+
invalid.push(token);
|
|
30
|
+
}
|
|
31
|
+
return { valid, invalid };
|
|
32
|
+
}
|
|
33
|
+
|
|
1
34
|
// src/contract.ts
|
|
2
35
|
var DEFAULT_DIRECTIVES = {
|
|
3
36
|
visible: { attribute: "client:visible", rootMargin: "200px", threshold: 0 },
|
|
@@ -6,7 +39,7 @@ var DEFAULT_DIRECTIVES = {
|
|
|
6
39
|
defer: { attribute: "client:defer", delay: 3000 },
|
|
7
40
|
interaction: {
|
|
8
41
|
attribute: "client:interaction",
|
|
9
|
-
events: [
|
|
42
|
+
events: [...DEFAULT_INTERACTION_EVENTS]
|
|
10
43
|
}
|
|
11
44
|
};
|
|
12
45
|
var DEFAULT_RETRY = { retries: 0, delay: 1000 };
|
|
@@ -14,6 +47,7 @@ function normalizeReviveOptions(options) {
|
|
|
14
47
|
const d = DEFAULT_DIRECTIVES;
|
|
15
48
|
const r = DEFAULT_RETRY;
|
|
16
49
|
const dir = options?.directives;
|
|
50
|
+
validateInteractionEvents(dir?.interaction?.events);
|
|
17
51
|
return {
|
|
18
52
|
directives: {
|
|
19
53
|
visible: { ...d.visible, ...dir?.visible },
|
|
@@ -160,13 +194,22 @@ function createDirectiveOrchestrator(waiters = {
|
|
|
160
194
|
}
|
|
161
195
|
const interactionAttr = el.getAttribute(directives.interaction.attribute);
|
|
162
196
|
if (interactionAttr !== null) {
|
|
163
|
-
let events = directives.interaction.events;
|
|
197
|
+
let events = [...directives.interaction.events];
|
|
164
198
|
if (interactionAttr) {
|
|
165
199
|
const tokens = interactionAttr.split(/\s+/).filter(Boolean);
|
|
166
|
-
if (tokens.length
|
|
167
|
-
events = tokens;
|
|
168
|
-
else {
|
|
200
|
+
if (tokens.length === 0) {
|
|
169
201
|
console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} has no valid event tokens — using default events`);
|
|
202
|
+
} else {
|
|
203
|
+
const { valid, invalid } = partitionInteractionEventTokens(tokens);
|
|
204
|
+
if (invalid.length > 0) {
|
|
205
|
+
if (valid.length > 0) {
|
|
206
|
+
console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} contains unsupported event token${invalid.length === 1 ? "" : "s"} (${invalid.join(", ")}) — ignoring invalid token${invalid.length === 1 ? "" : "s"}; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`);
|
|
207
|
+
} else {
|
|
208
|
+
console.warn(`[islands] <${tagName}> ${directives.interaction.attribute} contains no supported event tokens (${invalid.join(", ")}) — using default events; supported tokens: ${INTERACTION_EVENT_NAMES_LABEL}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (valid.length > 0)
|
|
212
|
+
events = valid;
|
|
170
213
|
}
|
|
171
214
|
}
|
|
172
215
|
log.note(`waiting for ${directives.interaction.attribute} (${events.join(", ")})`);
|
|
@@ -231,6 +274,145 @@ function createDirectiveOrchestrator(waiters = {
|
|
|
231
274
|
};
|
|
232
275
|
}
|
|
233
276
|
|
|
277
|
+
// src/lifecycle.ts
|
|
278
|
+
function createIslandLifecycleCoordinator(opts) {
|
|
279
|
+
const queued = new Set;
|
|
280
|
+
const loaded = new Set;
|
|
281
|
+
const retryCount = new Map;
|
|
282
|
+
const cancellableElements = new Map;
|
|
283
|
+
let initialWalkComplete = false;
|
|
284
|
+
let walkImpl;
|
|
285
|
+
const queue = (tag) => {
|
|
286
|
+
if (queued.has(tag) || loaded.has(tag))
|
|
287
|
+
return false;
|
|
288
|
+
queued.add(tag);
|
|
289
|
+
return true;
|
|
290
|
+
};
|
|
291
|
+
const settleSuccess = (tag) => {
|
|
292
|
+
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
293
|
+
queued.delete(tag);
|
|
294
|
+
loaded.add(tag);
|
|
295
|
+
retryCount.delete(tag);
|
|
296
|
+
return attempt;
|
|
297
|
+
};
|
|
298
|
+
const settleFailure = (tag) => {
|
|
299
|
+
const attempt = (retryCount.get(tag) ?? 0) + 1;
|
|
300
|
+
if (attempt <= opts.retries) {
|
|
301
|
+
retryCount.set(tag, attempt);
|
|
302
|
+
return { retryDelayMs: opts.retryDelay * 2 ** (attempt - 1), attempt };
|
|
303
|
+
}
|
|
304
|
+
retryCount.delete(tag);
|
|
305
|
+
queued.delete(tag);
|
|
306
|
+
return { retryDelayMs: null, attempt };
|
|
307
|
+
};
|
|
308
|
+
const evict = (tag) => {
|
|
309
|
+
retryCount.delete(tag);
|
|
310
|
+
queued.delete(tag);
|
|
311
|
+
};
|
|
312
|
+
const isQueued = (tag) => queued.has(tag);
|
|
313
|
+
const watchCancellable = (el, cancel) => {
|
|
314
|
+
cancellableElements.set(el, cancel);
|
|
315
|
+
return () => {
|
|
316
|
+
cancellableElements.delete(el);
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
const cancelDetached = () => {
|
|
320
|
+
if (cancellableElements.size === 0)
|
|
321
|
+
return;
|
|
322
|
+
for (const [el, cancel] of cancellableElements) {
|
|
323
|
+
if (!el.isConnected) {
|
|
324
|
+
cancellableElements.delete(el);
|
|
325
|
+
cancel();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const start = (input) => {
|
|
330
|
+
let disconnected = false;
|
|
331
|
+
let initialized = false;
|
|
332
|
+
const customElementFilter = {
|
|
333
|
+
acceptNode: (node) => {
|
|
334
|
+
const tag = node.tagName;
|
|
335
|
+
if (!tag.includes("-"))
|
|
336
|
+
return NodeFilter.FILTER_SKIP;
|
|
337
|
+
return isQueued(tag.toLowerCase()) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const activate = (el) => {
|
|
341
|
+
const tagName = el.tagName.toLowerCase();
|
|
342
|
+
const loader = input.islandMap.get(tagName);
|
|
343
|
+
if (!loader)
|
|
344
|
+
return;
|
|
345
|
+
let ancestor = el.parentElement;
|
|
346
|
+
while (ancestor) {
|
|
347
|
+
if (isQueued(ancestor.tagName.toLowerCase()))
|
|
348
|
+
return;
|
|
349
|
+
ancestor = ancestor.parentElement;
|
|
350
|
+
}
|
|
351
|
+
if (!queue(tagName))
|
|
352
|
+
return;
|
|
353
|
+
input.onActivate(tagName, el, loader);
|
|
354
|
+
};
|
|
355
|
+
const walk = (el) => {
|
|
356
|
+
activate(el);
|
|
357
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT, customElementFilter);
|
|
358
|
+
let node;
|
|
359
|
+
while (node = walker.nextNode())
|
|
360
|
+
activate(node);
|
|
361
|
+
};
|
|
362
|
+
walkImpl = walk;
|
|
363
|
+
const handleAdditions = (mutations) => {
|
|
364
|
+
for (const { addedNodes } of mutations) {
|
|
365
|
+
for (const node of addedNodes) {
|
|
366
|
+
if (node.nodeType === Node.ELEMENT_NODE)
|
|
367
|
+
walk(node);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const observer = new MutationObserver((mutations) => {
|
|
372
|
+
cancelDetached();
|
|
373
|
+
handleAdditions(mutations);
|
|
374
|
+
});
|
|
375
|
+
const init = () => {
|
|
376
|
+
if (disconnected || initialized)
|
|
377
|
+
return;
|
|
378
|
+
const root = input.getRoot();
|
|
379
|
+
if (!root)
|
|
380
|
+
return;
|
|
381
|
+
initialized = true;
|
|
382
|
+
input.onBeforeInitialWalk?.();
|
|
383
|
+
walk(root);
|
|
384
|
+
initialWalkComplete = true;
|
|
385
|
+
input.onInitialWalkComplete?.();
|
|
386
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
387
|
+
};
|
|
388
|
+
if (document.readyState === "loading") {
|
|
389
|
+
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
390
|
+
} else {
|
|
391
|
+
init();
|
|
392
|
+
}
|
|
393
|
+
const disconnect = () => {
|
|
394
|
+
disconnected = true;
|
|
395
|
+
document.removeEventListener("DOMContentLoaded", init);
|
|
396
|
+
observer.disconnect();
|
|
397
|
+
};
|
|
398
|
+
return { disconnect };
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
settleSuccess,
|
|
402
|
+
settleFailure,
|
|
403
|
+
evict,
|
|
404
|
+
isQueued,
|
|
405
|
+
get initialWalkComplete() {
|
|
406
|
+
return initialWalkComplete;
|
|
407
|
+
},
|
|
408
|
+
watchCancellable,
|
|
409
|
+
walk(root) {
|
|
410
|
+
walkImpl?.(root);
|
|
411
|
+
},
|
|
412
|
+
start
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
234
416
|
// src/runtime-surface.ts
|
|
235
417
|
var SILENT_LOGGER = {
|
|
236
418
|
note() {},
|
|
@@ -300,68 +482,6 @@ function getRuntimeSurface() {
|
|
|
300
482
|
function isRevivePayload(v) {
|
|
301
483
|
return typeof v === "object" && v !== null && "islands" in v && !Array.isArray(v);
|
|
302
484
|
}
|
|
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
485
|
function revive(islandsOrPayload, options, customDirectives) {
|
|
366
486
|
const runtimeSurface2 = getRuntimeSurface();
|
|
367
487
|
const payload = isRevivePayload(islandsOrPayload) ? islandsOrPayload : { islands: islandsOrPayload, options, customDirectives };
|
|
@@ -375,24 +495,14 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
375
495
|
const attrInteraction = opts.directives.interaction.attribute;
|
|
376
496
|
const debug = opts.debug;
|
|
377
497
|
const directiveTimeout = opts.directiveTimeout;
|
|
378
|
-
const
|
|
498
|
+
const lifecycle = createIslandLifecycleCoordinator({
|
|
379
499
|
retries: opts.retry.retries,
|
|
380
500
|
retryDelay: opts.retry.delay
|
|
381
501
|
});
|
|
382
502
|
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
|
-
};
|
|
503
|
+
let disconnected = false;
|
|
394
504
|
async function loadIsland(tagName, el, loader) {
|
|
395
|
-
if (debug && !
|
|
505
|
+
if (debug && !lifecycle.initialWalkComplete) {
|
|
396
506
|
const parts = [];
|
|
397
507
|
const pushAttr = (attr, val) => {
|
|
398
508
|
if (val !== null)
|
|
@@ -420,17 +530,17 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
420
530
|
return Promise.resolve();
|
|
421
531
|
const t0 = performance.now();
|
|
422
532
|
return loader().then(() => {
|
|
423
|
-
const attempt =
|
|
533
|
+
const attempt = lifecycle.settleSuccess(tagName);
|
|
424
534
|
runtimeSurface2.dispatchLoad({
|
|
425
535
|
tag: tagName,
|
|
426
536
|
duration: performance.now() - t0,
|
|
427
537
|
attempt
|
|
428
538
|
});
|
|
429
|
-
if (el.children.length)
|
|
430
|
-
walk(el);
|
|
539
|
+
if (!disconnected && el.children.length)
|
|
540
|
+
lifecycle.walk(el);
|
|
431
541
|
}).catch((err) => {
|
|
432
542
|
console.error(`[islands] Failed to load <${tagName}>:`, err);
|
|
433
|
-
const { retryDelayMs, attempt } =
|
|
543
|
+
const { retryDelayMs, attempt } = lifecycle.settleFailure(tagName);
|
|
434
544
|
runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt });
|
|
435
545
|
if (retryDelayMs !== null) {
|
|
436
546
|
setTimeout(run, retryDelayMs);
|
|
@@ -446,7 +556,7 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
446
556
|
console.error(`[islands] Built-in directive failed for <${tagName}>:`, err);
|
|
447
557
|
}
|
|
448
558
|
runtimeSurface2.dispatchError({ tag: tagName, error: err, attempt: 1 });
|
|
449
|
-
|
|
559
|
+
lifecycle.evict(tagName);
|
|
450
560
|
};
|
|
451
561
|
try {
|
|
452
562
|
const matchedCustomDirectives = await directiveOrchestrator.run({
|
|
@@ -455,7 +565,7 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
455
565
|
directives: opts.directives,
|
|
456
566
|
customDirectives: resolvedDirectives,
|
|
457
567
|
directiveTimeout,
|
|
458
|
-
watchCancellable:
|
|
568
|
+
watchCancellable: lifecycle.watchCancellable,
|
|
459
569
|
log,
|
|
460
570
|
run,
|
|
461
571
|
onError: handleDirectiveError
|
|
@@ -470,63 +580,27 @@ function revive(islandsOrPayload, options, customDirectives) {
|
|
|
470
580
|
log.flush("triggered");
|
|
471
581
|
run();
|
|
472
582
|
}
|
|
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
|
-
}
|
|
583
|
+
let endReadyLog;
|
|
584
|
+
const disconnectLifecycle = lifecycle.start({
|
|
585
|
+
getRoot: () => document.body,
|
|
586
|
+
islandMap,
|
|
587
|
+
onActivate: loadIsland,
|
|
588
|
+
onBeforeInitialWalk: () => {
|
|
589
|
+
endReadyLog = runtimeSurface2.beginReadyLog(islandMap.size, debug);
|
|
590
|
+
},
|
|
591
|
+
onInitialWalkComplete: () => {
|
|
592
|
+
endReadyLog?.();
|
|
593
|
+
endReadyLog = undefined;
|
|
501
594
|
}
|
|
502
|
-
}
|
|
503
|
-
const observer = new MutationObserver((mutations) => {
|
|
504
|
-
registry.cancelDetached();
|
|
505
|
-
handleAdditions(mutations);
|
|
506
595
|
});
|
|
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();
|
|
596
|
+
return {
|
|
597
|
+
disconnect() {
|
|
598
|
+
disconnected = true;
|
|
599
|
+
endReadyLog?.();
|
|
600
|
+
endReadyLog = undefined;
|
|
601
|
+
disconnectLifecycle.disconnect();
|
|
602
|
+
}
|
|
528
603
|
};
|
|
529
|
-
return { disconnect };
|
|
530
604
|
}
|
|
531
605
|
export {
|
|
532
606
|
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.1"
|
|
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
|
|
@@ -6,17 +6,22 @@ description: >
|
|
|
6
6
|
client:defer (setTimeout delay), client:interaction (mouseenter/touchstart/focusin).
|
|
7
7
|
Directives resolve sequentially — visible → media → idle → defer →
|
|
8
8
|
interaction → custom. Per-element value overrides. Empty client:media
|
|
9
|
-
warning.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
warning. `client:interaction` now validates per-element tokens at runtime:
|
|
10
|
+
whitespace-only values warn and fall back; mixed supported/unsupported values
|
|
11
|
+
warn and ignore the unsupported tokens; fully unsupported values warn and fall
|
|
12
|
+
back to default events. Global `directives.interaction.events` config is
|
|
13
|
+
intentionally narrowed to the curated set `mouseenter`, `touchstart`, and
|
|
14
|
+
`focusin`. Current directive sequencing and custom-directive latching are
|
|
15
|
+
owned by src/directive-orchestration.ts.
|
|
12
16
|
type: core
|
|
13
17
|
library: vite-plugin-shopify-theme-islands
|
|
14
|
-
library_version: "1.
|
|
18
|
+
library_version: "1.3.1"
|
|
15
19
|
sources:
|
|
16
20
|
- Rees1993/vite-plugin-shopify-theme-islands:src/directive-orchestration.ts
|
|
17
21
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
|
|
18
22
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
19
23
|
- Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
|
|
24
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/interaction-events.ts
|
|
20
25
|
---
|
|
21
26
|
|
|
22
27
|
## Setup
|
|
@@ -70,11 +75,13 @@ Combined directives are AND-latched. The island loads only after every condition
|
|
|
70
75
|
<!-- Fixed delay in ms; empty attribute uses the global default (3000ms) -->
|
|
71
76
|
<chat-widget client:defer="8000"></chat-widget>
|
|
72
77
|
|
|
73
|
-
<!-- Override interaction events for this element only
|
|
78
|
+
<!-- Override interaction events for this element only -->
|
|
74
79
|
<cart-flyout client:interaction="mouseenter"></cart-flyout>
|
|
75
80
|
```
|
|
76
81
|
|
|
77
82
|
The attribute value overrides the globally configured default for that element. Other elements are unaffected.
|
|
83
|
+
In config, `directives.interaction.events` is stricter and only accepts the curated package-owned list: `mouseenter`, `touchstart`, and `focusin`.
|
|
84
|
+
At runtime, per-element `client:interaction` values use that same curated set. Unsupported tokens are ignored with a warning; if no supported tokens remain, the runtime warns and falls back to the default interaction events.
|
|
78
85
|
|
|
79
86
|
### `client:defer` without a value uses the global default
|
|
80
87
|
|
|
@@ -102,6 +109,18 @@ An empty `client:interaction` attribute uses the configured default events with
|
|
|
102
109
|
|
|
103
110
|
Source: src/directive-orchestration.ts — interaction token parsing and fallback warning
|
|
104
111
|
|
|
112
|
+
### Mixed supported and unsupported interaction tokens
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<!-- "click" is ignored with a warning; "mouseenter" still triggers load -->
|
|
116
|
+
<cart-flyout client:interaction="mouseenter click"></cart-flyout>
|
|
117
|
+
|
|
118
|
+
<!-- No supported tokens remain; warns and falls back to default events -->
|
|
119
|
+
<cart-flyout client:interaction="click submit"></cart-flyout>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Per-element values are no longer treated as an unconstrained event surface. The runtime filters them against the curated package-owned set.
|
|
123
|
+
|
|
105
124
|
### Changing built-in directive defaults globally
|
|
106
125
|
|
|
107
126
|
```ts
|
|
@@ -165,6 +184,25 @@ Whitespace-only values are not treated the same as an empty attribute. The runti
|
|
|
165
184
|
|
|
166
185
|
Source: src/directive-orchestration.ts — `interactionAttr.split(/\s+/).filter(Boolean)`
|
|
167
186
|
|
|
187
|
+
### MEDIUM Unsupported per-element interaction tokens are warned and ignored
|
|
188
|
+
|
|
189
|
+
Wrong:
|
|
190
|
+
|
|
191
|
+
```html
|
|
192
|
+
<cart-flyout client:interaction="mouseenter click"></cart-flyout>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Correct:
|
|
196
|
+
|
|
197
|
+
```html
|
|
198
|
+
<!-- Use only the curated supported tokens -->
|
|
199
|
+
<cart-flyout client:interaction="mouseenter focusin"></cart-flyout>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The runtime no longer attaches arbitrary listeners for unsupported per-element tokens. Supported tokens still work; unsupported ones are ignored with a warning. If no supported tokens remain, the runtime falls back to the configured default events.
|
|
203
|
+
|
|
204
|
+
Source: src/directive-orchestration.ts — `partitionInteractionEventTokens()` handling
|
|
205
|
+
|
|
168
206
|
### HIGH Multiple directives are AND, not OR
|
|
169
207
|
|
|
170
208
|
Wrong assumption:
|
|
@@ -243,6 +281,32 @@ Directive attributes are case-sensitive. An unrecognised attribute is silently i
|
|
|
243
281
|
|
|
244
282
|
Source: src/directive-orchestration.ts — built-ins read exact configured attribute names
|
|
245
283
|
|
|
284
|
+
### HIGH Unsupported interaction events in config fail plugin setup
|
|
285
|
+
|
|
286
|
+
Wrong:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
shopifyThemeIslands({
|
|
290
|
+
directives: {
|
|
291
|
+
interaction: { events: ["click"] as never[] },
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Correct:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
shopifyThemeIslands({
|
|
300
|
+
directives: {
|
|
301
|
+
interaction: { events: ["mouseenter", "focusin"] },
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
Source: src/interaction-events.ts — validateInteractionEvents()
|
|
309
|
+
|
|
246
310
|
### HIGH Agent uses default attribute name when developer has configured a custom one
|
|
247
311
|
|
|
248
312
|
Wrong:
|
|
@@ -9,13 +9,16 @@ 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, while
|
|
14
|
+
runtime observability and event dispatch are routed through src/runtime-surface.ts.
|
|
13
15
|
type: core
|
|
14
16
|
library: vite-plugin-shopify-theme-islands
|
|
15
|
-
library_version: "1.
|
|
17
|
+
library_version: "1.3.1"
|
|
16
18
|
sources:
|
|
17
19
|
- Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
|
|
18
20
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime-surface.ts
|
|
21
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/lifecycle.ts
|
|
19
22
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
20
23
|
- Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
|
|
21
24
|
- Rees1993/vite-plugin-shopify-theme-islands:src/revive-module.ts
|
|
@@ -90,6 +93,10 @@ disconnect();
|
|
|
90
93
|
|
|
91
94
|
`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
95
|
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
Load/error events and debug-ready groups are dispatched through the runtime surface, but the user-facing lifecycle behavior remains the same: startup is lazy, activation is subtree-aware, and teardown prevents later observation.
|
|
99
|
+
|
|
93
100
|
### Raw DOM events (when type augmentation is in scope)
|
|
94
101
|
|
|
95
102
|
```ts
|
|
@@ -192,4 +199,6 @@ Source: src/runtime.ts — handleDirectiveError dispatches `islands:error`
|
|
|
192
199
|
|
|
193
200
|
### LOW Removed elements waiting on `client:visible` / `client:interaction` do not emit `islands:error`
|
|
194
201
|
|
|
195
|
-
If an element is removed from the DOM before a cancellable built-in directive resolves, the runtime treats
|
|
202
|
+
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.
|
|
203
|
+
|
|
204
|
+
Source: src/lifecycle.ts — cancelDetached() with watchCancellable() ownership
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -5,17 +5,22 @@ 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). Per-element `client:interaction` values are runtime-validated
|
|
11
|
+
against the same curated set: unsupported tokens warn and are ignored; if no
|
|
12
|
+
supported tokens remain, the runtime falls back to the configured default
|
|
13
|
+
events. Load when setting up the plugin, configuring island scan directories,
|
|
14
|
+
or enabling retry / directive timeout.
|
|
11
15
|
type: core
|
|
12
16
|
library: vite-plugin-shopify-theme-islands
|
|
13
|
-
library_version: "1.
|
|
17
|
+
library_version: "1.3.1"
|
|
14
18
|
sources:
|
|
15
19
|
- Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
|
|
16
20
|
- Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
|
|
17
21
|
- Rees1993/vite-plugin-shopify-theme-islands:src/options.ts
|
|
18
22
|
- Rees1993/vite-plugin-shopify-theme-islands:src/config-policy.ts
|
|
23
|
+
- Rees1993/vite-plugin-shopify-theme-islands:src/interaction-events.ts
|
|
19
24
|
---
|
|
20
25
|
|
|
21
26
|
## Setup
|
|
@@ -49,6 +54,10 @@ import "vite-plugin-shopify-theme-islands/revive";
|
|
|
49
54
|
|
|
50
55
|
This activates the runtime — islands are never loaded without this import.
|
|
51
56
|
|
|
57
|
+
For SPA teardown, the virtual `/revive` module also exports `disconnect()`.
|
|
58
|
+
If it is called before `DOMContentLoaded`, the runtime cancels its pending
|
|
59
|
+
startup listener so islands never initialize later against stale DOM.
|
|
60
|
+
|
|
52
61
|
### 3. Add directives to Liquid templates
|
|
53
62
|
|
|
54
63
|
```html
|
|
@@ -83,6 +92,8 @@ shopifyThemeIslands({
|
|
|
83
92
|
```
|
|
84
93
|
|
|
85
94
|
Per-directive options are deep-merged — overriding `visible.rootMargin` preserves `visible.threshold` at its default of `0`.
|
|
95
|
+
For config, `directives.interaction.events` is intentionally narrow and only accepts `mouseenter`, `touchstart`, and `focusin`.
|
|
96
|
+
Per-element `client:interaction="..."` values are checked at runtime against that same set. Unsupported tokens warn and are ignored; if all tokens are unsupported, the runtime warns and falls back to the configured default events.
|
|
86
97
|
|
|
87
98
|
### Enable automatic retry with exponential backoff
|
|
88
99
|
|
|
@@ -253,3 +264,35 @@ shopifyThemeIslands({
|
|
|
253
264
|
`directiveTimeout` is a top-level plugin option, not part of the per-directive config object.
|
|
254
265
|
|
|
255
266
|
Source: src/options.ts — ShopifyThemeIslandsOptions
|
|
267
|
+
|
|
268
|
+
### HIGH Empty or unsupported `directives.interaction.events` values fail config resolution
|
|
269
|
+
|
|
270
|
+
Wrong:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
shopifyThemeIslands({
|
|
274
|
+
directives: {
|
|
275
|
+
interaction: { events: [] },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
shopifyThemeIslands({
|
|
280
|
+
directives: {
|
|
281
|
+
interaction: { events: ["click"] as never[] },
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Correct:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
shopifyThemeIslands({
|
|
290
|
+
directives: {
|
|
291
|
+
interaction: { events: ["mouseenter", "focusin"] },
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
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.
|
|
297
|
+
|
|
298
|
+
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.1"
|
|
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
|