vite-plugin-shopify-theme-islands 1.3.0 → 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 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 (space-separated MDN event names):
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"], // DOM events that trigger load
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).
@@ -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.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // src/index.ts
2
- import { readFileSync as readFileSync2 } from "node:fs";
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,10 +67,73 @@ 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
+ }
56
132
 
57
133
  // src/interaction-events.ts
58
134
  var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"];
59
135
  var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES];
136
+ var INTERACTION_EVENT_NAMES_LABEL = INTERACTION_EVENT_NAMES.join(", ");
60
137
  var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
61
138
  var PREFIX = "[vite-plugin-shopify-theme-islands]";
62
139
  function isInteractionEventName(value) {
@@ -68,11 +145,23 @@ function validateInteractionEvents(events) {
68
145
  if (events.length === 0) {
69
146
  throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
70
147
  }
71
- const invalidEvent = events.find((eventName) => !isInteractionEventName(eventName));
148
+ const { invalid } = partitionInteractionEventTokens(events);
149
+ const invalidEvent = invalid[0];
72
150
  if (invalidEvent) {
73
151
  throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
74
152
  }
75
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
+ }
76
165
 
77
166
  // src/contract.ts
78
167
  var DEFAULT_DIRECTIVES = {
@@ -234,9 +323,10 @@ function createReviveBootstrapCompiler(ports, runtimePath) {
234
323
  name,
235
324
  entrypoint: await ports.resolveEntrypoint(entrypoint)
236
325
  }))) : null;
326
+ const directoryGlobs = input.directories.map((dir) => dir + "**/*.{ts,js}");
237
327
  return {
238
328
  runtimePath,
239
- directoryGlobs: input.directories.map((dir) => dir + "**/*.{ts,js}"),
329
+ directoryGlobs,
240
330
  islandPaths,
241
331
  customDirectives,
242
332
  reviveOptions: input.reviveOptions
@@ -265,89 +355,57 @@ var defaultDirectories = ["/frontend/js/islands/"];
265
355
  function normalizeDir(dir) {
266
356
  return dir.endsWith("/") ? dir : dir + "/";
267
357
  }
268
- function resolveAliases(dirs, config) {
269
- const aliases = [...config.resolve.alias].sort((a, b) => (typeof b.find === "string" ? b.find.length : 0) - (typeof a.find === "string" ? a.find.length : 0));
270
- return dirs.map((dir) => {
271
- for (const { find, replacement } of aliases) {
272
- if (typeof find === "string" && dir.startsWith(find))
273
- return dir.replace(find, replacement);
274
- if (find instanceof RegExp && find.test(dir))
275
- return dir.replace(find, replacement);
276
- }
277
- return dir;
278
- });
279
- }
280
358
  function shopifyThemeIslands(options = {}) {
281
359
  const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaultDirectories[0]]).map(normalizeDir);
282
360
  const policy = resolveThemeIslandsPolicy(options);
283
361
  const { directives, customDirectives: clientDirectiveDefinitions, debug } = policy.plugin;
284
362
  const { runtime: reviveOptions } = policy;
285
363
  const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
