@pyreon/server 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"client.js","children":[{"name":"src/client.ts","uid":"9000fe03-1"}]}],"isRoot":true},"nodeParts":{"9000fe03-1":{"renderedLength":4420,"gzipLength":1683,"brotliLength":0,"metaUid":"9000fe03-0"}},"nodeMetas":{"9000fe03-0":{"id":"/src/client.ts","moduleParts":{"client.js":"9000fe03-1"},"imported":[{"uid":"9000fe03-2"},{"uid":"9000fe03-3"},{"uid":"9000fe03-4"}],"importedBy":[],"isEntry":true},"9000fe03-2":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"9000fe03-0"}]},"9000fe03-3":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"9000fe03-0"}]},"9000fe03-4":{"id":"@pyreon/runtime-dom","moduleParts":{},"imported":[],"importedBy":[{"uid":"9000fe03-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"client.js","children":[{"name":"src/client.ts","uid":"edf0a820-1"}]}],"isRoot":true},"nodeParts":{"edf0a820-1":{"renderedLength":11827,"gzipLength":3912,"brotliLength":0,"metaUid":"edf0a820-0"}},"nodeMetas":{"edf0a820-0":{"id":"/src/client.ts","moduleParts":{"client.js":"edf0a820-1"},"imported":[{"uid":"edf0a820-2"},{"uid":"edf0a820-3"},{"uid":"edf0a820-4"}],"importedBy":[],"isEntry":true},"edf0a820-2":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"edf0a820-0"}]},"edf0a820-3":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"edf0a820-0"}]},"edf0a820-4":{"id":"@pyreon/runtime-dom","moduleParts":{},"imported":[],"importedBy":[{"uid":"edf0a820-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"339bb46f-1","name":"html.ts"},{"uid":"339bb46f-3","name":"middleware.ts"},{"uid":"339bb46f-5","name":"handler.ts"},{"uid":"339bb46f-7","name":"island.ts"},{"uid":"339bb46f-9","name":"ssg.ts"},{"uid":"339bb46f-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"339bb46f-1":{"renderedLength":2790,"gzipLength":1162,"brotliLength":0,"metaUid":"339bb46f-0"},"339bb46f-3":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"339bb46f-2"},"339bb46f-5":{"renderedLength":3261,"gzipLength":1391,"brotliLength":0,"metaUid":"339bb46f-4"},"339bb46f-7":{"renderedLength":1401,"gzipLength":664,"brotliLength":0,"metaUid":"339bb46f-6"},"339bb46f-9":{"renderedLength":2806,"gzipLength":1214,"brotliLength":0,"metaUid":"339bb46f-8"},"339bb46f-11":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"339bb46f-10"}},"nodeMetas":{"339bb46f-0":{"id":"/src/html.ts","moduleParts":{"index.js":"339bb46f-1"},"imported":[],"importedBy":[{"uid":"339bb46f-10"},{"uid":"339bb46f-4"}]},"339bb46f-2":{"id":"/src/middleware.ts","moduleParts":{"index.js":"339bb46f-3"},"imported":[{"uid":"339bb46f-12"}],"importedBy":[{"uid":"339bb46f-10"},{"uid":"339bb46f-4"}]},"339bb46f-4":{"id":"/src/handler.ts","moduleParts":{"index.js":"339bb46f-5"},"imported":[{"uid":"339bb46f-12"},{"uid":"339bb46f-13"},{"uid":"339bb46f-14"},{"uid":"339bb46f-15"},{"uid":"339bb46f-0"},{"uid":"339bb46f-2"}],"importedBy":[{"uid":"339bb46f-10"}]},"339bb46f-6":{"id":"/src/island.ts","moduleParts":{"index.js":"339bb46f-7"},"imported":[{"uid":"339bb46f-12"}],"importedBy":[{"uid":"339bb46f-10"}]},"339bb46f-8":{"id":"/src/ssg.ts","moduleParts":{"index.js":"339bb46f-9"},"imported":[{"uid":"339bb46f-16"},{"uid":"339bb46f-17"}],"importedBy":[{"uid":"339bb46f-10"}]},"339bb46f-10":{"id":"/src/index.ts","moduleParts":{"index.js":"339bb46f-11"},"imported":[{"uid":"339bb46f-4"},{"uid":"339bb46f-0"},{"uid":"339bb46f-6"},{"uid":"339bb46f-2"},{"uid":"339bb46f-8"}],"importedBy":[],"isEntry":true},"339bb46f-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"339bb46f-4"},{"uid":"339bb46f-6"},{"uid":"339bb46f-2"}]},"339bb46f-13":{"id":"@pyreon/head/ssr","moduleParts":{},"imported":[],"importedBy":[{"uid":"339bb46f-4"}]},"339bb46f-14":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"339bb46f-4"}]},"339bb46f-15":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"339bb46f-4"}]},"339bb46f-16":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"339bb46f-8"}]},"339bb46f-17":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"339bb46f-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"22c74963-1","name":"html.ts"},{"uid":"22c74963-3","name":"middleware.ts"},{"uid":"22c74963-5","name":"handler.ts"},{"uid":"22c74963-7","name":"island.ts"},{"uid":"22c74963-9","name":"ssg.ts"},{"uid":"22c74963-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"22c74963-1":{"renderedLength":2752,"gzipLength":1146,"brotliLength":0,"metaUid":"22c74963-0"},"22c74963-3":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"22c74963-2"},"22c74963-5":{"renderedLength":3356,"gzipLength":1446,"brotliLength":0,"metaUid":"22c74963-4"},"22c74963-7":{"renderedLength":2957,"gzipLength":1377,"brotliLength":0,"metaUid":"22c74963-6"},"22c74963-9":{"renderedLength":2806,"gzipLength":1214,"brotliLength":0,"metaUid":"22c74963-8"},"22c74963-11":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"22c74963-10"}},"nodeMetas":{"22c74963-0":{"id":"/src/html.ts","moduleParts":{"index.js":"22c74963-1"},"imported":[{"uid":"22c74963-14"}],"importedBy":[{"uid":"22c74963-10"},{"uid":"22c74963-4"}]},"22c74963-2":{"id":"/src/middleware.ts","moduleParts":{"index.js":"22c74963-3"},"imported":[{"uid":"22c74963-12"}],"importedBy":[{"uid":"22c74963-10"},{"uid":"22c74963-4"}]},"22c74963-4":{"id":"/src/handler.ts","moduleParts":{"index.js":"22c74963-5"},"imported":[{"uid":"22c74963-12"},{"uid":"22c74963-13"},{"uid":"22c74963-14"},{"uid":"22c74963-15"},{"uid":"22c74963-0"},{"uid":"22c74963-2"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-6":{"id":"/src/island.ts","moduleParts":{"index.js":"22c74963-7"},"imported":[{"uid":"22c74963-12"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-8":{"id":"/src/ssg.ts","moduleParts":{"index.js":"22c74963-9"},"imported":[{"uid":"22c74963-16"},{"uid":"22c74963-17"}],"importedBy":[{"uid":"22c74963-10"}]},"22c74963-10":{"id":"/src/index.ts","moduleParts":{"index.js":"22c74963-11"},"imported":[{"uid":"22c74963-4"},{"uid":"22c74963-0"},{"uid":"22c74963-6"},{"uid":"22c74963-2"},{"uid":"22c74963-8"}],"importedBy":[],"isEntry":true},"22c74963-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"},{"uid":"22c74963-6"},{"uid":"22c74963-2"}]},"22c74963-13":{"id":"@pyreon/head/ssr","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"}]},"22c74963-14":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"},{"uid":"22c74963-0"}]},"22c74963-15":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-4"}]},"22c74963-16":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-8"}]},"22c74963-17":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"22c74963-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/client.js CHANGED
@@ -3,6 +3,8 @@ import { RouterProvider, createRouter, hydrateLoaderData } from "@pyreon/router"
3
3
  import { hydrateRoot, mount } from "@pyreon/runtime-dom";
