@pyreon/router 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.
@@ -1,4 +1,4 @@
1
- import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData } from '../loader'
1
+ import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from '../loader'
2
2
  import { createRouter, setActiveRouter, useIsActive, useSearchParams } from '../router'
3
3
  import { lazy } from '../types'
4
4
  import type { RouteRecord, RouterInstance } from '../types'
@@ -130,6 +130,81 @@ describe('loader data serialization — edge cases', () => {
130
130
  })
131
131
  })
132
132
 
133
+ // ─── M2.2 — stringifyLoaderData ────────────────────────────────────────────
134
+
135
+ describe('stringifyLoaderData (M2.2)', () => {
136
+ // Bisect-load-bearing: revert the replacer (use bare `JSON.stringify(d).replace(/<\//g, '<\\/')`)
137
+ // → the function-strip + circular-error specs fail. The bare-strings-only spec
138
+ // would still pass since JSON.stringify also drops function values for objects.
139
+
140
+ test('strips function values silently', () => {
141
+ const json = stringifyLoaderData({
142
+ '/home': { data: 1, fn: () => {} },
143
+ })
144
+ expect(json).not.toContain('fn')
145
+ expect(json).toContain('"data":1')
146
+ })
147
+
148
+ test('strips symbol values silently', () => {
149
+ const json = stringifyLoaderData({
150
+ '/home': { data: 1, sym: Symbol('x') as unknown as string },
151
+ })
152
+ expect(json).not.toContain('sym')
153
+ expect(json).toContain('"data":1')
154
+ })
155
+
156
+ test('throws Pyreon-prefixed error on circular reference naming the offending key', () => {
157
+ interface Cyclic {
158
+ data: number
159
+ self?: Cyclic
160
+ }
161
+ const cyclic: Cyclic = { data: 1 }
162
+ cyclic.self = cyclic
163
+ expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\[Pyreon\] Loader returned circular reference/)
164
+ // The error names the path: `/posts/1.self` (or similar).
165
+ expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\/posts\/1/)
166
+ })
167
+
168
+ test('escapes </script> to prevent script-tag escape', () => {
169
+ const json = stringifyLoaderData({
170
+ '/home': { html: '</script><script>alert(1)' },
171
+ })
172
+ expect(json).not.toContain('</script>')
173
+ expect(json).toContain('<\\/script>')
174
+ })
175
+
176
+ test('passes plain data through unchanged', () => {
177
+ const json = stringifyLoaderData({
178
+ '/posts': [{ id: 1, title: 'A' }],
179
+ '/about': { count: 42 },
180
+ })
181
+ expect(JSON.parse(json)).toEqual({
182
+ '/posts': [{ id: 1, title: 'A' }],
183
+ '/about': { count: 42 },
184
+ })
185
+ })
186
+
187
+ test('handles deeply-nested data without falsely flagging shared references as cycles', () => {
188
+ // A non-cyclic shared reference (two keys pointing at the same array)
189
+ // SHOULD throw — JSON serialization can't represent shared identity
190
+ // without `references`, and a runtime cycle-detector treating shared
191
+ // refs as cycles is the safe default for hydration semantics. Verify
192
+ // the throw shape — if this becomes too aggressive, relax with a
193
+ // post-visit drop instead of WeakSet.
194
+ const shared = [1, 2, 3]
195
+ expect(() =>
196
+ stringifyLoaderData({
197
+ '/a': shared,
198
+ '/b': shared,
199
+ }),
200
+ ).toThrow(/circular reference/)
201
+ })
202
+
203
+ test('empty record produces empty object JSON', () => {
204
+ expect(stringifyLoaderData({})).toBe('{}')
205
+ })
206
+ })
207
+
133
208
  // ─── useIsActive — edge cases ────────────────────────────────────────────────
134
209
 
