diff --git a/.changeset/silly-worms-sleep.md b/.changeset/silly-worms-sleep.md new file mode 100644 index 0000000000..9485d21b1e --- /dev/null +++ b/.changeset/silly-worms-sleep.md @@ -0,0 +1,5 @@ +--- +"react-router": minor +--- + +Implement ``/`useAbsoluteRoutes` as alternatiuves to ``/`useRoutes` when using absolute paths is required. This is primarily intended to be used to ease migrations from v5 applications where this was a common pattern. It's also useful for descendant routes when you want to manage your paths in an external data structure. diff --git a/docs/api/components/AbsoluteRoutes.md b/docs/api/components/AbsoluteRoutes.md new file mode 100644 index 0000000000..fdc81f8063 --- /dev/null +++ b/docs/api/components/AbsoluteRoutes.md @@ -0,0 +1,47 @@ +--- +title: AbsoluteRoutes +--- + +# AbsoluteRoutes + +[MODES: framework, data, declarative] + +## Summary + +[Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react_router.AbsoluteRoutes.html) + +An alternate version of [](./Routes) that expects absolute paths on routes instead of relative paths. This is mostly intended to be used as a tool to help migrate from v5 where absolute paths were a common pattern, or for when you want to define your paths in a separate data structure using absolute paths. + +```tsx +import { AbsoluteRoutes, Route } from "react-router"; + + + } /> +; + +function Dashboard() { + return ( + + } + /> + } /> + + ); +} +``` + +## Props + +### children + +[modes: framework, data, declarative] + +Nested [Route](../components/Route) elements using absolute paths + +### location + +[modes: framework, data, declarative] + +The location to match against. Defaults to the current location. diff --git a/docs/api/hooks/useAbsoluteRoutes.md b/docs/api/hooks/useAbsoluteRoutes.md new file mode 100644 index 0000000000..ef1600204d --- /dev/null +++ b/docs/api/hooks/useAbsoluteRoutes.md @@ -0,0 +1,78 @@ +--- +title: useAbsoluteRoutes +--- + +# useAbsoluteRoutes + +[MODES: framework, data, declarative] + +## Summary + +[Reference Documentation ↗](https://api.reactrouter.com/v7/functions/react_router.useAbsoluteRoutes.html) + +An alternate version of [useRoutes](./useRoutes) that expects absolute paths on routes instead of relative paths. This is mostly intended to be used as a tool to help migrate from v5 where absolute paths were a common pattern, or for when you want to define your paths in a separate data structure using absolute paths. This hook expects absolute paths both when used at the top level of your application, or within a set of descendant routes inside a splat route. + +The return value of `useAbsoluteRoutes` is either a valid React element you can use to render the route tree, or `null` if nothing matched. + +```tsx +import * as React from "react"; +import { useAbsoluteRoutes } from "react-router"; + +const routes = { + dashboard: { + path: "/dashboard", + href: () => `/dashboard`, + }, + dashboardMessages: { + path: "/dashboard/messages", + href: () => `/dashboard/messages`, + }, + dashboardMessage: { + path: "/dashboard/:id", + href: (id: number) => `/dashboard/${id}`, + }, +}; + +function App() { + let element = useAbsoluteRoutes([ + { + path: routes.dashboard.path, + element: , + children: [ + { + path: routes.dashboardMessages.path, + element: , + children: [ + { + path: routes.dashboardMessage.path, + element: , + }, + ], + }, + ], + }, + ]); + + return element; +} +``` + +## Signature + +```tsx +useAbsoluteRoutes(routes, locationArg): undefined +``` + +## Params + +### routes + +[modes: framework, data, declarative] + +Your routes to use to render this location, defined using absolute paths + +### locationArg + +[modes: framework, data, declarative] + +The location to render instead of the current location diff --git a/packages/react-router/__tests__/absolute-routes-test.tsx b/packages/react-router/__tests__/absolute-routes-test.tsx new file mode 100644 index 0000000000..19d618373d --- /dev/null +++ b/packages/react-router/__tests__/absolute-routes-test.tsx @@ -0,0 +1,300 @@ +import * as React from "react"; +import * as TestRenderer from "react-test-renderer"; +import { + AbsoluteRoutes, + MemoryRouter, + Routes, + Route, + createRoutesFromElements, + useAbsoluteRoutes, + Outlet, +} from "react-router"; + +describe("/useAbsoluteRoutes", () => { + it(" treats descendant route paths as absolute", () => { + function App({ url }) { + return ( + + + } /> + + + ); + } + + function Auth() { + return ( + + Auth Login} /> + Nope} /> + Not Found} /> + + ); + } + + // Matches absolute descendant routes + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Auth Login +

+ `); + + // Falls through to splat/not-found routes + let renderer2: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer2 = TestRenderer.create(); + }); + + expect(renderer2.toJSON()).toMatchInlineSnapshot(` +

+ Not Found +

+ `); + + // Does not match child relative paths + let renderer3: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer3 = TestRenderer.create(); + }); + + expect(renderer3.toJSON()).toMatchInlineSnapshot(` +

+ Not Found +

+ `); + }); + + it("useAbsoluteRoutes() treats descendant route paths as absolute", () => { + function App({ url }) { + return ( + + + } /> + + + ); + } + + function Auth() { + let childRoutes = createRoutesFromElements( + <> + Auth Login} /> + Nope} /> + Not Found} /> + + ); + return useAbsoluteRoutes(childRoutes); + } + + // Matches absolute descendant routes + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Auth Login +

+ `); + + // Falls through to splat/not-found routes + let renderer2: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer2 = TestRenderer.create(); + }); + + expect(renderer2.toJSON()).toMatchInlineSnapshot(` +

