@buzola/router 0.0.17 → 0.0.19

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.
Files changed (59) hide show
  1. package/dist/define/create-page.js +1 -1
  2. package/dist/engine/loader-cache.d.ts +1 -1
  3. package/dist/engine/loader-cache.js +1 -1
  4. package/dist/engine/loader-state.d.ts +5 -5
  5. package/dist/engine/loader-state.d.ts.map +1 -1
  6. package/dist/engine/loader-state.js +8 -8
  7. package/dist/engine/loader-state.js.map +1 -1
  8. package/dist/engine/matcher.js +4 -4
  9. package/dist/engine/matcher.js.map +1 -1
  10. package/dist/engine/navigation-adapter.js +6 -6
  11. package/dist/engine/navigation-adapter.js.map +1 -1
  12. package/dist/engine/route-tree.js +2 -2
  13. package/dist/engine/route-tree.js.map +1 -1
  14. package/dist/engine/router.d.ts +31 -5
  15. package/dist/engine/router.d.ts.map +1 -1
  16. package/dist/engine/router.js +47 -10
  17. package/dist/engine/router.js.map +1 -1
  18. package/dist/engine/types.d.ts +28 -8
  19. package/dist/engine/types.d.ts.map +1 -1
  20. package/dist/engine/utils.d.ts +8 -3
  21. package/dist/engine/utils.d.ts.map +1 -1
  22. package/dist/engine/utils.js +16 -3
  23. package/dist/engine/utils.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/react/hooks.d.ts +2 -2
  28. package/dist/react/hooks.js +1 -1
  29. package/dist/react/link.d.ts +4 -4
  30. package/dist/react/link.js +1 -1
  31. package/dist/react/outlet.js +2 -2
  32. package/dist/react/outlet.js.map +1 -1
  33. package/dist/react/provider.d.ts +4 -1
  34. package/dist/react/provider.d.ts.map +1 -1
  35. package/dist/react/provider.js +6 -0
  36. package/dist/react/provider.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__tests__/create-page.test.ts +1 -1
  39. package/src/__tests__/loader-cache.test.ts +1 -1
  40. package/src/__tests__/loader-state.test.ts +12 -12
  41. package/src/__tests__/navigation-adapter.test.ts +2 -2
  42. package/src/__tests__/react.test.tsx +70 -4
  43. package/src/__tests__/router.test.ts +223 -4
  44. package/src/__tests__/setup-dom.ts +1 -1
  45. package/src/__tests__/type-safety.test.tsx +22 -22
  46. package/src/define/create-page.tsx +1 -1
  47. package/src/engine/loader-cache.ts +1 -1
  48. package/src/engine/loader-state.ts +9 -9
  49. package/src/engine/matcher.ts +4 -4
  50. package/src/engine/navigation-adapter.ts +6 -6
  51. package/src/engine/route-tree.ts +3 -3
  52. package/src/engine/router.ts +59 -11
  53. package/src/engine/types.ts +30 -8
  54. package/src/engine/utils.ts +16 -3
  55. package/src/index.ts +2 -0
  56. package/src/react/hooks.ts +2 -2
  57. package/src/react/link.tsx +5 -5
  58. package/src/react/outlet.tsx +2 -2
  59. package/src/react/provider.tsx +10 -2
@@ -3,7 +3,7 @@ import { matchRoutes } from '../engine/matcher.js'
3
3
  import { createMemoryNavigationAdapter } from '../engine/navigation-adapter.js'
4
4
  import { buildRouteTree } from '../engine/route-tree.js'
5
5
  import { Router } from '../engine/router.js'
6
- import type { BuzolaNavigateEvent, NavigationAdapter, RouteConfig } from '../engine/types.js'
6
+ import type { BuzolaNavigateEvent, InterceptInfo, NavigationAdapter, RouteConfig, ShouldInterceptFn } from '../engine/types.js'
7
7
 
