@pyreon/server 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/island.ts CHANGED
@@ -45,11 +45,34 @@
45
45
  * ## Hydration strategies
46
46
  *
47
47
  * Control when an island hydrates via the `hydrate` option:
48
- * - "load" (default) — hydrate immediately on page load
49
- * - "idle" — hydrate when the browser is idle (requestIdleCallback)
50
- * - "visible" — hydrate when the island scrolls into the viewport
51
- * - "media(query)" — hydrate when a media query matches
52
- * - "never" never hydrate (render-only, no client JS)
48
+ * - "load" (default) — hydrate immediately on page load
49
+ * - "idle" — hydrate when the browser is idle (requestIdleCallback)
50
+ * - "visible" — hydrate when the island scrolls into the viewport
51
+ * - "interaction" — hydrate on first user interaction (focus/click/pointerenter/touchstart)
52
+ * - "interaction(<events>)" — hydrate on first matching event (e.g. "interaction(focus)" or "interaction(click,touchstart)")
53
+ * - "media(query)" — hydrate when a media query matches
54
+ * - "never" — never hydrate (render-only, no client JS)
55
+ *
56
+ * Use `interaction` for components that are interactive but not visible on
57
+ * load — modals, dropdowns, command palettes. The component stays as a
58
+ * non-hydrated DOM region until the user reaches for it (clicks the trigger,
59
+ * tabs through the page, hovers, taps).
60
+ *
61
+ * ## Prefetch hint
62
+ *
63
+ * Pair a deferred-hydration strategy (`visible` / `interaction` / `media(...)`)
64
+ * with a `prefetch` hint to start fetching the island's chunk BEFORE it's needed
65
+ * for hydration — the chunk is warm in the module cache by the time the
66
+ * hydration trigger fires, so hydration is instant instead of blank-while-fetching.
67
+ *
68
+ * - "none" (default) — no prefetch
69
+ * - "idle" — call loader() during browser idle time (requestIdleCallback)
70
+ * - "visible" — call loader() ~200px before the island scrolls into view
71
+ *
72
+ * Pair `hydrate: 'visible'` with `prefetch: 'idle'` for the canonical "fetch
73
+ * during idle, hydrate on scroll-in" pattern. Prefetch is a no-op (silently
74
+ * skipped) for `hydrate: 'load'` (loader runs synchronously already) and
75
+ * `hydrate: 'never'` (defeats the zero-JS strategy).
53
76
  */
54
77
 
55
78
  import type { ComponentFn, Props, VNode } from '@pyreon/core'
@@ -57,19 +80,35 @@ import { h } from '@pyreon/core'
57
80
 
58
81
  // ─── Types ───────────────────────────────────────────────────────────────────
59
82
 
60
- export type HydrationStrategy = 'load' | 'idle' | 'visible' | 'never' | `media(${string})`
83
+ export type HydrationStrategy =
84
+ | 'load'
85
+ | 'idle'
86
+ | 'visible'
87
+ | 'interaction'
88
+ | 'never'
89
+ | `media(${string})`
90
+ | `interaction(${string})`
91
+
92
+ export type PrefetchStrategy = 'none' | 'idle' | 'visible'
61
93
 
62
94
  export interface IslandOptions {
63
95
  /** Unique name — must match the key in the client-side hydrateIslands() registry */
64
96
  name: string
65
97
  /** When to hydrate on the client (default: "load") */
66
98
  hydrate?: HydrationStrategy
99
+ /**
100
+ * Pre-warm the island's chunk before its hydration trigger fires.
101
+ * Best paired with `hydrate: 'visible'` or `hydrate: 'media(...)'`.
102
+ * Default: "none". No-op when paired with `hydrate: 'load'` or `'never'`.
103
+ */
104
+ prefetch?: PrefetchStrategy
67
105
  }
68
106
 
69
107
  export interface IslandMeta {
70
108
  readonly __island: true
71
109
  readonly name: string
72
110
  readonly hydrate: HydrationStrategy
111
+ readonly prefetch: PrefetchStrategy
73
112
  }
