Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,21 +266,24 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>

### `onEnter` property

- Type: `(match: RouteMatch) => void`
- Type: `(match: RouteMatch, ctx: { location: ParsedLocation }) => void`
- Optional
- A function that will be called when a route is matched and loaded after not being matched in the previous location.
- `ctx.location` is the resolved destination [`ParsedLocation`](./ParsedLocationType.md) of the navigation that triggered the hook.

### `onStay` property

- Type: `(match: RouteMatch) => void`
- Type: `(match: RouteMatch, ctx: { location: ParsedLocation }) => void`
- Optional
- A function that will be called when a route is matched and loaded after being matched in the previous location.
- `ctx.location` is the resolved destination [`ParsedLocation`](./ParsedLocationType.md) of the navigation that triggered the hook.

### `onLeave` property

- Type: `(match: RouteMatch) => void`
- Type: `(match: RouteMatch, ctx: { location: ParsedLocation }) => void`
- Optional
- A function that will be called when a route is no longer matched after being matched in the previous location.
- `ctx.location` is the resolved destination [`ParsedLocation`](./ParsedLocationType.md) of the navigation being made, not the location being left.

### `onCatch` property

Expand Down
18 changes: 15 additions & 3 deletions packages/react-router/tests/route.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1343,9 +1343,21 @@ test('when creating a child route with context, search, params, loader, loaderDe
invoicePage: deps.search.page,
}),
loader: () => ({ detailLoader: 'detailResult' }) as const,
onEnter: (match) => expectTypeOf(match).toMatchTypeOf<TExpectedMatch>(),
onStay: (match) => expectTypeOf(match).toMatchTypeOf<TExpectedMatch>(),
onLeave: (match) => expectTypeOf(match).toMatchTypeOf<TExpectedMatch>(),
onEnter: (match, ctx) => {
expectTypeOf(match).toMatchTypeOf<TExpectedMatch>()
expectTypeOf(ctx.location).toEqualTypeOf<ParsedLocation>()
expectTypeOf(ctx.location.search).toEqualTypeOf<{}>()
},
onStay: (match, ctx) => {
expectTypeOf(match).toMatchTypeOf<TExpectedMatch>()
expectTypeOf(ctx.location).toEqualTypeOf<ParsedLocation>()
expectTypeOf(ctx.location.search).toEqualTypeOf<{}>()
},
onLeave: (match, ctx) => {
expectTypeOf(match).toMatchTypeOf<TExpectedMatch>()
expectTypeOf(ctx.location).toEqualTypeOf<ParsedLocation>()
expectTypeOf(ctx.location.search).toEqualTypeOf<{}>()
},
})
})

Expand Down
9 changes: 9 additions & 0 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,9 @@ export interface UpdatableRouteOptions<
>,
TLoaderDeps
>,
ctx: {
location: ParsedLocation // Do not supply search schema here so as to demotivate people from trying to shortcut loaderDeps
},
) => void
onStay?: (
match: RouteMatch<
Expand All @@ -1339,6 +1342,9 @@ export interface UpdatableRouteOptions<
>,
TLoaderDeps
>,
ctx: {
location: ParsedLocation // Do not supply search schema here so as to demotivate people from trying to shortcut loaderDeps
},
) => void
onLeave?: (
match: RouteMatch<
Expand All @@ -1355,6 +1361,9 @@ export interface UpdatableRouteOptions<
>,
TLoaderDeps
>,
ctx: {
location: ParsedLocation // Do not supply search schema here so as to demotivate people from trying to shortcut loaderDeps
},
) => void
headers?: (
ctx: AssetFnContextOptions<
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2591,6 +2591,7 @@ export class RouterCore<
for (const match of matches as Array<AnyRouteMatch>) {
this.looseRoutesById[match.routeId]!.options[hook]?.(
match,
{ location: next },
)
}
}
Expand Down
58 changes: 56 additions & 2 deletions packages/router-core/tests/callbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,19 @@ describe('callbacks', () => {
expect(onEnter).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ id: '/foo/foo' }),
expect.objectContaining({
location: expect.objectContaining({ pathname: '/foo' }),
}),
)