135
210
  describe('useIsActive — edge cases', () => {
@@ -580,4 +655,254 @@ describe('router.preload', () => {
580
655
  expect(lazyLoadCalls).toBe(1)
581
656
  expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
582
657
  })
658
+
659
+ // ─── PR C — skipLoaders option for 404 build paths ──────────────────────
660
+ //
661
+ // The SSG plugin's `__renderNotFound` opts out of loader execution
662
+ // when generating a static 404 page — parent-layout loaders that hit
663
+ // auth resources / external APIs shouldn't fire when there's no real
664
+ // request context to drive them. `skipLoaders: true` skips the loader
665
+ // step entirely while keeping the lazy-component resolution intact
666
+ // (so the synthetic chain still renders cleanly).
667
+ test('skipLoaders: true skips loader execution', async () => {
668
+ let calls = 0
669
+ const routes: RouteRecord[] = [
670
+ { path: '/', component: Home },
671
+ {
672
+ path: '/u/:id',
673
+ component: User,
674
+ loader: async ({ params }) => {
675
+ calls++
676
+ return { id: params.id }
677
+ },
678
+ },
679
+ ]
680
+ const router = createRouter({ routes, url: '/' }) as RouterInstance
681
+
682
+ await router.preload('/u/7', undefined, { skipLoaders: true })
683
+
684
+ expect(calls).toBe(0)
685
+ expect(router._loaderData.get(routes[1] as RouteRecord)).toBeUndefined()
686
+ })
687
+
688
+ test('skipLoaders: false (default) still runs loaders', async () => {
689
+ let calls = 0
690
+ const routes: RouteRecord[] = [
691
+ { path: '/', component: Home },
692
+ {
693
+ path: '/u/:id',
694
+ component: User,
695
+ loader: async () => {
696
+ calls++
697
+ return null
698
+ },
699
+ },
700
+ ]
701
+ const router = createRouter({ routes, url: '/' }) as RouterInstance
702
+
703
+ // No options arg
704
+ await router.preload('/u/7')
705
+ expect(calls).toBe(1)
706
+
707
+ // Explicit false
708
+ await router.preload('/u/7', undefined, { skipLoaders: false })
709
+ expect(calls).toBe(2)
710
+ })
711
+
712
+ test('skipLoaders: true still loads lazy components (preserves render readiness)', async () => {
713
+ // The 404 build path needs the synthetic-chain components resolved
714
+ // so the render pass doesn't fall back to loadingComponent. Only
715
+ // the data-fetching `r.loader()` calls are skipped.
716
+ let lazyLoadCalls = 0
717
+ let loaderCalls = 0
718
+ const Lazy = () => null
719
+ const routes: RouteRecord[] = [
720
+ { path: '/', component: Home },
721
+ {
722
+ path: '/lazy',
723
+ component: lazy(async () => {
724
+ lazyLoadCalls++
725
+ return Lazy
726
+ }),
727
+ loader: async () => {
728
+ loaderCalls++
729
+ return null
730
+ },
731
+ },
732
+ ]
733
+ const router = createRouter({ routes, url: '/' }) as RouterInstance
734
+
735
+ await router.preload('/lazy', undefined, { skipLoaders: true })
736
+
737
+ // Lazy component IS resolved (needed for render readiness).
738
+ expect(lazyLoadCalls).toBe(1)
739
+ expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
740
+ // Loader was NOT called.
741
+ expect(loaderCalls).toBe(0)
742
+ })
743
+
744
+ test('skipLoaders: true preserves currentRoute (preload is non-navigational)', async () => {
745
+ const routes: RouteRecord[] = [
746
+ { path: '/', component: Home },
747
+ {
748
+ path: '/u/:id',
749
+ component: User,
750
+ loader: async () => null,
751
+ },
752
+ ]
753
+ const router = createRouter({ routes, url: '/' }) as RouterInstance
754
+
755
+ await router.preload('/u/7', undefined, { skipLoaders: true })
756
+
757
+ expect(router.currentRoute().path).toBe('/')
758
+ })
759
+ })
760
+
761
+ // ─── _loaderCache LRU cap (regression for missing _maxCacheSize wiring) ────
762
+ describe('router — _loaderCache LRU cap', () => {
763
+ // Pre-fix: `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
764
+ // (default 100) but the loader cache write paths in router.ts never read it
765
+ // — only `_componentCache` enforced the cap. Long-running SPAs navigating
766
+ // dynamic-param routes (`/posts/:id` with hundreds of unique IDs) would
767
+ // accumulate `_loaderCache` entries until manual `invalidateLoader()`.
768
+ // Post-fix: the helper `loaderCacheSet` evicts oldest (insertion-order FIFO)
769
+ // when over the cap, mirroring `_componentCache`.
770
+ test('caps _loaderCache at maxCacheSize, evicts oldest first', async () => {
771
+ const Page = () => null
772
+ const routes: RouteRecord[] = [
773
+ {
774
+ path: '/posts/:id',
775
+ component: Page,
776
+ loader: async ({ params }) => `post-${params.id}`,
777
+ loaderKey: ({ params }) => `posts:${params.id}`,
778
+ },
779
+ ]
780
+ const router = createRouter({ routes, maxCacheSize: 3, url: '/' }) as RouterInstance
781
+
782
+ // Drive the loader through 4 distinct keys
783
+ await router.push('/posts/1')
784
+ await router.push('/posts/2')
785
+ await router.push('/posts/3')
786
+ await router.push('/posts/4')
787
+
788
+ // Cache must be capped at 3 (not 4).
789
+ expect(router._loaderCache.size).toBe(3)
790
+
791
+ // FIFO: the OLDEST insertion (posts:1) must have been evicted.
792
+ expect(router._loaderCache.has('posts:1')).toBe(false)
793
+ expect(router._loaderCache.has('posts:2')).toBe(true)
794
+ expect(router._loaderCache.has('posts:3')).toBe(true)
795
+ expect(router._loaderCache.has('posts:4')).toBe(true)
796
+ })
797
+
798
+ test('does not evict when cap is not exceeded', async () => {
799
+ const Page = () => null
800
+ const routes: RouteRecord[] = [
801
+ {
802
+ path: '/posts/:id',
803
+ component: Page,
804
+ loader: async ({ params }) => `post-${params.id}`,
805
+ loaderKey: ({ params }) => `posts:${params.id}`,
806
+ },
807
+ ]
808
+ const router = createRouter({ routes, maxCacheSize: 100, url: '/' }) as RouterInstance
809
+
810
+ await router.push('/posts/1')
811
+ await router.push('/posts/2')
812
+ await router.push('/posts/3')
813
+
814
+ expect(router._loaderCache.size).toBe(3)
815
+ expect(router._loaderCache.has('posts:1')).toBe(true)
816
+ expect(router._loaderCache.has('posts:2')).toBe(true)
817
+ expect(router._loaderCache.has('posts:3')).toBe(true)
818
+ })
819
+
820
+ test('default maxCacheSize (100) caps cache after 100 unique keys', async () => {
821
+ const Page = () => null
822
+ const routes: RouteRecord[] = [
823
+ {
824
+ path: '/posts/:id',
825
+ component: Page,
826
+ loader: async ({ params }) => `post-${params.id}`,
827
+ loaderKey: ({ params }) => `posts:${params.id}`,
828
+ },
829
+ ]
830
+ // No explicit maxCacheSize — uses default 100
831
+ const router = createRouter({ routes, url: '/' }) as RouterInstance
832
+
833
+ for (let i = 0; i < 105; i++) {
834
+ await router.push(`/posts/${i}`)
835
+ }
836
+
837
+ expect(router._loaderCache.size).toBe(100)
838
+ // Earliest 5 keys (0-4) evicted.
839
+ expect(router._loaderCache.has('posts:0')).toBe(false)
840
+ expect(router._loaderCache.has('posts:4')).toBe(false)
841
+ expect(router._loaderCache.has('posts:5')).toBe(true)
842
+ expect(router._loaderCache.has('posts:104')).toBe(true)
843
+ })
844
+ })
845
+
846
+ // ─── Regression: dedup must not return aborted in-flight promise ───────────
847
+ //
848
+ // Pre-fix: `router.push` aborts `_abortController` BEFORE starting the next
849
+ // nav. If two pushes to the same path happen back-to-back, the in-flight
850
+ // Map still holds nav-1's promise (its `.catch` hasn't run yet). The dedup
851
+ // returned that promise to nav-2 — but its bound signal is already aborted,
852
+ // so nav-2's data path is broken even though it has its own fresh signal.
853
+ //
854
+ // Post-fix: `_loaderInflight` stores `{ promise, signal }`. Dedup is gated
855
+ // on `!signal.aborted`. Aborted entries fall through to a fresh execute
856
+ // using nav-2's signal.
857
+ describe('router — _loaderInflight aborted-signal dedup', () => {
858
+ test('back-to-back navigation re-executes loader with fresh signal when previous was aborted', async () => {
859
+ let invocations = 0
860
+ let resolveLoader1: ((data: unknown) => void) | null = null
861
+
862
+ const Page = () => null
863
+ const routes: RouteRecord[] = [
864
+ { path: '/', component: Page },
865
+ {
866
+ path: '/data',
867
+ component: Page,
868
+ loader: async ({ signal }) => {
869
+ invocations++
870
+ const myInvocation = invocations
871
+ // Wire signal-abort → reject so the nav actually fails on abort.
872
+ // The first invocation hangs until manually resolved; later
873
+ // invocations resolve immediately.
874
+ if (myInvocation === 1) {
875
+ return new Promise((_resolve, reject) => {
876
+ signal?.addEventListener('abort', () => reject(new Error('aborted')))
877
+ resolveLoader1 = _resolve
878
+ })
879
+ }
880
+ return `data-${myInvocation}`
881
+ },
882
+ },
883
+ ]
884
+ const router = createRouter({ routes, url: '/' }) as RouterInstance
885
+
886
+ // Nav 1 → /data. Loader invocation #1 starts, hangs.
887
+ const nav1 = router.push('/data').catch(() => {})
888
+ await new Promise<void>((r) => queueMicrotask(() => r()))
889
+
890
+ // Nav 2 → /data. router.push aborts ac1 first, then calls executeLoader.
891
+ // Pre-fix: dedup returns nav-1's promise (whose signal is now aborted).
892
+ // Post-fix: dedup skipped (signal.aborted=true), fresh loader runs.
893
+ const nav2 = router.push('/data')
894
+
895
+ // Resolve nav-1's hung promise (won't actually deliver — already aborted)
896
+ const r1 = resolveLoader1 as ((d: unknown) => void) | null
897
+ if (r1) r1('data-1')
898
+
899
+ await nav2
900
+ await nav1
901
+
902
+ // Post-fix: 2 invocations (nav-1 aborted, nav-2 ran fresh).
903
+ // Pre-fix: 1 invocation (nav-2 deduped to nav-1's aborted promise).
904
+ expect(invocations).toBe(2)
905
+ expect(router.currentRoute().path).toBe('/data')
906
+ })
907
+
583
908
  })
