@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.
@@ -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
- const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Direct' })
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 ─────────────────────────────────────────────────────────────────────