74
113
 
75
114
  // ─── Server-side island factory ──────────────────────────────────────────────
@@ -86,30 +125,35 @@ export function island<P extends Props = Props>(
86
125
  loader: () => Promise<{ default: ComponentFn<P> } | ComponentFn<P>>,
87
126
  options: IslandOptions,
88
127
  ): ComponentFn<P> & IslandMeta {
89
- const { name, hydrate = 'load' } = options
128
+ const { name, hydrate = 'load', prefetch = 'none' } = options
90
129
 
91
130
  const IslandWrapper = async function IslandWrapper(props: P): Promise<VNode | null> {
92
131
  const mod = await loader()
93
132
  const Comp = typeof mod === 'function' ? mod : mod.default
94
- const serializedProps = serializeIslandProps(props)
133
+ const serializedProps = serializeIslandProps(props, name)
95
134
 
96
- return h(
97
- 'pyreon-island',
98
- {
99
- 'data-component': name,
100
- 'data-props': serializedProps,
101
- 'data-hydrate': hydrate,
102
- },
103
- h(Comp, props),
104
- )
135
+ // Only emit data-prefetch when it actually changes behavior. `none` is the
136
+ // default and pointless on `load` / `never` — keep the rendered HTML clean.
137
+ const attrs: Record<string, string> = {
138
+ 'data-component': name,
139
+ 'data-props': serializedProps,
140
+ 'data-hydrate': hydrate,
141
+ }
142
+ if (prefetch !== 'none' && hydrate !== 'load' && hydrate !== 'never') {
143
+ attrs['data-prefetch'] = prefetch
144
+ }
145
+
146
+ return h('pyreon-island', attrs, h(Comp, props))
105
147
  }
106
148
 
107
- // Attach metadata so the Vite plugin can detect islands for code-splitting
149
+ // Attach metadata so tooling (CLI project scanner, MCP, future codegen) can
150
+ // detect islands without runtime introspection.
108
151
  const wrapper = IslandWrapper as unknown as ComponentFn<P> & IslandMeta
109
152
  Object.defineProperties(wrapper, {
110
153
  __island: { value: true, enumerable: true },
111
154
  name: { value: name, enumerable: true, writable: false, configurable: true },
112
155
  hydrate: { value: hydrate, enumerable: true },
156
+ prefetch: { value: prefetch, enumerable: true },
113
157
  })
114
158
 
115
159
  return wrapper
@@ -118,20 +162,61 @@ export function island<P extends Props = Props>(
118
162
  // ─── Helpers ─────────────────────────────────────────────────────────────────
119
163
 
120
164
  /**
121
- * Serialize component props to a JSON string for embedding in HTML attributes.
122
- * Strips non-serializable values (functions, symbols, children).
165
+ * Serialize island props to JSON for embedding in `data-props`.
166
+ *
167
+ * **Prop contract** (what survives the SSR → client roundtrip):
168
+ *
169
+ * - ✅ JSON-native: strings, finite numbers, booleans, null, arrays, plain objects
170
+ * - ❌ **Dropped silently**: `children`, functions, symbols, `undefined` (a warning
171
+ * fires in dev when `children` is dropped — it's the most common surprise)
172
+ * - ❌ **Coerced**: `Date` becomes an ISO string (no auto-revival on the client),
173
+ * `Map` / `Set` / class instances lose their type
174
+ * - ⚠️ **`BigInt` is unsupported**: `JSON.stringify` throws on `BigInt` values.
175
+ * We catch the throw, log in dev, and emit `{}` rather than 500ing the SSR.
176
+ * Convert to string yourself before passing as a prop.
177
+ *
178
+ * For anything more complex than JSON, pass an ID and have the island component
179
+ * fetch / restore the rich value on the client.
123
180
  */
124
- function serializeIslandProps(props: Record<string, unknown>): string {
181
+ function serializeIslandProps(
182
+ props: Record<string, unknown>,
183
+ islandName: string,
184
+ ): string {
125
185
  const clean: Record<string, unknown> = {}
186
+ let droppedChildren = false
126
187
  for (const [key, value] of Object.entries(props)) {
127
- // Skip non-serializable or internal props
128
- if (key === 'children') continue
188
+ if (key === 'children') {
189
+ if (value !== undefined) droppedChildren = true
190
+ continue
191
+ }
129
192
  if (typeof value === 'function') continue
130
193
  if (typeof value === 'symbol') continue
131
194
  if (value === undefined) continue
132
195
  clean[key] = value
133
196
  }
197
+ if (droppedChildren && process.env.NODE_ENV !== 'production') {
198
+ // eslint-disable-next-line no-console
199
+ console.warn(
200
+ `[Pyreon] island "${islandName}" was passed children, but island props ` +
201
+ `do not support children — they were dropped. Render the children inside ` +
202
+ `the island component itself.`,
203
+ )
204
+ }
134
205
  // The SSR renderer's renderProp() already applies escapeHtml() to attribute
135
206
  // values, so the JSON is safe to embed in HTML attributes without double-escaping.
136
- return JSON.stringify(clean)
207
+ try {
208
+ return JSON.stringify(clean)
209
+ } catch (err) {
210
+ // JSON.stringify throws on BigInt and on circular references. Don't 500
211
+ // the SSR — emit empty props and warn so the dev sees it before users do.
212
+ if (process.env.NODE_ENV !== 'production') {
213
+ // eslint-disable-next-line no-console
214
+ console.error(
215
+ `[Pyreon] island "${islandName}" props could not be serialized (likely ` +
216
+ `BigInt or circular reference). Falling back to empty props. Original ` +
217
+ `error: ${(err as Error).message}`,
218
+ )
219
+ }
220
+ return '{}'
221
+ }
137
222
  }
package/src/manifest.ts CHANGED
@@ -13,8 +13,9 @@ export default defineManifest({
13
13
  'mode: "string" (renderToString) or "stream" (renderToStream with Suspense out-of-order)',
14
14
  'Middleware chain — `(ctx) => Response | void | Promise<…>`, short-circuit on first Response',
15
15
  'prerender({ handler, paths, outDir, origin?, onPage? }) — SSG with onPage callback',
16
- 'island(loader, { name, hydrate }) — lazy island with hydration strategy',
16
+ 'island(loader, { name, hydrate, prefetch? }) — lazy island with hydration strategy + optional prefetch hint',
17
17
  'Hydration strategies: "load" | "idle" | "visible" | "media(...)" | "never"',
18
+ 'Prefetch hints: "idle" | "visible" — pre-warm the chunk before the hydration trigger fires',
18
19
  'useRequestLocals() bridges middleware `ctx.locals` into the component tree',
19
20
  'Loader-data inline-script escaping — `</script>` becomes `<\\/script>`',
20
21
  ],
@@ -87,22 +88,77 @@ export default createHandler({
87
88
  name: 'island',
88
89
  kind: 'function',
89
90
  signature:
90
- 'island(loader: () => Promise<ComponentFn>, options: { name: string; hydrate?: HydrationStrategy }): ComponentFn',
91
+ 'island(loader: () => Promise<ComponentFn>, options: { name: string; hydrate?: HydrationStrategy; prefetch?: PrefetchStrategy }): ComponentFn',
91
92
  summary:
92
- 'Wrap a lazily-loaded component in a `<pyreon-island>` boundary with a hydration strategy. The rest of the page stays HTML-only; only the island fetches its JS bundle and hydrates. Strategies: `"load"` (immediate), `"idle"` (`requestIdleCallback`), `"visible"` (IntersectionObserver), `"media(query)"` (matchMedia), `"never"` (HTML-only, no JS). Props passed to islands are JSON-serialized — non-JSON values (functions, symbols, undefined, children) are stripped.',
93
- example: `const SearchBar = island(
94
- () => import("./SearchBar"),
95
- { name: "SearchBar", hydrate: "visible" }
93
+ 'Wrap a lazily-loaded component in a `<pyreon-island>` boundary with a hydration strategy. The rest of the page stays HTML-only; only the island fetches its JS bundle and hydrates. Strategies: `"load"` (immediate), `"idle"` (`requestIdleCallback`), `"visible"` (IntersectionObserver), `"interaction"` (first focus/click/pointerenter/touchstart — also `"interaction(<events>)"` for custom event lists; clicks are REPLAYED on the equivalent live element after hydration so the first click both wakes the island AND fires the action), `"media(query)"` (matchMedia), `"never"` (HTML-only, no JS). Props passed to islands are JSON-serialized — non-JSON values (functions, symbols, undefined, children) are stripped. Pair with `prefetch: "idle"` or `"visible"` to pre-warm the chunk BEFORE the hydration trigger fires — eliminates the blank-while-fetching flash on deferred-strategy islands. Prefetch is a no-op for `hydrate: "load"` (loader runs synchronously already) and `hydrate: "never"` (defeats the zero-JS strategy).',
94
+ example: `// Visible-hydration paired with idle-prefetch — chunk arrives during
95
+ // browser idle so by scroll-in, hydration is instant.
96
+ const Comments = island(
97
+ () => import("./Comments"),
98
+ { name: "Comments", hydrate: "visible", prefetch: "idle" }
96
99
  )
97
100
 
98
- // Hydration strategies: "load" | "idle" | "visible" | "media" | "never"`,
101
+ // Interaction-hydration perfect for modals / dropdowns / command palettes.
102
+ const CommandPalette = island(
103
+ () => import("./CommandPalette"),
104
+ { name: "CommandPalette", hydrate: "interaction" }, // first focus/click/pointerenter/touchstart
105
+ )
106
+
107
+ // Hydration strategies: "load" | "idle" | "visible" | "interaction" | "media" | "never"
108
+ // Prefetch strategies: "none" (default) | "idle" | "visible"`,
99
109
  mistakes: [
100
110
  'Passing function props (event handlers, callbacks) — silently stripped during JSON serialization, the island sees `undefined`',
101
111
  'Passing children to an island — stripped; islands cannot render arbitrary descendant trees from props',
102
- 'Forgetting to call `hydrateIslands({ Name: () => import("./Path") })` on the client — islands render as HTML and never hydrate',
112
+ 'Forgetting to wire client-side hydration — under `@pyreon/vite-plugin` use `hydrateIslandsAuto(registry)` (the registry is auto-generated from `island()` calls); without a plugin use the manual `hydrateIslands({ Name: () => import("./Path") })`',
103
113
  'Using a duplicate `name` across two islands — the client-side registry collapses them, only one loader will fire',
114
+ 'Setting `prefetch: "idle"` on a `hydrate: "load"` island — load runs the loader synchronously, prefetch is redundant (silently suppressed; no `data-prefetch` attribute is emitted)',
115
+ 'Setting any `prefetch` on a `hydrate: "never"` island — defeats the whole zero-JS point of `never` (silently suppressed)',
116
+ 'Registering a `hydrate: "never"` island in `hydrateIslands({ ... })` — defeats the strategy by pulling the component module into the client bundle. The whole point of `never` is zero client JS. The runtime short-circuits never-strategy before the registry lookup so missing entries are silent (no `data-island-error="no-loader"`); the auto-registry omits never-strategy islands by design.',
117
+ 'Using `"interaction"` for visible-on-load components — defeats the strategy. Use `"load"` for above-the-fold interactive content; reserve `"interaction"` for modals / dropdowns / command palettes that are interactive but only shown on user demand',
118
+ 'Relying on focus/pointerenter to trigger the SAME action as click for `"interaction"` — only clicks are replayed post-hydration. Non-click events trigger hydration but no replay (focus can\\\'t be reliably re-dispatched once the user has tabbed past; pointerenter is passive)',
119
+ ],
120
+ seeAlso: ['createHandler', 'hydrateIslands', 'hydrateIslandsAuto'],
121
+ },
122
+ {
123
+ name: 'hydrateIslands',
124
+ kind: 'function',
125
+ signature:
126
+ 'hydrateIslands(registry: Record<string, () => Promise<ComponentFn | { default: ComponentFn }>>): () => void',
127
+ summary:
128
+ 'Client-side counterpart to `island()`. Walks every `<pyreon-island>` element on the page and schedules hydration per its `data-hydrate` strategy. Manual form: the user maintains the `Name → loader` mapping by hand (must match every `island()` `name` field). Returns a cleanup function that disconnects pending observers / listeners. Use `hydrateIslandsAuto()` under `@pyreon/vite-plugin` to skip the manual sync. Imported from `@pyreon/server/client`, NOT from `@pyreon/server` (server-only entry).',
129
+ example: `import { hydrateIslands } from "@pyreon/server/client"
130
+
131
+ hydrateIslands({
132
+ Counter: () => import("./Counter"),
133
+ SearchBar: () => import("./SearchBar"),
134
+ // hydrate: "never" islands are intentionally omitted —
135
+ // registering them defeats the zero-JS contract.
136
+ })`,
137
+ mistakes: [
138
+ 'Registry key must match the `island()` `name` field exactly — typo / drift causes runtime `data-island-error="no-loader"`. Use `hydrateIslandsAuto()` to eliminate this manual sync.',
139
+ 'Including a `hydrate: "never"` island in the registry — defeats the strategy by pulling its module into the client bundle. Skip never-islands; the runtime short-circuits silently for them.',
140
+ 'Importing from `@pyreon/server` instead of `@pyreon/server/client` — the main entry is server-only and stubs/throws on client-side use.',
141
+ ],
142
+ seeAlso: ['island', 'hydrateIslandsAuto'],
143
+ },
144
+ {
145
+ name: 'hydrateIslandsAuto',
146
+ kind: 'function',
147
+ signature:
148
+ 'hydrateIslandsAuto(registry: AutoIslandRegistry): () => void',
149
+ summary:
150
+ 'Auto-discovered counterpart to `hydrateIslands()`. Under `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default), the plugin pre-scans your source for `island()` declarations and emits a `virtual:pyreon/islands-registry` virtual module. The user imports it into `entry-client.ts` and passes it here. Eliminates the manual `Name → loader` sync that drives the #1 author foot-gun for islands. Never-strategy islands are omitted from the auto-registry by design — their components stay out of the client bundle.',
151
+ example: `// src/entry-client.ts
152
+ import { hydrateIslandsAuto } from "@pyreon/server/client"
153
+ import * as registry from "virtual:pyreon/islands-registry"
154
+
155
+ hydrateIslandsAuto(registry)`,
156
+ mistakes: [
157
+ 'Calling without the registry argument — the function takes the imported virtual module explicitly. The user-side `import` is what lets the plugin\\\'s `resolveId` hook run; importing from inside `@pyreon/server/client` would fail at build time because Rolldown\\\'s static-import analysis runs before plugin resolveId hooks for workspace sources.',
158
+ 'Using under a non-Vite bundler — the virtual module only exists under `@pyreon/vite-plugin`. Fall back to manual `hydrateIslands({ ... })` for non-Vite consumers.',
159
+ 'Setting `pyreon({ islands: false })` and still calling `hydrateIslandsAuto()` — the plugin emits a stub registry that throws at runtime with a clear error message. Either re-enable islands (the default) or use `hydrateIslands({ ... })` instead.',
104
160
  ],
105
- seeAlso: ['createHandler', 'hydrateIslands'],
161
+ seeAlso: ['hydrateIslands', 'island'],
106
162
  },
107
163
  {
108
164
  name: 'prerender',