Skip to content

Commit 1cb013f

Browse files
committed
feat: add PrefetchContext to PrefetchFunc signature
PrefetchFunc now receives a PrefetchContext object with params, url, and controller instead of a bare NavigationPrecommitController. This enables data prefetching during route navigation by providing route parameters and destination URL to prefetch handlers. BREAKING CHANGE: PrefetchFunc signature changed from (controller: NavigationPrecommitController) => void | Promise<void> to (context: PrefetchContext) => void | Promise<void>. Consumers must destructure { params, url, controller } from the context object instead of receiving the controller directly.
1 parent 5ebcfd2 commit 1cb013f

10 files changed

Lines changed: 166 additions & 62 deletions

src/react/ExampleMain.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ const router = createRouter(function (route) {
2020
route('/').render(HelloWorld)
2121

2222
route('/other')
23-
.prefetch(function (controller) {
24-
console.log('PREFETCHING ROUTE', controller)
23+
.prefetch(function ({ params, url, controller }) {
24+
console.log('PREFETCHING ROUTE', { params, url, controller })
2525

2626
return new Promise(function (r) {
2727
setTimeout(r, 2000)

src/react/components/Router.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,12 @@ export function Router(options: RouterProps) {
200200
return
201201
}
202202

203-
const precommitHandler = createPrecommitHandler(match.handler.prefetch)
203+
const precommitHandler = createPrecommitHandler({
204+
prefetch: match.handler.prefetch,
205+
params: match.params,
206+
url: new URL(event.destination.url),
207+
})
208+
204209
const handler = createHandler(function () {
205210
setCurrent({
206211
match,
@@ -226,7 +231,7 @@ export function Router(options: RouterProps) {
226231

227232
const CurrentComponent = current.match.handler.component
228233
const middlewares = current.match.handler.middlewares
229-
234+
1
230235
return (
231236
<TransitionContext value={transition}>
232237
<NavigationContext value={navigation}>

src/react/createRouter.test.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, vi } from 'vitest'
22
import { createRouter, type RouteFactory } from './createRouter'
33
import { type ComponentType } from 'react'
4-
import { type MiddlewareProps, type PrefetchFunc } from './router'
4+
import { type MiddlewareProps, type PrefetchContext, type PrefetchFunc } from './router'
55

66
/**
77
* Stub component used in tests where a real React component
@@ -34,15 +34,19 @@ function createMiddleware(): ComponentType<MiddlewareProps> {
3434
}
3535

3636
/**
37-
* Creates a mock NavigationPrecommitController with spy
38-
* methods. Each test that needs one creates its own to
37+
* Creates a mock PrefetchContext with spy methods on the
38+
* controller. Each test that needs one creates its own to
3939
* avoid shared state between concurrent tests.
4040
*/
41-
function createMockController() {
41+
function createMockContext(): PrefetchContext {
4242
return {
43-
redirect: vi.fn(),
44-
addHandler: vi.fn(),
45-
} as unknown as NavigationPrecommitController
43+
params: {},
44+
url: new URL('http://localhost/'),
45+
controller: {
46+
redirect: vi.fn(),
47+
addHandler: vi.fn(),
48+
} as unknown as NavigationPrecommitController,
49+
}
4650
}
4751

4852
describe('createRouter', { concurrent: true }, function () {
@@ -210,9 +214,9 @@ describe('createRouter', { concurrent: true }, function () {
210214
.render(Stub)
211215
})
212216

213-
const controller = createMockController()
217+
const context = createMockContext()
214218

215-
await router.match('/')?.handler.prefetch?.(controller)
219+
await router.match('/')?.handler.prefetch?.(context)
216220

217221
expect(order).toStrictEqual([1, 2])
218222
})
@@ -271,11 +275,11 @@ describe('createRouter', { concurrent: true }, function () {
271275
route('/old').redirect('/new')
272276
})
273277

274-
const controller = createMockController()
278+
const context = createMockContext()
275279

276-
router.match('/old')?.handler.prefetch?.(controller)
280+
router.match('/old')?.handler.prefetch?.(context)
277281

278-
expect(controller.redirect).toHaveBeenCalledWith('/new')
282+
expect(context.controller.redirect).toHaveBeenCalledWith('/new')
279283
})
280284

281285
it('uses a fallback component that renders null', function ({ expect }) {
@@ -306,11 +310,11 @@ describe('createRouter', { concurrent: true }, function () {
306310
app('/legacy').redirect('/new-page')
307311
})
308312

309-
const controller = createMockController()
313+
const context = createMockContext()
310314

311-
router.match('/app/legacy')?.handler.prefetch?.(controller)
315+
router.match('/app/legacy')?.handler.prefetch?.(context)
312316

313-
expect(controller.redirect).toHaveBeenCalledWith('/new-page')
317+
expect(context.controller.redirect).toHaveBeenCalledWith('/new-page')
314318
})
315319
})
316320

@@ -392,9 +396,9 @@ describe('createRouter', { concurrent: true }, function () {
392396
prefetched('/page').prefetch(routePrefetch).render(Stub)
393397
})
394398

395-
const controller = createMockController()
399+
const context = createMockContext()
396400

397-
await router.match('/page')?.handler.prefetch?.(controller)
401+
await router.match('/page')?.handler.prefetch?.(context)
398402

399403
expect(order).toStrictEqual([1, 2])
400404
})
@@ -408,11 +412,11 @@ describe('createRouter', { concurrent: true }, function () {
408412
prefetched('/page').render(Stub)
409413
})
410414

411-
const controller = createMockController()
415+
const context = createMockContext()
412416

413-
await router.match('/page')?.handler.prefetch?.(controller)
417+
await router.match('/page')?.handler.prefetch?.(context)
414418

415-
expect(groupPrefetch).toHaveBeenCalledWith(controller)
419+
expect(groupPrefetch).toHaveBeenCalledWith(context)
416420
})
417421
})
418422

@@ -462,9 +466,9 @@ describe('createRouter', { concurrent: true }, function () {
462466
inner('/deep').prefetch(third).render(Stub)
463467
})
464468

465-
const controller = createMockController()
469+
const context = createMockContext()
466470

467-
await router.match('/deep')?.handler.prefetch?.(controller)
471+
await router.match('/deep')?.handler.prefetch?.(context)
468472

469473
expect(order).toStrictEqual([1, 2, 3])
470474
})

src/react/createRouter.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type FormHandler,
55
type Handler,
66
type MiddlewareProps,
7+
type PrefetchContext,
78
type PrefetchFunc,
89
} from 'router/react:router'
910

