@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/tests/server.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentFn, VNode } from '@pyreon/core'
|
|
2
2
|
import { h } from '@pyreon/core'
|
|
3
|
+
import { RouterView } from '@pyreon/router'
|
|
3
4
|
import { createHandler } from '../handler'
|
|
4
5
|
import {
|
|
5
6
|
buildClientEntryTag,
|
|
@@ -225,6 +226,82 @@ describe('createHandler', () => {
|
|
|
225
226
|
})
|
|
226
227
|
})
|
|
227
228
|
|
|
229
|
+
// ─── M1.2 — runtime SSR 404 layout chrome ─────────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// PR L5 added `findNotFoundFallback` to the router's `resolveRoute` so an
|
|
232
|
+
// unmatched URL produces a synthetic chain `[...ancestorLayouts, syntheticLeaf]`
|
|
233
|
+
// with `isNotFound: true`. M1.2 wires the handler to read that flag and emit
|
|
234
|
+
// HTTP 404 instead of 200 — while still serving the layout-wrapped 404 HTML.
|
|
235
|
+
//
|
|
236
|
+
// Without M1.2, the synthetic chain still rendered (so the HTML body was
|
|
237
|
+
// correct under L5) but the response status stayed at 200 — broken contract
|
|
238
|
+
// for static-host CDNs, search engines, and curl-driven monitoring.
|
|
239
|
+
//
|
|
240
|
+
// Bisect: remove the `resolved?.isNotFound === true ? 404 : 200` ternary in
|
|
241
|
+
// `handler.ts` (replace with `200`) → both specs below fail with
|
|
242
|
+
// `expected 200 to be 404`.
|
|
243
|
+
|
|
244
|
+
describe('createHandler — M1.2 runtime SSR 404 layout chrome', () => {
|
|
245
|
+
const HomePage: ComponentFn = () => h('h1', { 'data-testid': 'home' }, 'Home')
|
|
246
|
+
const NotFound: ComponentFn = () => h('h1', { 'data-testid': 'not-found' }, 'Page Not Found')
|
|
247
|
+
const Layout: ComponentFn = () =>
|
|
248
|
+
h(
|
|
249
|
+
'div',
|
|
250
|
+
{ 'data-testid': 'layout' },
|
|
251
|
+
h('nav', { 'data-testid': 'nav' }, 'NAV'),
|
|
252
|
+
h(RouterView, {}),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// Routes tree shape mirrors fs-router's `_404.tsx` convention:
|
|
256
|
+
// - parent layout has `notFoundComponent` attached
|
|
257
|
+
// - matched children render normally
|
|
258
|
+
const routes = [
|
|
259
|
+
{
|
|
260
|
+
path: '/',
|
|
261
|
+
component: Layout,
|
|
262
|
+
notFoundComponent: NotFound,
|
|
263
|
+
children: [{ path: '/', component: HomePage }],
|
|
264
|
+
},
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
test('matched URL renders normally with status 200', async () => {
|
|
268
|
+
const handler = createHandler({ App: RouterView, routes })
|
|
269
|
+
const res = await handler(new Request('http://localhost/'))
|
|
270
|
+
expect(res.status).toBe(200)
|
|
271
|
+
const html = await res.text()
|
|
272
|
+
expect(html).toContain('data-testid="home"')
|
|
273
|
+
expect(html).toContain('data-testid="layout"') // chrome wraps page
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('unmatched URL emits HTTP 404 with layout chrome + not-found body', async () => {
|
|
277
|
+
const handler = createHandler({ App: RouterView, routes })
|
|
278
|
+
const res = await handler(new Request('http://localhost/this-page-does-not-exist'))
|
|
279
|
+
|
|
280
|
+
// M1.2 — status 404 sourced from router.currentRoute().isNotFound.
|
|
281
|
+
expect(res.status).toBe(404)
|
|
282
|
+
|
|
283
|
+
const html = await res.text()
|
|
284
|
+
// The 404 component renders.
|
|
285
|
+
expect(html).toContain('data-testid="not-found"')
|
|
286
|
+
expect(html).toContain('Page Not Found')
|
|
287
|
+
// PR L5 — the parent layout wraps the 404 (the win that started with L5,
|
|
288
|
+
// M1.2 just adds the HTTP status).
|
|
289
|
+
expect(html).toContain('data-testid="layout"')
|
|
290
|
+
expect(html).toContain('data-testid="nav"')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('legacy routes tree without notFoundComponent emits 200 (no synthetic chain)', async () => {
|
|
294
|
+
// Backward-compat: apps without `_404.tsx` in the routes tree fall through
|
|
295
|
+
// to whatever the App renders (typically empty RouterView). The handler
|
|
296
|
+
// doesn't synthesize a 404 status out of thin air — it requires the
|
|
297
|
+
// router's `findNotFoundFallback` to produce the synthetic chain first.
|
|
298
|
+
const plainRoutes = [{ path: '/specific', component: HomePage }]
|
|
299
|
+
const handler = createHandler({ App: RouterView, routes: plainRoutes })
|
|
300
|
+
const res = await handler(new Request('http://localhost/unrelated'))
|
|
301
|
+
expect(res.status).toBe(200)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
228
305
|
// ─── Stream mode ──────────────────────────────────────────────────────────────
|
|
229
306
|
|
|
230
307
|
describe('createHandler — stream mode', () => {
|
|
@@ -382,6 +459,27 @@ describe('middleware types', () => {
|
|
|
382
459
|
// Just verify the module can be imported — it's pure types
|
|
383
460
|
expect(mod).toBeDefined()
|
|
384
461
|
})
|
|
462
|
+
|
|
463
|
+
test('useRequestLocals returns the current request locals from context', async () => {
|
|
464
|
+
const { useRequestLocals } = await import('../middleware')
|
|
465
|
+
// Outside a request context, returns the default empty object — exercises
|
|
466
|
+
// the function-call path so coverage sees it (was 50%/never-called before).
|
|
467
|
+
const locals = useRequestLocals()
|
|
468
|
+
expect(typeof locals).toBe('object')
|
|
469
|
+
expect(locals).not.toBeNull()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('createHandler invokes collectStyles and prepends styleTag to head', async () => {
|
|
473
|
+
const App: ComponentFn = () => h('div', null, 'styled')
|
|
474
|
+
const handler = createHandler({
|
|
475
|
+
App,
|
|
476
|
+
routes: [{ path: '/', component: App }],
|
|
477
|
+
collectStyles: () => '<style data-test="style">.x{color:red}</style>',
|
|
478
|
+
})
|
|
479
|
+
const res = await handler(new Request('http://localhost/'))
|
|
480
|
+
const html = await res.text()
|
|
481
|
+
expect(html).toContain('<style data-test="style">')
|
|
482
|
+
})
|
|
385
483
|
})
|
|
386
484
|
|
|
387
485
|
// ─── Islands ─────────────────────────────────────────────────────────────────
|
|
@@ -462,7 +560,12 @@ describe('island', () => {
|
|
|
462
560
|
|
|
463
561
|
test('island() resolves direct function module (not { default })', async () => {
|
|
464
562
|
const Inner: ComponentFn = () => h('span', null, 'direct')
|
|
465
|
-
|
|
563
|
+
// Loader returns the ComponentFn DIRECTLY (no { default } wrapper) —
|
|
564
|
+
// covers the function-typeof branch in the unwrap code.
|
|
565
|
+
const Widget = island(
|
|
566
|
+
() => Promise.resolve(Inner as unknown as ComponentFn),
|
|
567
|
+
{ name: 'Direct' },
|
|
568
|
+
)
|
|
466
569
|
|
|
467
570
|
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
468
571
|
{},
|
|
@@ -488,6 +591,63 @@ describe('island', () => {
|
|
|
488
591
|
expect(meta.hydrate).toBe('visible')
|
|
489
592
|
})
|
|
490
593
|
|
|
594
|
+
test("island() defaults prefetch to 'none' and does NOT emit data-prefetch attribute", async () => {
|
|
595
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
596
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), {
|
|
597
|
+
name: 'NoPrefetch',
|
|
598
|
+
hydrate: 'visible',
|
|
599
|
+
})
|
|
600
|
+
expect((Widget as unknown as { prefetch: string }).prefetch).toBe('none')
|
|
601
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
602
|
+
{},
|
|
603
|
+
)
|
|
604
|
+
expect('data-prefetch' in vnode.props).toBe(false)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
test("island() emits data-prefetch when paired with a deferred hydrate strategy", async () => {
|
|
608
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
609
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), {
|
|
610
|
+
name: 'PrefetchIdle',
|
|
611
|
+
hydrate: 'visible',
|
|
612
|
+
prefetch: 'idle',
|
|
613
|
+
})
|
|
614
|
+
expect((Widget as unknown as { prefetch: string }).prefetch).toBe('idle')
|
|
615
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
616
|
+
{},
|
|
617
|
+
)
|
|
618
|
+
expect(vnode.props['data-prefetch']).toBe('idle')
|
|
619
|
+
expect(vnode.props['data-hydrate']).toBe('visible')
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test("island() suppresses data-prefetch when hydrate='load' (loader runs synchronously)", async () => {
|
|
623
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
624
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), {
|
|
625
|
+
name: 'PointlessPrefetch',
|
|
626
|
+
hydrate: 'load',
|
|
627
|
+
prefetch: 'idle',
|
|
628
|
+
})
|
|
629
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
630
|
+
{},
|
|
631
|
+
)
|
|
632
|
+
// Metadata still records what the user asked for, but the runtime
|
|
633
|
+
// attribute is suppressed because prefetch is meaningless on load.
|
|
634
|
+
expect((Widget as unknown as { prefetch: string }).prefetch).toBe('idle')
|
|
635
|
+
expect('data-prefetch' in vnode.props).toBe(false)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
test("island() suppresses data-prefetch when hydrate='never' (defeats zero-JS strategy)", async () => {
|
|
639
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
640
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), {
|
|
641
|
+
name: 'NeverPrefetch',
|
|
642
|
+
hydrate: 'never',
|
|
643
|
+
prefetch: 'visible',
|
|
644
|
+
})
|
|
645
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
646
|
+
{},
|
|
647
|
+
)
|
|
648
|
+
expect('data-prefetch' in vnode.props).toBe(false)
|
|
649
|
+
})
|
|
650
|
+
|
|
491
651
|
test('island() serializes empty props as empty object', async () => {
|
|
492
652
|
const Inner: ComponentFn = () => h('div', null)
|
|
493
653
|
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Empty' })
|
|
@@ -497,6 +657,65 @@ describe('island', () => {
|
|
|
497
657
|
)
|
|
498
658
|
expect(vnode.props['data-props']).toBe('{}')
|
|
499
659
|
})
|
|
660
|
+
|
|
661
|
+
test('island() falls back to {} on BigInt props instead of throwing the SSR', async () => {
|
|
662
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
663
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'BigIntProps' })
|
|
664
|
+
|
|
665
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
666
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
667
|
+
// BigInt is not JSON-serializable; would throw inside JSON.stringify pre-fix
|
|
668
|
+
huge: BigInt('9007199254740993'),
|
|
669
|
+
})
|
|
670
|
+
expect(vnode.props['data-props']).toBe('{}')
|
|
671
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
672
|
+
expect.stringContaining('BigInt or circular reference'),
|
|
673
|
+
)
|
|
674
|
+
errorSpy.mockRestore()
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
test('island() falls back to {} on circular-reference props', async () => {
|
|
678
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
679
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'CircularProps' })
|
|
680
|
+
|
|
681
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
682
|
+
const cyclic: Record<string, unknown> = { name: 'foo' }
|
|
683
|
+
cyclic.self = cyclic
|
|
684
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
685
|
+
data: cyclic,
|
|
686
|
+
})
|
|
687
|
+
expect(vnode.props['data-props']).toBe('{}')
|
|
688
|
+
expect(errorSpy).toHaveBeenCalled()
|
|
689
|
+
errorSpy.mockRestore()
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
test('island() warns when children prop is dropped (dev only)', async () => {
|
|
693
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
694
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'WithKids' })
|
|
695
|
+
|
|
696
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
697
|
+
await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
698
|
+
title: 'has children',
|
|
699
|
+
children: h('span', null, 'kid'),
|
|
700
|
+
})
|
|
701
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
702
|
+
expect.stringContaining('island "WithKids" was passed children'),
|
|
703
|
+
)
|
|
704
|
+
warnSpy.mockRestore()
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
test('island() does NOT warn when children is undefined (no real children)', async () => {
|
|
708
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
709
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'NoKidsWarn' })
|
|
710
|
+
|
|
711
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
712
|
+
await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
713
|
+
title: 'no children',
|
|
714
|
+
children: undefined,
|
|
715
|
+
})
|
|
716
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
717
|
+
warnSpy.mockRestore()
|
|
718
|
+
})
|
|
500
719
|
})
|
|
501
720
|
|
|
502
721
|
// ─── SSG ─────────────────────────────────────────────────────────────────────
|