@pyreon/router 0.14.0 → 0.15.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 +174 -51
- package/lib/types/index.d.ts +83 -8
- package/package.json +5 -4
- package/src/components.tsx +162 -27
- package/src/env.d.ts +6 -0
- package/src/index.ts +2 -0
- package/src/loader.ts +14 -4
- package/src/manifest.ts +63 -0
- package/src/match.ts +12 -1
- package/src/redirect.ts +63 -0
- package/src/router.ts +94 -34
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- 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 +25 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/router.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, onUnmount, useContext } from '@pyreon/core'
|
|
2
2
|
import { computed, signal } from '@pyreon/reactivity'
|
|
3
3
|
import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from './match'
|
|
4
|
+
import { getRedirectInfo } from './redirect'
|
|
4
5
|
import { ScrollManager } from './scroll'
|
|
5
6
|
import {
|
|
6
7
|
type AfterEachHook,
|
|
@@ -26,8 +27,7 @@ import {
|
|
|
26
27
|
const _isBrowser = typeof window !== 'undefined'
|
|
27
28
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
28
29
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
29
|
-
|
|
30
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
30
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
31
31
|
|
|
32
32
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
33
33
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
@@ -569,18 +569,24 @@ export function createRouter<TNames extends string = string>(
|
|
|
569
569
|
record: RouteRecord,
|
|
570
570
|
ac: AbortController,
|
|
571
571
|
to: ResolvedRoute,
|
|
572
|
-
):
|
|
572
|
+
): GuardOutcome {
|
|
573
573
|
if (result.status === 'fulfilled') {
|
|
574
574
|
router._loaderData.set(record, result.value)
|
|
575
|
-
return
|
|
575
|
+
return { action: 'continue' }
|
|
576
576
|
}
|
|
577
|
-
if (ac.signal.aborted) return
|
|
577
|
+
if (ac.signal.aborted) return { action: 'continue' }
|
|
578
|
+
// `redirect()` from a loader: propagate as a router-level redirect so the
|
|
579
|
+
// navigate flow re-runs against the target path BEFORE the matched route's
|
|
580
|
+
// layout / page mounts. Bypasses the user-supplied `_onError` hook — a
|
|
581
|
+
// redirect is intentional flow control, not an error.
|
|
582
|
+
const info = getRedirectInfo(result.reason)
|
|
583
|
+
if (info) return { action: 'redirect', target: info.url }
|
|
578
584
|
if (router._onError) {
|
|
579
585
|
const cancel = router._onError(result.reason, to)
|
|
580
|
-
if (cancel === false) return
|
|
586
|
+
if (cancel === false) return { action: 'cancel' }
|
|
581
587
|
}
|
|
582
588
|
router._loaderData.set(record, undefined)
|
|
583
|
-
return
|
|
589
|
+
return { action: 'continue' }
|
|
584
590
|
}
|
|
585
591
|
|
|
586
592
|
function syncBrowserUrl(path: string, replace: boolean): void {
|
|
@@ -633,6 +639,25 @@ export function createRouter<TNames extends string = string>(
|
|
|
633
639
|
return Date.now() - entry.timestamp < gcTime
|
|
634
640
|
}
|
|
635
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
|
|
644
|
+
* FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
|
|
645
|
+
* without a size cap a long-running SPA navigating across many distinct
|
|
646
|
+
* loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
|
|
647
|
+
* accumulate cache entries indefinitely until manual `invalidateLoader()`
|
|
648
|
+
* — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
649
|
+
* (default 100) but the loader cache write paths never read it. Mirrors
|
|
650
|
+
* the same pattern used for `_componentCache` in `components.tsx`.
|
|
651
|
+
*/
|
|
652
|
+
function loaderCacheSet(key: string, data: unknown): void {
|
|
653
|
+
router._loaderCache.set(key, { data, timestamp: Date.now() })
|
|
654
|
+
if (router._loaderCache.size > router._maxCacheSize) {
|
|
655
|
+
// Map iterates in insertion order — first key is oldest
|
|
656
|
+
const oldest = router._loaderCache.keys().next().value as string | undefined
|
|
657
|
+
if (oldest !== undefined) router._loaderCache.delete(oldest)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
636
661
|
/**
|
|
637
662
|
* Execute a loader with cache + dedup:
|
|
638
663
|
* 1. Cache hit + fresh → return cached data (skip loader entirely)
|
|
@@ -653,25 +678,41 @@ export function createRouter<TNames extends string = string>(
|
|
|
653
678
|
}
|
|
654
679
|
}
|
|
655
680
|
|
|
656
|
-
// 2. Dedup in-flight
|
|
681
|
+
// 2. Dedup in-flight — but only if the in-flight signal is still live.
|
|
682
|
+
// Pre-fix: nav-1 starts loader (signal=ac1.signal). User navigates again
|
|
683
|
+
// to the same path → nav-2's `router.push` first calls `_abortController?.abort()`
|
|
684
|
+
// (aborting ac1), then calls executeLoader. The Map still holds nav-1's
|
|
685
|
+
// promise (the .catch hasn't run yet); deduping returns it, but its
|
|
686
|
+
// signal is already aborted → nav-2 ends up with a rejected promise
|
|
687
|
+
// even though it has its own fresh ac2.signal. Now we check liveness.
|
|
657
688
|
const inflight = router._loaderInflight.get(key)
|
|
658
|
-
if (inflight) return inflight
|
|
689
|
+
if (inflight && !inflight.signal.aborted) return inflight.promise
|
|
659
690
|
|
|
660
|
-
// 3. Execute
|
|
691
|
+
// 3. Execute. Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
|
|
692
|
+
// throw from the loader (`redirect('/login')` / `notFound()` / a plain
|
|
693
|
+
// `throw new Error(...)`) becomes a rejected promise the `.catch` can
|
|
694
|
+
// handle — instead of escaping past the promise chain and surfacing as
|
|
695
|
+
// an unhandled exception in `runBlockingLoaders`'s `Promise.allSettled`.
|
|
661
696
|
if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
|
|
662
|
-
const promise =
|
|
663
|
-
.loader(loaderCtx)
|
|
697
|
+
const promise = Promise.resolve()
|
|
698
|
+
.then(() => record.loader!(loaderCtx))
|
|
664
699
|
.then((data) => {
|
|
665
|
-
|
|
666
|
-
|
|
700
|
+
loaderCacheSet(key, data)
|
|
701
|
+
// Only delete if WE'RE still the registered in-flight (a later nav
|
|
702
|
+
// may have replaced the entry with a fresh promise).
|
|
703
|
+
if (router._loaderInflight.get(key)?.promise === promise) {
|
|
704
|
+
router._loaderInflight.delete(key)
|
|
705
|
+
}
|
|
667
706
|
return data
|
|
668
707
|
})
|
|
669
708
|
.catch((err) => {
|
|
670
|
-
router._loaderInflight.
|
|
709
|
+
if (router._loaderInflight.get(key)?.promise === promise) {
|
|
710
|
+
router._loaderInflight.delete(key)
|
|
711
|
+
}
|
|
671
712
|
throw err
|
|
672
713
|
})
|
|
673
714
|
|
|
674
|
-
router._loaderInflight.set(key, promise)
|
|
715
|
+
router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
|
|
675
716
|
return promise
|
|
676
717
|
}
|
|
677
718
|
|
|
@@ -680,17 +721,20 @@ export function createRouter<TNames extends string = string>(
|
|
|
680
721
|
to: ResolvedRoute,
|
|
681
722
|
gen: number,
|
|
682
723
|
ac: AbortController,
|
|
683
|
-
): Promise<
|
|
724
|
+
): Promise<GuardOutcome> {
|
|
684
725
|
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
685
726
|
const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
|
|
686
|
-
if (gen !== _navGen) return
|
|
727
|
+
if (gen !== _navGen) return { action: 'cancel' }
|
|
687
728
|
for (let i = 0; i < records.length; i++) {
|
|
688
729
|
const result = results[i]
|
|
689
730
|
const record = records[i]
|
|
690
731
|
if (!result || !record) continue
|
|
691
|
-
|
|
732
|
+
const outcome = processLoaderResult(result, record, ac, to)
|
|
733
|
+
// Short-circuit on first redirect or cancel — later loaders' results
|
|
734
|
+
// are irrelevant once we know the navigation isn't committing here.
|
|
735
|
+
if (outcome.action !== 'continue') return outcome
|
|
692
736
|
}
|
|
693
|
-
return
|
|
737
|
+
return { action: 'continue' }
|
|
694
738
|
}
|
|
695
739
|
|
|
696
740
|
/** Fire-and-forget background revalidation for stale-while-revalidate routes. */
|
|
@@ -705,7 +749,7 @@ export function createRouter<TNames extends string = string>(
|
|
|
705
749
|
router._loaderData.set(r, data)
|
|
706
750
|
// Update cache with fresh data
|
|
707
751
|
const key = getCacheKey(r, loaderCtx)
|
|
708
|
-
|
|
752
|
+
loaderCacheSet(key, data)
|
|
709
753
|
// Bump loadingSignal to trigger reactive re-render with fresh data
|
|
710
754
|
loadingSignal.update((n) => n + 1)
|
|
711
755
|
loadingSignal.update((n) => n - 1)
|
|
@@ -717,9 +761,13 @@ export function createRouter<TNames extends string = string>(
|
|
|
717
761
|
}
|
|
718
762
|
}
|
|
719
763
|
|
|
720
|
-
async function runLoaders(
|
|
764
|
+
async function runLoaders(
|
|
765
|
+
to: ResolvedRoute,
|
|
766
|
+
gen: number,
|
|
767
|
+
ac: AbortController,
|
|
768
|
+
): Promise<GuardOutcome> {
|
|
721
769
|
const loadableRecords = to.matched.filter((r) => r.loader)
|
|
722
|
-
if (loadableRecords.length === 0) return
|
|
770
|
+
if (loadableRecords.length === 0) return { action: 'continue' }
|
|
723
771
|
|
|
724
772
|
const blocking: RouteRecord[] = []
|
|
725
773
|
const swr: RouteRecord[] = []
|
|
@@ -732,11 +780,11 @@ export function createRouter<TNames extends string = string>(
|
|
|
732
780
|
}
|
|
733
781
|
|
|
734
782
|
if (blocking.length > 0) {
|
|
735
|
-
const
|
|
736
|
-
if (
|
|
783
|
+
const outcome = await runBlockingLoaders(blocking, to, gen, ac)
|
|
784
|
+
if (outcome.action !== 'continue') return outcome
|
|
737
785
|
}
|
|
738
786
|
if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
|
|
739
|
-
return
|
|
787
|
+
return { action: 'continue' }
|
|
740
788
|
}
|
|
741
789
|
|
|
742
790
|
async function commitNavigation(
|
|
@@ -932,9 +980,12 @@ export function createRouter<TNames extends string = string>(
|
|
|
932
980
|
const ac = new AbortController()
|
|
933
981
|
router._abortController = ac
|
|
934
982
|
|
|
935
|
-
const
|
|
936
|
-
if (
|
|
983
|
+
const loaderOutcome = await runLoaders(to, gen, ac)
|
|
984
|
+
if (loaderOutcome.action !== 'continue') {
|
|
937
985
|
loadingSignal.update((n) => n - 1)
|
|
986
|
+
if (loaderOutcome.action === 'redirect') {
|
|
987
|
+
return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
|
|
988
|
+
}
|
|
938
989
|
return
|
|
939
990
|
}
|
|
940
991
|
|
|
@@ -1046,7 +1097,7 @@ export function createRouter<TNames extends string = string>(
|
|
|
1046
1097
|
return router._readyPromise
|
|
1047
1098
|
},
|
|
1048
1099
|
|
|
1049
|
-
async preload(path: string) {
|
|
1100
|
+
async preload(path: string, request?: Request) {
|
|
1050
1101
|
const resolved = resolveRoute(path, routes)
|
|
1051
1102
|
// Load lazy components in parallel and populate the component cache so
|
|
1052
1103
|
// the synchronous render pass finds ready components instead of kicking
|
|
@@ -1076,11 +1127,20 @@ export function createRouter<TNames extends string = string>(
|
|
|
1076
1127
|
resolved.matched
|
|
1077
1128
|
.filter((r) => r.loader)
|
|
1078
1129
|
.map(async (r) => {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1130
|
+
// Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
|
|
1131
|
+
// throw — `redirect('/login')` from a sync loader, `notFound()`,
|
|
1132
|
+
// a plain `throw new Error(...)` — becomes a rejected promise
|
|
1133
|
+
// the surrounding Promise.all surfaces. Bare `await r.loader(...)`
|
|
1134
|
+
// would let synchronous throws escape past the `await` and
|
|
1135
|
+
// surface as an uncaught exception in the Vite dev SSR pipeline.
|
|
1136
|
+
const data = await Promise.resolve().then(() =>
|
|
1137
|
+
r.loader!({
|
|
1138
|
+
params: resolved.params,
|
|
1139
|
+
query: resolved.query,
|
|
1140
|
+
signal: ac.signal,
|
|
1141
|
+
...(request ? { request } : {}),
|
|
1142
|
+
}),
|
|
1143
|
+
)
|
|
1084
1144
|
router._loaderData.set(r, data)
|
|
1085
1145
|
}),
|
|
1086
1146
|
)
|
package/src/tests/loader.test.ts
CHANGED
|
@@ -581,3 +581,152 @@ describe('router.preload', () => {
|
|
|
581
581
|
expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
|
|
582
582
|
})
|
|
583
583
|
})
|
|
584
|
+
|
|
585
|
+
// ─── _loaderCache LRU cap (regression for missing _maxCacheSize wiring) ────
|
|
586
|
+
describe('router — _loaderCache LRU cap', () => {
|
|
587
|
+
// Pre-fix: `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
588
|
+
// (default 100) but the loader cache write paths in router.ts never read it
|
|
589
|
+
// — only `_componentCache` enforced the cap. Long-running SPAs navigating
|
|
590
|
+
// dynamic-param routes (`/posts/:id` with hundreds of unique IDs) would
|
|
591
|
+
// accumulate `_loaderCache` entries until manual `invalidateLoader()`.
|
|
592
|
+
// Post-fix: the helper `loaderCacheSet` evicts oldest (insertion-order FIFO)
|
|
593
|
+
// when over the cap, mirroring `_componentCache`.
|
|
594
|
+
test('caps _loaderCache at maxCacheSize, evicts oldest first', async () => {
|
|
595
|
+
const Page = () => null
|
|
596
|
+
const routes: RouteRecord[] = [
|
|
597
|
+
{
|
|
598
|
+
path: '/posts/:id',
|
|
599
|
+
component: Page,
|
|
600
|
+
loader: async ({ params }) => `post-${params.id}`,
|
|
601
|
+
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
602
|
+
},
|
|
603
|
+
]
|
|
604
|
+
const router = createRouter({ routes, maxCacheSize: 3, url: '/' }) as RouterInstance
|
|
605
|
+
|
|
606
|
+
// Drive the loader through 4 distinct keys
|
|
607
|
+
await router.push('/posts/1')
|
|
608
|
+
await router.push('/posts/2')
|
|
609
|
+
await router.push('/posts/3')
|
|
610
|
+
await router.push('/posts/4')
|
|
611
|
+
|
|
612
|
+
// Cache must be capped at 3 (not 4).
|
|
613
|
+
expect(router._loaderCache.size).toBe(3)
|
|
614
|
+
|
|
615
|
+
// FIFO: the OLDEST insertion (posts:1) must have been evicted.
|
|
616
|
+
expect(router._loaderCache.has('posts:1')).toBe(false)
|
|
617
|
+
expect(router._loaderCache.has('posts:2')).toBe(true)
|
|
618
|
+
expect(router._loaderCache.has('posts:3')).toBe(true)
|
|
619
|
+
expect(router._loaderCache.has('posts:4')).toBe(true)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test('does not evict when cap is not exceeded', async () => {
|
|
623
|
+
const Page = () => null
|
|
624
|
+
const routes: RouteRecord[] = [
|
|
625
|
+
{
|
|
626
|
+
path: '/posts/:id',
|
|
627
|
+
component: Page,
|
|
628
|
+
loader: async ({ params }) => `post-${params.id}`,
|
|
629
|
+
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
630
|
+
},
|
|
631
|
+
]
|
|
632
|
+
const router = createRouter({ routes, maxCacheSize: 100, url: '/' }) as RouterInstance
|
|
633
|
+
|
|
634
|
+
await router.push('/posts/1')
|
|
635
|
+
await router.push('/posts/2')
|
|
636
|
+
await router.push('/posts/3')
|
|
637
|
+
|
|
638
|
+
expect(router._loaderCache.size).toBe(3)
|
|
639
|
+
expect(router._loaderCache.has('posts:1')).toBe(true)
|
|
640
|
+
expect(router._loaderCache.has('posts:2')).toBe(true)
|
|
641
|
+
expect(router._loaderCache.has('posts:3')).toBe(true)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
test('default maxCacheSize (100) caps cache after 100 unique keys', async () => {
|
|
645
|
+
const Page = () => null
|
|
646
|
+
const routes: RouteRecord[] = [
|
|
647
|
+
{
|
|
648
|
+
path: '/posts/:id',
|
|
649
|
+
component: Page,
|
|
650
|
+
loader: async ({ params }) => `post-${params.id}`,
|
|
651
|
+
loaderKey: ({ params }) => `posts:${params.id}`,
|
|
652
|
+
},
|
|
653
|
+
]
|
|
654
|
+
// No explicit maxCacheSize — uses default 100
|
|
655
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < 105; i++) {
|
|
658
|
+
await router.push(`/posts/${i}`)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
expect(router._loaderCache.size).toBe(100)
|
|
662
|
+
// Earliest 5 keys (0-4) evicted.
|
|
663
|
+
expect(router._loaderCache.has('posts:0')).toBe(false)
|
|
664
|
+
expect(router._loaderCache.has('posts:4')).toBe(false)
|
|
665
|
+
expect(router._loaderCache.has('posts:5')).toBe(true)
|
|
666
|
+
expect(router._loaderCache.has('posts:104')).toBe(true)
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// ─── Regression: dedup must not return aborted in-flight promise ───────────
|
|
671
|
+
//
|
|
672
|
+
// Pre-fix: `router.push` aborts `_abortController` BEFORE starting the next
|
|
673
|
+
// nav. If two pushes to the same path happen back-to-back, the in-flight
|
|
674
|
+
// Map still holds nav-1's promise (its `.catch` hasn't run yet). The dedup
|
|
675
|
+
// returned that promise to nav-2 — but its bound signal is already aborted,
|
|
676
|
+
// so nav-2's data path is broken even though it has its own fresh signal.
|
|
677
|
+
//
|
|
678
|
+
// Post-fix: `_loaderInflight` stores `{ promise, signal }`. Dedup is gated
|
|
679
|
+
// on `!signal.aborted`. Aborted entries fall through to a fresh execute
|
|
680
|
+
// using nav-2's signal.
|
|
681
|
+
describe('router — _loaderInflight aborted-signal dedup', () => {
|
|
682
|
+
test('back-to-back navigation re-executes loader with fresh signal when previous was aborted', async () => {
|
|
683
|
+
let invocations = 0
|
|
684
|
+
let resolveLoader1: ((data: unknown) => void) | null = null
|
|
685
|
+
|
|
686
|
+
const Page = () => null
|
|
687
|
+
const routes: RouteRecord[] = [
|
|
688
|
+
{ path: '/', component: Page },
|
|
689
|
+
{
|
|
690
|
+
path: '/data',
|
|
691
|
+
component: Page,
|
|
692
|
+
loader: async ({ signal }) => {
|
|
693
|
+
invocations++
|
|
694
|
+
const myInvocation = invocations
|
|
695
|
+
// Wire signal-abort → reject so the nav actually fails on abort.
|
|
696
|
+
// The first invocation hangs until manually resolved; later
|
|
697
|
+
// invocations resolve immediately.
|
|
698
|
+
if (myInvocation === 1) {
|
|
699
|
+
return new Promise((_resolve, reject) => {
|
|
700
|
+
signal?.addEventListener('abort', () => reject(new Error('aborted')))
|
|
701
|
+
resolveLoader1 = _resolve
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
return `data-${myInvocation}`
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
]
|
|
708
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
709
|
+
|
|
710
|
+
// Nav 1 → /data. Loader invocation #1 starts, hangs.
|
|
711
|
+
const nav1 = router.push('/data').catch(() => {})
|
|
712
|
+
await new Promise<void>((r) => queueMicrotask(() => r()))
|
|
713
|
+
|
|
714
|
+
// Nav 2 → /data. router.push aborts ac1 first, then calls executeLoader.
|
|
715
|
+
// Pre-fix: dedup returns nav-1's promise (whose signal is now aborted).
|
|
716
|
+
// Post-fix: dedup skipped (signal.aborted=true), fresh loader runs.
|
|
717
|
+
const nav2 = router.push('/data')
|
|
718
|
+
|
|
719
|
+
// Resolve nav-1's hung promise (won't actually deliver — already aborted)
|
|
720
|
+
const r1 = resolveLoader1 as ((d: unknown) => void) | null
|
|
721
|
+
if (r1) r1('data-1')
|
|
722
|
+
|
|
723
|
+
await nav2
|
|
724
|
+
await nav1
|
|
725
|
+
|
|
726
|
+
// Post-fix: 2 invocations (nav-1 aborted, nav-2 ran fresh).
|
|
727
|
+
// Pre-fix: 1 invocation (nav-2 deduped to nav-1's aborted promise).
|
|
728
|
+
expect(invocations).toBe(2)
|
|
729
|
+
expect(router.currentRoute().path).toBe('/data')
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
})
|
|
@@ -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')
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getRedirectInfo, isRedirectError, redirect } from '../redirect'
|
|
3
|
+
|
|
4
|
+
describe('redirect()', () => {
|
|
5
|
+
it('throws an error branded with the REDIRECT symbol', () => {
|
|
6
|
+
expect(() => redirect('/login')).toThrow()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('captures URL + default 307 status on the thrown error', () => {
|
|
10
|
+
let caught: unknown
|
|
11
|
+
try {
|
|
12
|
+
redirect('/login')
|
|
13
|
+
} catch (err) {
|
|
14
|
+
caught = err
|
|
15
|
+
}
|
|
16
|
+
const info = getRedirectInfo(caught)
|
|
17
|
+
expect(info).toEqual({ url: '/login', status: 307 })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('captures the custom status when one is provided', () => {
|
|
21
|
+
let caught: unknown
|
|
22
|
+
try {
|
|
23
|
+
redirect('/perm', 308)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
caught = err
|
|
26
|
+
}
|
|
27
|
+
expect(getRedirectInfo(caught)?.status).toBe(308)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each([301, 302, 303, 307, 308] as const)('accepts %s as a valid status', (status) => {
|
|
31
|
+
let caught: unknown
|
|
32
|
+
try {
|
|
33
|
+
redirect('/x', status)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
caught = err
|
|
36
|
+
}
|
|
37
|
+
expect(getRedirectInfo(caught)?.status).toBe(status)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('produces a human-readable Error message', () => {
|
|
41
|
+
let caught: Error | undefined
|
|
42
|
+
try {
|
|
43
|
+
redirect('/login')
|
|
44
|
+
} catch (err) {
|
|
45
|
+
caught = err as Error
|
|
46
|
+
}
|
|
47
|
+
expect(caught?.message).toBe('Redirect to /login')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('isRedirectError()', () => {
|
|
52
|
+
it('returns true for an error thrown by redirect()', () => {
|
|
53
|
+
let caught: unknown
|
|
54
|
+
try {
|
|
55
|
+
redirect('/x')
|
|
56
|
+
} catch (err) {
|
|
57
|
+
caught = err
|
|
58
|
+
}
|
|
59
|
+
expect(isRedirectError(caught)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns false for a plain Error', () => {
|
|
63
|
+
expect(isRedirectError(new Error('plain'))).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns false for non-error values', () => {
|
|
67
|
+
expect(isRedirectError(null)).toBe(false)
|
|
68
|
+
expect(isRedirectError(undefined)).toBe(false)
|
|
69
|
+
expect(isRedirectError('string')).toBe(false)
|
|
70
|
+
expect(isRedirectError(42)).toBe(false)
|
|
71
|
+
expect(isRedirectError({})).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns false for objects with a different brand', () => {
|
|
75
|
+
const fake = new Error('fake')
|
|
76
|
+
;(fake as unknown as Record<symbol, unknown>)[Symbol.for('something.else')] = true
|
|
77
|
+
expect(isRedirectError(fake)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('getRedirectInfo()', () => {
|
|
82
|
+
it('returns null for non-redirect errors', () => {
|
|
83
|
+
expect(getRedirectInfo(new Error('plain'))).toBeNull()
|
|
84
|
+
expect(getRedirectInfo(null)).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns the redirect info for a thrown redirect()', () => {
|
|
88
|
+
let caught: unknown
|
|
89
|
+
try {
|
|
90
|
+
redirect('/destination', 303)
|
|
91
|
+
} catch (err) {
|
|
92
|
+
caught = err
|
|
93
|
+
}
|
|
94
|
+
expect(getRedirectInfo(caught)).toEqual({ url: '/destination', status: 303 })
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { h } from '@pyreon/core'
|
|
1
|
+
import { h, onMount } from '@pyreon/core'
|
|
2
2
|
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
4
|
import {
|
|
@@ -439,4 +439,71 @@ describe('router in real browser', () => {
|
|
|
439
439
|
expect(container.querySelector('#home')).toBeNull()
|
|
440
440
|
unmount()
|
|
441
441
|
})
|
|
442
|
+
|
|
443
|
+
// ── Lock-in for the layout-remount loop fix (PR #406) ──────────────────────
|
|
444
|
+
//
|
|
445
|
+
// Pre-fix `RouterView`'s reactive child accessor read `_loadingSignal()` and
|
|
446
|
+
// the full `currentRoute` snapshot directly. Each navigation flow writes
|
|
447
|
+
// `_loadingSignal` at least twice (start tick + end tick) and writes
|
|
448
|
+
// `currentPath` once via `commitNavigation`. Any of those writes re-emitted
|
|
449
|
+
// the reactive child, and `mountReactive`'s teardown-then-mount cleanup
|
|
450
|
+
// remounted the entire matched-component subtree on each emission. So a
|
|
451
|
+
// single `router.push()` produced 2-3+ mounts of the destination component
|
|
452
|
+
// (instead of 1) — the "layout double/triple mount" loop.
|
|
453
|
+
//
|
|
454
|
+
// The fix routes the structural decision through a single
|
|
455
|
+
// `computed<DepthEntry>` keyed on `(rec, comp, errored, route)` reference
|
|
456
|
+
// equality. Within-navigation `_loadingSignal` ticks don't change
|
|
457
|
+
// `currentRoute` (it's `computed` memoized on `currentPath`), so the
|
|
458
|
+
// structural emission stays at exactly one per navigation.
|
|
459
|
+
//
|
|
460
|
+
// This test instruments a counter inside the destination component's
|
|
461
|
+
// `onMount` — an inflated count after a single `await router.push()` would
|
|
462
|
+
// mean the loop is back. Bisect-verifies against the structural decoupling
|
|
463
|
+
// commit: reverting that commit pushes the count to ≥ 2 and this assertion
|
|
464
|
+
// fails.
|
|
465
|
+
it('a single router.push() mounts the destination component exactly once (loop-prevention regression)', async () => {
|
|
466
|
+
let aboutMountCount = 0
|
|
467
|
+
const InstrumentedAbout = () => {
|
|
468
|
+
onMount(() => {
|
|
469
|
+
aboutMountCount++
|
|
470
|
+
})
|
|
471
|
+
return h('div', { id: 'about-instrumented' }, 'About Page')
|
|
472
|
+
}
|
|
473
|
+
const localRoutes = [
|
|
474
|
+
{ path: '/', component: Home },
|
|
475
|
+
{ path: '/about', component: InstrumentedAbout },
|
|
476
|
+
]
|
|
477
|
+
const router = createRouter({ routes: localRoutes, mode: 'hash' })
|
|
478
|
+
const { container, unmount } = mountInBrowser(
|
|
479
|
+
h(RouterProvider, { router }, h(RouterView, {})),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
// Sanity: starting at /, About is not yet mounted.
|
|
483
|
+
expect(aboutMountCount).toBe(0)
|
|
484
|
+
expect(container.querySelector('#about-instrumented')).toBeNull()
|
|
485
|
+
|
|
486
|
+
await router.push('/about')
|
|
487
|
+
await flush()
|
|
488
|
+
|
|
489
|
+
// The structural decoupling fix means a single navigation produces a
|
|
490
|
+
// single emission at this depth. If `RouterView` ever reverts to reading
|
|
491
|
+
// `_loadingSignal` reactively, every loadingSignal tick during the
|
|
492
|
+
// navigate flow will remount the destination subtree and this count
|
|
493
|
+
// jumps to 2 or 3.
|
|
494
|
+
expect(container.querySelector('#about-instrumented')?.textContent).toBe('About Page')
|
|
495
|
+
expect(aboutMountCount).toBe(1)
|
|
496
|
+
|
|
497
|
+
// And navigating BACK to / + forward again to /about produces exactly
|
|
498
|
+
// one more mount — covers the case where stale subscribers from a prior
|
|
499
|
+
// mount could double-fire across navigations.
|
|
500
|
+
await router.push('/')
|
|
501
|
+
await flush()
|
|
502
|
+
await router.push('/about')
|
|
503
|
+
await flush()
|
|
504
|
+
expect(aboutMountCount).toBe(2)
|
|
505
|
+
|
|
506
|
+
expect(unhandledRejections).toEqual([])
|
|
507
|
+
unmount()
|
|
508
|
+
})
|
|
442
509
|
})
|