@pyreon/router 0.13.0 → 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.
@@ -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
  }