8
8
  function dummyComponent() {
9
9
  return null
@@ -12,7 +12,7 @@ function dummyComponent() {
12
12
  function createRouter(
13
13
  configs: RouteConfig[],
14
14
  initialURL = 'http://localhost/',
15
- options?: { persistentParams?: () => Record<string, string> },
15
+ options?: { persistentParams?: () => Record<string, string>; shouldIntercept?: ShouldInterceptFn },
16
16
  ) {
17
17
  const routes = buildRouteTree(configs)
18
18
  const adapter = createMemoryNavigationAdapter({ initialURL })
@@ -307,7 +307,7 @@ describe('Router same-URL navigation', () => {
307
307
  expect(count()).toBe(1)
308
308
  })
309
309
 
310
- it('does not invalidate when the URL changes — the href-keyed loader reloads on its own', () => {
310
+ it('does not invalidate when the URL changes: the href-keyed loader reloads on its own', () => {
311
311
  const { router, count } = createCountingRouter('http://localhost/')
312
312
 
313
313
  router.navigate('/list')
@@ -408,7 +408,7 @@ describe('Router focus reset', () => {
408
408
  try {
409
409
  const { router, captured } = createCapturingRouter('http://localhost/list')
410
410
 
411
- // No route matches — the router bails out before intercepting
411
+ // No route matches: the router bails out before intercepting
412
412
  router.navigate('/unmatched', { focusReset: 'manual' })
413
413
  expect(captured).toHaveLength(0)
414
414
 
@@ -518,3 +518,222 @@ describe('Router reload', () => {
518
518
  expect(router.getState().location).not.toBe(before)
519
519
  })
520
520
  })
521
+
522
+ describe('Router shouldIntercept', () => {
523
+ const flush = () => new Promise(resolve => setTimeout(resolve, 0))
524
+ const configs: RouteConfig[] = [
525
+ { path: '/list', component: dummyComponent },
526
+ { path: '/about', component: dummyComponent },
527
+ ]
528
+
529
+ it('intercepts every navigation when the option is not set', () => {
530
+ const { router, adapter } = createRouter(configs, 'http://localhost/list')
531
+
532
+ router.navigate('/about')
533
+ router.back()
534
+
535
+ expect(router.getState().location.pathname).toBe('/list')
536
+ expect(adapter.reloadCount()).toBe(0)
537
+ })
538
+
539
+ it('intercepts a navigation it allows', () => {
540
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => true })
541
+
542
+ router.navigate('/about')
543
+
544
+ expect(router.getState().location.pathname).toBe('/about')
545
+ })
546
+
547
+ it('describes the navigation it is asked about', () => {
548
+ const shouldIntercept = mock((info: InterceptInfo) => true)
549
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
550
+
551
+ router.navigate('/about?tab=team', { replace: true })
552
+
553
+ const info = shouldIntercept.mock.calls[0][0]
554
+ expect(info.destination.href).toBe('http://localhost/about?tab=team')
555
+ expect(info.from.href).toBe('http://localhost/list')
556
+ expect(info.navigationType).toBe('replace')
557
+ expect(info.userInitiated).toBe(false)
558
+ })
559
+
560
+ it('lets a declined push through to the browser as a document navigation', () => {
561
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
562
+
563
+ router.navigate('/about')
564
+
565
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
566
+ // The browser took it: the router neither rendered the destination nor turned it into a reload.
567
+ expect(router.getState().location.pathname).toBe('/list')
568
+ expect(adapter.reloadCount()).toBe(0)
569
+ })
570
+
571
+ it('lets a declined replace through to the browser as a document navigation', () => {
572
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
573
+
574
+ router.navigate('/about', { replace: true })
575
+
576
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
577
+ expect(router.getState().location.pathname).toBe('/list')
578
+ expect(adapter.reloadCount()).toBe(0)
579
+ })
580
+
581
+ it('reloads at the destination of a declined traversal', () => {
582
+ let intercept = true
583
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => intercept })
584
+ router.navigate('/about')
585
+
586
+ intercept = false
587
+ router.back()
588
+
589
+ expect(adapter.getCurrentURL().pathname).toBe('/list')
590
+ expect(adapter.reloadCount()).toBe(1)
591
+ expect(router.getState().location.pathname).toBe('/about')
592
+ })
593
+
594
+ it('intercepts a traversal it allows', () => {
595
+ const shouldIntercept = mock((info: InterceptInfo) => true)
596
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
597
+ router.navigate('/about')
598
+
599
+ router.back()
600
+
601
+ expect(shouldIntercept.mock.calls.at(-1)?.[0].navigationType).toBe('traverse')
602
+ expect(router.getState().location.pathname).toBe('/list')
603
+ expect(adapter.reloadCount()).toBe(0)
604
+ })
605
+
606
+ it('is not asked about a reload, which always goes to the browser', () => {
607
+ const shouldIntercept = mock(() => true)
608
+ const { adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
609
+
610
+ adapter.reload()
611
+
612
+ expect(shouldIntercept).not.toHaveBeenCalled()
613
+ expect(adapter.reloadCount()).toBe(1)
614
+ })
615
+
616
+ it('is not asked about a fragment navigation, which the browser keeps in the document anyway', () => {
617
+ const shouldIntercept = mock(() => false)
618
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
619
+
620
+ router.navigate('/list#details')
621
+
622
+ expect(shouldIntercept).not.toHaveBeenCalled()
623
+ expect(router.getState().location.hash).toBe('#details')
624
+ })
625
+
626
+ it('is asked when the path changes along with the hash', () => {
627
+ const shouldIntercept = mock(() => false)
628
+ const { router } = createRouter(configs, 'http://localhost/list#details', { shouldIntercept })
629
+
630
+ router.navigate('/about#team')
631
+
632
+ expect(shouldIntercept).toHaveBeenCalledTimes(1)
633
+ expect(router.getState().location.pathname).toBe('/list')
634
+ })
635
+
636
+ it('is asked when the hash is dropped, since that loads a document', () => {
637
+ const shouldIntercept = mock(() => false)
638
+ const { router } = createRouter(configs, 'http://localhost/list#details', { shouldIntercept })
639
+
640
+ router.navigate('/list')
641
+
642
+ expect(shouldIntercept).toHaveBeenCalledTimes(1)
643
+ })
644
+
645
+ it('is not asked about a URL handed to the browser with leaveApp', () => {
646
+ const shouldIntercept = mock(() => true)
647
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
648
+
649
+ router.leaveApp('/about')
650
+
651
+ expect(shouldIntercept).not.toHaveBeenCalled()
652
+ })
653
+
654
+ it('asks the blockers before a declined push and then loads the destination', async () => {
655
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
656
+ const blocker = mock(async () => true)
657
+ router.addBlocker(blocker)
658
+
659
+ router.navigate('/about')
660
+ await flush()
661
+
662
+ expect(blocker).toHaveBeenCalledTimes(1)
663
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
664
+ expect(adapter.reloadCount()).toBe(1)
665
+ expect(router.getState().location.pathname).toBe('/list')
666
+ })
667
+
668
+ it('does not load the destination of a declined navigation when a blocker refuses', async () => {
669
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
670
+ router.addBlocker(async () => false)
671
+
672
+ router.navigate('/about')
673
+ await flush()
674
+
675
+ expect(adapter.reloadCount()).toBe(0)
676
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
677
+ expect(router.getState().location.pathname).toBe('/list')
678
+ })
679
+
680
+ it('asks the blockers before a declined traversal and then loads the destination', async () => {
681
+ let intercept = true
682
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => intercept })
683
+ router.navigate('/about')
684
+ await flush()
685
+ const blocker = mock(async () => true)
686
+ router.addBlocker(blocker)
687
+
688
+ intercept = false
689
+ router.back()
690
+ await flush()
691
+
692
+ expect(blocker).toHaveBeenCalledTimes(1)
693
+ expect(adapter.getCurrentURL().pathname).toBe('/list')
694
+ expect(adapter.reloadCount()).toBe(1)
695
+ expect(router.getState().location.pathname).toBe('/about')
696
+ })
697
+
698
+ it('does not load the destination of a declined traversal when a blocker refuses', async () => {
699
+ let intercept = true
700
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => intercept })
701
+ router.navigate('/about')
702
+ await flush()
703
+ router.addBlocker(async () => false)
704
+
705
+ intercept = false
706
+ router.back()
707
+ await flush()
708
+
709
+ expect(adapter.reloadCount()).toBe(0)
710
+ expect(adapter.getCurrentURL().pathname).toBe('/list')
711
+ expect(router.getState().location.pathname).toBe('/about')
712
+ })
713
+
714
+ it('treats a fragment of the committed entry as a fragment navigation while a navigation is pending', async () => {
715
+ let intercept = true
716
+ const shouldIntercept = mock(() => intercept)
717
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
718
+ let blockerCalls = 0
719
+ router.addBlocker(async () => {
720
+ blockerCalls++
721
+ // The first navigation stays pending, like one held by an open unsaved-changes dialog.
722
+ if (blockerCalls === 1) return new Promise<boolean>(() => {})
723
+ return true
724
+ })
725
+ router.navigate('/about')
726
+ await flush()
727
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
728
+ expect(router.getState().location.pathname).toBe('/list')
729
+
730
+ intercept = false
731
+ router.navigate('/about#team')
732
+ await flush()
733
+
734
+ // The browser keeps `/about#team` in the document, so declining it would load nothing.
735
+ expect(shouldIntercept).toHaveBeenCalledTimes(1)
736
+ expect(adapter.reloadCount()).toBe(0)
737
+ expect(router.getState().location.href).toBe('http://localhost/about#team')
738
+ })
739
+ })
@@ -11,7 +11,7 @@ for (const key of Object.getOwnPropertyNames(window)) {
11
11
  configurable: true,
12
12
  })