// Entering bar
await router.navigate({ to: '/bar' })
expect(onEnter).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: '/bar/bar' }),
expect.objectContaining({
location: expect.objectContaining({ pathname: '/bar' }),
}),
)
})
})
Expand All @@ -96,18 +102,24 @@ describe('callbacks', () => {
const router = setup({ onLeave })
await router.navigate({ to: '/foo' })

// Leaving foo to bar
// Leaving foo to bar — the received location is the destination (/bar)
await router.navigate({ to: '/bar' })
expect(onLeave).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ id: '/foo/foo' }),
expect.objectContaining({
location: expect.objectContaining({ pathname: '/bar' }),
}),
)

// Leaving bar to foo
// Leaving bar to foo — the received location is the destination (/foo)
await router.navigate({ to: '/foo' })
expect(onLeave).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: '/bar/bar' }),
expect.objectContaining({
location: expect.objectContaining({ pathname: '/foo' }),
}),
)
})
})
Expand All @@ -123,13 +135,25 @@ describe('callbacks', () => {
expect(onStay).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ id: '/foo/foo', search: { foo: 'baz' } }),
expect.objectContaining({
location: expect.objectContaining({
pathname: '/foo',
search: { foo: 'baz' },
}),
}),
)

// Staying on foo
await router.navigate({ to: '/foo', search: { foo: 'quux' } })
expect(onStay).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: '/foo/foo', search: { foo: 'quux' } }),
expect.objectContaining({
location: expect.objectContaining({
pathname: '/foo',
search: { foo: 'quux' },
}),
}),
)
})

Expand Down Expand Up @@ -159,6 +183,36 @@ describe('callbacks', () => {
})
})

// The second argument of every lifecycle hook exposes the resolved
// destination location of the navigation that triggered it.
describe('destination location argument', () => {
it('passes the resolved destination location to all lifecycle hooks', async () => {
const onEnter = vi.fn()
const onLeave = vi.fn()
const onStay = vi.fn()
const router = setup({ onEnter, onLeave, onStay })

await router.navigate({ to: '/foo', search: { q: 'a' } })
const enterCtx = onEnter.mock.calls[0]?.[1]
expect(enterCtx.location.pathname).toBe('/foo')
expect(enterCtx.location.search).toEqual({ q: 'a' })
expect(enterCtx.location.href).toContain('/foo')

// onLeave for /foo receives the location being navigated TO (/bar),
// not the location being left behind.
await router.navigate({ to: '/bar' })
const leaveCtx = onLeave.mock.calls[0]?.[1]
expect(leaveCtx.location.pathname).toBe('/bar')
expect(leaveCtx.location.search).toEqual({})

// onStay receives the destination location including its search state.
await router.navigate({ to: '/bar', search: { tab: 'two' } })
const stayCtx = onStay.mock.calls.at(-1)?.[1]
expect(stayCtx.location.pathname).toBe('/bar')
expect(stayCtx.location.search).toEqual({ tab: 'two' })
})
})

// Regression tests: switching lifecycle hooks to use routeId must NOT break
// match-level caching, which still relies on match.id (routeId + params + loaderDeps).
describe('same-route match caching', () => {
Expand Down
18 changes: 15 additions & 3 deletions packages/solid-router/tests/route.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1306,9 +1306,21 @@ test('when creating a child route with context, search, params, loader, loaderDe
invoicePage: deps.search.page,
}),
loader: () => ({ detailLoader: 'detailResult' }) as const,
onEnter: (match) => expectTypeOf(match).toMatchTypeOf<TExpectedMatch>(),
onStay: (match) => expectTypeOf(match).toMatchTypeOf<TExpectedMatch>(),
onLeave: (match) => expectTypeOf(match).toMatchTypeOf<TExpectedMatch>(),
onEnter: (match, ctx) => {
expectTypeOf(match).toMatchTypeOf<TExpectedMatch>()
expectTypeOf(ctx.location).toEqualTypeOf<ParsedLocation>()
expectTypeOf(ctx.location.search).toEqualTypeOf<{}>()
},
onStay: (match, ctx) => {
expectTypeOf(match).toMatchTypeOf<TExpectedMatch>()
expectTypeOf(ctx.location).toEqualTypeOf<ParsedLocation>()
expectTypeOf(ctx.location.search).toEqualTypeOf<{}>()
},
onLeave: (match, ctx) => {
expectTypeOf(match).toMatchTypeOf<TExpectedMatch>()
expectTypeOf(ctx.location).toEqualTypeOf<ParsedLocation>()
expectTypeOf(ctx.location.search).toEqualTypeOf<{}>()
},
})
})

Expand Down