diff --git a/docs/router/eslint/create-route-property-order.md b/docs/router/eslint/create-route-property-order.md index 3ce5102013..7f28b9ba24 100644 --- a/docs/router/eslint/create-route-property-order.md +++ b/docs/router/eslint/create-route-property-order.md @@ -13,7 +13,7 @@ For the following functions, the property order of the passed in object matters The correct property order is as follows - `params`, `validateSearch` -- `loaderDeps`, `search.middlewares` +- `loaderDeps`, `search.middlewares`, `beforeNavigate` - `context` - `beforeLoad` - `loader` diff --git a/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts b/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts index bc5b367485..17b03d2935 100644 --- a/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts +++ b/packages/eslint-plugin-router/src/rules/create-route-property-order/constants.ts @@ -18,7 +18,7 @@ export type CreateRouteFunction = (typeof createRouteFunctions)[number] export const sortRules = [ [['params', 'validateSearch'], ['search']], - [['search'], ['loaderDeps']], + [['search'], ['loaderDeps', 'beforeNavigate']], [['loaderDeps'], ['context']], [['context'], ['beforeLoad']], [['beforeLoad'], ['loader']], diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index ad499eaa89..0053551065 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -50,10 +50,7 @@ export type BuildLocationFn = < TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - opts: ToOptions & { - leaveParams?: boolean - _includeValidateSearch?: boolean - }, + opts: ToOptions, ) => ParsedLocation export function RouterContextProvider< diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 1b8b0ab407..f15773f8ab 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -41,14 +41,14 @@ export function Transitioner() { React.useEffect(() => { const unsub = router.history.subscribe(router.load) - const nextLocation = router.buildLocation({ + const nextLocation = router.buildLocationInternal({ to: router.latestLocation.pathname, search: true, params: true, hash: true, state: true, _includeValidateSearch: true, - }) + }).location if ( trimPathRight(router.latestLocation.href) !== diff --git a/packages/react-router/src/cancelNavigation.ts b/packages/react-router/src/cancelNavigation.ts new file mode 100644 index 0000000000..80d9fdb095 --- /dev/null +++ b/packages/react-router/src/cancelNavigation.ts @@ -0,0 +1,7 @@ +export function cancelNavigation() { + return { __isCancelNavigation: true } +} + +export function isCancelNavigation(obj: any) { + return !!obj?.__isCancelNavigation +} diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index ffa9d57a41..84d72adfd5 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -155,9 +155,21 @@ export type FileBaseRouteOptions< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TRemountDepsFn = AnyContext, + TBeforeNavigateFn = undefined, > = ParamsOptions & { validateSearch?: Constrain + beforeNavigate?: Constrain< + TBeforeNavigateFn, + ( + opt: BeforeNavigateOptions< + Expand>, + Expand>, + TRouterContext + >, + ) => void | Promise + > + shouldReload?: | boolean | (( @@ -210,7 +222,7 @@ export type FileBaseRouteOptions< ( opt: RemountDepsOptions< TId, - FullSearchSchemaOption, + Expand>, Expand>, TLoaderDeps >, @@ -288,6 +300,16 @@ export interface RouteContextOptions< context: Expand> } +export interface BeforeNavigateOptions< + in out TFullSearchSchema, + in out TAllParams, + in out TRouterContext, +> { + search: TFullSearchSchema + params: TAllParams + context: TRouterContext +} + export interface RemountDepsOptions< in out TRouteId, in out TFullSearchSchema, diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 1786a28ecb..1cdbe23da1 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -30,6 +30,7 @@ import { isRedirect, isResolvedRedirect } from './redirects' import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' +import { isCancelNavigation } from './cancelNavigation' import type * as React from 'react' import type { HistoryLocation, @@ -530,8 +531,22 @@ export interface BuildNextOptions { export interface MatchedRoutesResult { matchedRoutes: Array routeParams: Record + foundRoute?: AnyRoute } +type BuildLocationInternalFn = < + TRouter extends RegisteredRouter, + TTo extends string | undefined, + TFrom extends RoutePaths | string = string, + TMaskFrom extends RoutePaths | string = TFrom, + TMaskTo extends string = '', +>( + opts: ToOptions & { + leaveParams?: boolean + _includeValidateSearch?: boolean + }, +) => { location: ParsedLocation; matchedRoutesResult: MatchedRoutesResult } + export type RouterConstructorOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -1396,7 +1411,10 @@ export class Router< return matches } - getMatchedRoutes = (next: ParsedLocation, dest?: BuildNextOptions) => { + getMatchedRoutes = ( + next: ParsedLocation, + dest?: BuildNextOptions, + ): MatchedRoutesResult => { let routeParams: Record = {} const trimmedPath = trimPathRight(next.pathname) const getMatchedParams = (route: AnyRoute) => { @@ -1455,6 +1473,10 @@ export class Router< } buildLocation: BuildLocationFn = (opts) => { + return this.buildLocationInternal(opts as any).location + } + + buildLocationInternal: BuildLocationInternalFn = (opts) => { const build = ( dest: BuildNextOptions & { unmaskOnReload?: boolean @@ -1742,7 +1764,7 @@ export class Router< final.maskedLocation = maskedFinal } - return final + return { location: final, matchedRoutesResult: nextMatches } } if (opts.mask) { @@ -1864,10 +1886,10 @@ export class Router< rest.hash = parsed.hash.slice(1) } - const location = this.buildLocation({ + const location = this.buildLocationInternal({ ...(rest as any), _includeValidateSearch: true, - }) + }).location return this.commitLocation({ ...location, viewTransition, @@ -1878,7 +1900,7 @@ export class Router< }) } - navigate: NavigateFn = ({ to, reloadDocument, href, ...rest }) => { + navigate: NavigateFn = async ({ to, reloadDocument, href, ...rest }) => { if (reloadDocument) { if (!href) { const location = this.buildLocation({ to, ...rest } as any) @@ -1891,7 +1913,28 @@ export class Router< } return } - + const next = this.buildLocationInternal({ + to, + ...rest, + _includeValidateSearch: true, + } as any) + try { + await next.matchedRoutesResult.foundRoute?.options.beforeNavigate?.({ + context: this.options.context, + search: next.location.search as any, + params: next.matchedRoutesResult.routeParams, + }) + } catch (err) { + if (isRedirect(err)) { + return this.navigate({ + ...err, + }) + } + if (isCancelNavigation(err)) { + return + } + throw err + } return this.buildAndCommitLocation({ ...rest, href, diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 65ebcbe66c..9b02c04d89 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -223,10 +223,13 @@ describe('Link', () => { const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', + validateSearch: z.object({ foo: z.string().optional() }), component: () => { + const { foo } = indexRoute.useSearch() return ( <>

Index

+
{foo ?? '$undefined'}
{ expect(indexFooBarLink).not.toHaveAttribute('data-status', 'active') // navigate to /?foo=bar - fireEvent.click(indexFooBarLink) + act(() => fireEvent.click(indexFooBarLink)) + expect(await screen.findByTestId('foo-value')).toHaveTextContent('bar') expect(indexExactLink).toHaveClass('inactive') expect(indexExactLink).not.toHaveClass('active') diff --git a/packages/react-router/tests/navigate.test.tsx b/packages/react-router/tests/navigate.test.tsx index c3c34edbb5..dddff7b309 100644 --- a/packages/react-router/tests/navigate.test.tsx +++ b/packages/react-router/tests/navigate.test.tsx @@ -1,11 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { createMemoryHistory, createRootRoute, + createRootRouteWithContext, createRoute, createRouter, + linkOptions, + redirect, } from '../src' +import { cancelNavigation } from '../src/cancelNavigation' import type { RouterHistory } from '../src' afterEach(() => { @@ -92,6 +97,7 @@ function createTestRouter(initialHistory?: RouterHistory) { projectTree, uTree, gTree, + searchRoute, ]) const router = createRouter({ routeTree, history }) @@ -548,3 +554,147 @@ describe('relative navigation', () => { expect(router.state.location.pathname).toBe('/posts/tkdodo') }) }) + +describe('beforeNavigate', () => { + type CustomRouterContext = { + greeting: string + universe: { + alpha: string + beta: number + } + } + + function createTestRouterWithBeforeNavigate(initialHistory?: RouterHistory) { + const postIdRouteBeforeLoad = vi.fn() + const lineItemIdRouteBeforeNavigate = vi.fn() + const history = + initialHistory ?? createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRouteWithContext()({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + }) + const postsIndexRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/', + beforeNavigate: () => { + throw redirect({ + to: '/posts/$postId1', + search: { redirect: true }, + params: { postId1: '1' }, + }) + }, + }) + const postIdRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + validateSearch: z.object({ redirect: z.boolean().optional() }), + beforeLoad: postIdRouteBeforeLoad, + }) + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'users', + }) + const userIdRoute = createRoute({ + getParentRoute: () => usersRoute, + path: '$userId', + beforeNavigate: ({ params }) => { + if (params.userId === 'user1') { + throw cancelNavigation() + } + }, + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: z.object({ invoicesRouteSearchParam: z.string() }), + }) + const invoiceIdRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: z.object({ + invoiceIdRouteSearchParam: z.string().catch('default-invoice-id-route'), + }), + }) + const lineItemIdRoute = createRoute({ + getParentRoute: () => invoiceIdRoute, + path: '$lineItemId', + validateSearch: z.object({ lineItemIdRouteSearchParam: z.string() }), + beforeNavigate: lineItemIdRouteBeforeNavigate, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsIndexRoute, postIdRoute]), + usersRoute.addChildren([userIdRoute]), + invoicesRoute.addChildren([ + invoiceIdRoute.addChildren([lineItemIdRoute]), + ]), + ]) + const context: CustomRouterContext = { + greeting: 'hello world', + universe: { alpha: 'a', beta: 123 }, + } + const router = createRouter({ routeTree, history, context }) + return { + router, + context, + mocks: { postIdRouteBeforeLoad, lineItemIdRouteBeforeNavigate }, + } + } + + it('should navigate to /posts/1?redirect=true if beforeNavigate in /posts throws redirect', async () => { + const { router, mocks } = createTestRouterWithBeforeNavigate() + + await router.load() + await router.navigate({ to: '/posts' }) + expect(router.state.location.href).toBe('/posts/1?redirect=true') + expect(router.state.location.search).toEqual({ redirect: true }) + expect(mocks.postIdRouteBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + params: { postId: '1' }, + search: { redirect: true }, + }), + ) + }) + + it('should cancel navigation if beforeNavigate in /users/user1 throws cancelNavigation', async () => { + const { router } = createTestRouterWithBeforeNavigate() + + await router.load() + await router.navigate({ to: '/users/user1' }) + expect(router.state.location.pathname).toBe('/') + }) + + it('beforeNavigate is called with all parent path, search params and router context', async () => { + const { router, context, mocks } = createTestRouterWithBeforeNavigate() + + await router.load() + const link = linkOptions({ + to: '/invoices/$invoiceId/$lineItemId', + params: { invoiceId: 'invoice-1', lineItemId: 'line-item-5' }, + search: { + invoicesRouteSearchParam: 'foo', + lineItemIdRouteSearchParam: 'bar', + }, + }) + const location = router.buildLocation(link) + await router.navigate(link) + expect(router.state.location.pathname).toBe(location.pathname) + expect(mocks.lineItemIdRouteBeforeNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + params: link.params, + search: { + ...link.search, + invoiceIdRouteSearchParam: 'default-invoice-id-route', + }, + context, + }), + ) + }) +}) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 0293de467b..54f3965fcf 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -953,9 +953,9 @@ test('when navigating from /invoices to ./invoiceId and the current route is /po <>

Details!