diff --git a/docs/components/absolute-routes.md b/docs/components/absolute-routes.md new file mode 100644 index 0000000000..5f477db979 --- /dev/null +++ b/docs/components/absolute-routes.md @@ -0,0 +1,55 @@ +--- +title: AbsoluteRoutes +--- + +# `` + +Rendered anywhere in the app, `` will match a set of child routes using absolute paths against the current [location][location] pathname. + +```tsx +interface AbsoluteRoutesProps { + children?: React.ReactNode; + location?: Partial | string; +} +``` + +If you're using a data router like [`createBrowserRouter`][createbrowserrouter] it is uncommon to use this component as routes defined as part of a descendant `` tree cannot leverage the [Data APIs][data-apis] available to [`RouterProvider`][router-provider] apps. You **can and should** use this component within your `RouterProvider` application [while you are migrating][migrating-to-router-provider]. + +This component is strictly a utility to be used to assist in migration from v5 to v6 so that folks can use absolute paths in descendant route definitions (which was a common pattern in RR v5). The intent is to remove this component in v7 so it is marked "deprecated" from the start as a reminder to work on moving your route definitions upwards out of descendant routes.

We expect the concept of "descendant routes" to be replaced by [Lazy Route Discovery][lazy-route-discovery-rfc] when that feature lands, so the plan is that folks can use `` to migrate from v5 to v6. Then, incrementally migrate those descendant routes to lazily discovered route `children` while on v6. Then when an eventual v7 releases, there will be no need for `AbsoluteRoutes` and it can be safely removed.
+ +Whenever the location changes, `` looks through all its child routes to find the best absolute-path match and renders that branch of the UI. `` elements may be nested to indicate nested UI, but their paths should all be specified via absolute paths. Parent routes render their child routes by rendering an [``][outlet]. + +```tsx +function App() { + return ( + + Home} /> + } /> + + ); +} + +function Auth() { + return ( + + }> + } /> + } /> + + + ); +} +``` + +See also: + +- [``][routes] + +[location]: ../utils/location +[outlet]: ./outlet +[createbrowserrouter]: ../routers/create-browser-router +[data-apis]: ../routers/picking-a-router#data-apis +[router-provider]: ../routers/router-provider +[migrating-to-router-provider]: ../upgrading/v6-data +[lazy-route-discovery-rfc]: https://github.com/remix-run/react-router/discussions/11113 +[routes]: ./routes diff --git a/docs/hooks/use-absolute-routes.md b/docs/hooks/use-absolute-routes.md new file mode 100644 index 0000000000..b430391706 --- /dev/null +++ b/docs/hooks/use-absolute-routes.md @@ -0,0 +1,64 @@ +--- +title: useAbsoluteRoutes +--- + +# `useAbsoluteRoutes` + +
+ Type declaration + +```tsx +declare function useAbsoluteRoutes( + routes: RouteObject[], + location?: Partial | string; +): React.ReactElement | null; +``` + +
+ +The `useAbsoluteRoutes` hook is the functional equivalent of [``][absoluteroutes], but it uses JavaScript objects instead of `` elements to define your routes. These objects have the same properties as normal [`` elements][route], but they don't require JSX. + +All route paths passed to `useAbsoluteRoutes` should be defined using absolute paths. + +This component is strictly a utility to be used to assist in migration from v5 to v6 so that folks can use absolute paths in descendant route definitions (which was a common pattern in RR v5). The intent is to remove this component in v7 so it is marked "deprecated" from the start as a reminder to work on moving your route definitions upwards out of descendant routes.