@@ -87,8 +87,12 @@ describe('gen-docs — router snapshot', () => {
87
87
 
88
88
  it('renders @pyreon/router to MCP api-reference entries — one per api[] item', () => {
89
89
  const record = renderApiReferenceEntries(routerManifest)
90
- expect(Object.keys(record).length).toBe(15)
90
+ expect(Object.keys(record).length).toBe(18)
91
91
  expect(Object.keys(record)).toContain('router/createRouter')
92
+ // PR-B added redirect/isRedirectError/getRedirectInfo entries.
93
+ expect(Object.keys(record)).toContain('router/redirect')
94
+ expect(Object.keys(record)).toContain('router/isRedirectError')
95
+ expect(Object.keys(record)).toContain('router/getRedirectInfo')
92
96
  // Spot-check the flagship API — createRouter is the factory
93
97
  const createRouter = record['router/createRouter']!
94
98
  expect(createRouter.notes).toContain('routes')
@@ -8,6 +8,12 @@ import {
8
8
  resolveRoute,
9
9
  stringifyQuery,
10
10
  } from '../match'
11
+ // Importing from components.tsx triggers the module-load side-effect that
12
+ // registers DefaultChromeLayout with match.ts (via _setDefaultChromeLayout).
13
+ // Without this import, the layout-less fallback in findNotFoundFallback
14
+ // returns null because no chrome layout is registered. Tests below verify
15
+ // the registered layout is used as the synthetic chain's first entry.
16
+ import { DefaultChromeLayout } from '../components'
11
17
  import type { RouteRecord } from '../types'
12
18
 
13
19
  const Home = () => null
@@ -496,3 +502,281 @@ describe('parseQueryMulti — + as space', () => {
496
502
  })
497
503
  })
498
504
  })
