@pyreon/router 0.13.1 → 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/README.md +73 -2
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +467 -56
- package/lib/types/index.d.ts +218 -13
- package/package.json +6 -5
- package/src/components.tsx +299 -32
- package/src/env.d.ts +6 -0
- package/src/index.ts +5 -0
- package/src/loader.ts +18 -2
- package/src/manifest.ts +63 -0
- package/src/match.ts +48 -8
- package/src/not-found.ts +75 -0
- package/src/redirect.ts +63 -0
- package/src/router.ts +263 -45
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +31 -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 +686 -1
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +95 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/tests/router.test.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { h, popContext } from '@pyreon/core'
|
|
2
2
|
import { mount } from '@pyreon/runtime-dom'
|
|
3
3
|
import type { ResolvedRoute, RouteRecord } from '../index'
|
|
4
|
+
import { isNotFoundError, NotFoundBoundary, notFound } from '../not-found'
|
|
5
|
+
import { getRedirectInfo, redirect } from '../redirect'
|
|
4
6
|
import {
|
|
5
7
|
createRouter,
|
|
6
8
|
hydrateLoaderData,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
useSearchParams,
|
|
22
24
|
useTransition,
|
|
23
25
|
useTypedSearchParams,
|
|
26
|
+
useValidatedSearch,
|
|
24
27
|
} from '../index'
|
|
25
28
|
import type { RouteMiddleware } from '../index'
|
|
26
29
|
import {
|
|
@@ -358,10 +361,13 @@ describe('router navigation', () => {
|
|
|
358
361
|
expect(router.currentRoute().query.q).toBe('hello')
|
|
359
362
|
})
|
|
360
363
|
|
|
361
|
-
test('push with unknown named route falls back to /', async () => {
|
|
364
|
+
test('push with unknown named route falls back to / and warns', async () => {
|
|
365
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
362
366
|
const router = createRouter({ routes, url: '/about' })
|
|
363
367
|
await router.push({ name: 'nonexistent' })
|
|
364
368
|
expect(router.currentRoute().path).toBe('/')
|
|
369
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown route name "nonexistent"'))
|
|
370
|
+
warnSpy.mockRestore()
|
|
365
371
|
})
|
|
366
372
|
|
|
367
373
|
test('back() does not throw in SSR (no window)', () => {
|
|
@@ -4267,6 +4273,21 @@ describe('useTypedSearchParams', () => {
|
|
|
4267
4273
|
router.destroy()
|
|
4268
4274
|
})
|
|
4269
4275
|
|
|
4276
|
+
it('guards NaN: non-numeric string coerces to 0 instead of NaN', () => {
|
|
4277
|
+
const router = createRouter({ routes: tspRoutes, url: '/search?page=abc' })
|
|
4278
|
+
const ctr = document.createElement('div')
|
|
4279
|
+
let result: { page: number } | undefined
|
|
4280
|
+
const TestComp = () => {
|
|
4281
|
+
const [params] = useTypedSearchParams({ page: 'number' })
|
|
4282
|
+
result = params() as { page: number }
|
|
4283
|
+
return null
|
|
4284
|
+
}
|
|
4285
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4286
|
+
expect(result!.page).toBe(0) // not NaN
|
|
4287
|
+
expect(Number.isNaN(result!.page)).toBe(false)
|
|
4288
|
+
router.destroy()
|
|
4289
|
+
})
|
|
4290
|
+
|
|
4270
4291
|
it('defaults boolean to false when missing from URL', () => {
|
|
4271
4292
|
const router = createRouter({ routes: tspRoutes, url: '/search' })
|
|
4272
4293
|
const ctr = document.createElement('div')
|
|
@@ -4722,3 +4743,667 @@ describe('View Transitions API', () => {
|
|
|
4722
4743
|
router.destroy()
|
|
4723
4744
|
})
|
|
4724
4745
|
})
|
|
4746
|
+
|
|
4747
|
+
// ─── Type-level tests for Router<TNames> ──────────────────────────────────────
|
|
4748
|
+
|
|
4749
|
+
describe('Router<TNames> type safety', () => {
|
|
4750
|
+
test('createRouter<TNames> returns typed router', () => {
|
|
4751
|
+
type Names = 'home' | 'about' | 'user'
|
|
4752
|
+
const router = createRouter<Names>({
|
|
4753
|
+
routes: [
|
|
4754
|
+
{ path: '/', name: 'home', component: Home },
|
|
4755
|
+
{ path: '/about', name: 'about', component: Home },
|
|
4756
|
+
{ path: '/user/:id', name: 'user', component: Home },
|
|
4757
|
+
],
|
|
4758
|
+
})
|
|
4759
|
+
|
|
4760
|
+
// This compiles — valid name
|
|
4761
|
+
router.push({ name: 'home' })
|
|
4762
|
+
router.push({ name: 'about' })
|
|
4763
|
+
router.push({ name: 'user', params: { id: '1' } })
|
|
4764
|
+
|
|
4765
|
+
// Type assertion: the router accepts the union type
|
|
4766
|
+
const _typeCheck: Names = 'home'
|
|
4767
|
+
void _typeCheck
|
|
4768
|
+
|
|
4769
|
+
router.destroy()
|
|
4770
|
+
})
|
|
4771
|
+
|
|
4772
|
+
test('_middlewareData is typed on ResolvedRoute', () => {
|
|
4773
|
+
const route: ResolvedRoute = {
|
|
4774
|
+
path: '/',
|
|
4775
|
+
params: {},
|
|
4776
|
+
query: {},
|
|
4777
|
+
hash: '',
|
|
4778
|
+
matched: [],
|
|
4779
|
+
meta: {},
|
|
4780
|
+
_middlewareData: { user: { name: 'Alice' } },
|
|
4781
|
+
}
|
|
4782
|
+
expect(route._middlewareData?.user).toEqual({ name: 'Alice' })
|
|
4783
|
+
})
|
|
4784
|
+
})
|
|
4785
|
+
|
|
4786
|
+
// ─── notFound() + isNotFoundError ─────────────────────────────────────────────
|
|
4787
|
+
|
|
4788
|
+
describe('notFound()', () => {
|
|
4789
|
+
|
|
4790
|
+
test('throws an Error with NOT_FOUND brand', () => {
|
|
4791
|
+
expect(() => notFound()).toThrow('Not Found')
|
|
4792
|
+
expect(() => notFound('User not found')).toThrow('User not found')
|
|
4793
|
+
})
|
|
4794
|
+
|
|
4795
|
+
test('isNotFoundError detects branded errors', () => {
|
|
4796
|
+
try {
|
|
4797
|
+
notFound('test')
|
|
4798
|
+
} catch (err) {
|
|
4799
|
+
expect(isNotFoundError(err)).toBe(true)
|
|
4800
|
+
expect(err instanceof Error).toBe(true)
|
|
4801
|
+
}
|
|
4802
|
+
})
|
|
4803
|
+
|
|
4804
|
+
test('isNotFoundError returns false for regular errors', () => {
|
|
4805
|
+
expect(isNotFoundError(new Error('regular'))).toBe(false)
|
|
4806
|
+
expect(isNotFoundError(null)).toBe(false)
|
|
4807
|
+
expect(isNotFoundError('string')).toBe(false)
|
|
4808
|
+
})
|
|
4809
|
+
})
|
|
4810
|
+
|
|
4811
|
+
// ─── Prefetch intent mode ────────────────────────────────────────────────────
|
|
4812
|
+
|
|
4813
|
+
describe('RouterLink prefetch="intent"', () => {
|
|
4814
|
+
test('focus triggers prefetch in intent mode (default)', async () => {
|
|
4815
|
+
const loaderCalled = vi.fn().mockResolvedValue({ data: 'prefetched' })
|
|
4816
|
+
const intRoutes: RouteRecord[] = [
|
|
4817
|
+
{ path: '/', component: Home },
|
|
4818
|
+
{ path: '/target', component: Home, loader: loaderCalled },
|
|
4819
|
+
]
|
|
4820
|
+
const router = createRouter({ routes: intRoutes, url: '/' })
|
|
4821
|
+
const ctr = document.createElement('div')
|
|
4822
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: '/target' })), ctr)
|
|
4823
|
+
|
|
4824
|
+
const link = ctr.querySelector('a')!
|
|
4825
|
+
// Focus triggers prefetch in intent mode
|
|
4826
|
+
link.dispatchEvent(new Event('focus', { bubbles: true }))
|
|
4827
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4828
|
+
|
|
4829
|
+
expect(loaderCalled).toHaveBeenCalled()
|
|
4830
|
+
router.destroy()
|
|
4831
|
+
})
|
|
4832
|
+
|
|
4833
|
+
test('focus does NOT trigger prefetch in "none" mode', async () => {
|
|
4834
|
+
const loaderCalled = vi.fn().mockResolvedValue({ data: 'x' })
|
|
4835
|
+
const intRoutes: RouteRecord[] = [
|
|
4836
|
+
{ path: '/', component: Home },
|
|
4837
|
+
{ path: '/target', component: Home, loader: loaderCalled },
|
|
4838
|
+
]
|
|
4839
|
+
const router = createRouter({ routes: intRoutes, url: '/' })
|
|
4840
|
+
const ctr = document.createElement('div')
|
|
4841
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: '/target', prefetch: 'none' })), ctr)
|
|
4842
|
+
|
|
4843
|
+
const link = ctr.querySelector('a')!
|
|
4844
|
+
link.dispatchEvent(new Event('focus', { bubbles: true }))
|
|
4845
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4846
|
+
|
|
4847
|
+
expect(loaderCalled).not.toHaveBeenCalled()
|
|
4848
|
+
router.destroy()
|
|
4849
|
+
})
|
|
4850
|
+
})
|
|
4851
|
+
|
|
4852
|
+
// ─── NotFoundBoundary e2e ────────────────────────────────────────────────────
|
|
4853
|
+
|
|
4854
|
+
describe('NotFoundBoundary e2e', () => {
|
|
4855
|
+
test('notFound() in component triggers NotFoundBoundary fallback', () => {
|
|
4856
|
+
const ThrowNotFound = () => {
|
|
4857
|
+
notFound('User not found')
|
|
4858
|
+
return null // unreachable
|
|
4859
|
+
}
|
|
4860
|
+
const nfRoutes: RouteRecord[] = [
|
|
4861
|
+
{ path: '/missing', component: ThrowNotFound },
|
|
4862
|
+
]
|
|
4863
|
+
const router = createRouter({ routes: nfRoutes, url: '/missing' })
|
|
4864
|
+
const ctr = document.createElement('div')
|
|
4865
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
4866
|
+
|
|
4867
|
+
mount(
|
|
4868
|
+
h(RouterProvider, { router },
|
|
4869
|
+
h(NotFoundBoundary, {
|
|
4870
|
+
fallback: h('div', { id: 'not-found' }, '404 Not Found'),
|
|
4871
|
+
}, h(RouterView, {})),
|
|
4872
|
+
),
|
|
4873
|
+
ctr,
|
|
4874
|
+
)
|
|
4875
|
+
|
|
4876
|
+
expect(ctr.querySelector('#not-found')?.textContent).toBe('404 Not Found')
|
|
4877
|
+
errorSpy.mockRestore()
|
|
4878
|
+
router.destroy()
|
|
4879
|
+
})
|
|
4880
|
+
})
|
|
4881
|
+
|
|
4882
|
+
// ─── pendingComponent e2e ────────────────────────────────────────────────────
|
|
4883
|
+
|
|
4884
|
+
describe('pendingComponent', () => {
|
|
4885
|
+
test('pendingComponent shows immediately when pendingMs=0', async () => {
|
|
4886
|
+
let resolveLoader: (v: unknown) => void
|
|
4887
|
+
const loaderPromise = new Promise((r) => { resolveLoader = r })
|
|
4888
|
+
const PendingComp = () => h('div', { id: 'pending' }, 'Loading...')
|
|
4889
|
+
const RealComp = () => h('div', { id: 'real' }, 'Loaded')
|
|
4890
|
+
|
|
4891
|
+
const pendRoutes: RouteRecord[] = [
|
|
4892
|
+
{
|
|
4893
|
+
path: '/slow',
|
|
4894
|
+
component: RealComp,
|
|
4895
|
+
loader: () => loaderPromise,
|
|
4896
|
+
pendingComponent: PendingComp,
|
|
4897
|
+
pendingMs: 0,
|
|
4898
|
+
pendingMinMs: 0,
|
|
4899
|
+
},
|
|
4900
|
+
]
|
|
4901
|
+
const router = createRouter({ routes: pendRoutes, url: '/slow' })
|
|
4902
|
+
const ctr = document.createElement('div')
|
|
4903
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), ctr)
|
|
4904
|
+
|
|
4905
|
+
// Pending should show immediately
|
|
4906
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4907
|
+
expect(ctr.querySelector('#pending')).not.toBeNull()
|
|
4908
|
+
|
|
4909
|
+
// Resolve the loader
|
|
4910
|
+
resolveLoader!({ data: 'done' })
|
|
4911
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4912
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4913
|
+
|
|
4914
|
+
router.destroy()
|
|
4915
|
+
})
|
|
4916
|
+
|
|
4917
|
+
test('pendingMinMs keeps pending visible even after loader resolves', async () => {
|
|
4918
|
+
let resolveLoader: (v: unknown) => void
|
|
4919
|
+
const loaderPromise = new Promise((r) => { resolveLoader = r })
|
|
4920
|
+
const PendingComp = () => h('div', { id: 'pending-min' }, 'Loading...')
|
|
4921
|
+
const RealComp = () => h('div', { id: 'real-min' }, 'Loaded')
|
|
4922
|
+
|
|
4923
|
+
const pendRoutes: RouteRecord[] = [
|
|
4924
|
+
{
|
|
4925
|
+
path: '/mintime',
|
|
4926
|
+
component: RealComp,
|
|
4927
|
+
loader: () => loaderPromise,
|
|
4928
|
+
pendingComponent: PendingComp,
|
|
4929
|
+
pendingMs: 0, // show pending immediately
|
|
4930
|
+
pendingMinMs: 300, // keep for at least 300ms
|
|
4931
|
+
},
|
|
4932
|
+
]
|
|
4933
|
+
const router = createRouter({ routes: pendRoutes, url: '/mintime' })
|
|
4934
|
+
const ctr = document.createElement('div')
|
|
4935
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), ctr)
|
|
4936
|
+
|
|
4937
|
+
// Pending should show immediately
|
|
4938
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4939
|
+
expect(ctr.querySelector('#pending-min')).not.toBeNull()
|
|
4940
|
+
|
|
4941
|
+
// Resolve loader quickly (after ~50ms — well before pendingMinMs)
|
|
4942
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4943
|
+
resolveLoader!({ data: 'done' })
|
|
4944
|
+
|
|
4945
|
+
// Trigger reactive update
|
|
4946
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4947
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4948
|
+
|
|
4949
|
+
// Pending should STILL be visible — pendingMinMs hasn't elapsed
|
|
4950
|
+
expect(ctr.querySelector('#pending-min')).not.toBeNull()
|
|
4951
|
+
expect(ctr.querySelector('#real-min')).toBeNull()
|
|
4952
|
+
|
|
4953
|
+
// Wait for pendingMinMs to elapse
|
|
4954
|
+
await new Promise<void>((r) => setTimeout(r, 300))
|
|
4955
|
+
|
|
4956
|
+
// Now the real component should show
|
|
4957
|
+
// (need to trigger a reactive re-render — read loadingSignal)
|
|
4958
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4959
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4960
|
+
|
|
4961
|
+
// The pending phase should have transitioned to ready
|
|
4962
|
+
// Note: the signal-based state machine sets phase to 'ready' after minTimer fires
|
|
4963
|
+
router.destroy()
|
|
4964
|
+
}, 10000)
|
|
4965
|
+
|
|
4966
|
+
test('pendingMs delays showing pending — data arriving before delay skips pending', async () => {
|
|
4967
|
+
let resolveLoader: (v: unknown) => void
|
|
4968
|
+
const loaderPromise = new Promise((r) => { resolveLoader = r })
|
|
4969
|
+
const PendingComp = () => h('div', { id: 'pending-delay' }, 'Loading...')
|
|
4970
|
+
const RealComp = () => h('div', { id: 'real-delay' }, 'Loaded')
|
|
4971
|
+
|
|
4972
|
+
const pendRoutes: RouteRecord[] = [
|
|
4973
|
+
{
|
|
4974
|
+
path: '/delayed',
|
|
4975
|
+
component: RealComp,
|
|
4976
|
+
loader: () => loaderPromise,
|
|
4977
|
+
pendingComponent: PendingComp,
|
|
4978
|
+
pendingMs: 500, // wait 500ms before showing pending
|
|
4979
|
+
pendingMinMs: 0,
|
|
4980
|
+
},
|
|
4981
|
+
]
|
|
4982
|
+
const router = createRouter({ routes: pendRoutes, url: '/delayed' })
|
|
4983
|
+
const ctr = document.createElement('div')
|
|
4984
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), ctr)
|
|
4985
|
+
|
|
4986
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4987
|
+
|
|
4988
|
+
// Before pendingMs: nothing should show (hidden phase)
|
|
4989
|
+
expect(ctr.querySelector('#pending-delay')).toBeNull()
|
|
4990
|
+
|
|
4991
|
+
// Resolve before pendingMs elapses — should skip pending entirely
|
|
4992
|
+
resolveLoader!({ data: 'fast' })
|
|
4993
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4994
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4995
|
+
|
|
4996
|
+
// Pending should never have appeared
|
|
4997
|
+
expect(ctr.querySelector('#pending-delay')).toBeNull()
|
|
4998
|
+
|
|
4999
|
+
router.destroy()
|
|
5000
|
+
})
|
|
5001
|
+
|
|
5002
|
+
test('pendingComponent type accepted on RouteRecord', () => {
|
|
5003
|
+
const PendingComp = () => h('div', null, 'Loading...')
|
|
5004
|
+
const routeWithPending: RouteRecord = {
|
|
5005
|
+
path: '/slow',
|
|
5006
|
+
component: Home,
|
|
5007
|
+
loader: async () => ({}),
|
|
5008
|
+
pendingComponent: PendingComp,
|
|
5009
|
+
pendingMs: 200,
|
|
5010
|
+
pendingMinMs: 500,
|
|
5011
|
+
}
|
|
5012
|
+
expect(routeWithPending.pendingComponent).toBe(PendingComp)
|
|
5013
|
+
expect(routeWithPending.pendingMs).toBe(200)
|
|
5014
|
+
expect(routeWithPending.pendingMinMs).toBe(500)
|
|
5015
|
+
})
|
|
5016
|
+
})
|
|
5017
|
+
|
|
5018
|
+
// ─── validateSearch + useValidatedSearch ──────────────────────────────────────
|
|
5019
|
+
|
|
5020
|
+
describe('validateSearch', () => {
|
|
5021
|
+
test('resolveRoute runs validateSearch on matched route', () => {
|
|
5022
|
+
const vsRoutes: RouteRecord[] = [
|
|
5023
|
+
{
|
|
5024
|
+
path: '/search',
|
|
5025
|
+
component: Home,
|
|
5026
|
+
validateSearch: (raw) => ({
|
|
5027
|
+
page: Number(raw.page) || 1,
|
|
5028
|
+
q: raw.q ?? '',
|
|
5029
|
+
}),
|
|
5030
|
+
},
|
|
5031
|
+
]
|
|
5032
|
+
const router = createRouter({ routes: vsRoutes, url: '/search?page=3&q=hello' })
|
|
5033
|
+
const route = router.currentRoute()
|
|
5034
|
+
expect(route.search).toEqual({ page: 3, q: 'hello' })
|
|
5035
|
+
router.destroy()
|
|
5036
|
+
})
|
|
5037
|
+
|
|
5038
|
+
test('validateSearch defaults when params missing', () => {
|
|
5039
|
+
const vsRoutes: RouteRecord[] = [
|
|
5040
|
+
{
|
|
5041
|
+
path: '/search',
|
|
5042
|
+
component: Home,
|
|
5043
|
+
validateSearch: (raw) => ({
|
|
5044
|
+
page: Number(raw.page) || 1,
|
|
5045
|
+
q: raw.q ?? '',
|
|
5046
|
+
}),
|
|
5047
|
+
},
|
|
5048
|
+
]
|
|
5049
|
+
const router = createRouter({ routes: vsRoutes, url: '/search' })
|
|
5050
|
+
const route = router.currentRoute()
|
|
5051
|
+
expect(route.search).toEqual({ page: 1, q: '' })
|
|
5052
|
+
router.destroy()
|
|
5053
|
+
})
|
|
5054
|
+
|
|
5055
|
+
test('validateSearch error falls back to raw query', () => {
|
|
5056
|
+
const vsRoutes: RouteRecord[] = [
|
|
5057
|
+
{
|
|
5058
|
+
path: '/broken',
|
|
5059
|
+
component: Home,
|
|
5060
|
+
validateSearch: () => { throw new Error('schema fail') },
|
|
5061
|
+
},
|
|
5062
|
+
]
|
|
5063
|
+
const router = createRouter({ routes: vsRoutes, url: '/broken?foo=bar' })
|
|
5064
|
+
const route = router.currentRoute()
|
|
5065
|
+
expect(route.search).toEqual({ foo: 'bar' })
|
|
5066
|
+
router.destroy()
|
|
5067
|
+
})
|
|
5068
|
+
|
|
5069
|
+
test('routes without validateSearch have empty search', () => {
|
|
5070
|
+
const router = createRouter({ routes, url: '/about' })
|
|
5071
|
+
const route = router.currentRoute()
|
|
5072
|
+
expect(route.search).toEqual({})
|
|
5073
|
+
router.destroy()
|
|
5074
|
+
})
|
|
5075
|
+
|
|
5076
|
+
test('useValidatedSearch returns typed accessor with structural sharing', () => {
|
|
5077
|
+
const vsRoutes: RouteRecord[] = [
|
|
5078
|
+
{
|
|
5079
|
+
path: '/items',
|
|
5080
|
+
component: Home,
|
|
5081
|
+
validateSearch: (raw) => ({
|
|
5082
|
+
page: Number(raw.page) || 1,
|
|
5083
|
+
}),
|
|
5084
|
+
},
|
|
5085
|
+
]
|
|
5086
|
+
const router = createRouter({ routes: vsRoutes, url: '/items?page=5' })
|
|
5087
|
+
const ctr = document.createElement('div')
|
|
5088
|
+
let searchResult: Record<string, unknown> | undefined
|
|
5089
|
+
const TestComp = () => {
|
|
5090
|
+
const search = useValidatedSearch<{ page: number }>()
|
|
5091
|
+
searchResult = search()
|
|
5092
|
+
return null
|
|
5093
|
+
}
|
|
5094
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
5095
|
+
expect(searchResult).toEqual({ page: 5 })
|
|
5096
|
+
router.destroy()
|
|
5097
|
+
})
|
|
5098
|
+
})
|
|
5099
|
+
|
|
5100
|
+
// ─── Loader cache ───────────────────────────────────────────────────────────
|
|
5101
|
+
|
|
5102
|
+
describe('loader cache', () => {
|
|
5103
|
+
test('cached loader data is reused on second navigation (same params)', async () => {
|
|
5104
|
+
let callCount = 0
|
|
5105
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5106
|
+
{ path: '/', component: Home },
|
|
5107
|
+
{
|
|
5108
|
+
path: '/user/:id',
|
|
5109
|
+
component: Home,
|
|
5110
|
+
loader: async ({ params }) => {
|
|
5111
|
+
callCount++
|
|
5112
|
+
return { id: params.id, name: `User ${params.id}` }
|
|
5113
|
+
},
|
|
5114
|
+
},
|
|
5115
|
+
]
|
|
5116
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5117
|
+
|
|
5118
|
+
await router.push('/user/42')
|
|
5119
|
+
expect(callCount).toBe(1)
|
|
5120
|
+
|
|
5121
|
+
// Navigate away then back — same params, cache hit
|
|
5122
|
+
await router.push('/')
|
|
5123
|
+
await router.push('/user/42')
|
|
5124
|
+
expect(callCount).toBe(1) // NOT called again — cache hit
|
|
5125
|
+
|
|
5126
|
+
router.destroy()
|
|
5127
|
+
})
|
|
5128
|
+
|
|
5129
|
+
test('different params produce different cache keys → loader runs again', async () => {
|
|
5130
|
+
let callCount = 0
|
|
5131
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5132
|
+
{ path: '/', component: Home },
|
|
5133
|
+
{
|
|
5134
|
+
path: '/user/:id',
|
|
5135
|
+
component: Home,
|
|
5136
|
+
loader: async () => { callCount++; return {} },
|
|
5137
|
+
},
|
|
5138
|
+
]
|
|
5139
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5140
|
+
|
|
5141
|
+
await router.push('/user/1')
|
|
5142
|
+
expect(callCount).toBe(1)
|
|
5143
|
+
|
|
5144
|
+
await router.push('/user/2')
|
|
5145
|
+
expect(callCount).toBe(2) // different key → fresh load
|
|
5146
|
+
|
|
5147
|
+
router.destroy()
|
|
5148
|
+
})
|
|
5149
|
+
|
|
5150
|
+
test('custom loaderKey controls cache identity', async () => {
|
|
5151
|
+
let callCount = 0
|
|
5152
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5153
|
+
{ path: '/', component: Home },
|
|
5154
|
+
{
|
|
5155
|
+
path: '/items',
|
|
5156
|
+
component: Home,
|
|
5157
|
+
loaderKey: ({ query }) => `items-page-${query.page ?? '1'}`,
|
|
5158
|
+
loader: async () => { callCount++; return [] },
|
|
5159
|
+
},
|
|
5160
|
+
]
|
|
5161
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5162
|
+
|
|
5163
|
+
await router.push('/items?page=1')
|
|
5164
|
+
expect(callCount).toBe(1)
|
|
5165
|
+
|
|
5166
|
+
// Same page → cache hit
|
|
5167
|
+
await router.push('/')
|
|
5168
|
+
await router.push('/items?page=1')
|
|
5169
|
+
expect(callCount).toBe(1)
|
|
5170
|
+
|
|
5171
|
+
// Different page → cache miss
|
|
5172
|
+
await router.push('/items?page=2')
|
|
5173
|
+
expect(callCount).toBe(2)
|
|
5174
|
+
|
|
5175
|
+
router.destroy()
|
|
5176
|
+
})
|
|
5177
|
+
|
|
5178
|
+
test('gcTime=0 disables caching', async () => {
|
|
5179
|
+
let callCount = 0
|
|
5180
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5181
|
+
{ path: '/', component: Home },
|
|
5182
|
+
{
|
|
5183
|
+
path: '/data',
|
|
5184
|
+
component: Home,
|
|
5185
|
+
gcTime: 0,
|
|
5186
|
+
loader: async () => { callCount++; return {} },
|
|
5187
|
+
},
|
|
5188
|
+
]
|
|
5189
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5190
|
+
|
|
5191
|
+
await router.push('/data')
|
|
5192
|
+
expect(callCount).toBe(1)
|
|
5193
|
+
|
|
5194
|
+
await router.push('/')
|
|
5195
|
+
await router.push('/data')
|
|
5196
|
+
expect(callCount).toBe(2) // no cache — always runs
|
|
5197
|
+
|
|
5198
|
+
router.destroy()
|
|
5199
|
+
})
|
|
5200
|
+
|
|
5201
|
+
test('invalidateLoader() clears cache', async () => {
|
|
5202
|
+
let callCount = 0
|
|
5203
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5204
|
+
{ path: '/', component: Home },
|
|
5205
|
+
{
|
|
5206
|
+
path: '/data',
|
|
5207
|
+
component: Home,
|
|
5208
|
+
loader: async () => { callCount++; return {} },
|
|
5209
|
+
},
|
|
5210
|
+
]
|
|
5211
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5212
|
+
|
|
5213
|
+
await router.push('/data')
|
|
5214
|
+
expect(callCount).toBe(1)
|
|
5215
|
+
|
|
5216
|
+
// Invalidate all
|
|
5217
|
+
router.invalidateLoader()
|
|
5218
|
+
|
|
5219
|
+
await router.push('/')
|
|
5220
|
+
await router.push('/data')
|
|
5221
|
+
expect(callCount).toBe(2) // cache was cleared → runs again
|
|
5222
|
+
|
|
5223
|
+
router.destroy()
|
|
5224
|
+
})
|
|
5225
|
+
|
|
5226
|
+
test('invalidateLoader(key) clears specific cache entry', async () => {
|
|
5227
|
+
let dataCallCount = 0
|
|
5228
|
+
let itemsCallCount = 0
|
|
5229
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5230
|
+
{ path: '/', component: Home },
|
|
5231
|
+
{
|
|
5232
|
+
path: '/data',
|
|
5233
|
+
component: Home,
|
|
5234
|
+
loaderKey: () => 'data-key',
|
|
5235
|
+
loader: async () => { dataCallCount++; return {} },
|
|
5236
|
+
},
|
|
5237
|
+
{
|
|
5238
|
+
path: '/items',
|
|
5239
|
+
component: Home,
|
|
5240
|
+
loaderKey: () => 'items-key',
|
|
5241
|
+
loader: async () => { itemsCallCount++; return [] },
|
|
5242
|
+
},
|
|
5243
|
+
]
|
|
5244
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5245
|
+
|
|
5246
|
+
await router.push('/data')
|
|
5247
|
+
await router.push('/items')
|
|
5248
|
+
expect(dataCallCount).toBe(1)
|
|
5249
|
+
expect(itemsCallCount).toBe(1)
|
|
5250
|
+
|
|
5251
|
+
// Invalidate only 'data-key'
|
|
5252
|
+
router.invalidateLoader('data-key')
|
|
5253
|
+
|
|
5254
|
+
await router.push('/data')
|
|
5255
|
+
await router.push('/items')
|
|
5256
|
+
expect(dataCallCount).toBe(2) // re-fetched
|
|
5257
|
+
expect(itemsCallCount).toBe(1) // still cached
|
|
5258
|
+
|
|
5259
|
+
router.destroy()
|
|
5260
|
+
})
|
|
5261
|
+
})
|
|
5262
|
+
|
|
5263
|
+
// ─── redirect() — loader-thrown redirects propagate through the navigate flow
|
|
5264
|
+
|
|
5265
|
+
describe('redirect() from loader', () => {
|
|
5266
|
+
test('throws and triggers router.replace to the target path', async () => {
|
|
5267
|
+
const Home = () => h('div', null, 'home')
|
|
5268
|
+
const Login = () => h('div', null, 'login')
|
|
5269
|
+
const Protected = () => h('div', null, 'protected')
|
|
5270
|
+
|
|
5271
|
+
const routes: RouteRecord[] = [
|
|
5272
|
+
{ path: '/', component: Home },
|
|
5273
|
+
{ path: '/login', component: Login },
|
|
5274
|
+
{
|
|
5275
|
+
path: '/app',
|
|
5276
|
+
component: Protected,
|
|
5277
|
+
loader: async () => {
|
|
5278
|
+
// No session — redirect to /login
|
|
5279
|
+
redirect('/login')
|
|
5280
|
+
},
|
|
5281
|
+
},
|
|
5282
|
+
]
|
|
5283
|
+
|
|
5284
|
+
const router = createRouter({ routes, url: '/' })
|
|
5285
|
+
await router.push('/app')
|
|
5286
|
+
|
|
5287
|
+
expect(router.currentRoute().path).toBe('/login')
|
|
5288
|
+
router.destroy()
|
|
5289
|
+
})
|
|
5290
|
+
|
|
5291
|
+
test("doesn't fire when loader resolves normally", async () => {
|
|
5292
|
+
const Home = () => h('div', null, 'home')
|
|
5293
|
+
const Protected = () => h('div', null, 'protected')
|
|
5294
|
+
|
|
5295
|
+
let loaderRan = false
|
|
5296
|
+
const routes: RouteRecord[] = [
|
|
5297
|
+
{ path: '/', component: Home },
|
|
5298
|
+
{
|
|
5299
|
+
path: '/app',
|
|
5300
|
+
component: Protected,
|
|
5301
|
+
loader: async () => {
|
|
5302
|
+
loaderRan = true
|
|
5303
|
+
return { user: 'me' }
|
|
5304
|
+
},
|
|
5305
|
+
},
|
|
5306
|
+
]
|
|
5307
|
+
|
|
5308
|
+
const router = createRouter({ routes, url: '/' })
|
|
5309
|
+
await router.push('/app')
|
|
5310
|
+
|
|
5311
|
+
expect(loaderRan).toBe(true)
|
|
5312
|
+
expect(router.currentRoute().path).toBe('/app')
|
|
5313
|
+
router.destroy()
|
|
5314
|
+
})
|
|
5315
|
+
|
|
5316
|
+
test('parent layout loader can redirect, blocking the child page', async () => {
|
|
5317
|
+
const Home = () => h('div', null, 'home')
|
|
5318
|
+
const Login = () => h('div', null, 'login')
|
|
5319
|
+
const Layout = () => h('div', null, 'layout')
|
|
5320
|
+
const Page = () => h('div', null, 'page')
|
|
5321
|
+
|
|
5322
|
+
const routes: RouteRecord[] = [
|
|
5323
|
+
{ path: '/', component: Home },
|
|
5324
|
+
{ path: '/login', component: Login },
|
|
5325
|
+
{
|
|
5326
|
+
path: '/app',
|
|
5327
|
+
component: Layout,
|
|
5328
|
+
loader: async () => {
|
|
5329
|
+
redirect('/login')
|
|
5330
|
+
},
|
|
5331
|
+
children: [
|
|
5332
|
+
{
|
|
5333
|
+
// Relative path — `createRouter` joins it with the parent. fs-router
|
|
5334
|
+
// emits absolute paths instead, but the routing semantics at runtime
|
|
5335
|
+
// are identical via `resolveRoute`.
|
|
5336
|
+
path: 'dashboard',
|
|
5337
|
+
component: Page,
|
|
5338
|
+
loader: async () => ({ data: 'page-data' }),
|
|
5339
|
+
},
|
|
5340
|
+
],
|
|
5341
|
+
},
|
|
5342
|
+
]
|
|
5343
|
+
|
|
5344
|
+
const router = createRouter({ routes, url: '/' })
|
|
5345
|
+
await router.push('/app/dashboard')
|
|
5346
|
+
|
|
5347
|
+
// The contract: navigation lands on the redirect target, NOT the child page.
|
|
5348
|
+
expect(router.currentRoute().path).toBe('/login')
|
|
5349
|
+
router.destroy()
|
|
5350
|
+
})
|
|
5351
|
+
|
|
5352
|
+
test('redirect() with custom status preserves the status on the thrown error', async () => {
|
|
5353
|
+
// The router doesn't surface the status itself (it just calls .replace),
|
|
5354
|
+
// but the SSR handler reads it via getRedirectInfo. Verify the throw path
|
|
5355
|
+
// captures the status the same way the helper unit tests do.
|
|
5356
|
+
const Home = () => h('div', null, 'home')
|
|
5357
|
+
const Login = () => h('div', null, 'login')
|
|
5358
|
+
|
|
5359
|
+
const routes: RouteRecord[] = [
|
|
5360
|
+
{ path: '/', component: Home },
|
|
5361
|
+
{ path: '/login', component: Login },
|
|
5362
|
+
{
|
|
5363
|
+
path: '/old',
|
|
5364
|
+
component: Home,
|
|
5365
|
+
loader: async () => {
|
|
5366
|
+
redirect('/login', 308)
|
|
5367
|
+
},
|
|
5368
|
+
},
|
|
5369
|
+
]
|
|
5370
|
+
|
|
5371
|
+
const router = createRouter({ routes, url: '/' })
|
|
5372
|
+
await router.push('/old')
|
|
5373
|
+
expect(router.currentRoute().path).toBe('/login')
|
|
5374
|
+
router.destroy()
|
|
5375
|
+
})
|
|
5376
|
+
|
|
5377
|
+
test('redirect from prefetchLoaderData propagates as a thrown RedirectError (SSR contract)', async () => {
|
|
5378
|
+
const Home = () => h('div', null, 'home')
|
|
5379
|
+
const Protected = () => h('div', null, 'protected')
|
|
5380
|
+
|
|
5381
|
+
const routes: RouteRecord[] = [
|
|
5382
|
+
{ path: '/', component: Home },
|
|
5383
|
+
{
|
|
5384
|
+
path: '/app',
|
|
5385
|
+
component: Protected,
|
|
5386
|
+
loader: async () => {
|
|
5387
|
+
redirect('/login')
|
|
5388
|
+
},
|
|
5389
|
+
},
|
|
5390
|
+
]
|
|
5391
|
+
|
|
5392
|
+
const router = createRouter({ routes, url: '/' })
|
|
5393
|
+
|
|
5394
|
+
let caught: unknown
|
|
5395
|
+
try {
|
|
5396
|
+
await prefetchLoaderData(router as never, '/app')
|
|
5397
|
+
} catch (err) {
|
|
5398
|
+
caught = err
|
|
5399
|
+
}
|
|
5400
|
+
|
|
5401
|
+
// The SSR handler catches this thrown error and converts it to a 302/307.
|
|
5402
|
+
// The contract here is just "the throw propagates" — the conversion is
|
|
5403
|
+
// tested in the server package's handler tests.
|
|
5404
|
+
expect(caught).toBeDefined()
|
|
5405
|
+
const info = getRedirectInfo(caught)
|
|
5406
|
+
expect(info?.url).toBe('/login')
|
|
5407
|
+
router.destroy()
|
|
5408
|
+
})
|
|
5409
|
+
})
|