@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.
@@ -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
+ })