4
4
 
5
5
  //#region src/client.ts
6
+ const __DEV__ = process.env.NODE_ENV !== "production";
7
+ const _countSink = globalThis;
6
8
  /**
7
9
  * Hydrate a server-rendered Pyreon app on the client.
8
10
  *
@@ -14,6 +16,7 @@ import { hydrateRoot, mount } from "@pyreon/runtime-dom";
14
16
  * Returns a cleanup function that unmounts the app.
15
17
  */
16
18
  function startClient(options) {
19
+ /* v8 ignore next 3 */
17
20
  if (typeof document === "undefined") throw new Error("[Pyreon] startClient() can only be called in the browser.");
18
21
  const { App, routes, container = "#app" } = options;
19
22
  const el = typeof container === "string" ? document.querySelector(container) : container;
@@ -32,43 +35,138 @@ function startClient(options) {
32
35
  * Hydrate all `<pyreon-island>` elements on the page.
33
36
  *
34
37
  * Only loads JavaScript for components that are actually present in the HTML.
35
- * Respects hydration strategies (load, idle, visible, media, never).
38
+ * Respects hydration strategies (load, idle, visible, media, never). Returns
39
+ * a cleanup function that disconnects any pending observers/listeners.
40
+ *
41
+ * **`hydrate: 'never'` islands do NOT require a registry entry** — the whole
42
+ * point of the strategy is shipping zero client JS, so importing the loader
43
+ * (which would pull the component into the client bundle graph) defeats it.
44
+ * Such islands are silently skipped here without a `data-island-error` flag.
36
45
  *
37
46
  * @example
38
47
  * hydrateIslands({
39
48
  * Counter: () => import("./Counter"),
40
49
  * Search: () => import("./Search"),
50
+ * // No entry for `StaticBadge` even though it appears as a never-island
51
+ * // in the HTML — registering one would defeat the strategy.
41
52
  * })
42
53
  */
43
- /**
44
- * Hydrate all `<pyreon-island>` elements on the page.
45
- * Returns a cleanup function that disconnects any pending observers/listeners.
46
- */
47
54
  function hydrateIslands(registry) {
55
+ /* v8 ignore next */
48
56
  if (typeof document === "undefined") return () => {};
49
57
  const islands = document.querySelectorAll("pyreon-island");
50
58
  const cleanups = [];
51
59
  for (const el of islands) {
52
60
  const componentId = el.getAttribute("data-component");
53
61
  if (!componentId) continue;
62
+ if (el.parentElement?.closest("pyreon-island")) {
63
+ console.error(`[Pyreon] island "${componentId}" is nested inside another <pyreon-island>. Nested islands are not supported — the outer island's hydrateRoot replaces the inner element before its loader runs. Move the inner island out of the outer island's tree, or fold them into a single component.`);
64
+ el.setAttribute("data-island-error", "nested");
65
+ if (__DEV__) _countSink.__pyreon_count__?.("island.skipped.nested");
66
+ continue;
67
+ }
68
+ const strategy = el.getAttribute("data-hydrate") ?? "load";
69
+ if (strategy === "never") {
70
+ if (__DEV__) _countSink.__pyreon_count__?.("island.skipped.never");
71
+ continue;
72
+ }
54
73
  const loader = registry[componentId];
55
74
  if (!loader) {
56
75
  console.warn(`No loader registered for island "${componentId}"`);
76
+ el.setAttribute("data-island-error", "no-loader");
77
+ if (__DEV__) _countSink.__pyreon_count__?.("island.skipped.no-loader");
57
78
  continue;
58
79
  }
59
- const strategy = el.getAttribute("data-hydrate") ?? "load";
60
- const cleanup = scheduleHydration(el, loader, el.getAttribute("data-props") ?? "{}", strategy);
80
+ const propsJson = el.getAttribute("data-props") ?? "{}";
81
+ const prefetchCleanup = schedulePrefetch(el, loader, el.getAttribute("data-prefetch") ?? "none");
82
+ if (prefetchCleanup) cleanups.push(prefetchCleanup);
83
+ if (__DEV__) _countSink.__pyreon_count__?.("island.scheduled");
84
+ const cleanup = scheduleHydration(el, loader, propsJson, strategy);
61
85
  if (cleanup) cleanups.push(cleanup);
62
86
  }
63
87
  return () => {
64
88
  for (const fn of cleanups) fn();
65
89
  };
66
90
  }
91
+ function schedulePrefetch(el, loader, prefetch) {
92
+ if (prefetch === "none") return null;
93
+ /* v8 ignore next */
94
+ if (typeof window === "undefined") return null;
95
+ let cancelled = false;
96
+ const prime = () => {
97
+ if (cancelled) return;
98
+ if (__DEV__) _countSink.__pyreon_count__?.("island.prefetch");
99
+ loader().catch(() => {});
100
+ };
101
+ if (prefetch === "idle") {
102
+ if ("requestIdleCallback" in window) {
103
+ const id = requestIdleCallback(prime);
104
+ return () => {
105
+ cancelled = true;
106
+ cancelIdleCallback(id);
107
+ };
108
+ }
109
+ const id = setTimeout(prime, 200);
110
+ return () => {
111
+ cancelled = true;
112
+ clearTimeout(id);
113
+ };
114
+ }
115
+ if (!("IntersectionObserver" in window)) {
116
+ prime();
117
+ return null;
118
+ }
119
+ const observer = new IntersectionObserver((entries) => {
120
+ for (const entry of entries) if (entry.isIntersecting) {
121
+ observer.disconnect();
122
+ prime();
123
+ return;
124
+ }
125
+ }, { rootMargin: "200px" });
126
+ observer.observe(el);
127
+ return () => {
128
+ cancelled = true;
129
+ observer.disconnect();
130
+ };
131
+ }
132
+ /**
133
+ * Hydrate all `<pyreon-island>` elements using a registry auto-generated by
134
+ * `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default).
135
+ *
136
+ * Eliminates the manual sync between `island()` declarations in source and
137
+ * the client-side `hydrateIslands({ ... })` call — typo / forgotten entry /
138
+ * registry drift is the #1 author foot-gun for islands.
139
+ *
140
+ * The auto-registry omits `hydrate: 'never'` islands by design; their
141
+ * components stay out of the client bundle entirely. Other strategies
142
+ * resolve via the same dynamic-import paths their `island()` declaration
143
+ * specified.
144
+ *
145
+ * The user passes the virtual-module result. We don't import it inside
146
+ * `@pyreon/server/client` because Rolldown's static-import analysis runs
147
+ * before plugin resolveId hooks for workspace sources, and would fail to
148
+ * resolve the virtual specifier. Importing in the user's entry-client (where
149
+ * the plugin's resolveId fires natively) is the clean shape.
150
+ *
151
+ * @example
152
+ * // src/entry-client.ts
153
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
154
+ * import * as registry from 'virtual:pyreon/islands-registry'
155
+ * hydrateIslandsAuto(registry)
156
+ */
157
+ function hydrateIslandsAuto(registry) {
158
+ /* v8 ignore next */
159
+ if (typeof document === "undefined") return () => {};
160
+ if (!registry.__pyreonIslandsEnabled) throw new Error("[Pyreon] hydrateIslandsAuto() requires `pyreon({ islands: true })` in vite.config.ts (the default). The plugin emitted a stub registry because islands support was explicitly disabled. Either re-enable islands in the plugin, or use the manual hydrateIslands({ ... }) form.");
161
+ return hydrateIslands(registry.__pyreonIslandRegistry);
162
+ }
67
163
  function scheduleHydration(el, loader, propsJson, strategy) {
164
+ /* v8 ignore next */
68
165
  if (typeof window === "undefined") return null;
69
166
  let cancelled = false;
70
167
  const hydrate = () => {
71
- if (!cancelled) hydrateIsland(el, loader, propsJson);
168
+ if (cancelled) return Promise.resolve();
169
+ return hydrateIsland(el, loader, propsJson);
72
170
  };
73
171
  switch (strategy) {
74
172
  case "load":
@@ -89,7 +187,7 @@ function scheduleHydration(el, loader, propsJson, strategy) {
89
187
  };
90
188
  }
91
189
  case "visible": return observeVisibility(el, hydrate);
92
- case "never": return null;
190
+ case "interaction": return scheduleInteractionHydration(el, hydrate, DEFAULT_INTERACTION_EVENTS);
93
191
  default:
94
192
  if (strategy.startsWith("media(")) {
95
193
  const query = strategy.slice(6, -1);
@@ -110,10 +208,102 @@ function scheduleHydration(el, loader, propsJson, strategy) {
110
208
  mql.removeEventListener("change", onChange);
111
209
  };
112
210
  }
211
+ if (strategy.startsWith("interaction(")) {
212
+ const eventsStr = strategy.slice(12, -1).trim();
213
+ return scheduleInteractionHydration(el, hydrate, eventsStr ? eventsStr.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_INTERACTION_EVENTS);
214
+ }
113
215
  hydrate();
114
216
  return null;
115
217
  }
116
218
  }
219
+ /**
220
+ * Default events for the `interaction` strategy. Picked to cover the common
221
+ * "user reaches for it" surface: keyboard (`focus`), mouse (`pointerenter`,
222
+ * `click`), and touch (`touchstart`). First matching event triggers hydrate
223
+ * + removes ALL listeners (one-shot).
224
+ */
225
+ const DEFAULT_INTERACTION_EVENTS = [
226
+ "focus",
227
+ "click",
228
+ "pointerenter",
229
+ "touchstart"
230
+ ];
231
+ function scheduleInteractionHydration(el, hydrate, events) {
232
+ let hydrationStarted = false;
233
+ let hydrated = false;
234
+ let replayPath = null;
235
+ el.setAttribute("data-island-state", "awaiting-interaction");
236
+ const startHydration = () => {
237
+ if (hydrationStarted) return;
238
+ hydrationStarted = true;
239
+ el.setAttribute("data-island-state", "hydrating");
240
+ hydrate().then(() => {
241
+ hydrated = true;
242
+ el.removeAttribute("data-island-state");
243
+ for (const ev of events) el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS);
244
+ if (!replayPath) return;
245
+ const liveTarget = resolveReplayPath(el, replayPath);
246
+ if (liveTarget && liveTarget.isConnected) liveTarget.dispatchEvent(new MouseEvent("click", {
247
+ bubbles: true,
248
+ cancelable: true
249
+ }));
250
+ });
251
+ };
252
+ const dispatch = (event) => {
253
+ if (hydrated) return;
254
+ if (event.type === "click") {
255
+ event.stopImmediatePropagation();
256
+ event.preventDefault();
257
+ const target = event.target;
258
+ if (target) replayPath = captureReplayPath(el, target);
259
+ }
260
+ startHydration();
261
+ };
262
+ for (const ev of events) el.addEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS);
263
+ return () => {
264
+ if (hydrated) return;
265
+ el.removeAttribute("data-island-state");
266
+ for (const ev of events) el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS);
267
+ };
268
+ }
269
+ const INTERACTION_LISTENER_OPTS = {
270
+ passive: false,
271
+ capture: true
272
+ };
273
+ function captureReplayPath(el, target) {
274
+ const testid = target.getAttribute?.("data-testid");
275
+ if (testid) return {
276
+ kind: "testid",
277
+ value: testid
278
+ };
279
+ const steps = [];
280
+ let node = target;
281
+ while (node !== el) {
282
+ const parent = node.parentElement;
283
+ if (!parent) return null;
284
+ const index = Array.from(parent.children).indexOf(node);
285
+ if (index < 0) return null;
286
+ steps.unshift({
287
+ tag: node.tagName,
288
+ index
289
+ });
290
+ node = parent;
291
+ }
292
+ return {
293
+ kind: "path",
294
+ steps
295
+ };
296
+ }
297
+ function resolveReplayPath(el, path) {
298
+ if (path.kind === "testid") return el.querySelector(`[data-testid="${path.value}"]`);
299
+ let node = el;
300
+ for (const { tag, index } of path.steps) {
301
+ const child = node?.children[index];
302
+ if (!child || child.tagName !== tag) return null;
303
+ node = child;
304
+ }
305
+ return node;
306
+ }
117
307
  async function hydrateIsland(el, loader, propsJson) {
118
308
  const name = el.getAttribute("data-component") ?? "unknown";
119
309
  try {
@@ -123,15 +313,21 @@ async function hydrateIsland(el, loader, propsJson) {
123
313
  if (typeof props !== "object" || props === null || Array.isArray(props)) throw new TypeError("Expected object");
124
314
  } catch (parseErr) {
125
315
  console.error(`Invalid island props JSON for "${name}"`, parseErr);
316
+ el.setAttribute("data-island-error", "invalid-props");
317
+ if (__DEV__) _countSink.__pyreon_count__?.("island.error");
126
318
  return;
127
319
  }
128
320
  const mod = await loader();
129
321
  hydrateRoot(el, h(typeof mod === "function" ? mod : mod.default, props));
322
+ if (__DEV__) _countSink.__pyreon_count__?.("island.hydrated");
130
323
  } catch (err) {
131
324
  console.error(`Failed to hydrate island "${name}"`, err);
325
+ el.setAttribute("data-island-error", "hydration-failed");
326
+ if (__DEV__) _countSink.__pyreon_count__?.("island.error");
132
327
  }
133
328
  }
