vite-plugin-shopify-theme-islands 0.6.1 → 0.7.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
@@ -36,6 +36,14 @@ import "vite-plugin-shopify-theme-islands/revive";
36
36
 
37
37
  That's it. The plugin automatically scans your islands directory and wires everything up.
38
38
 
39
+ For SPA navigation teardown, import `disconnect` to stop the MutationObserver:
40
+
41
+ ```ts
42
+ import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
43
+ // Call during SPA teardown:
44
+ disconnect();
45
+ ```
46
+
39
47
  ## Writing islands
40
48
 
41
49
  Two approaches — use either or both.
@@ -100,6 +108,17 @@ The plugin detects the mixin import at build time and includes the file as a laz
100
108
 
101
109
  Both can be used together — directory scanning for new islands, the mixin for existing components you want to adopt without moving.
102
110
 
111
+ ### Child island cascade
112
+
113
+ Child islands nested inside a parent island are automatically held until the parent's module has loaded. The runtime re-walks the parent's subtree on success, so child islands activate with their normal directives intact — no extra configuration needed.
114
+
115
+ ```html
116
+ <product-form client:visible>
117
+ <!-- tab-switcher will not load until product-form has loaded -->
118
+ <tab-switcher client:idle></tab-switcher>
119
+ </product-form>
120
+ ```
121
+
103
122
  ## Directives
104
123
 
105
124
  Add these attributes to your custom elements in Liquid to control when the JavaScript loads. Without a directive, the island loads immediately.
@@ -133,6 +152,8 @@ Loads the island when a CSS media query matches.
133
152
  </mobile-menu>
134
153
  ```
135
154
 
155
+ An empty attribute (`client:media=""`) logs a console warning and skips the media check — the island still loads.
156
+
136
157
  ### `client:idle`
137
158
 
138
159
  Loads the island once the browser is idle (uses `requestIdleCallback` with a 500ms deadline, falls back to `setTimeout`).
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ function resolveAliases(dirs, config) {
34
34
  return dir;
35
35
  });
36
36
  }
37
- function collectTagNames(dir, names) {
37
+ function walkDir(dir, visitor) {
38
38
  let entries;
39
39
  try {
40
40
  entries = readdirSync(dir, { withFileTypes: true });
@@ -44,34 +44,23 @@ function collectTagNames(dir, names) {
44
44
  for (const entry of entries) {
45
45
  if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
46
46
  continue;
47
- if (entry.isDirectory()) {
48
- collectTagNames(join(dir, entry.name), names);
49
- } else if (TS_JS_RE.test(entry.name)) {
50
- names.push(entry.name.replace(/\.(ts|js)$/, ""));
51
- }
47
+ const full = join(dir, entry.name);
48
+ if (entry.isDirectory())
49
+ walkDir(full, visitor);
50
+ else if (TS_JS_RE.test(entry.name))
51
+ visitor(entry.name, full);
52
52
  }
53
53
  }
54
+ function collectTagNames(dir, names) {
55
+ walkDir(dir, (name) => names.push(name.replace(TS_JS_RE, "")));
56
+ }
54
57
  function scanForIslandFiles(dir, found) {
55
- let entries;
56
- try {
57
- entries = readdirSync(dir, { withFileTypes: true });
58
- } catch {
59
- return;
60
- }
61
- for (const entry of entries) {
62
- if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
63
- continue;
64
- const full = join(dir, entry.name);
65
- if (entry.isDirectory()) {
66
- scanForIslandFiles(full, found);
67
- } else if (TS_JS_RE.test(entry.name)) {
68
- try {
69
- const content = readFileSync(full, "utf-8");
70
- if (ISLAND_IMPORT_RE.test(content))
71
- found.add(full);
72
- } catch {}
73
- }
74
- }
58
+ walkDir(dir, (_, full) => {
59
+ try {
60
+ if (ISLAND_IMPORT_RE.test(readFileSync(full, "utf-8")))
61
+ found.add(full);
62
+ } catch {}
63
+ });
75
64
  }
76
65
  function shopifyThemeIslands(options = {}) {
77
66
  const rawDirs = (Array.isArray(options.directories) ? options.directories : [options.directories ?? defaults.directories[0]]).map(normalizeDir);
@@ -189,10 +178,8 @@ function shopifyThemeIslands(options = {}) {
189
178
  ${mapEntries.join(`,
190
179
  `)}
191
180
  ]);`);
192
- lines.push(`_islands(islands, options, customDirectives);`);
193
- } else {
194
- lines.push(`_islands(islands, options);`);
195
181
  }
182
+ lines.push(`export const disconnect = _islands(islands, options${mapEntries.length ? ", customDirectives" : ""});`);
196
183
  return lines.join(`
197
184
  `);
198
185
  }
package/dist/runtime.d.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * A MutationObserver re-runs the same logic for elements added dynamically.
14
14
  */