@@ -269,9 +270,9 @@ function chainPrefetches(
269270
return prefetches[0]
270271
}
271272

272-
return async function (controller: NavigationPrecommitController) {
273+
return async function (context: PrefetchContext) {
273274
for (const fn of prefetches) {
274-
await fn(controller)
275+
await fn(context)
275276
}
276277
}
277278
}
@@ -410,8 +411,8 @@ function createRouteFactory(
410411
const handler: Handler = {
411412
component: RedirectFallback,
412413
middlewares: resolveMiddlewares(),
413-
prefetch: function (controller) {
414-
controller.redirect(target)
414+
prefetch: function (context) {
415+
context.controller.redirect(target)
415416
},
416417
scroll: state.scroll,
417418
focusReset: state.focusReset,

src/react/hooks/useNavigationHandlers.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ describe('useNavigationHandlers', { concurrent: true }, function () {
4545

4646
onTestFinished(unmount)
4747

48-
const result = current.createPrecommitHandler()
48+
const result = current.createPrecommitHandler({
49+
params: {},
50+
url: new URL('http://localhost/'),
51+
})
4952

5053
expect(result).toBeUndefined()
5154
})
@@ -65,12 +68,16 @@ describe('useNavigationHandlers', { concurrent: true }, function () {
6568
onTestFinished(unmount)
6669

6770
const prefetchSpy = vi.fn()
68-
const handler = current.createPrecommitHandler(prefetchSpy)
71+
const handler = current.createPrecommitHandler({
72+
prefetch: prefetchSpy,
73+
params: {},
74+
url: new URL('http://localhost/'),
75+
})
6976

7077
expect(typeof handler).toBe('function')
7178
})
7279

73-
it('createPrecommitHandler calls the prefetch function with the controller', async function ({ expect, onTestFinished }) {
80+
it('createPrecommitHandler calls the prefetch function with a PrefetchContext', async function ({ expect, onTestFinished }) {
7481
/**
7582
* Stub startTransition for the prefetch invocation test.
7683
*/
@@ -85,7 +92,14 @@ describe('useNavigationHandlers', { concurrent: true }, function () {
8592
onTestFinished(unmount)
8693

8794
const prefetchSpy = vi.fn()
88-
const handler = current.createPrecommitHandler(prefetchSpy)!
95+
const params = { id: '42' }
96+
const url = new URL('http://localhost/user/42')
97+
98+
const handler = current.createPrecommitHandler({
99+
prefetch: prefetchSpy,
100+
params,
101+
url,
102+
})!
89103

90104
const mockController = {
91105
redirect: vi.fn(),
@@ -94,7 +108,13 @@ describe('useNavigationHandlers', { concurrent: true }, function () {
94108

95109
await handler(mockController)
96110

97-
expect(prefetchSpy).toHaveBeenCalledWith(mockController)
111+
expect(prefetchSpy).toHaveBeenCalledTimes(1)
112+
113+
const context = prefetchSpy.mock.calls[0][0]
114+
115+
expect(context.params).toBe(params)
116+
expect(context.url).toBe(url)
117+
expect(context.controller).toBe(mockController)
98118
})
99119

100120
it('createHandler returns a function that wraps callback in startTransition', function ({ expect, onTestFinished }) {

src/react/hooks/useNavigationHandlers.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
import { type TransitionFunction, use, useTransition } from 'react'
22
import { TransitionContext } from 'router/react:context/TransitionContext'
3-
import { type PrefetchFunc } from 'router/react:router'
3+
import { type PrefetchFunc, type PrefetchContext } from 'router/react:router'
4+
5+
/**
6+
* Options for creating a precommit handler that forwards
7+
* route context to the prefetch function.
8+
*/
9+
export interface PrecommitHandlerOptions {
10+
/**
11+
* The prefetch function from the matched route handler.
12+
* When undefined, no precommit handler is created.
13+
*/
14+
readonly prefetch?: PrefetchFunc
15+
16+
/**
17+
* Dynamic route parameters extracted from the matched
18+
* URL pattern.
19+
*/
20+
readonly params: Record<string, string>
21+
22+
/**
23+
* The destination URL being navigated to.
24+
*/
25+
readonly url: URL
26+
}
427

528
/**
629
* Creates handler functions for the Navigation API's
@@ -35,18 +58,27 @@ export function useNavigationHandlers(
3558
const [, startTransition] = contextTransition
3659

3760
/**
38-
* Creates a precommit handler that receives the
39-
* NavigationPrecommitController and forwards it to
40-
* the route's prefetch function. Runs before the URL
41-
* commits, so no React state transitions are needed here.
61+
* Creates a precommit handler that constructs a
62+
* `PrefetchContext` from the matched route information
63+
* and forwards it to the route's prefetch function.
64+
* Runs before the URL commits, so no React state
65+
* transitions are needed here.
4266
*/
43-
function createPrecommitHandler(prefetch?: PrefetchFunc) {
44-
if (prefetch === undefined) {
67+
function createPrecommitHandler(options: PrecommitHandlerOptions) {
68+
if (options.prefetch === undefined) {
4569
return undefined
4670
}
4771

72+
const prefetch = options.prefetch
73+
4874
return async function (controller: NavigationPrecommitController) {
49-
await prefetch(controller)
75+
const context: PrefetchContext = {
76+
params: options.params,
77+
url: options.url,
78+
controller,
79+
}
80+
81+
await prefetch(context)
5082
}
5183
}
5284

src/react/hooks/usePrefetch.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('usePrefetch', { concurrent: true }, function () {
2828
expect(typeof current).toBe('function')
2929
})
3030

31-
it('calls the matched route prefetch handler with a stub controller', function ({ expect, onTestFinished }) {
31+
it('calls the matched route prefetch handler with a PrefetchContext', function ({ expect, onTestFinished }) {
3232
const prefetchSpy = vi.fn()
3333
const matcher = createMatcher<Handler>()
3434

@@ -47,10 +47,13 @@ describe('usePrefetch', { concurrent: true }, function () {
4747

4848
expect(prefetchSpy).toHaveBeenCalledTimes(1)
4949

50-
const controller = prefetchSpy.mock.calls[0][0]
50+
const context = prefetchSpy.mock.calls[0][0]
5151

52-
expect(typeof controller.redirect).toBe('function')
53-
expect(typeof controller.addHandler).toBe('function')
52+
expect(context.params).toStrictEqual({})
53+
expect(context.url).toBeInstanceOf(URL)
54+
expect(context.url.pathname).toBe('/about')
55+
expect(typeof context.controller.redirect).toBe('function')
56+
expect(typeof context.controller.addHandler).toBe('function')
5457
})
5558

5659
it('returns undefined when no route matches', function ({ expect, onTestFinished }) {

src/react/hooks/usePrefetch.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { use } from 'react'
22
import { MatcherContext } from 'router/react:context/MatcherContext'
3-
import { type Handler } from 'router/react:router'
3+
import { type Handler, type PrefetchContext } from 'router/react:router'
44
import { type Matcher } from 'router:matcher'
55

66
/**
@@ -34,16 +34,17 @@ export function usePrefetch(options?: PrefetchOptions) {
3434
/**
3535
* Triggers prefetch for the given URL by matching it against
3636
* registered routes and calling the route's prefetch function
37-
* with a stub controller. Extracts the pathname from the URL
38-
* before matching to handle both absolute and relative URLs.
37+
* with a context containing the matched params, the parsed
38+
* URL, and a stub controller. Extracts the pathname from the
39+
* URL before matching to handle both absolute and relative URLs.
3940
*
4041
* @param url - The URL or path to prefetch data for.
4142
* @returns The prefetch promise, or undefined if no prefetch
4243
* handler is registered for the matched route.
4344
*/
4445
return function (url: string) {
45-
const pathname = new URL(url, 'http://localhost').pathname
46-
const match = matcher.match(pathname)
46+
const parsed = new URL(url, 'http://localhost')
47+
const match = matcher.match(parsed.pathname)
4748

4849
if (match?.handler.prefetch === undefined) {
4950
return
@@ -59,6 +60,12 @@ export function usePrefetch(options?: PrefetchOptions) {
5960
addHandler() {},
6061
}
6162

62-
return match.handler.prefetch(stubController)
63+
const context: PrefetchContext = {
64+
params: match.params,
65+
url: parsed,
66+
controller: stubController,
67+
}
68+
69+
return match.handler.prefetch(context)
6370
}
6471
}

0 commit comments

Comments
 (0)