286
- let resolvedDirs = rawDirs;
287
- let root = process.cwd();
288
- let absDirs = rawDirs;
289
- const islandFiles = new Set;
290
- let scanned = false;
364
+ const inventory = createIslandInventory(rawDirs);
291
365
  return {
292
366
  name: "vite-plugin-shopify-theme-islands",
293
367
  enforce: "pre",
294
368
  configResolved(config) {
295
- root = config.root;
296
- resolvedDirs = resolveAliases(rawDirs, config);
297
- absDirs = resolvedDirs.map((d) => d.startsWith(root) ? d : join2(root, d.replace(/^\//, "")));
369
+ inventory.configure({
370
+ root: config.root,
371
+ aliases: config.resolve.alias
372
+ });
298
373
  },
299
374
  buildStart() {
300
- if (scanned)
301
- return;
302
- scanned = true;
303
375
  const t0 = performance.now();
304
- const initial = discoverIslandFiles(root, absDirs);
305
- islandFiles.clear();
306
- initial.forEach((f) => islandFiles.add(f));
376
+ const snapshot = inventory.scan();
377
+ if (!snapshot)
378
+ return;
307
379
  if (debug) {
308
380
  const scanMs = (performance.now() - t0).toFixed(1);
309
381
  log(`Scanned in ${scanMs}ms`);
310
- log("Scanning directories:", resolvedDirs.map((d) => d + "**/*.{ts,js}").join(", "));
311
- const dirNames = absDirs.flatMap((dir) => collectTagNames(dir));
312
- if (dirNames.length)
313
- log(`Found ${dirNames.length} directory island(s): [${dirNames.join(", ")}]`);
314
- if (islandFiles.size) {
315
- log(`Found ${islandFiles.size} island file(s) via mixin import:`);
316
- for (const f of islandFiles)
317
- log(" ", relative2(root, f));
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));
318
391
  }
319
392
  log("Directives:", directives);
320
393
  }
321
394
  },
322
395
  transform(code, id) {
323
- if (!TS_JS_RE.test(id))
396
+ const change = inventory.applyTransform(id, code);
397
+ if (!change)
324
398
  return;
325
- if (code.includes("shopify-theme-islands/island") && ISLAND_IMPORT_RE.test(code) && !inDirectory(id, absDirs)) {
326
- islandFiles.add(id);
327
- log("Detected island:", relative2(root, id));
328
- } else {
329
- if (islandFiles.delete(id))
330
- log("Removed island:", relative2(root, id));
331
- }
399
+ const root = inventory.getRoot();
400
+ log(change.type === "detected" ? "Detected island:" : "Removed island:", relative2(root, change.file));
332
401
  },
333
402
  watchChange(id, { event }) {
334
- if (!TS_JS_RE.test(id))
403
+ const change = inventory.applyWatchChange(id, event);
404
+ if (!change)
335
405
  return;
336
- if (event === "delete") {
337
- if (islandFiles.delete(id))
338
- log("Removed island (deleted):", relative2(root, id));
339
- } else {
340
- try {
341
- const content = readFileSync2(id, "utf-8");
342
- if (ISLAND_IMPORT_RE.test(content) && !inDirectory(id, absDirs)) {
343
- islandFiles.add(id);
344
- log("Detected island (watchChange):", relative2(root, id));
345
- } else {
346
- if (islandFiles.delete(id))
347
- log("Removed island (watchChange):", relative2(root, id));
348
- }
349
- } catch {}
350
- }
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));
351
409
  },
352
410
  resolveId(id) {
353
411
  if (id === VIRTUAL_ID)
@@ -369,9 +427,7 @@ function shopifyThemeIslands(options = {}) {
369
427
  toLoadPaths: getIslandPathsForLoad
370
428
  }, runtimePath);
371
429
  const plan = await compiler.plan({
372
- root,
373
- directories: resolvedDirs,
374
- islandFiles,
430
+ ...inventory.getBootstrapState(),
375
431
  customDirectives: clientDirectiveDefinitions,
376
432
  reviveOptions
377
433
  });
@@ -8,5 +8,11 @@
8
8
  export declare const INTERACTION_EVENT_NAMES: readonly ["mouseenter", "touchstart", "focusin"];
9
9
  export type InteractionEventName = (typeof INTERACTION_EVENT_NAMES)[number];
10
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
+ }
11
16
  export declare function isInteractionEventName(value: string): value is InteractionEventName;
12
17
  export declare function validateInteractionEvents(events: readonly string[] | undefined): asserts events is readonly InteractionEventName[];
18
+ export declare function partitionInteractionEventTokens(tokens: readonly string[]): InteractionEventTokenPartition;
package/dist/runtime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/interaction-events.ts
2
2
  var INTERACTION_EVENT_NAMES = ["mouseenter", "touchstart", "focusin"];
3
3
  var DEFAULT_INTERACTION_EVENTS = [...INTERACTION_EVENT_NAMES];
4
+ var INTERACTION_EVENT_NAMES_LABEL = INTERACTION_EVENT_NAMES.join(", ");
4
5
  var INTERACTION_EVENT_NAME_SET = new Set(INTERACTION_EVENT_NAMES);
5
6
  var PREFIX = "[vite-plugin-shopify-theme-islands]";
