@pyreon/router 0.13.1 → 0.14.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 +309 -21
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +138 -8
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/components.tsx +139 -7
- package/src/index.ts +3 -0
- package/src/loader.ts +6 -0
- package/src/match.ts +36 -7
- package/src/not-found.ts +75 -0
- package/src/router.ts +179 -21
- package/src/tests/match.test.ts +31 -0
- package/src/tests/router.test.ts +537 -1
- package/src/types.ts +72 -0
package/src/tests/router.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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'
|
|
4
5
|
import {
|
|
5
6
|
createRouter,
|
|
6
7
|
hydrateLoaderData,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
useSearchParams,
|
|
22
23
|
useTransition,
|
|
23
24
|
useTypedSearchParams,
|
|
25
|
+
useValidatedSearch,
|
|
24
26
|
} from '../index'
|
|
25
27
|
import type { RouteMiddleware } from '../index'
|
|
26
28
|
import {
|
|
@@ -358,10 +360,13 @@ describe('router navigation', () => {
|
|
|
358
360
|
expect(router.currentRoute().query.q).toBe('hello')
|
|
359
361
|
})
|
|
360
362
|
|
|
361
|
-
test('push with unknown named route falls back to /', async () => {
|
|
363
|
+
test('push with unknown named route falls back to / and warns', async () => {
|
|
364
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
362
365
|
const router = createRouter({ routes, url: '/about' })
|
|
363
366
|
await router.push({ name: 'nonexistent' })
|
|
364
367
|
expect(router.currentRoute().path).toBe('/')
|
|
368
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown route name "nonexistent"'))
|
|
369
|
+
warnSpy.mockRestore()
|
|
365
370
|
})
|
|
366
371
|
|
|
367
372
|
test('back() does not throw in SSR (no window)', () => {
|
|
@@ -4267,6 +4272,21 @@ describe('useTypedSearchParams', () => {
|
|
|
4267
4272
|
router.destroy()
|
|
4268
4273
|
})
|
|
4269
4274
|
|
|
4275
|
+
it('guards NaN: non-numeric string coerces to 0 instead of NaN', () => {
|
|
4276
|
+
const router = createRouter({ routes: tspRoutes, url: '/search?page=abc' })
|
|
4277
|
+
const ctr = document.createElement('div')
|
|
4278
|
+
let result: { page: number } | undefined
|
|
4279
|
+
const TestComp = () => {
|
|
4280
|
+
const [params] = useTypedSearchParams({ page: 'number' })
|
|
4281
|
+
result = params() as { page: number }
|
|
4282
|
+
return null
|
|
4283
|
+
}
|
|
4284
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
4285
|
+
expect(result!.page).toBe(0) // not NaN
|
|
4286
|
+
expect(Number.isNaN(result!.page)).toBe(false)
|
|
4287
|
+
router.destroy()
|
|
4288
|
+
})
|
|
4289
|
+
|
|
4270
4290
|
it('defaults boolean to false when missing from URL', () => {
|
|
4271
4291
|
const router = createRouter({ routes: tspRoutes, url: '/search' })
|
|
4272
4292
|
const ctr = document.createElement('div')
|
|
@@ -4722,3 +4742,519 @@ describe('View Transitions API', () => {
|
|
|
4722
4742
|
router.destroy()
|
|
4723
4743
|
})
|
|
4724
4744
|
})
|
|
4745
|
+
|
|
4746
|
+
// ─── Type-level tests for Router<TNames> ──────────────────────────────────────
|
|
4747
|
+
|
|
4748
|
+
describe('Router<TNames> type safety', () => {
|
|
4749
|
+
test('createRouter<TNames> returns typed router', () => {
|
|
4750
|
+
type Names = 'home' | 'about' | 'user'
|
|
4751
|
+
const router = createRouter<Names>({
|
|
4752
|
+
routes: [
|
|
4753
|
+
{ path: '/', name: 'home', component: Home },
|
|
4754
|
+
{ path: '/about', name: 'about', component: Home },
|
|
4755
|
+
{ path: '/user/:id', name: 'user', component: Home },
|
|
4756
|
+
],
|
|
4757
|
+
})
|
|
4758
|
+
|
|
4759
|
+
// This compiles — valid name
|
|
4760
|
+
router.push({ name: 'home' })
|
|
4761
|
+
router.push({ name: 'about' })
|
|
4762
|
+
router.push({ name: 'user', params: { id: '1' } })
|
|
4763
|
+
|
|
4764
|
+
// Type assertion: the router accepts the union type
|
|
4765
|
+
const _typeCheck: Names = 'home'
|
|
4766
|
+
void _typeCheck
|
|
4767
|
+
|
|
4768
|
+
router.destroy()
|
|
4769
|
+
})
|
|
4770
|
+
|
|
4771
|
+
test('_middlewareData is typed on ResolvedRoute', () => {
|
|
4772
|
+
const route: ResolvedRoute = {
|
|
4773
|
+
path: '/',
|
|
4774
|
+
params: {},
|
|
4775
|
+
query: {},
|
|
4776
|
+
hash: '',
|
|
4777
|
+
matched: [],
|
|
4778
|
+
meta: {},
|
|
4779
|
+
_middlewareData: { user: { name: 'Alice' } },
|
|
4780
|
+
}
|
|
4781
|
+
expect(route._middlewareData?.user).toEqual({ name: 'Alice' })
|
|
4782
|
+
})
|
|
4783
|
+
})
|
|
4784
|
+
|
|
4785
|
+
// ─── notFound() + isNotFoundError ─────────────────────────────────────────────
|
|
4786
|
+
|
|
4787
|
+
describe('notFound()', () => {
|
|
4788
|
+
|
|
4789
|
+
test('throws an Error with NOT_FOUND brand', () => {
|
|
4790
|
+
expect(() => notFound()).toThrow('Not Found')
|
|
4791
|
+
expect(() => notFound('User not found')).toThrow('User not found')
|
|
4792
|
+
})
|
|
4793
|
+
|
|
4794
|
+
test('isNotFoundError detects branded errors', () => {
|
|
4795
|
+
try {
|
|
4796
|
+
notFound('test')
|
|
4797
|
+
} catch (err) {
|
|
4798
|
+
expect(isNotFoundError(err)).toBe(true)
|
|
4799
|
+
expect(err instanceof Error).toBe(true)
|
|
4800
|
+
}
|
|
4801
|
+
})
|
|
4802
|
+
|
|
4803
|
+
test('isNotFoundError returns false for regular errors', () => {
|
|
4804
|
+
expect(isNotFoundError(new Error('regular'))).toBe(false)
|
|
4805
|
+
expect(isNotFoundError(null)).toBe(false)
|
|
4806
|
+
expect(isNotFoundError('string')).toBe(false)
|
|
4807
|
+
})
|
|
4808
|
+
})
|
|
4809
|
+
|
|
4810
|
+
// ─── Prefetch intent mode ────────────────────────────────────────────────────
|
|
4811
|
+
|
|
4812
|
+
describe('RouterLink prefetch="intent"', () => {
|
|
4813
|
+
test('focus triggers prefetch in intent mode (default)', async () => {
|
|
4814
|
+
const loaderCalled = vi.fn().mockResolvedValue({ data: 'prefetched' })
|
|
4815
|
+
const intRoutes: RouteRecord[] = [
|
|
4816
|
+
{ path: '/', component: Home },
|
|
4817
|
+
{ path: '/target', component: Home, loader: loaderCalled },
|
|
4818
|
+
]
|
|
4819
|
+
const router = createRouter({ routes: intRoutes, url: '/' })
|
|
4820
|
+
const ctr = document.createElement('div')
|
|
4821
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: '/target' })), ctr)
|
|
4822
|
+
|
|
4823
|
+
const link = ctr.querySelector('a')!
|
|
4824
|
+
// Focus triggers prefetch in intent mode
|
|
4825
|
+
link.dispatchEvent(new Event('focus', { bubbles: true }))
|
|
4826
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4827
|
+
|
|
4828
|
+
expect(loaderCalled).toHaveBeenCalled()
|
|
4829
|
+
router.destroy()
|
|
4830
|
+
})
|
|
4831
|
+
|
|
4832
|
+
test('focus does NOT trigger prefetch in "none" mode', async () => {
|
|
4833
|
+
const loaderCalled = vi.fn().mockResolvedValue({ data: 'x' })
|
|
4834
|
+
const intRoutes: RouteRecord[] = [
|
|
4835
|
+
{ path: '/', component: Home },
|
|
4836
|
+
{ path: '/target', component: Home, loader: loaderCalled },
|
|
4837
|
+
]
|
|
4838
|
+
const router = createRouter({ routes: intRoutes, url: '/' })
|
|
4839
|
+
const ctr = document.createElement('div')
|
|
4840
|
+
mount(h(RouterProvider, { router }, h(RouterLink, { to: '/target', prefetch: 'none' })), ctr)
|
|
4841
|
+
|
|
4842
|
+
const link = ctr.querySelector('a')!
|
|
4843
|
+
link.dispatchEvent(new Event('focus', { bubbles: true }))
|
|
4844
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4845
|
+
|
|
4846
|
+
expect(loaderCalled).not.toHaveBeenCalled()
|
|
4847
|
+
router.destroy()
|
|
4848
|
+
})
|
|
4849
|
+
})
|
|
4850
|
+
|
|
4851
|
+
// ─── NotFoundBoundary e2e ────────────────────────────────────────────────────
|
|
4852
|
+
|
|
4853
|
+
describe('NotFoundBoundary e2e', () => {
|
|
4854
|
+
test('notFound() in component triggers NotFoundBoundary fallback', () => {
|
|
4855
|
+
const ThrowNotFound = () => {
|
|
4856
|
+
notFound('User not found')
|
|
4857
|
+
return null // unreachable
|
|
4858
|
+
}
|
|
4859
|
+
const nfRoutes: RouteRecord[] = [
|
|
4860
|
+
{ path: '/missing', component: ThrowNotFound },
|
|
4861
|
+
]
|
|
4862
|
+
const router = createRouter({ routes: nfRoutes, url: '/missing' })
|
|
4863
|
+
const ctr = document.createElement('div')
|
|
4864
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
4865
|
+
|
|
4866
|
+
mount(
|
|
4867
|
+
h(RouterProvider, { router },
|
|
4868
|
+
h(NotFoundBoundary, {
|
|
4869
|
+
fallback: h('div', { id: 'not-found' }, '404 Not Found'),
|
|
4870
|
+
}, h(RouterView, {})),
|
|
4871
|
+
),
|
|
4872
|
+
ctr,
|
|
4873
|
+
)
|
|
4874
|
+
|
|
4875
|
+
expect(ctr.querySelector('#not-found')?.textContent).toBe('404 Not Found')
|
|
4876
|
+
errorSpy.mockRestore()
|
|
4877
|
+
router.destroy()
|
|
4878
|
+
})
|
|
4879
|
+
})
|
|
4880
|
+
|
|
4881
|
+
// ─── pendingComponent e2e ────────────────────────────────────────────────────
|
|
4882
|
+
|
|
4883
|
+
describe('pendingComponent', () => {
|
|
4884
|
+
test('pendingComponent shows immediately when pendingMs=0', async () => {
|
|
4885
|
+
let resolveLoader: (v: unknown) => void
|
|
4886
|
+
const loaderPromise = new Promise((r) => { resolveLoader = r })
|
|
4887
|
+
const PendingComp = () => h('div', { id: 'pending' }, 'Loading...')
|
|
4888
|
+
const RealComp = () => h('div', { id: 'real' }, 'Loaded')
|
|
4889
|
+
|
|
4890
|
+
const pendRoutes: RouteRecord[] = [
|
|
4891
|
+
{
|
|
4892
|
+
path: '/slow',
|
|
4893
|
+
component: RealComp,
|
|
4894
|
+
loader: () => loaderPromise,
|
|
4895
|
+
pendingComponent: PendingComp,
|
|
4896
|
+
pendingMs: 0,
|
|
4897
|
+
pendingMinMs: 0,
|
|
4898
|
+
},
|
|
4899
|
+
]
|
|
4900
|
+
const router = createRouter({ routes: pendRoutes, url: '/slow' })
|
|
4901
|
+
const ctr = document.createElement('div')
|
|
4902
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), ctr)
|
|
4903
|
+
|
|
4904
|
+
// Pending should show immediately
|
|
4905
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4906
|
+
expect(ctr.querySelector('#pending')).not.toBeNull()
|
|
4907
|
+
|
|
4908
|
+
// Resolve the loader
|
|
4909
|
+
resolveLoader!({ data: 'done' })
|
|
4910
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4911
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4912
|
+
|
|
4913
|
+
router.destroy()
|
|
4914
|
+
})
|
|
4915
|
+
|
|
4916
|
+
test('pendingMinMs keeps pending visible even after loader resolves', async () => {
|
|
4917
|
+
let resolveLoader: (v: unknown) => void
|
|
4918
|
+
const loaderPromise = new Promise((r) => { resolveLoader = r })
|
|
4919
|
+
const PendingComp = () => h('div', { id: 'pending-min' }, 'Loading...')
|
|
4920
|
+
const RealComp = () => h('div', { id: 'real-min' }, 'Loaded')
|
|
4921
|
+
|
|
4922
|
+
const pendRoutes: RouteRecord[] = [
|
|
4923
|
+
{
|
|
4924
|
+
path: '/mintime',
|
|
4925
|
+
component: RealComp,
|
|
4926
|
+
loader: () => loaderPromise,
|
|
4927
|
+
pendingComponent: PendingComp,
|
|
4928
|
+
pendingMs: 0, // show pending immediately
|
|
4929
|
+
pendingMinMs: 300, // keep for at least 300ms
|
|
4930
|
+
},
|
|
4931
|
+
]
|
|
4932
|
+
const router = createRouter({ routes: pendRoutes, url: '/mintime' })
|
|
4933
|
+
const ctr = document.createElement('div')
|
|
4934
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), ctr)
|
|
4935
|
+
|
|
4936
|
+
// Pending should show immediately
|
|
4937
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4938
|
+
expect(ctr.querySelector('#pending-min')).not.toBeNull()
|
|
4939
|
+
|
|
4940
|
+
// Resolve loader quickly (after ~50ms — well before pendingMinMs)
|
|
4941
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4942
|
+
resolveLoader!({ data: 'done' })
|
|
4943
|
+
|
|
4944
|
+
// Trigger reactive update
|
|
4945
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4946
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4947
|
+
|
|
4948
|
+
// Pending should STILL be visible — pendingMinMs hasn't elapsed
|
|
4949
|
+
expect(ctr.querySelector('#pending-min')).not.toBeNull()
|
|
4950
|
+
expect(ctr.querySelector('#real-min')).toBeNull()
|
|
4951
|
+
|
|
4952
|
+
// Wait for pendingMinMs to elapse
|
|
4953
|
+
await new Promise<void>((r) => setTimeout(r, 300))
|
|
4954
|
+
|
|
4955
|
+
// Now the real component should show
|
|
4956
|
+
// (need to trigger a reactive re-render — read loadingSignal)
|
|
4957
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4958
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4959
|
+
|
|
4960
|
+
// The pending phase should have transitioned to ready
|
|
4961
|
+
// Note: the signal-based state machine sets phase to 'ready' after minTimer fires
|
|
4962
|
+
router.destroy()
|
|
4963
|
+
}, 10000)
|
|
4964
|
+
|
|
4965
|
+
test('pendingMs delays showing pending — data arriving before delay skips pending', async () => {
|
|
4966
|
+
let resolveLoader: (v: unknown) => void
|
|
4967
|
+
const loaderPromise = new Promise((r) => { resolveLoader = r })
|
|
4968
|
+
const PendingComp = () => h('div', { id: 'pending-delay' }, 'Loading...')
|
|
4969
|
+
const RealComp = () => h('div', { id: 'real-delay' }, 'Loaded')
|
|
4970
|
+
|
|
4971
|
+
const pendRoutes: RouteRecord[] = [
|
|
4972
|
+
{
|
|
4973
|
+
path: '/delayed',
|
|
4974
|
+
component: RealComp,
|
|
4975
|
+
loader: () => loaderPromise,
|
|
4976
|
+
pendingComponent: PendingComp,
|
|
4977
|
+
pendingMs: 500, // wait 500ms before showing pending
|
|
4978
|
+
pendingMinMs: 0,
|
|
4979
|
+
},
|
|
4980
|
+
]
|
|
4981
|
+
const router = createRouter({ routes: pendRoutes, url: '/delayed' })
|
|
4982
|
+
const ctr = document.createElement('div')
|
|
4983
|
+
mount(h(RouterProvider, { router }, h(RouterView, {})), ctr)
|
|
4984
|
+
|
|
4985
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4986
|
+
|
|
4987
|
+
// Before pendingMs: nothing should show (hidden phase)
|
|
4988
|
+
expect(ctr.querySelector('#pending-delay')).toBeNull()
|
|
4989
|
+
|
|
4990
|
+
// Resolve before pendingMs elapses — should skip pending entirely
|
|
4991
|
+
resolveLoader!({ data: 'fast' })
|
|
4992
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
4993
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
4994
|
+
|
|
4995
|
+
// Pending should never have appeared
|
|
4996
|
+
expect(ctr.querySelector('#pending-delay')).toBeNull()
|
|
4997
|
+
|
|
4998
|
+
router.destroy()
|
|
4999
|
+
})
|
|
5000
|
+
|
|
5001
|
+
test('pendingComponent type accepted on RouteRecord', () => {
|
|
5002
|
+
const PendingComp = () => h('div', null, 'Loading...')
|
|
5003
|
+
const routeWithPending: RouteRecord = {
|
|
5004
|
+
path: '/slow',
|
|
5005
|
+
component: Home,
|
|
5006
|
+
loader: async () => ({}),
|
|
5007
|
+
pendingComponent: PendingComp,
|
|
5008
|
+
pendingMs: 200,
|
|
5009
|
+
pendingMinMs: 500,
|
|
5010
|
+
}
|
|
5011
|
+
expect(routeWithPending.pendingComponent).toBe(PendingComp)
|
|
5012
|
+
expect(routeWithPending.pendingMs).toBe(200)
|
|
5013
|
+
expect(routeWithPending.pendingMinMs).toBe(500)
|
|
5014
|
+
})
|
|
5015
|
+
})
|
|
5016
|
+
|
|
5017
|
+
// ─── validateSearch + useValidatedSearch ──────────────────────────────────────
|
|
5018
|
+
|
|
5019
|
+
describe('validateSearch', () => {
|
|
5020
|
+
test('resolveRoute runs validateSearch on matched route', () => {
|
|
5021
|
+
const vsRoutes: RouteRecord[] = [
|
|
5022
|
+
{
|
|
5023
|
+
path: '/search',
|
|
5024
|
+
component: Home,
|
|
5025
|
+
validateSearch: (raw) => ({
|
|
5026
|
+
page: Number(raw.page) || 1,
|
|
5027
|
+
q: raw.q ?? '',
|
|
5028
|
+
}),
|
|
5029
|
+
},
|
|
5030
|
+
]
|
|
5031
|
+
const router = createRouter({ routes: vsRoutes, url: '/search?page=3&q=hello' })
|
|
5032
|
+
const route = router.currentRoute()
|
|
5033
|
+
expect(route.search).toEqual({ page: 3, q: 'hello' })
|
|
5034
|
+
router.destroy()
|
|
5035
|
+
})
|
|
5036
|
+
|
|
5037
|
+
test('validateSearch defaults when params missing', () => {
|
|
5038
|
+
const vsRoutes: RouteRecord[] = [
|
|
5039
|
+
{
|
|
5040
|
+
path: '/search',
|
|
5041
|
+
component: Home,
|
|
5042
|
+
validateSearch: (raw) => ({
|
|
5043
|
+
page: Number(raw.page) || 1,
|
|
5044
|
+
q: raw.q ?? '',
|
|
5045
|
+
}),
|
|
5046
|
+
},
|
|
5047
|
+
]
|
|
5048
|
+
const router = createRouter({ routes: vsRoutes, url: '/search' })
|
|
5049
|
+
const route = router.currentRoute()
|
|
5050
|
+
expect(route.search).toEqual({ page: 1, q: '' })
|
|
5051
|
+
router.destroy()
|
|
5052
|
+
})
|
|
5053
|
+
|
|
5054
|
+
test('validateSearch error falls back to raw query', () => {
|
|
5055
|
+
const vsRoutes: RouteRecord[] = [
|
|
5056
|
+
{
|
|
5057
|
+
path: '/broken',
|
|
5058
|
+
component: Home,
|
|
5059
|
+
validateSearch: () => { throw new Error('schema fail') },
|
|
5060
|
+
},
|
|
5061
|
+
]
|
|
5062
|
+
const router = createRouter({ routes: vsRoutes, url: '/broken?foo=bar' })
|
|
5063
|
+
const route = router.currentRoute()
|
|
5064
|
+
expect(route.search).toEqual({ foo: 'bar' })
|
|
5065
|
+
router.destroy()
|
|
5066
|
+
})
|
|
5067
|
+
|
|
5068
|
+
test('routes without validateSearch have empty search', () => {
|
|
5069
|
+
const router = createRouter({ routes, url: '/about' })
|
|
5070
|
+
const route = router.currentRoute()
|
|
5071
|
+
expect(route.search).toEqual({})
|
|
5072
|
+
router.destroy()
|
|
5073
|
+
})
|
|
5074
|
+
|
|
5075
|
+
test('useValidatedSearch returns typed accessor with structural sharing', () => {
|
|
5076
|
+
const vsRoutes: RouteRecord[] = [
|
|
5077
|
+
{
|
|
5078
|
+
path: '/items',
|
|
5079
|
+
component: Home,
|
|
5080
|
+
validateSearch: (raw) => ({
|
|
5081
|
+
page: Number(raw.page) || 1,
|
|
5082
|
+
}),
|
|
5083
|
+
},
|
|
5084
|
+
]
|
|
5085
|
+
const router = createRouter({ routes: vsRoutes, url: '/items?page=5' })
|
|
5086
|
+
const ctr = document.createElement('div')
|
|
5087
|
+
let searchResult: Record<string, unknown> | undefined
|
|
5088
|
+
const TestComp = () => {
|
|
5089
|
+
const search = useValidatedSearch<{ page: number }>()
|
|
5090
|
+
searchResult = search()
|
|
5091
|
+
return null
|
|
5092
|
+
}
|
|
5093
|
+
mount(h(RouterProvider, { router }, h(TestComp, {})), ctr)
|
|
5094
|
+
expect(searchResult).toEqual({ page: 5 })
|
|
5095
|
+
router.destroy()
|
|
5096
|
+
})
|
|
5097
|
+
})
|
|
5098
|
+
|
|
5099
|
+
// ─── Loader cache ───────────────────────────────────────────────────────────
|
|
5100
|
+
|
|
5101
|
+
describe('loader cache', () => {
|
|
5102
|
+
test('cached loader data is reused on second navigation (same params)', async () => {
|
|
5103
|
+
let callCount = 0
|
|
5104
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5105
|
+
{ path: '/', component: Home },
|
|
5106
|
+
{
|
|
5107
|
+
path: '/user/:id',
|
|
5108
|
+
component: Home,
|
|
5109
|
+
loader: async ({ params }) => {
|
|
5110
|
+
callCount++
|
|
5111
|
+
return { id: params.id, name: `User ${params.id}` }
|
|
5112
|
+
},
|
|
5113
|
+
},
|
|
5114
|
+
]
|
|
5115
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5116
|
+
|
|
5117
|
+
await router.push('/user/42')
|
|
5118
|
+
expect(callCount).toBe(1)
|
|
5119
|
+
|
|
5120
|
+
// Navigate away then back — same params, cache hit
|
|
5121
|
+
await router.push('/')
|
|
5122
|
+
await router.push('/user/42')
|
|
5123
|
+
expect(callCount).toBe(1) // NOT called again — cache hit
|
|
5124
|
+
|
|
5125
|
+
router.destroy()
|
|
5126
|
+
})
|
|
5127
|
+
|
|
5128
|
+
test('different params produce different cache keys → loader runs again', async () => {
|
|
5129
|
+
let callCount = 0
|
|
5130
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5131
|
+
{ path: '/', component: Home },
|
|
5132
|
+
{
|
|
5133
|
+
path: '/user/:id',
|
|
5134
|
+
component: Home,
|
|
5135
|
+
loader: async () => { callCount++; return {} },
|
|
5136
|
+
},
|
|
5137
|
+
]
|
|
5138
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5139
|
+
|
|
5140
|
+
await router.push('/user/1')
|
|
5141
|
+
expect(callCount).toBe(1)
|
|
5142
|
+
|
|
5143
|
+
await router.push('/user/2')
|
|
5144
|
+
expect(callCount).toBe(2) // different key → fresh load
|
|
5145
|
+
|
|
5146
|
+
router.destroy()
|
|
5147
|
+
})
|
|
5148
|
+
|
|
5149
|
+
test('custom loaderKey controls cache identity', async () => {
|
|
5150
|
+
let callCount = 0
|
|
5151
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5152
|
+
{ path: '/', component: Home },
|
|
5153
|
+
{
|
|
5154
|
+
path: '/items',
|
|
5155
|
+
component: Home,
|
|
5156
|
+
loaderKey: ({ query }) => `items-page-${query.page ?? '1'}`,
|
|
5157
|
+
loader: async () => { callCount++; return [] },
|
|
5158
|
+
},
|
|
5159
|
+
]
|
|
5160
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5161
|
+
|
|
5162
|
+
await router.push('/items?page=1')
|
|
5163
|
+
expect(callCount).toBe(1)
|
|
5164
|
+
|
|
5165
|
+
// Same page → cache hit
|
|
5166
|
+
await router.push('/')
|
|
5167
|
+
await router.push('/items?page=1')
|
|
5168
|
+
expect(callCount).toBe(1)
|
|
5169
|
+
|
|
5170
|
+
// Different page → cache miss
|
|
5171
|
+
await router.push('/items?page=2')
|
|
5172
|
+
expect(callCount).toBe(2)
|
|
5173
|
+
|
|
5174
|
+
router.destroy()
|
|
5175
|
+
})
|
|
5176
|
+
|
|
5177
|
+
test('gcTime=0 disables caching', async () => {
|
|
5178
|
+
let callCount = 0
|
|
5179
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5180
|
+
{ path: '/', component: Home },
|
|
5181
|
+
{
|
|
5182
|
+
path: '/data',
|
|
5183
|
+
component: Home,
|
|
5184
|
+
gcTime: 0,
|
|
5185
|
+
loader: async () => { callCount++; return {} },
|
|
5186
|
+
},
|
|
5187
|
+
]
|
|
5188
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5189
|
+
|
|
5190
|
+
await router.push('/data')
|
|
5191
|
+
expect(callCount).toBe(1)
|
|
5192
|
+
|
|
5193
|
+
await router.push('/')
|
|
5194
|
+
await router.push('/data')
|
|
5195
|
+
expect(callCount).toBe(2) // no cache — always runs
|
|
5196
|
+
|
|
5197
|
+
router.destroy()
|
|
5198
|
+
})
|
|
5199
|
+
|
|
5200
|
+
test('invalidateLoader() clears cache', async () => {
|
|
5201
|
+
let callCount = 0
|
|
5202
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5203
|
+
{ path: '/', component: Home },
|
|
5204
|
+
{
|
|
5205
|
+
path: '/data',
|
|
5206
|
+
component: Home,
|
|
5207
|
+
loader: async () => { callCount++; return {} },
|
|
5208
|
+
},
|
|
5209
|
+
]
|
|
5210
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5211
|
+
|
|
5212
|
+
await router.push('/data')
|
|
5213
|
+
expect(callCount).toBe(1)
|
|
5214
|
+
|
|
5215
|
+
// Invalidate all
|
|
5216
|
+
router.invalidateLoader()
|
|
5217
|
+
|
|
5218
|
+
await router.push('/')
|
|
5219
|
+
await router.push('/data')
|
|
5220
|
+
expect(callCount).toBe(2) // cache was cleared → runs again
|
|
5221
|
+
|
|
5222
|
+
router.destroy()
|
|
5223
|
+
})
|
|
5224
|
+
|
|
5225
|
+
test('invalidateLoader(key) clears specific cache entry', async () => {
|
|
5226
|
+
let dataCallCount = 0
|
|
5227
|
+
let itemsCallCount = 0
|
|
5228
|
+
const cacheRoutes: RouteRecord[] = [
|
|
5229
|
+
{ path: '/', component: Home },
|
|
5230
|
+
{
|
|
5231
|
+
path: '/data',
|
|
5232
|
+
component: Home,
|
|
5233
|
+
loaderKey: () => 'data-key',
|
|
5234
|
+
loader: async () => { dataCallCount++; return {} },
|
|
5235
|
+
},
|
|
5236
|
+
{
|
|
5237
|
+
path: '/items',
|
|
5238
|
+
component: Home,
|
|
5239
|
+
loaderKey: () => 'items-key',
|
|
5240
|
+
loader: async () => { itemsCallCount++; return [] },
|
|
5241
|
+
},
|
|
5242
|
+
]
|
|
5243
|
+
const router = createRouter({ routes: cacheRoutes, url: '/' })
|
|
5244
|
+
|
|
5245
|
+
await router.push('/data')
|
|
5246
|
+
await router.push('/items')
|
|
5247
|
+
expect(dataCallCount).toBe(1)
|
|
5248
|
+
expect(itemsCallCount).toBe(1)
|
|
5249
|
+
|
|
5250
|
+
// Invalidate only 'data-key'
|
|
5251
|
+
router.invalidateLoader('data-key')
|
|
5252
|
+
|
|
5253
|
+
await router.push('/data')
|
|
5254
|
+
await router.push('/items')
|
|
5255
|
+
expect(dataCallCount).toBe(2) // re-fetched
|
|
5256
|
+
expect(itemsCallCount).toBe(1) // still cached
|
|
5257
|
+
|
|
5258
|
+
router.destroy()
|
|
5259
|
+
})
|
|
5260
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -69,6 +69,15 @@ export interface ResolvedRoute<
|
|
|
69
69
|
/** All matched records from root to leaf (one per nesting level) */
|
|
70
70
|
matched: RouteRecord[]
|
|
71
71
|
meta: RouteMeta
|
|
72
|
+
/**
|
|
73
|
+
* Validated search params — populated when the matched route has `validateSearch`.
|
|
74
|
+
* Contains the typed result of `validateSearch(query)`. Use `useValidatedSearch()`
|
|
75
|
+
* to access this in components with full type inference.
|
|
76
|
+
* Empty object `{}` when no `validateSearch` is configured.
|
|
77
|
+
*/
|
|
78
|
+
search?: Record<string, unknown> | undefined
|
|
79
|
+
/** Middleware data attached during navigation (populated by middleware chain) */
|
|
80
|
+
_middlewareData?: Record<string, unknown> | undefined
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
// ─── Lazy component ───────────────────────────────────────────────────────────
|
|
@@ -203,8 +212,58 @@ export interface RouteRecord<TPath extends string = string> {
|
|
|
203
212
|
* Only applies when navigating to a route that already has cached loader data.
|
|
204
213
|
*/
|
|
205
214
|
staleWhileRevalidate?: boolean
|
|
215
|
+
/**
|
|
216
|
+
* Cache key function for loader data. Returns a string key derived from
|
|
217
|
+
* route params/query. When the key matches cached data, the loader is
|
|
218
|
+
* skipped (cache hit). Default: `path + JSON.stringify(params)`.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* loaderKey: ({ params }) => `user-${params.id}`
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
loaderKey?: (ctx: Pick<LoaderContext, 'params' | 'query'>) => string
|
|
226
|
+
/**
|
|
227
|
+
* Time in ms to keep cached loader data before garbage collection.
|
|
228
|
+
* Default: 300000 (5 minutes). Set to 0 to disable caching.
|
|
229
|
+
* Stale data is still served immediately if `staleWhileRevalidate` is true.
|
|
230
|
+
*/
|
|
231
|
+
gcTime?: number
|
|
206
232
|
/** Component rendered when this route's loader throws an error */
|
|
207
233
|
errorComponent?: ComponentFn
|
|
234
|
+
/**
|
|
235
|
+
* Component rendered while this route's loader is running.
|
|
236
|
+
* Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
|
|
237
|
+
* Once shown, displayed for at least `pendingMinMs` (default: 200) to avoid flicker.
|
|
238
|
+
*/
|
|
239
|
+
pendingComponent?: ComponentFn
|
|
240
|
+
/** Delay in ms before showing pendingComponent (default: 0). Prevents flash on fast loaders. */
|
|
241
|
+
pendingMs?: number
|
|
242
|
+
/** Minimum display time in ms for pendingComponent once shown (default: 200). Prevents flicker. */
|
|
243
|
+
pendingMinMs?: number
|
|
244
|
+
/**
|
|
245
|
+
* Validate and transform raw query string parameters into typed values.
|
|
246
|
+
* Receives the raw `Record<string, string>` from the URL and returns
|
|
247
|
+
* a typed object. The validated result is available via `useValidatedSearch()`.
|
|
248
|
+
*
|
|
249
|
+
* Accepts any function — use Zod `.parse`, Valibot, or a plain function:
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```ts
|
|
253
|
+
* // Plain function:
|
|
254
|
+
* validateSearch: (raw) => ({
|
|
255
|
+
* page: Number(raw.page) || 1,
|
|
256
|
+
* q: raw.q ?? '',
|
|
257
|
+
* })
|
|
258
|
+
*
|
|
259
|
+
* // With Zod:
|
|
260
|
+
* validateSearch: z.object({
|
|
261
|
+
* page: z.coerce.number().default(1),
|
|
262
|
+
* q: z.string().default(''),
|
|
263
|
+
* }).parse
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
validateSearch?: (raw: Record<string, string>) => Record<string, unknown>
|
|
208
267
|
/** Per-route middleware — runs before guards, can accumulate context data. */
|
|
209
268
|
middleware?: RouteMiddleware | RouteMiddleware[]
|
|
210
269
|
}
|
|
@@ -325,6 +384,13 @@ export interface Router<TNames extends string = string> {
|
|
|
325
384
|
* call this for the same `url` you initialised the router with.
|
|
326
385
|
*/
|
|
327
386
|
preload(path: string): Promise<void>
|
|
387
|
+
/**
|
|
388
|
+
* Invalidate cached loader data. Forces loaders to re-run on next navigation.
|
|
389
|
+
* - No args: invalidate ALL cached loader data
|
|
390
|
+
* - String: invalidate by cache key (as returned by `loaderKey`)
|
|
391
|
+
* - Function: invalidate entries where the predicate returns true
|
|
392
|
+
*/
|
|
393
|
+
invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)): void
|
|
328
394
|
/** Remove all event listeners, clear caches, and abort in-flight navigations. */
|
|
329
395
|
destroy(): void
|
|
330
396
|
}
|
|
@@ -365,4 +431,10 @@ export interface RouterInstance extends Router {
|
|
|
365
431
|
_readyResolve: (() => void) | null
|
|
366
432
|
/** The isReady() promise instance */
|
|
367
433
|
_readyPromise: Promise<void>
|
|
434
|
+
/** Timestamp when the current navigation started — used for pendingMs timing */
|
|
435
|
+
_navigationStartTime: number
|
|
436
|
+
/** Key-based loader cache: cacheKey → { data, timestamp } */
|
|
437
|
+
_loaderCache: Map<string, { data: unknown; timestamp: number }>
|
|
438
|
+
/** In-flight loader dedup: cacheKey → Promise */
|
|
439
|
+
_loaderInflight: Map<string, Promise<unknown>>
|
|
368
440
|
}
|