+ Not Found +

+ `); + + // Does not match child relative paths + let renderer3: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer3 = TestRenderer.create(); + }); + + expect(renderer3.toJSON()).toMatchInlineSnapshot(` +

+ Not Found +

+ `); + }); + + it("works for descendant pathless layout routes (no path specified)", () => { + function App({ url }) { + return ( + + + } /> + + + ); + } + + function Auth() { + return ( + + }> + Auth Login} /> + Nope} /> + Not Found} /> + + + ); + } + + function AuthLayout() { + return ( + <> +

Auth Layout

+ + + ); + } + + // Matches absolute descendant routes + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Auth Layout +

, +

+ Auth Login +

, + ] + `); + + // Falls through to splat/not-found routes + let renderer2: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer2 = TestRenderer.create(); + }); + + expect(renderer2.toJSON()).toMatchInlineSnapshot(` + [ +

+ Auth Layout +

, +

+ Not Found +

, + ] + `); + + // Does not match child relative paths + let renderer3: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer3 = TestRenderer.create(); + }); + + expect(renderer3.toJSON()).toMatchInlineSnapshot(` + [ +

+ Auth Layout +

, +

+ Not Found +

, + ] + `); + }); + + it("works for descendant pathless layout routes (absolute path)", () => { + // Once you do an absolute layout route, you can start using relative on + // children again since they get flattened down together during matching + function App({ url }) { + return ( + + + } /> + + + ); + } + + function Auth() { + return ( + + }> + Auth Login} /> + Works} /> + Not Found} /> + + + ); + } + + function AuthLayout() { + return ( + <> +

Auth Layout

+ + + ); + } + + // Matches absolute descendant routes + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Auth Layout +

, +

+ Auth Login +

, + ] + `); + + // Falls through to splat/not-found routes + let renderer2: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer2 = TestRenderer.create(); + }); + + expect(renderer2.toJSON()).toMatchInlineSnapshot(` + [ +

+ Auth Layout +

, +

+ Not Found +

, + ] + `); + + // Does not match child relative paths + let renderer3: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer3 = TestRenderer.create(); + }); + + expect(renderer3.toJSON()).toMatchInlineSnapshot(` + [ +

+ Auth Layout +

, +

+ Works +

, + ] + `); + }); +}); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index fddcd940eb..a5e4158e9c 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -89,6 +89,7 @@ export type { RouteObject, } from "./lib/context"; export type { + AbsoluteRoutesProps, AwaitProps, IndexRouteProps, LayoutRouteProps, @@ -103,6 +104,7 @@ export type { RoutesProps, } from "./lib/components"; export { + AbsoluteRoutes, Await, MemoryRouter, Navigate, @@ -118,6 +120,7 @@ export { } from "./lib/components"; export type { NavigateFunction } from "./lib/hooks"; export { + useAbsoluteRoutes, useBlocker, useActionData, useAsyncError, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 769dc28bf1..aceb51d856 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -53,6 +53,7 @@ import { } from "./context"; import { _renderMatches, + useAbsoluteRoutes, useActionData, useAsyncValue, useInRouterContext, @@ -865,6 +866,39 @@ export function Routes({ return useRoutes(createRoutesFromChildren(children), location); } +export interface AbsoluteRoutesProps extends RoutesProps {} + +/** + * An alternate implementation of `` that expects absolute paths even + * when used as descendant routes. Note that these routes do not participate in + * data loading, actions, code splitting, or any other route module features. + * + * ```tsx + * import { AbsoluteRoutes, Route } from "react-router" + * + * + * } /> + * + * + * function Dashboard() { + * return ( + * + * } /> + * } /> + * + * ); + * } + * ``` + * + * @category Components + */ +export function AbsoluteRoutes({ + children, + location, +}: AbsoluteRoutesProps): React.ReactElement | null { + return useAbsoluteRoutes(createRoutesFromChildren(children), location); +} + export interface AwaitResolveRenderFunction { (data: Awaited): React.ReactNode; } diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 2b0953d73b..9a9a374e5f 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -425,6 +425,19 @@ export function useRoutes( return useRoutesImpl(routes, locationArg); } +/** + * Returns the element of the route that matched the current location using + * absolute path matching, prepared with the correct context to render the + * remainder of the route tree. Route elements in the tree must render an + * `` to render their child route's element. + */ +export function useAbsoluteRoutes( + routes: RouteObject[], + locationArg?: Partial | string +): React.ReactElement | null { + return useRoutesImpl(routes, locationArg, undefined, undefined, true); +} + /** * Internal implementation with accept optional param for RouterProvider usage * @@ -435,7 +448,8 @@ export function useRoutesImpl( routes: RouteObject[], locationArg?: Partial | string, dataRouterState?: DataRouter["state"], - future?: DataRouter["future"] + future?: DataRouter["future"], + absolute?: boolean ): React.ReactElement | null { invariant( useInRouterContext(), @@ -511,7 +525,7 @@ export function useRoutesImpl( let pathname = location.pathname || "/"; let remainingPathname = pathname; - if (parentPathnameBase !== "/") { + if (!absolute && parentPathnameBase !== "/") { // Determine the remaining pathname by removing the # of URL segments the // parentPathnameBase has, instead of removing based on character count. // This is because we can't guarantee that incoming/outgoing encodings/