diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 1fb4c4eb30..9c748128f9 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -266,21 +266,24 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record ### `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 diff --git a/packages/react-router/tests/route.test-d.tsx b/packages/react-router/tests/route.test-d.tsx index 41b003e5d1..10946a5bfe 100644 --- a/packages/react-router/tests/route.test-d.tsx +++ b/packages/react-router/tests/route.test-d.tsx @@ -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(), - onStay: (match) => expectTypeOf(match).toMatchTypeOf(), - onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + onEnter: (match, ctx) => { + expectTypeOf(match).toMatchTypeOf() + expectTypeOf(ctx.location).toEqualTypeOf() + expectTypeOf(ctx.location.search).toEqualTypeOf<{}>() + }, + onStay: (match, ctx) => { + expectTypeOf(match).toMatchTypeOf() + expectTypeOf(ctx.location).toEqualTypeOf() + expectTypeOf(ctx.location.search).toEqualTypeOf<{}>() + }, + onLeave: (match, ctx) => { + expectTypeOf(match).toMatchTypeOf() + expectTypeOf(ctx.location).toEqualTypeOf() + expectTypeOf(ctx.location.search).toEqualTypeOf<{}>() + }, }) }) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 2de7dc57f0..87a5910200 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -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< @@ -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< @@ -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< diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739..d487f4c941 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2591,6 +2591,7 @@ export class RouterCore< for (const match of matches as Array) { this.looseRoutesById[match.routeId]!.options[hook]?.( match, + { location: next }, ) } } diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index 1673626f7e..37224db869 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -79,6 +79,9 @@ describe('callbacks', () => { expect(onEnter).toHaveBeenNthCalledWith( 1, expect.objectContaining({ id: '/foo/foo' }), + expect.objectContaining({ + location: expect.objectContaining({ pathname: '/foo' }), + }), ) // Entering bar @@ -86,6 +89,9 @@ describe('callbacks', () => { expect(onEnter).toHaveBeenNthCalledWith( 2, expect.objectContaining({ id: '/bar/bar' }), + expect.objectContaining({ + location: expect.objectContaining({ pathname: '/bar' }), + }), ) }) }) @@ -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' }), + }), ) }) }) @@ -123,6 +135,12 @@ 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 @@ -130,6 +148,12 @@ describe('callbacks', () => { expect(onStay).toHaveBeenNthCalledWith( 2, expect.objectContaining({ id: '/foo/foo', search: { foo: 'quux' } }), + expect.objectContaining({ + location: expect.objectContaining({ + pathname: '/foo', + search: { foo: 'quux' }, + }), + }), ) }) @@ -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', () => { diff --git a/packages/solid-router/tests/route.test-d.tsx b/packages/solid-router/tests/route.test-d.tsx index f2dfcada86..302bd5e0bd 100644 --- a/packages/solid-router/tests/route.test-d.tsx +++ b/packages/solid-router/tests/route.test-d.tsx @@ -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(), - onStay: (match) => expectTypeOf(match).toMatchTypeOf(), - onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + onEnter: (match, ctx) => { + expectTypeOf(match).toMatchTypeOf() + expectTypeOf(ctx.location).toEqualTypeOf() + expectTypeOf(ctx.location.search).toEqualTypeOf<{}>() + }, + onStay: (match, ctx) => { + expectTypeOf(match).toMatchTypeOf() + expectTypeOf(ctx.location).toEqualTypeOf() + expectTypeOf(ctx.location.search).toEqualTypeOf<{}>() + }, + onLeave: (match, ctx) => { + expectTypeOf(match).toMatchTypeOf() + expectTypeOf(ctx.location).toEqualTypeOf() + expectTypeOf(ctx.location.search).toEqualTypeOf<{}>() + }, }) })