@buzola/router 0.0.18 → 0.0.20

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 (51) 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.d.ts +3 -1
  11. package/dist/engine/navigation-adapter.d.ts.map +1 -1
  12. package/dist/engine/navigation-adapter.js +27 -2
  13. package/dist/engine/navigation-adapter.js.map +1 -1
  14. package/dist/engine/route-tree.js +2 -2
  15. package/dist/engine/route-tree.js.map +1 -1
  16. package/dist/engine/router.d.ts +7 -6
  17. package/dist/engine/router.d.ts.map +1 -1
  18. package/dist/engine/router.js +60 -26
  19. package/dist/engine/router.js.map +1 -1
  20. package/dist/engine/types.d.ts +15 -9
  21. package/dist/engine/types.d.ts.map +1 -1
  22. package/dist/engine/utils.d.ts +3 -3
  23. package/dist/engine/utils.js +3 -3
  24. package/dist/react/hooks.d.ts +2 -2
  25. package/dist/react/hooks.js +1 -1
  26. package/dist/react/link.d.ts +4 -4
  27. package/dist/react/link.js +1 -1
  28. package/dist/react/outlet.js +2 -2
  29. package/dist/react/outlet.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/__tests__/create-page.test.ts +1 -1
  32. package/src/__tests__/loader-cache.test.ts +1 -1
  33. package/src/__tests__/loader-state.test.ts +12 -12
  34. package/src/__tests__/navigation-adapter.test.ts +76 -3
  35. package/src/__tests__/react.test.tsx +4 -4
  36. package/src/__tests__/router.test.ts +293 -4
  37. package/src/__tests__/setup-dom.ts +1 -1
  38. package/src/__tests__/type-safety.test.tsx +22 -22
  39. package/src/define/create-page.tsx +1 -1
  40. package/src/engine/loader-cache.ts +1 -1
  41. package/src/engine/loader-state.ts +9 -9
  42. package/src/engine/matcher.ts +4 -4
  43. package/src/engine/navigation-adapter.ts +27 -2
  44. package/src/engine/route-tree.ts +3 -3
  45. package/src/engine/router.ts +68 -27
  46. package/src/engine/types.ts +15 -9
  47. package/src/engine/utils.ts +3 -3
  48. package/src/env.d.ts +8 -0
  49. package/src/react/hooks.ts +2 -2
  50. package/src/react/link.tsx +5 -5
  51. package/src/react/outlet.tsx +2 -2
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, mock, spyOn } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
2
2
  import { matchRoutes } from '../engine/matcher.js'
3
3
  import { createMemoryNavigationAdapter } from '../engine/navigation-adapter.js'
4
4
  import { buildRouteTree } from '../engine/route-tree.js'
@@ -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>; shouldIntercept?: ShouldInterceptFn },
15
+ options?: { persistentParams?: () => Record<string, string>; shouldIntercept?: ShouldInterceptFn; viewTransitions?: boolean },
16
16
  ) {
17
17
  const routes = buildRouteTree(configs)
18
18
  const adapter = createMemoryNavigationAdapter({ initialURL })
@@ -307,7 +307,27 @@ 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 on a traversal between two entries with the same URL', () => {
311
+ const { router, count } = createCountingRouter('http://localhost/list')
312
+ router.navigate('/list')
313
+
314
+ router.back()
315
+
316
+ expect(count()).toBe(1)
317
+ })
318
+
319
+ it('does not invalidate on an intercepted traversal between two entries with the same URL', async () => {
320
+ const { router, count } = createCountingRouter('http://localhost/list')
321
+ router.navigate('/list')
322
+ router.addBlocker(async () => true)
323
+
324
+ router.back()
325
+ await new Promise(resolve => setTimeout(resolve, 0))
326
+
327
+ expect(count()).toBe(1)
328
+ })
329
+
330
+ it('does not invalidate when the URL changes: the href-keyed loader reloads on its own', () => {
311
331
  const { router, count } = createCountingRouter('http://localhost/')
312
332
 
313
333
  router.navigate('/list')
@@ -317,6 +337,116 @@ describe('Router same-URL navigation', () => {
317
337
  })
318
338
  })