13
13
  } catch {
14
- // Some properties can't be set — skip them
14
+ // Some properties can't be set: skip them
15
15
  }
16
16
  }
17
17
 
@@ -29,7 +29,7 @@ describe('createPage route type safety', () => {
29
29
  })
30
30
 
31
31
  it('rejects dynamic route without params', () => {
32
- // @ts-expect-error — :id in route but no .params() defined
32
+ // @ts-expect-error: :id in route but no .params() defined
33
33
  const _page = createPage().route('/users/:id').render(() => null)
34
34
  expect(true).toBe(true)
35
35
  })
@@ -45,7 +45,7 @@ describe('createPage route type safety', () => {
45
45
  it('rejects dynamic route when param is not in schema', () => {
46
46
  const _page = createPage()
47
47
  .params(mockSchema<{ userId: string }>())
48
- // @ts-expect-error — :id is not a key of { userId: string }
48
+ // @ts-expect-error: :id is not a key of { userId: string }
49
49
  .route('/users/:id')
50
50
  .render(({ params }) => null)
51
51
  expect(true).toBe(true)
@@ -62,7 +62,7 @@ describe('createPage route type safety', () => {
62
62
  it('rejects when one route param is missing from schema', () => {
63
63
  const _page = createPage()
64
64
  .params(mockSchema<{ userId: string }>())
65
- // @ts-expect-error — :postId is not in { userId: string }
65
+ // @ts-expect-error: :postId is not in { userId: string }
66
66
  .route('/users/:userId/posts/:postId')
67
67
  .render(({ params }) => null)
68
68
  expect(true).toBe(true)
@@ -97,14 +97,14 @@ describe('createPage catch type safety', () => {
97
97
 
98
98
  it('no .catch() after .catch()', () => {
99
99
  const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
100
- // @ts-expect-error — .catch() should not be available after .catch()
100
+ // @ts-expect-error: .catch() should not be available after .catch()
101
101
  withCatch.catch
102
102
  expect(true).toBe(true)
103
103
  })
104
104
 
105
105
  it('no .loader() after .catch()', () => {
106
106
  const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
107
- // @ts-expect-error — .loader() should not be available after .catch()
107
+ // @ts-expect-error: .loader() should not be available after .catch()
108
108
  withCatch.loader
109
109
  expect(true).toBe(true)
110
110
  })
@@ -125,7 +125,7 @@ describe('createPage catch type safety', () => {
125
125
  it('.catch() handler has empty params when no schema defined', () => {
126
126
  const _page = createPage()
127
127
  .catch(({ params }) => {
128
- // @ts-expect-error — no params defined, accessing unknown key
128
+ // @ts-expect-error: no params defined, accessing unknown key
129
129
  params.anything
130
130
  return null
131
131
  })
@@ -144,7 +144,7 @@ describe('LinkProps type safety', () => {
144
144
  })
145
145
 
146
146
  it('static page rejects params', () => {
147
- // @ts-expect-error — static page should not accept params
147
+ // @ts-expect-error: static page should not accept params
148
148
  const _a: LinkProps<'about'> = { to: 'about', params: { foo: 'bar' } }
149
149
  expect(true).toBe(true)
150
150
  })
@@ -152,13 +152,13 @@ describe('LinkProps type safety', () => {
152
152
  it('dynamic page requires params', () => {
153
153
  const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { userId: '1' } }
154
154
 
155
- // @ts-expect-error — missing required params
155
+ // @ts-expect-error: missing required params
156
156
  const _b: LinkProps<'users/detail'> = { to: 'users/detail' }
157
157
  expect(true).toBe(true)
158
158
  })
159
159
 
160
160
  it('dynamic page requires correct param names', () => {
161
- // @ts-expect-error — wrong param name
161
+ // @ts-expect-error: wrong param name
162
162
  const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { id: '1' } }
163
163
  expect(true).toBe(true)
164
164
  })
@@ -171,14 +171,14 @@ describe('LinkProps type safety', () => {
171
171
 
172
172
  const _b: LinkProps<'users/posts'> = {
173
173
  to: 'users/posts',
174
- // @ts-expect-error — missing postId
174
+ // @ts-expect-error: missing postId
175
175
  params: { userId: '1' },
176
176
  }
177
177
  expect(true).toBe(true)
178
178
  })
179
179
 
180
180
  it('rejects unregistered pages', () => {
181
- // @ts-expect-error — page not in BuzolaPageMap
181
+ // @ts-expect-error: page not in BuzolaPageMap
182
182
  const _a: LinkProps<'unknown'> = { to: 'unknown' }
183
183
  expect(true).toBe(true)
184
184
  })
@@ -196,7 +196,7 @@ describe('Link JSX generic inference', () => {
196
196
  })
197
197
 
198
198
  it('rejects params on static page', () => {
199
- // @ts-expect-error — static page should not accept params
199
+ // @ts-expect-error: static page should not accept params
200
200
  const _el = <Link to="about" params={{ foo: 'bar' }}>About</Link>
201
201
  expect(true).toBe(true)
202
202
  })
@@ -204,13 +204,13 @@ describe('Link JSX generic inference', () => {
204
204
  it('requires params on dynamic page', () => {
205
205
  const _el = <Link to="users/detail" params={{ userId: '1' }}>User</Link>
206
206
 
207
- // @ts-expect-error — missing required params
207
+ // @ts-expect-error: missing required params
208
208
  const _el2 = <Link to="users/detail">User</Link>
209
209
  expect(true).toBe(true)
210
210
  })
211
211
 
212
212
  it('rejects wrong param names', () => {
213
- // @ts-expect-error — wrong param name
213
+ // @ts-expect-error: wrong param name
214
214
  const _el = <Link to="users/detail" params={{ id: '1' }}>User</Link>
215
215
  expect(true).toBe(true)
216
216
  })
@@ -218,13 +218,13 @@ describe('Link JSX generic inference', () => {
218
218
  it('requires all params for multi-param page', () => {
219
219
  const _ok = <Link to="users/posts" params={{ userId: '1', postId: '2' }}>Post</Link>
220
220
 
221
- // @ts-expect-error — missing postId
221
+ // @ts-expect-error: missing postId
222
222
  const _bad = <Link to="users/posts" params={{ userId: '1' }}>Post</Link>
223
223
  expect(true).toBe(true)
224
224
  })
225
225
 
226
226
  it('rejects unregistered pages', () => {
227
- // @ts-expect-error — page not in BuzolaPageMap
227
+ // @ts-expect-error: page not in BuzolaPageMap
228
228
  const _el = <Link to="nope">Nope</Link>
229
229
  expect(true).toBe(true)
230
230
  })
@@ -258,7 +258,7 @@ describe('NavigateFn type safety', () => {
258
258
  })
259
259
 
260
260
  it('static page rejects params', () => {
261
- // @ts-expect-error — static page should not accept params
261
+ // @ts-expect-error: static page should not accept params
262
262
  noop('home', { params: { foo: 'bar' } })
263
263
  expect(true).toBe(true)
264
264
  })
@@ -266,19 +266,19 @@ describe('NavigateFn type safety', () => {
266
266
  it('dynamic page requires options with params', () => {
267
267
  noop('users/detail', { params: { userId: '1' } })
268
268
 
269
- // @ts-expect-error — missing required options with params
269
+ // @ts-expect-error: missing required options with params
270
270
  noop('users/detail')
271
271
  expect(true).toBe(true)
272
272
  })
273
273
 
274
274
  it('dynamic page requires correct param names', () => {
275
- // @ts-expect-error — wrong param name (id instead of userId)
275
+ // @ts-expect-error: wrong param name (id instead of userId)
276
276
  noop('users/detail', { params: { id: '1' } })
277
277
  expect(true).toBe(true)
278
278
  })
279
279
 
280
280
  it('dynamic page rejects params passed directly as options (without params wrapper)', () => {
281
- // @ts-expect-error — { userId: '1' } is not valid, must be { params: { userId: '1' } }
281
+ // @ts-expect-error: { userId: '1' } is not valid, must be { params: { userId: '1' } }
282
282
  noop('users/detail', { userId: '1' })
283
283
  expect(true).toBe(true)
284
284
  })
@@ -286,13 +286,13 @@ describe('NavigateFn type safety', () => {
286
286
  it('multi-param page requires all params', () => {
287
287
  noop('users/posts', { params: { userId: '1', postId: '2' } })
288
288
 
289
- // @ts-expect-error — missing postId
289
+ // @ts-expect-error: missing postId
290
290
  noop('users/posts', { params: { userId: '1' } })
291
291
  expect(true).toBe(true)
292
292
  })
293
293
 
294
294
  it('rejects unregistered pages', () => {
295
- // @ts-expect-error — page not in BuzolaPageMap
295
+ // @ts-expect-error: page not in BuzolaPageMap
296
296
  noop('unknown')
297
297
  expect(true).toBe(true)
298
298
  })
@@ -156,7 +156,7 @@ function combineLoaders(loaders: LoaderFn[]): LoaderFn | undefined {
156
156
  }
157
157
 
158
158
  /**
159
- * Follow a loader redirect, unless it points at the URL the page is already on —
159
+ * Follow a loader redirect, unless it points at the URL the page is already on:
160
160
  * that navigation re-runs the loader, which returns the same redirect, forever.
161
161
  */
162
162
  function followRedirect(router: Router | null, target: BuzolaRedirect): void {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Global LRU cache for page loader data.
3
3
  * Shared across all page components to limit total memory usage.
4
- * Uses Map insertion order for LRU eviction — oldest entries are evicted first.
4
+ * Uses Map insertion order for LRU eviction: oldest entries are evicted first.
5
5
  */
6
6
  export class LoaderCache {
7
7
  private cache = new Map<string, unknown>()
@@ -8,7 +8,7 @@ export type LoaderInstruction =
8
8
  | { action: 'suspend'; promise: Promise<unknown> }
9
9
 
10
10
  export interface LoaderResolveContext {
11
- /** Current URL href — used for stale cache keying */
11
+ /** Current URL href: used for stale cache keying */
12
12
  currentUrlHref: string
13
13
  /** Cache key that changes on URL change or invalidation */
14
14
  cacheKey: string
@@ -31,7 +31,7 @@ let nextId = 0
31
31
  /**
32
32
  * Manages loader data lifecycle outside of React's render cycle.
33
33
  *
34
- * This state survives React Suspense retries — when use() suspends, React
34
+ * This state survives React Suspense retries. When use() suspends, React
35
35
  * discards uncommitted hook state (useRef, useState, useMemo). Closure/instance
36
36
  * state is preserved, preventing infinite suspend loops from promise recreation.
37
37
  *
@@ -43,7 +43,7 @@ export class PageLoaderState {
43
43
  private activeCacheKey: string | null = null
44
44
  private activePromise: Promise<unknown> | null = null
45
45
  private activeResolvedData: unknown
46
- /** URL the currently held data was loaded for — data is cached under this href, not the active one. */
46
+ /** URL the currently held data was loaded for: data is cached under this href, not the active one. */
47
47
  private resolvedUrlHref: string | null = null
48
48
  private hasResolved = false
49
49
  private pendingBackgroundKey: string | null = null
@@ -62,7 +62,7 @@ export class PageLoaderState {
62
62
 
63
63
  this.handleUrlChange(currentUrlHref, loaderCache)
64
64
 
65
- // ─── Same cache key — return current state ─────
65
+ // ─── Same cache key: return current state ─────
66
66
  if (this.activeCacheKey === cacheKey) {
67
67
  if (this.pendingBackgroundKey === cacheKey) {
68
68
  return { action: 'render', data: this.activeResolvedData, isLoading: true }
@@ -110,14 +110,14 @@ export class PageLoaderState {
110
110
  this.hasResolved = true
111
111
  }
112
112
 
113
- /** Commit background SWR success — update data and clear pending flag */
113
+ /** Commit background SWR success: update data and clear pending flag */
114
114
  commitBackgroundResult(data: unknown): void {
115
115
  this.activeResolvedData = data
116
116
  this.resolvedUrlHref = this.activeUrlHref
117
117
  this.pendingBackgroundKey = null
118
118
  }
119
119
 
120
- /** Mark background load as failed — next render will suspend with rejected promise */
120
+ /** Mark background load as failed: next render will suspend with rejected promise */
121
121
  commitBackgroundError(): void {
122
122
  this.hasResolved = false
123
123
  this.pendingBackgroundKey = null
@@ -141,7 +141,7 @@ export class PageLoaderState {
141
141
 
142
142
  private handleUrlChange(currentUrlHref: string, loaderCache: LoaderCache | undefined): void {
143
143
  if (this.activeUrlHref !== null && this.activeUrlHref !== currentUrlHref) {
144
- // URL changed — save the held data to stale cache under the href it was loaded for
144
+ // URL changed: save the held data to stale cache under the href it was loaded for
145
145
  if (loaderCache && this.hasResolved && this.activeResolvedData !== undefined && this.resolvedUrlHref) {
146
146
  this.saveToStaleCache(loaderCache, this.resolvedUrlHref, this.activeResolvedData)
147
147
  }
@@ -152,7 +152,7 @@ export class PageLoaderState {
152
152
  this.resolvedUrlHref = currentUrlHref
153
153
  this.hasResolved = true
154
154
  } else if (!isSamePath(this.activeUrlHref, currentUrlHref)) {
155
- // Different path — the held data describes another page, drop it and suspend
155
+ // Different path: the held data describes another page, so drop it and suspend
156
156
  this.hasResolved = false
157
157
  this.activeResolvedData = undefined
158
158
  this.resolvedUrlHref = null
@@ -161,7 +161,7 @@ export class PageLoaderState {
161
161
  // keep the data as stale so resolve() serves it while the new query loads.
162
162
  this.pendingBackgroundKey = null
163
163
  } else if (this.activeUrlHref === null && loaderCache) {
164
- // Remount — try to restore from stale cache
164
+ // Remount: try to restore from stale cache
165
165
  const cached = this.restoreFromStaleCache(loaderCache, currentUrlHref)
166
166
  if (cached.hit) {
167
167
  this.activeResolvedData = cached.value
@@ -20,7 +20,7 @@ function matchTrie(
20
20
  values: string[],
21
21
  pathname: string,
22
22
  ): RouteMatch[] | null {
23
- // All segments consumed — check if this trie node has a terminal route
23
+ // All segments consumed: check if this trie node has a terminal route
24
24
  if (index === segments.length) {
25
25
  if (node.route) {
26
26
  return buildMatchChain(node.route, values, pathname)
@@ -30,20 +30,20 @@ function matchTrie(
30
30
 
31
31
  const segment = segments[index]
32
32
 
33
- // 1. Try static child first — O(1) Map lookup, always most specific
33
+ // 1. Try static child first: O(1) Map lookup, always most specific
34
34
  const staticChild = node.staticChildren.get(segment)
35
35
  if (staticChild) {
36
36
  const result = matchTrie(staticChild, segments, index + 1, values, pathname)
37
37
  if (result) return result
38
38
  }
39
39
 
40
- // 2. Try dynamic child — matches any single segment
40
+ // 2. Try dynamic child: matches any single segment
41
41
  if (node.dynamicChild) {
42
42
  const result = matchTrie(node.dynamicChild, segments, index + 1, [...values, decodeURIComponent(segment)], pathname)
43
43
  if (result) return result
44
44
  }
45
45
 
46
- // 3. Try catch-all child — consumes all remaining segments
46
+ // 3. Try catch-all child: consumes all remaining segments
47
47
  if (node.catchAllChild) {
48
48
  const remaining = segments.slice(index).map(s => decodeURIComponent(s)).join('/')
49
49
  return buildMatchChain(node.catchAllChild, [...values, remaining], pathname)
@@ -152,19 +152,19 @@ export function createMemoryNavigationAdapter(
152
152
  }
153
153
 
154
154
  if (intercepted && interceptHandler) {
155
- // Run the handler and only commit navigation state on success.
155
+ // Like the browser, commit the destination entry before the handler runs. A handler that
156
+ // fails or reloads therefore sees the destination as the current entry.
157
+ commitNavigation(true)
156
158
  void interceptHandler().then(
157
- () => {
158
- commitNavigation(true)
159
- },
159
+ () => {},
160
160
  (error) => {
161
- // Guard aborts are expected — rethrow unexpected errors
161
+ // Guard aborts are expected: rethrow unexpected errors
162
162
  if (error instanceof Error && error.name === 'NavigationAbortedError') return
163
163
  throw error
164
164
  },
165
165
  )
166
166
  } else {
167
- // No interception — commit immediately
167
+ // No interception: commit immediately
168
168
  commitNavigation(false)
169
169
  }
170
170
  }
@@ -61,7 +61,7 @@ export function buildRouteTree(configs: RouteConfig[], parentPath = ''): RouteTr
61
61
  interface FlatEntry {
62
62
  /** The URL pattern for matching (e.g., "/project/:id/edit"). */
63
63
  pattern: string
64
- /** Layout chain from root to leaf — each becomes a RouteMatch. */
64
+ /** Layout chain from root to leaf: each becomes a RouteMatch. */
65
65
  chain: RouteNode[]
66
66
  }
67
67
 
@@ -127,7 +127,7 @@ function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): vo
127
127
  const segment = segments[i]
128
128
 
129
129
  if (segment.startsWith(':') && (segment.endsWith('+') || segment.endsWith('*'))) {
130
- // Catch-all segment — consumes all remaining segments
130
+ // Catch-all segment: consumes all remaining segments
131
131
  paramNames.push(segment.slice(1, -1))
132
132
  node.catchAllChild = { chain, paramNames }
133
133
  return
@@ -150,7 +150,7 @@ function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): vo
150
150
  }
151
151
  }
152
152
 
153
- // Terminal — route matches at this trie node
153
+ // Terminal: route matches at this trie node
154
154
  node.route = { chain, paramNames }
155
155
  }
156
156