6
7
  function isInteractionEventName(value) {
@@ -12,11 +13,23 @@ function validateInteractionEvents(events) {
12
13
  if (events.length === 0) {
13
14
  throw new Error(`${PREFIX} "directives.interaction.events" must not be empty`);
14
15
  }
15
- const invalidEvent = events.find((eventName) => !isInteractionEventName(eventName));
16
+ const { invalid } = partitionInteractionEventTokens(events);
17
+ const invalidEvent = invalid[0];
16
18
  if (invalidEvent) {
17
19
  throw new Error(`${PREFIX} "directives.interaction.events" contains unsupported event "${invalidEvent}"`);
18
20
  }
19
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
+ }
20
33
 
21
34
  // src/contract.ts
22
35
  var DEFAULT_DIRECTIVES = {
@@ -184,10 +197,19 @@ function createDirectiveOrchestrator(waiters = {
184
197
  let events = [...directives.interaction.events];
185
198
  if (interactionAttr) {
186
199
  const tokens = interactionAttr.split(/\s+/).filter(Boolean);
187
- if (tokens.length > 0)
188
- events = tokens;
189
- else {
200
+ if (tokens.length === 0) {
190
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;
191
213
  }
192
214
  }
193
215
  log.note(`waiting for ${directives.interaction.attribute} (${events.join(", ")})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -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.3.0"
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,14 +6,16 @@ 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. Whitespace-only client:interaction values warn and fall back to
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.
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.
14
16
  type: core
15
17
  library: vite-plugin-shopify-theme-islands
16
- library_version: "1.3.0"
18
+ library_version: "1.3.1"
17
19
  sources:
18
20
  - Rees1993/vite-plugin-shopify-theme-islands:src/directive-orchestration.ts
19
21
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime.ts
@@ -79,6 +81,7 @@ Combined directives are AND-latched. The island loads only after every condition
79
81
 
80
82
  The attribute value overrides the globally configured default for that element. Other elements are unaffected.
81
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.
82
85
 
83
86
  ### `client:defer` without a value uses the global default
84
87
 
@@ -106,6 +109,18 @@ An empty `client:interaction` attribute uses the configured default events with
106
109
 
107
110
  Source: src/directive-orchestration.ts — interaction token parsing and fallback warning
108
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
+
109
124
  ### Changing built-in directive defaults globally
110
125
 
111
126
  ```ts
@@ -169,6 +184,25 @@ Whitespace-only values are not treated the same as an empty attribute. The runti
169
184
 
170
185
  Source: src/directive-orchestration.ts — `interactionAttr.split(/\s+/).filter(Boolean)`
171
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
+
172
206
  ### HIGH Multiple directives are AND, not OR
173
207
 
174
208
  Wrong assumption:
@@ -10,10 +10,11 @@ description: >
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
12
  starting if called early. Startup, DOM walking, mutation observation, and
13
- parent/child activation gating are now owned by src/lifecycle.ts.
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.
14
15
  type: core
15
16
  library: vite-plugin-shopify-theme-islands
16
- library_version: "1.3.0"
17
+ library_version: "1.3.1"
17
18
  sources:
18
19
  - Rees1993/vite-plugin-shopify-theme-islands:src/events.ts
19
20
  - Rees1993/vite-plugin-shopify-theme-islands:src/runtime-surface.ts
@@ -94,6 +95,8 @@ disconnect();
94
95
 
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.
96
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
+
97
100
  ### Raw DOM events (when type augmentation is in scope)
98
101
 
99
102
  ```ts
@@ -7,11 +7,14 @@ description: >
7
7
  defer, interaction, custom), retry (retries, delay with exponential
8
8
  backoff), directiveTimeout for hung custom directives, and the curated
9
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.
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.
12
15
  type: core
13
16
  library: vite-plugin-shopify-theme-islands
14
- library_version: "1.3.0"
17
+ library_version: "1.3.1"
15
18
  sources:
16
19
  - Rees1993/vite-plugin-shopify-theme-islands:src/index.ts
17
20
  - Rees1993/vite-plugin-shopify-theme-islands:src/contract.ts
@@ -51,6 +54,10 @@ import "vite-plugin-shopify-theme-islands/revive";
51
54
 
52
55
  This activates the runtime — islands are never loaded without this import.
53
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
+
54
61
  ### 3. Add directives to Liquid templates
55
62
 
56
63
  ```html
@@ -86,6 +93,7 @@ shopifyThemeIslands({
86
93
 
87
94
  Per-directive options are deep-merged — overriding `visible.rootMargin` preserves `visible.threshold` at its default of `0`.
88
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.
89
97
 
90
98
  ### Enable automatic retry with exponential backoff
91
99
 
@@ -9,7 +9,7 @@ description: >
9
9
  src/lifecycle.ts.
10
10
  type: core
11
11
  library: vite-plugin-shopify-theme-islands
12
- library_version: "1.3.0"
12
+ library_version: "1.3.1"
13
13
  sources:
14
14
  - Rees1993/vite-plugin-shopify-theme-islands:src/island.ts
15
15
  - Rees1993/vite-plugin-shopify-theme-islands:src/discovery.ts