319
339
 
340
+ describe('Router scroll to top', () => {
341
+ // Stands in for the window the router scrolls. Other test files install a DOM, so the real global is put back.
342
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'window')
343
+ const scrollTo = mock((_options: ScrollToOptions) => {})
344
+ beforeEach(() => {
345
+ scrollTo.mockClear()
346
+ Object.defineProperty(globalThis, 'window', { value: { scrollTo }, writable: true, configurable: true })
347
+ })
348
+ afterEach(() => {
349
+ if (original) {
350
+ Object.defineProperty(globalThis, 'window', original)
351
+ } else {
352
+ delete (globalThis as { window?: unknown }).window
353
+ }
354
+ })
355
+
356
+ // Records the scroll option of every navigation the router intercepts.
357
+ function createRecordingRouter() {
358
+ const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/list' })
359
+ const scroll: (string | undefined)[] = []
360
+ const recording = new Map<(event: BuzolaNavigateEvent) => void, (event: BuzolaNavigateEvent) => void>()
361
+ const router = new Router({
362
+ routes: buildRouteTree([
363
+ { path: '/list', component: dummyComponent },
364
+ { path: '/about', component: dummyComponent },
365
+ ]),
366
+ adapter: {
367
+ ...adapter,
368
+ addEventListener(type, listener) {
369
+ const record = (event: BuzolaNavigateEvent) =>
370
+ listener({
371
+ ...event,
372
+ intercept(options) {
373
+ scroll.push(options.scroll)
374
+ event.intercept(options)
375
+ },
376
+ })
377
+ recording.set(listener, record)
378
+ adapter.addEventListener(type, record)
379
+ },
380
+ removeEventListener(type, listener) {
381
+ const record = recording.get(listener)
382
+ if (record) adapter.removeEventListener(type, record)
383
+ },
384
+ },
385
+ })
386
+ router.start()
387
+ return { router, scroll }
388
+ }
389
+
390
+ it('scrolls to the top itself instead of leaving it to the browser on a push', () => {
391
+ const { router, scroll } = createRecordingRouter()
392
+
393
+ router.navigate('/about')
394
+
395
+ expect(scroll).toEqual(['manual'])
396
+ expect(scrollTo).toHaveBeenCalledTimes(1)
397
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'instant' })
398
+ })
399
+
400
+ it('scrolls to the top itself on a replace', () => {
401
+ const { router, scroll } = createRecordingRouter()
402
+
403
+ router.navigate('/about', { replace: true })
404
+
405
+ expect(scroll).toEqual(['manual'])
406
+ expect(scrollTo).toHaveBeenCalledTimes(1)
407
+ })
408
+
409
+ it('leaves a fragment to the browser', () => {
410
+ const { router, scroll } = createRecordingRouter()
411
+
412
+ router.navigate('/about#team')
413
+
414
+ expect(scroll).toEqual(['after-transition'])
415
+ expect(scrollTo).not.toHaveBeenCalled()
416
+ })
417
+
418
+ it('does not scroll on a traversal, whose position the browser restores', () => {
419
+ const { router, scroll } = createRecordingRouter()
420
+ router.navigate('/about')
421
+
422
+ router.back()
423
+
424
+ expect(scroll).toEqual(['manual'])
425
+ expect(scrollTo).toHaveBeenCalledTimes(1)
426
+ })
427
+
428
+ it('leaves the restore to the browser on a traversal it intercepts', async () => {
429
+ const { router, scroll } = createRecordingRouter()
430
+ router.navigate('/about')
431
+ router.addBlocker(async () => true)
432
+
433
+ router.back()
434
+ await new Promise(resolve => setTimeout(resolve, 0))
435
+
436
+ expect(scroll).toEqual(['manual', 'after-transition'])
437
+ expect(scrollTo).toHaveBeenCalledTimes(1)
438
+ })
439
+
440
+ it('does not scroll when resetScroll is false', () => {
441
+ const { router, scroll } = createRecordingRouter()
442
+
443
+ router.navigate('/about', { resetScroll: false })
444
+
445
+ expect(scroll).toEqual(['manual'])
446
+ expect(scrollTo).not.toHaveBeenCalled()
447
+ })
448
+ })
449
+
320
450
  describe('Router focus reset', () => {
321
451
  function createCapturingRouter(initialURL: string) {
322
452
  const routes = buildRouteTree([
@@ -408,7 +538,7 @@ describe('Router focus reset', () => {
408
538
  try {
409
539
  const { router, captured } = createCapturingRouter('http://localhost/list')
410
540
 
411
- // No route matches — the router bails out before intercepting
541
+ // No route matches: the router bails out before intercepting
412
542
  router.navigate('/unmatched', { focusReset: 'manual' })
413
543
  expect(captured).toHaveLength(0)
414
544
 
@@ -421,6 +551,165 @@ describe('Router focus reset', () => {
421
551
  })
422
552
  })
423
553
 
554
+ describe('Router traversals', () => {
555
+ const flush = () => new Promise(resolve => setTimeout(resolve, 0))
556
+ const configs: RouteConfig[] = [
557
+ { path: '/list', component: dummyComponent },
558
+ { path: '/about', component: dummyComponent },
559
+ ]
560
+
561
+ // Safari only restores scroll reliably on the classic, non-intercepted path (see Router.start).
562
+ it('leaves a traversal to the browser and renders the destination once it commits', () => {
563
+ const { router, adapter } = createRouter(configs, 'http://localhost/list')
564
+ router.navigate('/about')
565
+
566
+ router.back()
567
+
568
+ expect(adapter.interceptCount('traverse')).toBe(0)
569
+ expect(router.getState().location.pathname).toBe('/list')
570
+ expect(router.getState().isPending).toBe(false)
571
+ })
572
+
573
+ it('takes the navigation state from the entry it traverses to', () => {
574
+ const { router } = createRouter(configs, 'http://localhost/list')
575
+ router.navigate('/about', { state: { tab: 'team' } })
576
+ router.navigate('/list')
577
+
578
+ router.back()
579
+
580
+ expect(router.getState().navigationState).toEqual({ tab: 'team' })
581
+ })
582
+
583
+ it('intercepts a traversal while a blocker is registered, so the blocker can hold it', async () => {
584
+ const { router, adapter } = createRouter(configs, 'http://localhost/list')
585
+ router.navigate('/about')
586
+ await flush()
587
+ const blocker = mock(async () => true)
588
+ router.addBlocker(blocker)
589
+
590
+ router.back()
591
+ await flush()
592
+
593
+ expect(adapter.interceptCount('traverse')).toBe(1)
594
+ expect(blocker).toHaveBeenCalledTimes(1)
595
+ expect(router.getState().location.pathname).toBe('/list')
596
+ })
597
+
598
+ // Lets a test change the navigate events the router sees.
599
+ function createRouterSeeing(change: (event: BuzolaNavigateEvent) => BuzolaNavigateEvent) {
600
+ const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/list' })
601
+ const wrapped = new Map<(event: BuzolaNavigateEvent) => void, (event: BuzolaNavigateEvent) => void>()
602
+ const router = new Router({
603
+ routes: buildRouteTree(configs),
604
+ adapter: {
605
+ ...adapter,
606
+ addEventListener(type, listener) {
607
+ const changed = (event: BuzolaNavigateEvent) => listener(change(event))
608
+ wrapped.set(listener, changed)
609
+ adapter.addEventListener(type, changed)
610
+ },
611
+ removeEventListener(type, listener) {
612
+ const changed = wrapped.get(listener)
613
+ if (changed) adapter.removeEventListener(type, changed)
614
+ },
615
+ },
616
+ })
617
+ router.start()
618
+ return { router, adapter }
619
+ }
620
+
621
+ it('renders a traversal only when the browser commits it, so a cancelled one never shows', () => {
622
+ let commit: (() => void) | undefined
623
+ const { router } = createRouterSeeing(event => ({ ...event, whenCommitted: callback => (commit = callback) }))
624
+ router.navigate('/about')
625
+
626
+ router.back()
627
+ expect(router.getState().location.pathname).toBe('/about')
628
+
629
+ commit?.()
630
+ expect(router.getState().location.pathname).toBe('/list')
631
+ })
632
+
633
+ it('intercepts a traversal when the adapter cannot report its commit', () => {
634
+ const { router, adapter } = createRouterSeeing(({ whenCommitted: _, ...event }) => event)
635
+ router.navigate('/about')
636
+
637
+ router.back()
638
+
639
+ expect(adapter.interceptCount('traverse')).toBe(1)
640
+ expect(router.getState().location.pathname).toBe('/list')
641
+ })
642
+
643
+ it('leaves a forward traversal to the browser too', () => {
644
+ const { router, adapter } = createRouter(configs, 'http://localhost/list')
645
+ router.navigate('/about', { state: { tab: 'team' } })
646
+ router.back()
647
+
648
+ router.forward()
649
+
650
+ expect(adapter.interceptCount('traverse')).toBe(0)
651
+ expect(router.getState().location.pathname).toBe('/about')
652
+ expect(router.getState().navigationState).toEqual({ tab: 'team' })
653
+ })
654
+
655
+ describe('with view transitions', () => {
656
+ // Stands in for the document, holding each view transition update until the test runs it.
657
+ // Other test files install a DOM, so the real global is put back afterwards.
658
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'document')
659
+ let transitions: (() => void)[] = []
660
+ function withDocument(viewTransitions: boolean) {
661
+ transitions = []
662
+ const document = viewTransitions ? { startViewTransition: (update: () => void) => transitions.push(update) } : {}
663
+ Object.defineProperty(globalThis, 'document', { value: document, writable: true, configurable: true })
664
+ }
665
+ afterEach(() => {
666
+ if (original) {
667
+ Object.defineProperty(globalThis, 'document', original)
668
+ } else {
669
+ delete (globalThis as { document?: unknown }).document
670
+ }
671
+ })
672
+
673
+ it('intercepts a traversal that runs a view transition', async () => {
674
+ withDocument(true)
675
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { viewTransitions: true })
676
+ router.navigate('/about')
677
+ await flush()
678
+ transitions.shift()?.()
679
+
680
+ router.back()
681
+ await flush()
682
+ transitions.shift()?.()
683
+
684
+ expect(adapter.interceptCount('traverse')).toBe(1)
685
+ expect(router.getState().location.pathname).toBe('/list')
686
+ })
687
+
688
+ it('leaves a traversal to the browser when it has no view transitions', () => {
689
+ withDocument(false)
690
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { viewTransitions: true })
691
+ router.navigate('/about')
692
+
693
+ router.back()
694
+
695
+ expect(adapter.interceptCount('traverse')).toBe(0)
696
+ expect(router.getState().location.pathname).toBe('/list')
697
+ })
698
+
699
+ it('drops the update of a transition that a traversal overtook', async () => {
700
+ withDocument(true)
701
+ const { router } = createRouter(configs, 'http://localhost/list')
702
+ router.navigate('/about', { viewTransition: true })
703
+ await flush()
704
+
705
+ router.back()
706
+ transitions.shift()?.()
707
+
708
+ expect(router.getState().location.pathname).toBe('/list')
709
+ })
710
+ })
711
+ })
712
+
424
713
  describe('Router.leaveApp', () => {
425
714
  // A catch-all 404 route matches every path on the origin, so without a release the router
426
715
  // swallows the server's own URLs and renders "Not found" with no request ever sent.
@@ -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)