505
+
506
+ // ─── resolveRoute — notFoundComponent fallback (PR L5) ───────────────────────
507
+ //
508
+ // When a URL doesn't match any route AND a parent record has a
509
+ // `notFoundComponent`, resolveRoute builds a synthetic matched chain
510
+ // `[...ancestors, parentLayout, syntheticLeaf]` so the not-found
511
+ // component renders INSIDE its ancestor layouts' chrome.
512
+
513
+ describe('resolveRoute — notFoundComponent fallback', () => {
514
+ const Layout = () => null
515
+ const NotFoundPage = () => null
516
+
517
+ it('synthesises chain through root layout when URL is unmatched', () => {
518
+ const routes: RouteRecord[] = [
519
+ {
520
+ path: '/',
521
+ component: Layout,
522
+ notFoundComponent: NotFoundPage,
523
+ children: [
524
+ { path: '/', component: Home },
525
+ { path: '/about', component: About },
526
+ ],
527
+ },
528
+ ]
529
+
530
+ const r = resolveRoute('/this-does-not-exist', routes)
531
+ expect(r.isNotFound).toBe(true)
532
+ // Chain: [rootLayout, syntheticLeaf]. The synthetic leaf carries
533
+ // NotFoundPage as its component so the deepest RouterView resolves it.
534
+ expect(r.matched.length).toBe(2)
535
+ expect(r.matched[0]?.component).toBe(Layout)
536
+ expect(r.matched[1]?.component).toBe(NotFoundPage)
537
+ expect(r.matched[1]?.path).toBe('__pyreon_not_found_leaf__')
538
+ })
539
+
540
+ it('returns empty matched when no notFoundComponent anywhere', () => {
541
+ const routes: RouteRecord[] = [
542
+ { path: '/', component: Home },
543
+ { path: '/about', component: About },
544
+ ]
545
+
546
+ const r = resolveRoute('/unknown', routes)
547
+ expect(r.isNotFound).toBeUndefined()
548
+ expect(r.matched.length).toBe(0)
549
+ })
550
+
551
+ it('does not trigger fallback for matched routes', () => {
552
+ const routes: RouteRecord[] = [
553
+ {
554
+ path: '/',
555
+ component: Layout,
556
+ notFoundComponent: NotFoundPage,
557
+ children: [{ path: '/about', component: About }],
558
+ },
559
+ ]
560
+
561
+ const r = resolveRoute('/about', routes)
562
+ expect(r.isNotFound).toBeUndefined()
563
+ expect(r.matched).not.toContain(NotFoundPage)
564
+ })
565
+
566
+ it('picks the DEEPEST matching parent when nested layouts have notFoundComponent', () => {
567
+ const DeNotFound = () => null
568
+ const RootNotFound = () => null
569
+ const DeLayout = () => null
570
+ const routes: RouteRecord[] = [
571
+ {
572
+ path: '/',
573
+ component: Layout,
574
+ notFoundComponent: RootNotFound,
575
+ children: [
576
+ {
577
+ path: '/de',
578
+ component: DeLayout,
579
+ notFoundComponent: DeNotFound,
580
+ children: [{ path: '/de/about', component: About }],
581
+ },
582
+ ],
583
+ },
584
+ ]
585
+
586
+ // URL under /de prefix — should pick the DEEPER /de layout's notFound,
587
+ // not the root's
588
+ const r = resolveRoute('/de/unknown', routes)
589
+ expect(r.isNotFound).toBe(true)
590
+ expect(r.matched[r.matched.length - 1]?.component).toBe(DeNotFound)
591
+ // URL under root only — should fall back to root layout's notFound
592
+ const r2 = resolveRoute('/about-typo', routes)
593
+ expect(r2.isNotFound).toBe(true)
594
+ expect(r2.matched[r2.matched.length - 1]?.component).toBe(RootNotFound)
595
+ })
596
+
597
+ it('respects segment boundary in path-prefix match (no substring confusion)', () => {
598
+ const EnNotFound = () => null
599
+ const routes: RouteRecord[] = [
600
+ {
601
+ path: '/en',
602
+ component: Layout,
603
+ notFoundComponent: EnNotFound,
604
+ children: [],
605
+ },
606
+ ]
607
+
608
+ // `/encyclopedia` MUST NOT match `/en` as a prefix — full segment boundary required.
609
+ const r = resolveRoute('/encyclopedia', routes)
610
+ expect(r.isNotFound).toBeUndefined()
611
+ expect(r.matched.length).toBe(0)
612
+ })
613
+
614
+ it('non-matching URL under a layout prefix triggers fallback (deeper than root)', () => {
615
+ const routes: RouteRecord[] = [
616
+ {
617
+ path: '/admin',
618
+ component: Layout,
619
+ notFoundComponent: NotFoundPage,
620
+ children: [{ path: '/admin/users', component: User }],
621
+ },
622
+ ]
623
+
624
+ // `/admin/missing` doesn't match `/admin` (layout itself) OR `/admin/users`
625
+ // → notFoundComponent fallback applies, chain wraps the admin layout
626
+ const r = resolveRoute('/admin/missing', routes)
627
+ expect(r.isNotFound).toBe(true)
628
+ expect(r.matched[0]?.component).toBe(Layout)
629
+ expect(r.matched[r.matched.length - 1]?.component).toBe(NotFoundPage)
630
+ })
631
+
632
+ it('synthetic leaf has the right path marker (for runtime identification)', () => {
633
+ const routes: RouteRecord[] = [
634
+ {
635
+ path: '/',
636
+ component: Layout,
637
+ notFoundComponent: NotFoundPage,
638
+ children: [{ path: '/', component: Home }],
639
+ },
640
+ ]
641
+ const r = resolveRoute('/unknown', routes)
642
+ expect(r.matched[r.matched.length - 1]?.path).toBe('__pyreon_not_found_leaf__')
643
+ })
644
+
645
+ it('preserves query string on the synthetic 404 resolution', () => {
646
+ const routes: RouteRecord[] = [
647
+ {
648
+ path: '/',
649
+ component: Layout,
650
+ notFoundComponent: NotFoundPage,
651
+ children: [{ path: '/', component: Home }],
652
+ },
653
+ ]
654
+ const r = resolveRoute('/unknown?foo=bar', routes)
655
+ expect(r.isNotFound).toBe(true)
656
+ expect(r.query).toEqual({ foo: 'bar' })
657
+ expect(r.path).toBe('/unknown')
658
+ })
659
+
660
+ it('fires fallback via DefaultChromeLayout when the only notFoundComponent is on a page record without children', () => {
661
+ // PR B (layout-less app fallback): page-level `notFoundComponent` now
662
+ // gets wrapped in a synthetic `DefaultChromeLayout` (`<main data-
663
+ // pyreon-default-chrome>`) so the render pipeline produces semantic-
664
+ // HTML output instead of bare component markup. Pre-PR-B the resolver
665
+ // returned an empty chain here — the standalone-render path in the
666
+ // SSG plugin / runtime handler would render the component bare with
667
+ // no wrapping (the documented "no chrome" limitation).
668
+ //
669
+ // Tests in the `layout-less app fallback (PR B)` describe block
670
+ // below cover the synthetic chain shape in detail.
671
+ const PageOnly = () => null
672
+ const routes: RouteRecord[] = [
673
+ { path: '/', component: PageOnly, notFoundComponent: NotFoundPage },
674
+ ]
675
+ const r = resolveRoute('/unknown', routes)
676
+ expect(r.isNotFound).toBe(true)
677
+ // Synthetic chain: [DefaultChromeLayout, syntheticLeaf]
678
+ expect(r.matched).toHaveLength(2)
679
+ expect(r.matched[0]?.component).toBe(DefaultChromeLayout)
680
+ expect(r.matched[1]?.component).toBe(NotFoundPage)
681
+ })
682
+
683
+ it('does NOT fire when wildcard catch-all is configured', () => {
684
+ const Catchall = () => null
685
+ const routes: RouteRecord[] = [
686
+ { path: '/', component: Home, notFoundComponent: NotFoundPage },
687
+ { path: '(.*)', component: Catchall },
688
+ ]
689
+
690
+ // Wildcard catches everything first — notFoundComponent fallback never runs.
691
+ const r = resolveRoute('/unknown', routes)
692
+ expect(r.isNotFound).toBeUndefined()
693
+ expect(r.matched[0]?.component).toBe(Catchall)
694
+ })
695
+
696
+ // ─── Layout-less app fallback (PR B) ───────────────────────────────────────
697
+ //
698
+ // When the user has a page-level `notFoundComponent` (`_404.tsx` at the
699
+ // route root without a wrapping `_layout.tsx`), the resolver synthesizes
700
+ // a chain `[DefaultChromeLayout, syntheticLeaf]` so the render pipeline
701
+ // produces 404 HTML wrapped in `<main data-pyreon-default-chrome>`.
702
+ //
703
+ // These tests import `./components` so the setter call at the bottom of
704
+ // components.tsx runs and registers `DefaultChromeLayout` with match.ts.
705
+ // Without that import, `_defaultChromeLayout` would be null and the
706
+ // fallback returns null (graceful degradation to the standalone-render
707
+ // path). The import happens at the top of the test file via the
708
+ // top-level `import` chain — describe block doesn't need to do anything.
709
+ describe('layout-less app fallback (PR B)', () => {
710
+ it('synthesizes a [DefaultChromeLayout, syntheticLeaf] chain when only a page record has notFoundComponent', () => {
711
+ const Index = () => null
712
+ const NotFound = () => null
713
+ const routes: RouteRecord[] = [
714
+ { path: '/', component: Index, notFoundComponent: NotFound },
715
+ ]
716
+ const r = resolveRoute('/missing', routes)
717
+ expect(r.isNotFound).toBe(true)
718
+ // Chain shape: [synthetic chrome layout, synthetic leaf]
719
+ expect(r.matched).toHaveLength(2)
720
+ // First entry is the synthetic chrome layout (with the
721
+ // page's `fullPath` carried for downstream identification).
722
+ expect(r.matched[0]?.path).toBe('/')
723
+ expect(typeof r.matched[0]?.component).toBe('function')
724
+ // Second entry is the synthetic leaf with the user's notFoundComponent.
725
+ expect(r.matched[1]?.component).toBe(NotFound)
726
+ })
727
+
728
+ it('the synthetic chrome layout wraps the leaf in <main data-pyreon-default-chrome>', () => {
729
+ // Render the chain through the actual default chrome component to
730
+ // confirm the `<main>` wrapper materializes. The component reads
731
+ // RouterContext to render its inner RouterView, so we need a
732
+ // minimal harness — easiest path is to verify it's the DefaultChromeLayout
733
+ // we exported from components.tsx (identity check).
734
+ const NotFound = () => null
735
+ const routes: RouteRecord[] = [
736
+ { path: '/', component: () => null, notFoundComponent: NotFound },
737
+ ]
738
+ const r = resolveRoute('/missing', routes)
739
+ // Identity-check: the synthetic layout's component IS the registered
740
+ // DefaultChromeLayout. Avoids re-rendering — the runtime render path
741
+ // is covered by the verify-modes / e2e cells.
742
+ expect(r.matched[0]?.component).toBe(DefaultChromeLayout)
743
+ })
744
+
745
+ it('layout-with-notFoundComponent still wins over a page-level one (same urlPath)', () => {
746
+ // Both layout AND page have notFoundComponent. The layout-first
747
+ // logic from PR L5 still applies — page-level is ONLY the fallback.
748
+ const PageNotFound = () => null
749
+ const LayoutNotFound = () => null
750
+ const routes: RouteRecord[] = [
751
+ {
752
+ path: '/',
753
+ component: () => null,
754
+ notFoundComponent: LayoutNotFound,
755
+ children: [
756
+ { path: '/page', component: () => null, notFoundComponent: PageNotFound },
757
+ ],
758
+ },
759
+ ]
760
+ const r = resolveRoute('/missing', routes)
761
+ expect(r.isNotFound).toBe(true)
762
+ // Should pick the layout, not the page — layout has children so
763
+ // the layout pass matches and wins.
764
+ const leaf = r.matched[r.matched.length - 1]
765
+ expect(leaf?.component).toBe(LayoutNotFound)
766
+ })
767
+
768
+ it('does NOT wrap when there is a wildcard catch-all (wildcard always wins)', () => {
769
+ // The wildcard route matches the URL directly, so the fallback never
770
+ // fires. Same precedence as the existing wildcard test above.
771
+ const Catchall = () => null
772
+ const NotFound = () => null
773
+ const routes: RouteRecord[] = [
774
+ { path: '/', component: () => null, notFoundComponent: NotFound },
775
+ { path: '(.*)', component: Catchall },
776
+ ]
777
+ const r = resolveRoute('/missing', routes)
778
+ expect(r.isNotFound).toBeUndefined()
779
+ expect(r.matched[0]?.component).toBe(Catchall)
780
+ })
781
+ })
782
+ })
@@ -0,0 +1,18 @@
1
+ import { isNativeCompat } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { RouterLink, RouterProvider, RouterView } from '../components'
4
+
5
+ // Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
6
+ // any of the `nativeCompat(...)` calls in components.tsx fails the
7
+ // corresponding test.
8
+ describe('native-compat markers — @pyreon/router', () => {
9
+ it('RouterProvider is marked native', () => {
10
+ expect(isNativeCompat(RouterProvider)).toBe(true)
11
+ })
12
+ it('RouterView is marked native', () => {
13
+ expect(isNativeCompat(RouterView)).toBe(true)
14
+ })
15
+ it('RouterLink is marked native', () => {
16
+ expect(isNativeCompat(RouterLink)).toBe(true)
17
+ })
18
+ })