15
15
  import type { ClientDirective, ReviveOptions } from "./index.js";
16
- export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): void;
16
+ export declare function revive(islands: Record<string, () => Promise<unknown>>, options?: ReviveOptions, customDirectives?: Map<string, ClientDirective>): () => void;
package/dist/runtime.js CHANGED
@@ -10,14 +10,11 @@ function media(query) {
10
10
  }
11
11
  function visible(element, rootMargin, threshold, pending) {
12
12
  return new Promise((resolve, reject) => {
13
- const io = new IntersectionObserver((entries) => {
14
- for (const entry of entries) {
15
- if (entry.isIntersecting) {
16
- io.disconnect();
17
- pending.delete(element);
18
- resolve();
19
- return;
20
- }
13
+ const io = new IntersectionObserver(([entry]) => {
14
+ if (entry.isIntersecting) {
15
+ io.disconnect();
16
+ pending.delete(element);
17
+ resolve();
21
18
  }
22
19
  }, { rootMargin, threshold });
23
20
  io.observe(element);
@@ -38,9 +35,6 @@ function idle(timeout) {
38
35
  setTimeout(resolve, timeout);
39
36
  });
40
37
  }
41
- var customElementFilter = {
42
- acceptNode: (node) => node.tagName.includes("-") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
43
- };
44
38
  function revive(islands, options, customDirectives) {
45
39
  const attrVisible = options?.directives?.visible?.attribute ?? "client:visible";
46
40
  const attrMedia = options?.directives?.media?.attribute ?? "client:media";
@@ -51,52 +45,87 @@ function revive(islands, options, customDirectives) {
51
45
  const idleTimeout = options?.directives?.idle?.timeout ?? 500;
52
46
  const deferDelay = options?.directives?.defer?.delay ?? 3000;
53
47
  const debug = options?.debug ?? false;
54
- const log = debug ? (...args) => console.log("[islands]", ...args) : () => {};
55
48
  const islandMap = new Map;
56
49
  for (const [key, loader] of Object.entries(islands)) {
57
- const tagName = key.split("/").pop().replace(/\.(ts|js)$/, "");
50
+ const filename = key.split("/").pop();
51
+ const tagName = filename.replace(/\.(ts|js)$/, "");
58
52
  if (!tagName.includes("-")) {
59
- console.warn(`[islands] Skipping "${key.split("/").pop()}" — filename must contain a hyphen to match a valid custom element tag name (e.g. rename to "${tagName}-island.ts")`);
53
+ console.warn(`[islands] Skipping "${filename}" — filename must contain a hyphen to match a valid custom element tag name (e.g. rename to "${tagName}-island.ts")`);
60
54
  continue;
61
55
  }
62
56
  if (!islandMap.has(tagName))
63
57
  islandMap.set(tagName, loader);
64
58
  }
65
- log(`revive() ready — ${islandMap.size} island(s)`);
66
59
  const queued = new Set;
60
+ let initDone = false;
61
+ const loaded = new Set;
67
62
  const pendingVisible = new Map;
63
+ const isUnloadedIsland = (tag) => queued.has(tag) && !loaded.has(tag);
64
+ const customElementFilter = {
65
+ acceptNode: (node) => {
66
+ const tag = node.tagName;
67
+ if (!tag.includes("-"))
68
+ return NodeFilter.FILTER_SKIP;
69
+ const lowerTag = tag.toLowerCase();
70
+ if (isUnloadedIsland(lowerTag))
71
+ return NodeFilter.FILTER_REJECT;
72
+ return NodeFilter.FILTER_ACCEPT;
73
+ }
74
+ };
68
75
  async function loadIsland(tagName, el, loader) {
69
- log(`<${tagName}> activating`);
76
+ if (debug && !initDone) {
77
+ const parts = [];
78
+ const visibleVal = el.getAttribute(attrVisible);
79
+ if (visibleVal !== null)
80
+ parts.push(visibleVal ? `${attrVisible}="${visibleVal}"` : attrVisible);
81
+ const mediaVal = el.getAttribute(attrMedia);
82
+ if (mediaVal)
83
+ parts.push(`${attrMedia}="${mediaVal}"`);
84
+ const idleVal = el.getAttribute(attrIdle);
85
+ if (idleVal !== null)
86
+ parts.push(idleVal ? `${attrIdle}="${idleVal}"` : attrIdle);
87
+ const deferVal = el.getAttribute(attrDefer);
88
+ if (deferVal !== null)
89
+ parts.push(deferVal ? `${attrDefer}="${deferVal}"` : attrDefer);
90
+ if (customDirectives?.size) {
91
+ for (const a of customDirectives.keys()) {
92
+ if (el.hasAttribute(a))
93
+ parts.push(a);
94
+ }
95
+ }
96
+ if (parts.length > 0)
97
+ console.log("[islands]", `<${tagName}> waiting · ${parts.join(", ")}`);
98
+ }
70
99
  const msgs = [];
71
- const note = debug ? (msg) => {
72
- msgs.push(msg);
73
- } : () => {};
100
+ const note = debug ? (msg) => msgs.push(msg) : () => {};
74
101
  const flush = debug ? (final) => {
75
102
  if (msgs.length === 0) {
76
103
  console.log("[islands]", `<${tagName}> ${final}`);
77
104
  } else {
78
- console.groupCollapsed(`[islands] <${tagName}>`);
105
+ console.groupCollapsed(`[islands] <${tagName}> ${final}`);
79
106
  for (const m of msgs)
80
107
  console.log(m);
81
- console.log(final);
82
108
  console.groupEnd();
83
109
  }
84
110
  } : () => {};
85
111
  try {
86
- if (el.hasAttribute(attrVisible)) {
87
- const elRootMargin = el.getAttribute(attrVisible) || rootMargin;
112
+ const visibleAttr = el.getAttribute(attrVisible);
113
+ if (visibleAttr !== null) {
88
114
  note(`waiting for ${attrVisible}`);
89
- await visible(el, elRootMargin, threshold, pendingVisible);
115
+ await visible(el, visibleAttr || rootMargin, threshold, pendingVisible);
90
116
  }
91
117
  const query = el.getAttribute(attrMedia);
92
- if (query) {
118
+ if (query === "") {
119
+ console.warn(`[islands] <${tagName}> ${attrMedia} has no value — skipping media check`);
120
+ } else if (query) {
93
121
  note(`waiting for ${attrMedia}="${query}"`);
94
122
  await media(query);
95
123
  }
96
- if (el.hasAttribute(attrIdle)) {
97
- const rawIdle = parseInt(el.getAttribute(attrIdle), 10);
98
- const elTimeout = Number.isNaN(rawIdle) ? idleTimeout : rawIdle;
99
- note(`waiting for ${attrIdle} (timeout: ${elTimeout}ms)`);
124
+ const idleAttr = el.getAttribute(attrIdle);
125
+ if (idleAttr !== null) {
126
+ const raw = parseInt(idleAttr, 10);
127
+ const elTimeout = Number.isNaN(raw) ? idleTimeout : raw;
128
+ note(`waiting for ${attrIdle} (${elTimeout}ms)`);
100
129
  await idle(elTimeout);
101
130
  }
102
131
  const d = el.getAttribute(attrDefer);
@@ -113,7 +142,11 @@ function revive(islands, options, customDirectives) {
113
142
  flush("aborted (element removed)");
114
143
  return;
115
144
  }
116
- const run = () => loader().catch((err) => {
145
+ const run = () => loader().then(() => {
146
+ loaded.add(tagName);
147
+ if (el.children.length)
148
+ walk(el);
149
+ }).catch((err) => {
117
150
  console.error(`[islands] Failed to load <${tagName}>:`, err);
118
151
  queued.delete(tagName);
119
152
  });
@@ -141,10 +174,16 @@ function revive(islands, options, customDirectives) {
141
174
  if (queued.has(tagName))
142
175
  return;
143
176
  const loader = islandMap.get(tagName);
144
- if (loader) {
145
- queued.add(tagName);
146
- loadIsland(tagName, el, loader);
177
+ if (!loader)
178
+ return;
179
+ let ancestor = el.parentElement;
180
+ while (ancestor) {
181
+ if (isUnloadedIsland(ancestor.tagName.toLowerCase()))
182
+ return;
183
+ ancestor = ancestor.parentElement;
147
184
  }
185
+ queued.add(tagName);
186
+ loadIsland(tagName, el, loader);
148
187
  }
149
188
  function walk(el) {
150
189
  activate(el);
@@ -168,7 +207,12 @@ function revive(islands, options, customDirectives) {
168
207
  }
169
208
  });
170
209
  function init() {
210
+ if (debug)
211
+ console.groupCollapsed(`[islands] ready — ${islandMap.size} island(s)`);
171
212
  walk(document.body);
213
+ initDone = true;
214
+ if (debug)
215
+ console.groupEnd();
172
216
  observer.observe(document.body, { childList: true, subtree: true });
173
217
  }
174
218
  if (document.readyState === "loading") {
@@ -176,6 +220,7 @@ function revive(islands, options, customDirectives) {
176
220
  } else {
177
221
  init();
178
222
  }
223
+ return () => observer.disconnect();
179
224
  }
180
225
  export {
181
226
  revive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-shopify-theme-islands",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Vite plugin for island architecture in Shopify themes",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
package/revive.d.ts CHANGED
@@ -1 +1,4 @@
1
- declare module "vite-plugin-shopify-theme-islands/revive" {}
1
+ declare module "vite-plugin-shopify-theme-islands/revive" {
2
+ /** Stops the island MutationObserver. Call during SPA navigation teardown. */
3
+ export const disconnect: () => void;
4
+ }