134
329
  function observeVisibility(el, callback) {
330
+ /* v8 ignore next */
135
331
  if (typeof window === "undefined") return null;
136
332
  if (!("IntersectionObserver" in window)) {
137
333
  callback();
@@ -149,5 +345,5 @@ function observeVisibility(el, callback) {
149
345
  }
150
346
 
151
347
  //#endregion
152
- export { hydrateIslands, startClient };
348
+ export { hydrateIslands, hydrateIslandsAuto, startClient };
153
349
  //# sourceMappingURL=client.js.map
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createContext, h, provide, useContext } from "@pyreon/core";
2
2
  import { renderWithHead } from "@pyreon/head/ssr";
3
- import { RouterProvider, createRouter, getRedirectInfo, prefetchLoaderData, serializeLoaderData } from "@pyreon/router";
3
+ import { RouterProvider, createRouter, getRedirectInfo, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from "@pyreon/router";
4
4
  import { renderToStream, runWithRequestContext } from "@pyreon/runtime-server";
5
5
  import { mkdir, writeFile } from "node:fs/promises";
6
6
  import { dirname, join, resolve } from "node:path";
@@ -61,7 +61,7 @@ function processCompiledTemplate(compiled, data) {
61
61
  function buildScripts(clientEntry, loaderData) {
62
62
  const parts = [];
63
63
  if (loaderData && Object.keys(loaderData).length > 0) {
64
- const json = JSON.stringify(loaderData).replace(/<\//g, "<\\/");
64
+ const json = stringifyLoaderData(loaderData);
65
65
  parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}<\/script>`);
66
66
  }
67
67
  parts.push(`<script type="module" src="${clientEntry}"><\/script>`);
@@ -73,7 +73,7 @@ function buildClientEntryTag(clientEntry) {
73
73
  }
74
74
  /** Fast path: build scripts with a pre-built client entry tag */
75
75
  function buildScriptsFast(clientEntryTag, loaderData) {
76
- if (loaderData && Object.keys(loaderData).length > 0) return `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}<\/script>\n ${clientEntryTag}`;
76
+ if (loaderData && Object.keys(loaderData).length > 0) return `<script>window.__PYREON_LOADER_DATA__=${stringifyLoaderData(loaderData)}<\/script>\n ${clientEntryTag}`;
77
77
  return clientEntryTag;
78
78
  }
79
79
 
@@ -177,8 +177,9 @@ function createHandler(options) {
177
177
  app: appHtml,
178
178
  scripts
179
179
  });
180
+ const status = router.currentRoute()?.isNotFound === true ? 404 : 200;
180
181
  return new Response(fullHtml, {
181
- status: 200,
182
+ status,
182
183
  headers: ctx.headers
183
184
  });
184
185
  } catch (err) {
@@ -221,6 +222,7 @@ async function renderStreamResponse(app, router, compiled, clientEntryTag, extra
221
222
  }
222
223
  push(shellTail);
223
224
  } catch (err) {
225
+ /* v8 ignore start */
224
226
  if (__DEV__) console.error("[Pyreon Server] Stream render failed:", err);
225
227
  push(`<script>console.error("[pyreon/server] Stream render failed")<\/script>`);
226
228
  push(shellTail);
@@ -245,15 +247,17 @@ async function renderStreamResponse(app, router, compiled, clientEntryTag, extra
245
247
  * 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy
246
248
  */
247
249
  function island(loader, options) {
248
- const { name, hydrate = "load" } = options;
250
+ const { name, hydrate = "load", prefetch = "none" } = options;
249
251
  const wrapper = async function IslandWrapper(props) {
250
252
  const mod = await loader();
251
253
  const Comp = typeof mod === "function" ? mod : mod.default;
252
- return h("pyreon-island", {
254
+ const attrs = {
253
255
  "data-component": name,
254
- "data-props": serializeIslandProps(props),
256
+ "data-props": serializeIslandProps(props, name),
255
257
  "data-hydrate": hydrate
256
- }, h(Comp, props));
258
+ };
259
+ if (prefetch !== "none" && hydrate !== "load" && hydrate !== "never") attrs["data-prefetch"] = prefetch;
260
+ return h("pyreon-island", attrs, h(Comp, props));
257
261
  };
258
262
  Object.defineProperties(wrapper, {
259
263
  __island: {
@@ -269,24 +273,51 @@ function island(loader, options) {
269
273
  hydrate: {
270
274
  value: hydrate,
271
275
  enumerable: true
276
+ },
277
+ prefetch: {
278
+ value: prefetch,
279
+ enumerable: true
272
280
  }
273
281
  });
274
282
  return wrapper;
275
283
  }
276
284
  /**
277
- * Serialize component props to a JSON string for embedding in HTML attributes.
278
- * Strips non-serializable values (functions, symbols, children).
285
+ * Serialize island props to JSON for embedding in `data-props`.
286
+ *
287
+ * **Prop contract** (what survives the SSR → client roundtrip):
288
+ *
289
+ * - ✅ JSON-native: strings, finite numbers, booleans, null, arrays, plain objects
290
+ * - ❌ **Dropped silently**: `children`, functions, symbols, `undefined` (a warning
291
+ * fires in dev when `children` is dropped — it's the most common surprise)
292
+ * - ❌ **Coerced**: `Date` becomes an ISO string (no auto-revival on the client),
293
+ * `Map` / `Set` / class instances lose their type
294
+ * - ⚠️ **`BigInt` is unsupported**: `JSON.stringify` throws on `BigInt` values.
295
+ * We catch the throw, log in dev, and emit `{}` rather than 500ing the SSR.
296
+ * Convert to string yourself before passing as a prop.
297
+ *
298
+ * For anything more complex than JSON, pass an ID and have the island component
299
+ * fetch / restore the rich value on the client.
279
300
  */
280
- function serializeIslandProps(props) {
301
+ function serializeIslandProps(props, islandName) {
281
302
  const clean = {};
303
+ let droppedChildren = false;
282
304
  for (const [key, value] of Object.entries(props)) {
283
- if (key === "children") continue;
305
+ if (key === "children") {
306
+ if (value !== void 0) droppedChildren = true;
307
+ continue;
308
+ }
284
309
  if (typeof value === "function") continue;
285
310
  if (typeof value === "symbol") continue;
286
311
  if (value === void 0) continue;
287
312
  clean[key] = value;
288
313
  }
289
- return JSON.stringify(clean);
314
+ if (droppedChildren && process.env.NODE_ENV !== "production") console.warn(`[Pyreon] island "${islandName}" was passed children, but island props do not support children — they were dropped. Render the children inside the island component itself.`);
315
+ try {
316
+ return JSON.stringify(clean);
317
+ } catch (err) {
318
+ if (process.env.NODE_ENV !== "production") console.error(`[Pyreon] island "${islandName}" props could not be serialized (likely BigInt or circular reference). Falling back to empty props. Original error: ${err.message}`);
319
+ return "{}";
320
+ }
290
321
  }
291
322
 
292
323
  //#endregion
@@ -28,19 +28,58 @@ type IslandLoader = () => Promise<{
28
28
  * Hydrate all `<pyreon-island>` elements on the page.
29
29
  *
30
30
  * Only loads JavaScript for components that are actually present in the HTML.
31
- * Respects hydration strategies (load, idle, visible, media, never).
31
+ * Respects hydration strategies (load, idle, visible, media, never). Returns
32
+ * a cleanup function that disconnects any pending observers/listeners.
33
+ *
34
+ * **`hydrate: 'never'` islands do NOT require a registry entry** — the whole
35
+ * point of the strategy is shipping zero client JS, so importing the loader
36
+ * (which would pull the component into the client bundle graph) defeats it.
37
+ * Such islands are silently skipped here without a `data-island-error` flag.
32
38
  *
33
39
  * @example
34
40
  * hydrateIslands({
35
41
  * Counter: () => import("./Counter"),
36
42
  * Search: () => import("./Search"),
43
+ * // No entry for `StaticBadge` even though it appears as a never-island
44
+ * // in the HTML — registering one would defeat the strategy.
37
45
  * })
38
46
  */
47
+ declare function hydrateIslands(registry: Record<string, IslandLoader>): () => void;
39
48
  /**
40
- * Hydrate all `<pyreon-island>` elements on the page.
41
- * Returns a cleanup function that disconnects any pending observers/listeners.
49
+ * Auto-discovered island registry shape emitted by `@pyreon/vite-plugin`
50
+ * as `virtual:pyreon/islands-registry`. The user passes the imported module
51
+ * to `hydrateIslandsAuto()`.
42
52
  */
43
- declare function hydrateIslands(registry: Record<string, IslandLoader>): () => void;
53
+ interface AutoIslandRegistry {
54
+ readonly __pyreonIslandRegistry: Record<string, IslandLoader>;
55
+ readonly __pyreonIslandsEnabled: boolean;
56
+ }
57
+ /**
58
+ * Hydrate all `<pyreon-island>` elements using a registry auto-generated by
59
+ * `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default).
60
+ *
61
+ * Eliminates the manual sync between `island()` declarations in source and
62
+ * the client-side `hydrateIslands({ ... })` call — typo / forgotten entry /
63
+ * registry drift is the #1 author foot-gun for islands.
64
+ *
65
+ * The auto-registry omits `hydrate: 'never'` islands by design; their
66
+ * components stay out of the client bundle entirely. Other strategies
67
+ * resolve via the same dynamic-import paths their `island()` declaration
68
+ * specified.
69
+ *
70
+ * The user passes the virtual-module result. We don't import it inside
71
+ * `@pyreon/server/client` because Rolldown's static-import analysis runs
72
+ * before plugin resolveId hooks for workspace sources, and would fail to
73
+ * resolve the virtual specifier. Importing in the user's entry-client (where
74
+ * the plugin's resolveId fires natively) is the clean shape.
75
+ *
76
+ * @example
77
+ * // src/entry-client.ts
78
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
79
+ * import * as registry from 'virtual:pyreon/islands-registry'
80
+ * hydrateIslandsAuto(registry)
81
+ */
82
+ declare function hydrateIslandsAuto(registry: AutoIslandRegistry): () => void;
44
83
  //#endregion
45
- export { StartClientOptions, hydrateIslands, startClient };
84
+ export { AutoIslandRegistry, StartClientOptions, hydrateIslands, hydrateIslandsAuto, startClient };
46
85
  //# sourceMappingURL=client2.d.ts.map
@@ -102,14 +102,6 @@ interface HandlerOptions {
102
102
  declare function createHandler(options: HandlerOptions): (req: Request) => Promise<Response>;
103
103
  //#endregion
104
104
  //#region src/html.d.ts
105
- /**
106
- * HTML template processing for SSR/SSG.
107
- *
108
- * Templates use comment placeholders:
109
- * <!--pyreon-head--> — replaced with <head> tags (title, meta, link, etc.)
110
- * <!--pyreon-app--> — replaced with rendered application HTML
111
- * <!--pyreon-scripts--> — replaced with client entry script + inline loader data
112
- */
113
105
  declare const DEFAULT_TEMPLATE = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <!--pyreon-head-->\n</head>\n<body>\n <div id=\"app\"><!--pyreon-app--></div>\n <!--pyreon-scripts-->\n</body>\n</html>";
114
106
  interface TemplateData {
115
107
  head: string;
@@ -138,17 +130,25 @@ declare function processCompiledTemplate(compiled: CompiledTemplate, data: Templ
138
130
  declare function buildScripts(clientEntry: string, loaderData: Record<string, unknown> | null): string;
139
131
  //#endregion
140
132
  //#region src/island.d.ts
141
- type HydrationStrategy = 'load' | 'idle' | 'visible' | 'never' | `media(${string})`;
133
+ type HydrationStrategy = 'load' | 'idle' | 'visible' | 'interaction' | 'never' | `media(${string})` | `interaction(${string})`;
134
+ type PrefetchStrategy = 'none' | 'idle' | 'visible';
142
135
  interface IslandOptions {
143
136
  /** Unique name — must match the key in the client-side hydrateIslands() registry */
144
137
  name: string;
145
138
  /** When to hydrate on the client (default: "load") */
146
139
  hydrate?: HydrationStrategy;
140
+ /**
141
+ * Pre-warm the island's chunk before its hydration trigger fires.
142
+ * Best paired with `hydrate: 'visible'` or `hydrate: 'media(...)'`.
143
+ * Default: "none". No-op when paired with `hydrate: 'load'` or `'never'`.
144
+ */
145
+ prefetch?: PrefetchStrategy;
147
146
  }
148
147
  interface IslandMeta {
149
148
  readonly __island: true;
150
149
  readonly name: string;
151
150
  readonly hydrate: HydrationStrategy;
151
+ readonly prefetch: PrefetchStrategy;
152
152
  }
153
153
  /**
154
154
  * Create an island component.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/server",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "SSR handler, SSG prerender, and island architecture for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/server#readme",
6
6
  "bugs": {
@@ -43,19 +43,22 @@
43
43
  "build": "vl_rolldown_build",
44
44
  "dev": "vl_rolldown_build-watch",
45
45
  "test": "vitest run",
46
+ "test:browser": "vitest run --config ./vitest.browser.config.ts",
46
47
  "typecheck": "tsc --noEmit",
47
48
  "lint": "oxlint .",
48
49
  "prepublishOnly": "bun run build"
49
50
  },
50
51
  "dependencies": {
51
- "@pyreon/core": "^0.15.0",
52
- "@pyreon/head": "^0.15.0",
53
- "@pyreon/reactivity": "^0.15.0",
54
- "@pyreon/router": "^0.15.0",
55
- "@pyreon/runtime-dom": "^0.15.0",
56
- "@pyreon/runtime-server": "^0.15.0"
52
+ "@pyreon/core": "^0.16.0",
53
+ "@pyreon/head": "^0.16.0",
54
+ "@pyreon/reactivity": "^0.16.0",
55
+ "@pyreon/router": "^0.16.0",
56
+ "@pyreon/runtime-dom": "^0.16.0",
57
+ "@pyreon/runtime-server": "^0.16.0"
57
58
  },
58
59
  "devDependencies": {
59
- "@pyreon/manifest": "0.13.1"
60
+ "@pyreon/manifest": "0.13.1",
61
+ "@vitest/browser": "^4.1.4",
62
+ "@vitest/browser-playwright": "^4.1.4"
60
63
  }
61
64
  }