We expect the concept of "descendant routes" to be replaced by [Lazy Route Discovery][lazy-route-discovery-rfc] when that feature lands, so the plan is that folks can use `` to migrate from v5 to v6. Then, incrementally migrate those descendant routes to lazily discovered route `children` while on v6. Then when an eventual v7 releases, there will be no need for `AbsoluteRoutes` and it can be safely removed.
+ +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-dom"; + +function App() { + return ( + + Home} /> + } /> + + ); +} + +function Auth() { + let element = useAbsoluteRoutes([ + path: "/auth", + element: , + children: [{ + path: "/auth", + element: AuthHome, + }, { + path: "/auth/login", + element: AuthLogin, + }], + }]); + + return element; +} +``` + +See also: + +- [`useRoutes`][useroutes] + +[absoluteroutes]: ../components/absolute-routes +[route]: ../components/route +[lazy-route-discovery-rfc]: https://github.com/remix-run/react-router/discussions/11113 +[useroutes]: ./use-routes diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index ae07125d13..191a100e60 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -47,6 +47,7 @@ * deprecate the deep require if we wanted to avoid the duplication here. */ export type { + AbsoluteRoutesProps, ActionFunction, ActionFunctionArgs, AwaitProps, @@ -112,6 +113,7 @@ export type { } from "./react-router-dom"; export { AbortedDeferredError, + AbsoluteRoutes, Await, BrowserRouter, Form, @@ -155,6 +157,7 @@ export { unstable_HistoryRouter, useBlocker, unstable_usePrompt, + useAbsoluteRoutes, useActionData, useAsyncError, useAsyncValue, diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 532658706e..a94da58edb 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -95,6 +95,7 @@ export { createSearchParams }; // Note: Keep in sync with react-router exports! export type { + AbsoluteRoutesProps, ActionFunction, ActionFunctionArgs, AwaitProps, @@ -146,6 +147,7 @@ export type { } from "react-router"; export { AbortedDeferredError, + AbsoluteRoutes, Await, MemoryRouter, Navigate, @@ -169,6 +171,7 @@ export { redirectDocument, renderMatches, resolvePath, + useAbsoluteRoutes, useActionData, useAsyncError, useAsyncValue, diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index f88de93b3d..0bc8369add 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -20,6 +20,7 @@ import URLSearchParams from "@ungap/url-search-params"; // Note: Keep in sync with react-router exports! export type { + AbsoluteRoutesProps, ActionFunction, ActionFunctionArgs, AwaitProps, @@ -71,6 +72,7 @@ export type { } from "react-router"; export { AbortedDeferredError, + AbsoluteRoutes, Await, MemoryRouter, Navigate, @@ -95,6 +97,7 @@ export { redirectDocument, renderMatches, resolvePath, + useAbsoluteRoutes, useActionData, useAsyncError, useAsyncValue, 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..8632c2e6ab --- /dev/null +++ b/packages/react-router/__tests__/absolute-routes-test.tsx @@ -0,0 +1,301 @@ +import * as React from "react"; +import * as TestRenderer from "react-test-renderer"; +import { + AbsoluteRoutes, + MemoryRouter, + Routes, + Route, + createRoutesFromElements, + useRoutes, + 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 fb7b787797..b9afb0867a 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -49,6 +49,7 @@ import { } from "@remix-run/router"; import type { + AbsoluteRoutesProps, AwaitProps, FutureConfig, IndexRouteProps, @@ -63,6 +64,7 @@ import type { RoutesProps, } from "./lib/components"; import { + AbsoluteRoutes, Await, MemoryRouter, Navigate, @@ -93,6 +95,7 @@ import { } from "./lib/context"; import type { NavigateFunction } from "./lib/hooks"; import { + useAbsoluteRoutes, useActionData, useAsyncError, useAsyncValue, @@ -125,6 +128,7 @@ type Search = string; // Expose react-router public API export type { + AbsoluteRoutesProps, ActionFunction, ActionFunctionArgs, AwaitProps, @@ -176,6 +180,7 @@ export type { }; export { AbortedDeferredError, + AbsoluteRoutes, Await, MemoryRouter, Navigate, @@ -200,6 +205,7 @@ export { renderMatches, resolvePath, useBlocker, + useAbsoluteRoutes, useActionData, useAsyncError, useAsyncValue, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 918bc34d39..1415746ea3 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -41,6 +41,7 @@ import { } from "./context"; import { _renderMatches, + useAbsoluteRoutes, useAsyncValue, useInRouterContext, useLocation, @@ -507,6 +508,38 @@ export function Routes({ return useRoutes(createRoutesFromChildren(children), location); } +export interface AbsoluteRoutesProps extends RoutesProps {} + +/** + * @deprecated + * A container for a nested tree of `` elements that renders the branch + * that best matches the current location using absolute path matching. + * + * IMPORTANT: This is strictly a utility to be used to assist in migration + * from v5 to v6 so that folks can use absolute paths in descendant route + * definitions (which was a common pattern in RR v5). The intent is to remove + * this component in v7 so it is marked "deprecated" from the start as a reminder + * to work on moving your route definitions upwards out of descendant routes. + * + * We expect the concept of "descendant routes" to be replaced by "Lazy Route + * Discovery" when that feature lands, so the plan is that folks can use + * `` to migrate from v5->v6. Then, incrementally migrate those + * descendant routes to lazily discovered route `children` while on v6. Then + * when an eventual v7 releases, there will be no need for AbsoluteRoutes and + * it can be safely removed. + * + * See the RFC for Lazy Route Discovery in: + * https://github.com/remix-run/react-router/discussions/11113) + * + * @see https://reactrouter.com/components/absolute-routes + */ +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 5d705553e7..50fb4f7588 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -343,12 +343,45 @@ export function useRoutes( return useRoutesImpl(routes, locationArg); } +/** + * @deprecated + * 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. + * + * IMPORTANT: This is strictly a utility to be used to assist in migration + * from v5 to v6 so that folks can use absolute paths in descendant route + * definitions (which was a common pattern in RR v5). The intent is to remove + * this hook in v7 so it is marked "deprecated" from the start as a reminder + * to work on moving your route definitions upwards out of descendant routes. + * + * We expect the concept of "descendant routes" to be replaced by "Lazy Route + * Discovery" when that feature lands, so the plan is that folks can use + * `useAbsoluteRoutes` to migrate from v5->v6. Then, incrementally migrate those + * descendant routes to lazily discovered route `children` while on v6. Then + * when an eventual v7 releases, there will be no need for `useAbsoluteRoutes` + * and it can be safely removed. + * + * See the RFC for Lazy Route Discovery in: + * https://github.com/remix-run/react-router/discussions/11113) + * + * @see https://reactrouter.com/hooks/use-absolute-routes + */ +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 export function useRoutesImpl( routes: RouteObject[], locationArg?: Partial | string, dataRouterState?: RemixRouter["state"], - future?: RemixRouter["future"] + future?: RemixRouter["future"], + absolute?: boolean ): React.ReactElement | null { invariant( useInRouterContext(), @@ -423,7 +456,7 @@ export function useRoutesImpl( let pathname = location.pathname || "/"; let remainingPathname = - parentPathnameBase === "/" + parentPathnameBase === "/" || absolute ? pathname : pathname.slice(parentPathnameBase.length) || "/";