@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.
- 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 +44 -13
- package/lib/types/client.d.ts +44 -5
- package/lib/types/index.d.ts +9 -9
- package/package.json +11 -8
- package/src/client.ts +340 -11
- package/src/handler.ts +17 -2
- 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 +220 -1
package/src/client.ts
CHANGED
|
@@ -29,7 +29,16 @@ import type { ComponentFn } from '@pyreon/core'
|
|
|
29
29
|
import { h } from '@pyreon/core'
|
|
30
30
|
import { createRouter, hydrateLoaderData, type RouteRecord, RouterProvider } from '@pyreon/router'
|
|
31
31
|
import { hydrateRoot, mount } from '@pyreon/runtime-dom'
|
|
32
|
-
import type { HydrationStrategy } from './island'
|
|
32
|
+
import type { HydrationStrategy, PrefetchStrategy } from './island'
|
|
33
|
+
|
|
34
|
+
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
35
|
+
// Same pattern as @pyreon/runtime-dom: bare process.env.NODE_ENV gate (the
|
|
36
|
+
// bundler-agnostic library standard) so counter strings tree-shake out under
|
|
37
|
+
// every modern bundler (Vite, Webpack/Next.js, Rolldown, esbuild, Rollup,
|
|
38
|
+
// Parcel, Bun) when consumers ship a production bundle. The optional-chain
|
|
39
|
+
// short-circuits in dev when no consumer has called perfHarness.install().
|
|
40
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
41
|
+
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
33
42
|
|
|
34
43
|
// ─── Full app hydration ──────────────────────────────────────────────────────
|
|
35
44
|
|
|
@@ -53,6 +62,9 @@ export interface StartClientOptions {
|
|
|
53
62
|
* Returns a cleanup function that unmounts the app.
|
|
54
63
|
*/
|
|
55
64
|
export function startClient(options: StartClientOptions): () => void {
|
|
65
|
+
// SSR-only guard: hard-fails if startClient is called server-side. Cannot be
|
|
66
|
+
// exercised under happy-dom (document is always defined in the test env).
|
|
67
|
+
/* v8 ignore next 3 */
|
|
56
68
|
if (typeof document === 'undefined') {
|
|
57
69
|
throw new Error('[Pyreon] startClient() can only be called in the browser.')
|
|
58
70
|
}
|
|
@@ -91,19 +103,24 @@ type IslandLoader = () => Promise<{ default: ComponentFn } | ComponentFn>
|
|
|
91
103
|
* Hydrate all `<pyreon-island>` elements on the page.
|
|
92
104
|
*
|
|
93
105
|
* Only loads JavaScript for components that are actually present in the HTML.
|
|
94
|
-
* Respects hydration strategies (load, idle, visible, media, never).
|
|
106
|
+
* Respects hydration strategies (load, idle, visible, media, never). Returns
|
|
107
|
+
* a cleanup function that disconnects any pending observers/listeners.
|
|
108
|
+
*
|
|
109
|
+
* **`hydrate: 'never'` islands do NOT require a registry entry** — the whole
|
|
110
|
+
* point of the strategy is shipping zero client JS, so importing the loader
|
|
111
|
+
* (which would pull the component into the client bundle graph) defeats it.
|
|
112
|
+
* Such islands are silently skipped here without a `data-island-error` flag.
|
|
95
113
|
*
|
|
96
114
|
* @example
|
|
97
115
|
* hydrateIslands({
|
|
98
116
|
* Counter: () => import("./Counter"),
|
|
99
117
|
* Search: () => import("./Search"),
|
|
118
|
+
* // No entry for `StaticBadge` even though it appears as a never-island
|
|
119
|
+
* // in the HTML — registering one would defeat the strategy.
|
|
100
120
|
* })
|
|
101
121
|
*/
|
|
102
|
-
/**
|
|
103
|
-
* Hydrate all `<pyreon-island>` elements on the page.
|
|
104
|
-
* Returns a cleanup function that disconnects any pending observers/listeners.
|
|
105
|
-
*/
|
|
106
122
|
export function hydrateIslands(registry: Record<string, IslandLoader>): () => void {
|
|
123
|
+
/* v8 ignore next */
|
|
107
124
|
if (typeof document === 'undefined') return () => {}
|
|
108
125
|
const islands = document.querySelectorAll('pyreon-island')
|
|
109
126
|
const cleanups: (() => void)[] = []
|
|
@@ -112,15 +129,52 @@ export function hydrateIslands(registry: Record<string, IslandLoader>): () => vo
|
|
|
112
129
|
const componentId = el.getAttribute('data-component')
|
|
113
130
|
if (!componentId) continue
|
|
114
131
|
|
|
132
|
+
// Detect nested islands. An island whose ancestor (up the DOM tree) is
|
|
133
|
+
// also a `<pyreon-island>` would, on hydration, be torn out and replaced
|
|
134
|
+
// when the outer island hydrates — losing its hydrate strategy entirely.
|
|
135
|
+
// Mark it as errored, log, and skip rather than silently break.
|
|
136
|
+
if (el.parentElement?.closest('pyreon-island')) {
|
|
137
|
+
console.error(
|
|
138
|
+
`[Pyreon] island "${componentId}" is nested inside another <pyreon-island>. ` +
|
|
139
|
+
`Nested islands are not supported — the outer island's hydrateRoot replaces ` +
|
|
140
|
+
`the inner element before its loader runs. Move the inner island out of the ` +
|
|
141
|
+
`outer island's tree, or fold them into a single component.`,
|
|
142
|
+
)
|
|
143
|
+
el.setAttribute('data-island-error', 'nested')
|
|
144
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.skipped.nested')
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const strategy = (el.getAttribute('data-hydrate') ?? 'load') as HydrationStrategy
|
|
149
|
+
|
|
150
|
+
// `hydrate: 'never'` deliberately ships zero client JS for the island —
|
|
151
|
+
// no loader is registered because the loader's whole purpose is to be
|
|
152
|
+
// imported. Skip the missing-loader warning for never-strategy islands;
|
|
153
|
+
// any other strategy without a loader IS a real misconfiguration.
|
|
154
|
+
if (strategy === 'never') {
|
|
155
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.skipped.never')
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
115
159
|
const loader = registry[componentId]
|
|
116
160
|
if (!loader) {
|
|
117
161
|
console.warn(`No loader registered for island "${componentId}"`)
|
|
162
|
+
el.setAttribute('data-island-error', 'no-loader')
|
|
163
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.skipped.no-loader')
|
|
118
164
|
continue
|
|
119
165
|
}
|
|
120
166
|
|
|
121
|
-
const strategy = (el.getAttribute('data-hydrate') ?? 'load') as HydrationStrategy
|
|
122
167
|
const propsJson = el.getAttribute('data-props') ?? '{}'
|
|
123
168
|
|
|
169
|
+
// Prefetch (if requested) primes the module cache before the hydration
|
|
170
|
+
// trigger fires. Independent of hydration scheduling — the same loader
|
|
171
|
+
// promise is reused, so a `visible` island with `prefetch: 'idle'` will
|
|
172
|
+
// hit a warm cache when the IntersectionObserver finally fires.
|
|
173
|
+
const prefetch = (el.getAttribute('data-prefetch') ?? 'none') as PrefetchStrategy
|
|
174
|
+
const prefetchCleanup = schedulePrefetch(el as HTMLElement, loader, prefetch)
|
|
175
|
+
if (prefetchCleanup) cleanups.push(prefetchCleanup)
|
|
176
|
+
|
|
177
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.scheduled')
|
|
124
178
|
const cleanup = scheduleHydration(el as HTMLElement, loader, propsJson, strategy)
|
|
125
179
|
if (cleanup) cleanups.push(cleanup)
|
|
126
180
|
}
|
|
@@ -130,16 +184,127 @@ export function hydrateIslands(registry: Record<string, IslandLoader>): () => vo
|
|
|
130
184
|
}
|
|
131
185
|
}
|
|
132
186
|
|
|
187
|
+
function schedulePrefetch(
|
|
188
|
+
el: HTMLElement,
|
|
189
|
+
loader: IslandLoader,
|
|
190
|
+
prefetch: PrefetchStrategy,
|
|
191
|
+
): (() => void) | null {
|
|
192
|
+
if (prefetch === 'none') return null
|
|
193
|
+
/* v8 ignore next */
|
|
194
|
+
if (typeof window === 'undefined') return null
|
|
195
|
+
let cancelled = false
|
|
196
|
+
// Fire and forget — we don't await; the dynamic import warms the module
|
|
197
|
+
// cache and the hydration path will await its OWN loader() call (which
|
|
198
|
+
// resolves to the same module via JS's import-promise dedup).
|
|
199
|
+
const prime = () => {
|
|
200
|
+
if (cancelled) return
|
|
201
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.prefetch')
|
|
202
|
+
loader().catch(() => {
|
|
203
|
+
// Silent — hydration will surface the failure with its own error path.
|
|
204
|
+
// Prefetch is a hint, not a contract.
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (prefetch === 'idle') {
|
|
209
|
+
if ('requestIdleCallback' in window) {
|
|
210
|
+
const id = requestIdleCallback(prime)
|
|
211
|
+
return () => {
|
|
212
|
+
cancelled = true
|
|
213
|
+
cancelIdleCallback(id)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const id = setTimeout(prime, 200)
|
|
217
|
+
return () => {
|
|
218
|
+
cancelled = true
|
|
219
|
+
clearTimeout(id)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 'visible' — fetch ~200px before the island enters the viewport
|
|
224
|
+
if (!('IntersectionObserver' in window)) {
|
|
225
|
+
prime()
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
const observer = new IntersectionObserver(
|
|
229
|
+
(entries) => {
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (entry.isIntersecting) {
|
|
232
|
+
observer.disconnect()
|
|
233
|
+
prime()
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{ rootMargin: '200px' },
|
|
239
|
+
)
|
|
240
|
+
observer.observe(el)
|
|
241
|
+
return () => {
|
|
242
|
+
cancelled = true
|
|
243
|
+
observer.disconnect()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Auto-discovered island registry shape — emitted by `@pyreon/vite-plugin`
|
|
249
|
+
* as `virtual:pyreon/islands-registry`. The user passes the imported module
|
|
250
|
+
* to `hydrateIslandsAuto()`.
|
|
251
|
+
*/
|
|
252
|
+
export interface AutoIslandRegistry {
|
|
253
|
+
readonly __pyreonIslandRegistry: Record<string, IslandLoader>
|
|
254
|
+
readonly __pyreonIslandsEnabled: boolean
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Hydrate all `<pyreon-island>` elements using a registry auto-generated by
|
|
259
|
+
* `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default).
|
|
260
|
+
*
|
|
261
|
+
* Eliminates the manual sync between `island()` declarations in source and
|
|
262
|
+
* the client-side `hydrateIslands({ ... })` call — typo / forgotten entry /
|
|
263
|
+
* registry drift is the #1 author foot-gun for islands.
|
|
264
|
+
*
|
|
265
|
+
* The auto-registry omits `hydrate: 'never'` islands by design; their
|
|
266
|
+
* components stay out of the client bundle entirely. Other strategies
|
|
267
|
+
* resolve via the same dynamic-import paths their `island()` declaration
|
|
268
|
+
* specified.
|
|
269
|
+
*
|
|
270
|
+
* The user passes the virtual-module result. We don't import it inside
|
|
271
|
+
* `@pyreon/server/client` because Rolldown's static-import analysis runs
|
|
272
|
+
* before plugin resolveId hooks for workspace sources, and would fail to
|
|
273
|
+
* resolve the virtual specifier. Importing in the user's entry-client (where
|
|
274
|
+
* the plugin's resolveId fires natively) is the clean shape.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* // src/entry-client.ts
|
|
278
|
+
* import { hydrateIslandsAuto } from '@pyreon/server/client'
|
|
279
|
+
* import * as registry from 'virtual:pyreon/islands-registry'
|
|
280
|
+
* hydrateIslandsAuto(registry)
|
|
281
|
+
*/
|
|
282
|
+
export function hydrateIslandsAuto(registry: AutoIslandRegistry): () => void {
|
|
283
|
+
/* v8 ignore next */
|
|
284
|
+
if (typeof document === 'undefined') return () => {}
|
|
285
|
+
if (!registry.__pyreonIslandsEnabled) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`[Pyreon] hydrateIslandsAuto() requires \`pyreon({ islands: true })\` ` +
|
|
288
|
+
`in vite.config.ts (the default). The plugin emitted a stub registry ` +
|
|
289
|
+
`because islands support was explicitly disabled. Either re-enable ` +
|
|
290
|
+
`islands in the plugin, or use the manual hydrateIslands({ ... }) form.`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
return hydrateIslands(registry.__pyreonIslandRegistry)
|
|
294
|
+
}
|
|
295
|
+
|
|
133
296
|
function scheduleHydration(
|
|
134
297
|
el: HTMLElement,
|
|
135
298
|
loader: IslandLoader,
|
|
136
299
|
propsJson: string,
|
|
137
300
|
strategy: HydrationStrategy,
|
|
138
301
|
): (() => void) | null {
|
|
302
|
+
/* v8 ignore next */
|
|
139
303
|
if (typeof window === 'undefined') return null
|
|
140
304
|
let cancelled = false
|
|
141
|
-
const hydrate = () => {
|
|
142
|
-
if (
|
|
305
|
+
const hydrate = (): Promise<void> => {
|
|
306
|
+
if (cancelled) return Promise.resolve()
|
|
307
|
+
return hydrateIsland(el, loader, propsJson)
|
|
143
308
|
}
|
|
144
309
|
|
|
145
310
|
switch (strategy) {
|
|
@@ -165,8 +330,15 @@ function scheduleHydration(
|
|
|
165
330
|
case 'visible':
|
|
166
331
|
return observeVisibility(el, hydrate)
|
|
167
332
|
|
|
168
|
-
case 'never'
|
|
169
|
-
|
|
333
|
+
// `case 'never'` is dead here — `hydrateIslands` short-circuits before
|
|
334
|
+
// calling `scheduleHydration` when strategy === 'never' (the whole point
|
|
335
|
+
// of the strategy is shipping zero client JS). Removing the case keeps
|
|
336
|
+
// the branch surface tight; if a future caller invokes scheduleHydration
|
|
337
|
+
// directly with 'never', the default fallback (immediate hydrate) is the
|
|
338
|
+
// wrong shape — gate it at the call site, not here.
|
|
339
|
+
|
|
340
|
+
case 'interaction':
|
|
341
|
+
return scheduleInteractionHydration(el, hydrate, DEFAULT_INTERACTION_EVENTS)
|
|
170
342
|
|
|
171
343
|
default:
|
|
172
344
|
// media(query)
|
|
@@ -189,11 +361,162 @@ function scheduleHydration(
|
|
|
189
361
|
mql.removeEventListener('change', onChange)
|
|
190
362
|
}
|
|
191
363
|
}
|
|
364
|
+
// interaction(<events>) — comma-separated event names
|
|
365
|
+
if (strategy.startsWith('interaction(')) {
|
|
366
|
+
const eventsStr = strategy.slice(12, -1).trim()
|
|
367
|
+
const events = eventsStr
|
|
368
|
+
? eventsStr.split(',').map((s) => s.trim()).filter(Boolean)
|
|
369
|
+
: DEFAULT_INTERACTION_EVENTS
|
|
370
|
+
return scheduleInteractionHydration(el, hydrate, events)
|
|
371
|
+
}
|
|
192
372
|
hydrate()
|
|
193
373
|
return null
|
|
194
374
|
}
|
|
195
375
|
}
|
|
196
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Default events for the `interaction` strategy. Picked to cover the common
|
|
379
|
+
* "user reaches for it" surface: keyboard (`focus`), mouse (`pointerenter`,
|
|
380
|
+
* `click`), and touch (`touchstart`). First matching event triggers hydrate
|
|
381
|
+
* + removes ALL listeners (one-shot).
|
|
382
|
+
*/
|
|
383
|
+
const DEFAULT_INTERACTION_EVENTS: readonly string[] = [
|
|
384
|
+
'focus',
|
|
385
|
+
'click',
|
|
386
|
+
'pointerenter',
|
|
387
|
+
'touchstart',
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
function scheduleInteractionHydration(
|
|
391
|
+
el: HTMLElement,
|
|
392
|
+
hydrate: () => Promise<void>,
|
|
393
|
+
events: readonly string[],
|
|
394
|
+
): () => void {
|
|
395
|
+
let hydrationStarted = false
|
|
396
|
+
let hydrated = false
|
|
397
|
+
// Holds replay info if a click came in during in-flight hydration —
|
|
398
|
+
// we replay the click on the equivalent post-hydration element so the
|
|
399
|
+
// user's first click both wakes the island AND fires the live handler.
|
|
400
|
+
//
|
|
401
|
+
// Hydration may REPLACE DOM nodes (mismatch fallback, or even successful
|
|
402
|
+
// hydrate-as-mount on some shapes). The original `event.target` reference
|
|
403
|
+
// can therefore be detached after hydration completes. To survive this,
|
|
404
|
+
// we capture an identifying "replay path" — preferring `data-testid` (a
|
|
405
|
+
// stable, semantic identifier) and falling back to a tag-based child
|
|
406
|
+
// index walk relative to `el`. After hydration we re-query the live tree.
|
|
407
|
+
let replayPath: ReplayPath | null = null
|
|
408
|
+
// Stamp a "scheduled" marker for tests / devtools introspection.
|
|
409
|
+
el.setAttribute('data-island-state', 'awaiting-interaction')
|
|
410
|
+
|
|
411
|
+
const startHydration = () => {
|
|
412
|
+
if (hydrationStarted) return
|
|
413
|
+
hydrationStarted = true
|
|
414
|
+
el.setAttribute('data-island-state', 'hydrating')
|
|
415
|
+
void hydrate().then(() => {
|
|
416
|
+
hydrated = true
|
|
417
|
+
el.removeAttribute('data-island-state')
|
|
418
|
+
for (const ev of events) {
|
|
419
|
+
el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS)
|
|
420
|
+
}
|
|
421
|
+
if (!replayPath) return
|
|
422
|
+
const liveTarget = resolveReplayPath(el, replayPath)
|
|
423
|
+
if (liveTarget && liveTarget.isConnected) {
|
|
424
|
+
liveTarget.dispatchEvent(
|
|
425
|
+
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const dispatch = (event: Event) => {
|
|
432
|
+
// After hydration, listeners are removed in the `then` above —
|
|
433
|
+
// this branch is defensive only.
|
|
434
|
+
if (hydrated) return
|
|
435
|
+
|
|
436
|
+
if (event.type === 'click') {
|
|
437
|
+
// Stop the click — the SSR DOM has no live click handler bound yet,
|
|
438
|
+
// so propagating it does nothing useful. Capture the click target
|
|
439
|
+
// for replay after hydration. Works whether or not hydration was
|
|
440
|
+
// already started by a previous non-click event — the click always
|
|
441
|
+
// replays as the user-intended action.
|
|
442
|
+
event.stopImmediatePropagation()
|
|
443
|
+
event.preventDefault()
|
|
444
|
+
const target = event.target as HTMLElement | null
|
|
445
|
+
if (target) replayPath = captureReplayPath(el, target)
|
|
446
|
+
}
|
|
447
|
+
startHydration()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// `passive: false` because the click handler may need preventDefault on
|
|
451
|
+
// the original event in the replay path. `capture: true` so we run
|
|
452
|
+
// BEFORE the live event-delegation handler that hydrateRoot installs
|
|
453
|
+
// on the container (otherwise the click would already have propagated
|
|
454
|
+
// past us by the time we react to non-click events firing first).
|
|
455
|
+
for (const ev of events) {
|
|
456
|
+
el.addEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS)
|
|
457
|
+
}
|
|
458
|
+
return () => {
|
|
459
|
+
if (hydrated) return
|
|
460
|
+
el.removeAttribute('data-island-state')
|
|
461
|
+
for (const ev of events) {
|
|
462
|
+
el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const INTERACTION_LISTENER_OPTS: AddEventListenerOptions = {
|
|
468
|
+
passive: false,
|
|
469
|
+
capture: true,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* A locator path for replaying a click after hydration MAY have replaced the
|
|
474
|
+
* original DOM node. Two strategies:
|
|
475
|
+
* - `testid`: re-query the live tree by `[data-testid="..."]`. Stable,
|
|
476
|
+
* semantic, survives DOM swap.
|
|
477
|
+
* - `path`: a tag-name + child-index walk from the island root. Fallback
|
|
478
|
+
* for elements without a test id. Less stable (assumes the live tree
|
|
479
|
+
* mirrors the SSR tree's shape) but covers the no-testid case.
|
|
480
|
+
*/
|
|
481
|
+
type ReplayPath =
|
|
482
|
+
| { kind: 'testid'; value: string }
|
|
483
|
+
| { kind: 'path'; steps: { tag: string; index: number }[] }
|
|
484
|
+
|
|
485
|
+
function captureReplayPath(el: Element, target: Element): ReplayPath | null {
|
|
486
|
+
const testid = target.getAttribute?.('data-testid')
|
|
487
|
+
if (testid) return { kind: 'testid', value: testid }
|
|
488
|
+
// Walk up from target to el, collecting (tag, child-index) at each step.
|
|
489
|
+
// `node` is non-null on every iteration because we early-return when
|
|
490
|
+
// `parent` is null (the only path that could leave node nullable). The
|
|
491
|
+
// `Element | null` type is preserved for the post-loop `node === el` check
|
|
492
|
+
// so the path-not-found case still returns null cleanly.
|
|
493
|
+
const steps: { tag: string; index: number }[] = []
|
|
494
|
+
let node: Element | null = target
|
|
495
|
+
while (node !== el) {
|
|
496
|
+
const parent: Element | null = node.parentElement
|
|
497
|
+
if (!parent) return null
|
|
498
|
+
const siblings = Array.from(parent.children)
|
|
499
|
+
const index = siblings.indexOf(node)
|
|
500
|
+
if (index < 0) return null
|
|
501
|
+
steps.unshift({ tag: node.tagName, index })
|
|
502
|
+
node = parent
|
|
503
|
+
}
|
|
504
|
+
return { kind: 'path', steps }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function resolveReplayPath(el: Element, path: ReplayPath): HTMLElement | null {
|
|
508
|
+
if (path.kind === 'testid') {
|
|
509
|
+
return el.querySelector<HTMLElement>(`[data-testid="${path.value}"]`)
|
|
510
|
+
}
|
|
511
|
+
let node: Element | null = el
|
|
512
|
+
for (const { tag, index } of path.steps) {
|
|
513
|
+
const child: Element | undefined = node?.children[index]
|
|
514
|
+
if (!child || child.tagName !== tag) return null
|
|
515
|
+
node = child
|
|
516
|
+
}
|
|
517
|
+
return node as HTMLElement | null
|
|
518
|
+
}
|
|
519
|
+
|
|
197
520
|
async function hydrateIsland(
|
|
198
521
|
el: HTMLElement,
|
|
199
522
|
loader: IslandLoader,
|
|
@@ -209,18 +532,24 @@ async function hydrateIsland(
|
|
|
209
532
|
}
|
|
210
533
|
} catch (parseErr) {
|
|
211
534
|
console.error(`Invalid island props JSON for "${name}"`, parseErr)
|
|
535
|
+
el.setAttribute('data-island-error', 'invalid-props')
|
|
536
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.error')
|
|
212
537
|
return
|
|
213
538
|
}
|
|
214
539
|
|
|
215
540
|
const mod = await loader()
|
|
216
541
|
const Comp = typeof mod === 'function' ? mod : mod.default
|
|
217
542
|
hydrateRoot(el, h(Comp, props))
|
|
543
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.hydrated')
|
|
218
544
|
} catch (err) {
|
|
219
545
|
console.error(`Failed to hydrate island "${name}"`, err)
|
|
546
|
+
el.setAttribute('data-island-error', 'hydration-failed')
|
|
547
|
+
if (__DEV__) _countSink.__pyreon_count__?.('island.error')
|
|
220
548
|
}
|
|
221
549
|
}
|
|
222
550
|
|
|
223
551
|
function observeVisibility(el: HTMLElement, callback: () => void): (() => void) | null {
|
|
552
|
+
/* v8 ignore next */
|
|
224
553
|
if (typeof window === 'undefined') return null
|
|
225
554
|
if (!('IntersectionObserver' in window)) {
|
|
226
555
|
callback()
|
package/src/handler.ts
CHANGED
|
@@ -160,7 +160,16 @@ export function createHandler(options: HandlerOptions): (req: Request) => Promis
|
|
|
160
160
|
const headWithStyles = styleTag ? `${styleTag}\n${head}` : head
|
|
161
161
|
const fullHtml = processCompiledTemplate(compiled, { head: headWithStyles, app: appHtml, scripts })
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// M1.2 — Status 404 when the matched chain resolved via the
|
|
164
|
+
// `notFoundComponent` fallback (PR L5). The router's
|
|
165
|
+
// `resolveRoute` sets `isNotFound: true` when no leaf matched
|
|
166
|
+
// and a parent layout's `notFoundComponent` was used as a
|
|
167
|
+
// synthetic leaf. Reading the flag after render lets the
|
|
168
|
+
// handler emit a real HTTP 404 while still serving the
|
|
169
|
+
// chrome-wrapped 404 HTML.
|
|
170
|
+
const resolved = router.currentRoute() as { isNotFound?: boolean }
|
|
171
|
+
const status = resolved?.isNotFound === true ? 404 : 200
|
|
172
|
+
return new Response(fullHtml, { status, headers: ctx.headers })
|
|
164
173
|
} catch (err) {
|
|
165
174
|
// `redirect()` thrown from a loader — convert to a real HTTP redirect
|
|
166
175
|
// before the SSR error path runs. Done inside the runWithRequestContext
|
|
@@ -226,12 +235,18 @@ async function renderStreamResponse(
|
|
|
226
235
|
|
|
227
236
|
push(shellTail)
|
|
228
237
|
} catch (err) {
|
|
238
|
+
// Defensive: catastrophic stream-level failure (rare; the SSR pipeline
|
|
239
|
+
// emits its own error markup for component-level errors). Status code
|
|
240
|
+
// is already 200 by the time we get here so we can only emit an
|
|
241
|
+
// inline error script and close the body. Branch is intentionally
|
|
242
|
+
// hard to exercise from tests without mocking `reader.read()`.
|
|
243
|
+
/* v8 ignore start */
|
|
229
244
|
if (__DEV__) {
|
|
230
245
|
console.error('[Pyreon Server] Stream render failed:', err)
|
|
231
246
|
}
|
|
232
|
-
// Emit an inline error indicator — status code is already sent (200)
|
|
233
247
|
push(`<script>console.error("[pyreon/server] Stream render failed")</script>`)
|
|
234
248
|
push(shellTail)
|
|
249
|
+
/* v8 ignore stop */
|
|
235
250
|
} finally {
|
|
236
251
|
controller.close()
|
|
237
252
|
}
|
package/src/html.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* <!--pyreon-app--> — replaced with rendered application HTML
|
|
7
7
|
* <!--pyreon-scripts--> — replaced with client entry script + inline loader data
|
|
8
8
|
*/
|
|
9
|
+
import { stringifyLoaderData } from '@pyreon/router'
|
|
9
10
|
|
|
10
11
|
export const DEFAULT_TEMPLATE = `<!DOCTYPE html>
|
|
11
12
|
<html lang="en">
|
|
@@ -78,8 +79,10 @@ export function buildScripts(
|
|
|
78
79
|
const parts: string[] = []
|
|
79
80
|
|
|
80
81
|
if (loaderData && Object.keys(loaderData).length > 0) {
|
|
81
|
-
//
|
|
82
|
-
|
|
82
|
+
// M2.2 — safe serializer: strips function/symbol values silently,
|
|
83
|
+
// throws a Pyreon-prefixed error on circular refs naming the offending
|
|
84
|
+
// key, and escapes </script> for safe inline embedding.
|
|
85
|
+
const json = stringifyLoaderData(loaderData)
|
|
83
86
|
parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}</script>`)
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -99,7 +102,8 @@ export function buildScriptsFast(
|
|
|
99
102
|
loaderData: Record<string, unknown> | null,
|
|
100
103
|
): string {
|
|
101
104
|
if (loaderData && Object.keys(loaderData).length > 0) {
|
|
102
|
-
|
|
105
|
+
// M2.2 — safe serializer (same contract as `buildScripts`).
|
|
106
|
+
const json = stringifyLoaderData(loaderData)
|
|
103
107
|
return `<script>window.__PYREON_LOADER_DATA__=${json}</script>\n ${clientEntryTag}`
|
|
104
108
|
}
|
|
105
109
|
return clientEntryTag
|