@pyreon/server 0.14.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.
- package/lib/analysis/client.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/client.js +206 -10
- package/lib/index.js +53 -115
- package/lib/types/client.d.ts +44 -5
- package/lib/types/index.d.ts +9 -9
- package/package.json +12 -8
- package/src/client.ts +340 -11
- package/src/handler.ts +34 -4
- package/src/html.ts +7 -3
- package/src/island.ts +109 -24
- package/src/manifest.ts +65 -9
- package/src/tests/client.test.ts +915 -1
- package/src/tests/islands.browser.test.tsx +512 -0
- package/src/tests/manifest-snapshot.test.ts +2 -0
- package/src/tests/server.test.ts +296 -1
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
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)
|
|
49
|
-
* - "idle"
|
|
50
|
-
* - "visible"
|
|
51
|
-
* - "
|
|
52
|
-
* - "
|
|
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 =
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
122
|
-
*
|
|
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(
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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:
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
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
|
|
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: ['
|
|
161
|
+
seeAlso: ['hydrateIslands', 'island'],
|
|
106
162
|
},
|
|
107
163
|
{
|
|
108
164
|
name: 'prerender',
|