@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +326 -51
- package/lib/types/index.d.ts +131 -8
- package/package.json +6 -5
- package/src/components.tsx +192 -27
- package/src/env.d.ts +6 -0
- package/src/index.ts +9 -1
- package/src/loader.ts +72 -4
- package/src/manifest.ts +63 -0
- package/src/match.ts +227 -2
- package/src/redirect.ts +63 -0
- package/src/router.ts +105 -35
- package/src/tests/loader.test.ts +326 -1
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +284 -0
- package/src/tests/native-markers.test.ts +18 -0
- package/src/tests/redirect.test.ts +96 -0
- package/src/tests/router.browser.test.tsx +68 -1
- package/src/tests/router.test.ts +149 -0
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +46 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/tests/loader.test.ts
CHANGED
|
@@ -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(
|
|
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')
|
package/src/tests/match.test.ts
CHANGED
|
@@ -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
|
+
})
|