diff --git a/.changeset/six-lobsters-think.md b/.changeset/six-lobsters-think.md new file mode 100644 index 0000000000..3db89c1017 --- /dev/null +++ b/.changeset/six-lobsters-think.md @@ -0,0 +1,104 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +New (unstable) `useRoute` hook for accessing data from specific routes + +For example, let's say you have an `admin` route somewhere in your app and you want any child routes of `admin` to all have access to the `loaderData` and `actionData` from `admin.` + +```tsx +// app/routes/admin.tsx +import { Outlet } from "react-router"; + +export const loader = () => ({ message: "Hello, loader!" }); + +export const action = () => ({ count: 1 }); + +export default function Component() { + return ( +
+ {/* ... */} + + {/* ... */} +
+ ); +} +``` + +You might even want to create a reusable widget that all of the routes nested under `admin` could use: + +```tsx +import { unstable_useRoute as useRoute } from "react-router"; + +export function AdminWidget() { + // How to get `message` and `count` from `admin` route? +} +``` + +In framework mode, `useRoute` knows all your app's routes and gives you TS errors when invalid route IDs are passed in: + +```tsx +export function AdminWidget() { + const admin = useRoute("routes/dmin"); + // ^^^^^^^^^^^ +} +``` + +`useRoute` returns `undefined` if the route is not part of the current page: + +```tsx +export function AdminWidget() { + const admin = useRoute("routes/admin"); + if (!admin) { + throw new Error(`AdminWidget used outside of "routes/admin"`); + } +} +``` + +Note: the `root` route is the exception since it is guaranteed to be part of the current page. +As a result, `useRoute` never returns `undefined` for `root`. + +`loaderData` and `actionData` are marked as optional since they could be accessed before the `action` is triggered or after the `loader` threw an error: + +```tsx +export function AdminWidget() { + const admin = useRoute("routes/admin"); + if (!admin) { + throw new Error(`AdminWidget used outside of "routes/admin"`); + } + const { loaderData, actionData } = admin; + console.log(loaderData); + // ^? { message: string } | undefined + console.log(actionData); + // ^? { count: number } | undefined +} +``` + +If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments: + +```tsx +export function AdminWidget() { + const currentRoute = useRoute(); + currentRoute.loaderData; + currentRoute.actionData; +} +``` + +This usage is equivalent to calling `useLoaderData` and `useActionData`, but consolidates all route data access into one hook: `useRoute`. + +Note: when calling `useRoute()` (without a route ID), TS has no way to know which route is the current route. +As a result, `loaderData` and `actionData` are typed as `unknown`. +If you want more type-safety, you can either narrow the type yourself with something like `zod` or you can refactor your app to pass down typed props to your `AdminWidget`: + +```tsx +export function AdminWidget({ + message, + count, +}: { + message: string; + count: number; +}) { + /* ... */ +} +``` diff --git a/integration/use-route-test.ts b/integration/use-route-test.ts new file mode 100644 index 0000000000..44b3bca2cb --- /dev/null +++ b/integration/use-route-test.ts @@ -0,0 +1,118 @@ +import tsx from "dedent"; +import { expect } from "@playwright/test"; + +import { test } from "./helpers/fixtures"; +import * as Stream from "./helpers/stream"; +import getPort from "get-port"; + +test.use({ + files: { + "app/expect-type.ts": tsx` + export type Expect = T + + export type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false + `, + "app/routes.ts": tsx` + import { type RouteConfig, route } from "@react-router/dev/routes" + + export default [ + route("parent", "routes/parent.tsx", [ + route("current", "routes/current.tsx") + ]), + route("other", "routes/other.tsx"), + ] satisfies RouteConfig + `, + "app/root.tsx": tsx` + import { Outlet } from "react-router" + + export const loader = () => ({ rootLoader: "root/loader" }) + export const action = () => ({ rootAction: "root/action" }) + + export default function Component() { + return ( + <> +

Root

+ + + ) + } + `, + "app/routes/parent.tsx": tsx` + import { Outlet } from "react-router" + + export const loader = () => ({ parentLoader: "parent/loader" }) + export const action = () => ({ parentAction: "parent/action" }) + + export default function Component() { + return ( + <> +

Parent

+ + + ) + } + `, + "app/routes/current.tsx": tsx` + import { unstable_useRoute as useRoute } from "react-router" + + import type { Expect, Equal } from "../expect-type" + + export const loader = () => ({ currentLoader: "current/loader" }) + export const action = () => ({ currentAction: "current/action" }) + + export default function Component() { + const current = useRoute() + type Test1 = Expect> + + const root = useRoute("root") + type Test2 = Expect> + + const parent = useRoute("routes/parent") + type Test3 = Expect> + + const other = useRoute("routes/other") + type Test4 = Expect> + + return ( + <> +
{root.loaderData?.rootLoader}
+
{parent?.loaderData?.parentLoader}
+ {/* @ts-expect-error */} +
{current?.loaderData?.currentLoader}
+
{other === undefined ? "undefined" : "something else"}
+ + ) + } + `, + "app/routes/other.tsx": tsx` + export const loader = () => ({ otherLoader: "other/loader" }) + export const action = () => ({ otherAction: "other/action" }) + + export default function Component() { + return

Other

+ } + `, + }, +}); + +test("useRoute", async ({ $, page }) => { + await $("pnpm typecheck"); + + const port = await getPort(); + const url = `http://localhost:${port}`; + + const dev = $(`pnpm dev --port ${port}`); + await Stream.match(dev.stdout, url); + + await page.goto(url + "/parent/current", { waitUntil: "networkidle" }); + + await expect(page.locator("[data-root]")).toHaveText("root/loader"); + + await expect(page.locator("[data-parent]")).toHaveText("parent/loader"); + + await expect(page.locator("[data-current]")).toHaveText("current/loader"); + + await expect(page.locator("[data-other]")).toHaveText("undefined"); +}); diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts index 74df004ee6..dcab03fd27 100644 --- a/packages/react-router-dev/typegen/generate.ts +++ b/packages/react-router-dev/typegen/generate.ts @@ -105,13 +105,16 @@ export function generateRoutes(ctx: Context): Array { interface Register { pages: Pages routeFiles: RouteFiles + routeModules: RouteModules } } ` + "\n\n" + Babel.generate(pagesType(allPages)).code + "\n\n" + - Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code, + Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code + + "\n\n" + + Babel.generate(routeModulesType(ctx)).code, }; // **/+types/*.ts @@ -193,6 +196,29 @@ function routeFilesType({ ); } +function routeModulesType(ctx: Context) { + return t.tsTypeAliasDeclaration( + t.identifier("RouteModules"), + null, + t.tsTypeLiteral( + Object.values(ctx.config.routes).map((route) => + t.tsPropertySignature( + t.stringLiteral(route.id), + t.tsTypeAnnotation( + t.tsTypeQuery( + t.tsImportType( + t.stringLiteral( + `./${Path.relative(ctx.rootDirectory, ctx.config.appDirectory)}/${route.file}`, + ), + ), + ), + ), + ), + ), + ), + ); +} + function isInAppDirectory(ctx: Context, routeFile: string): boolean { const path = Path.resolve(ctx.config.appDirectory, routeFile); return path.startsWith(ctx.config.appDirectory); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index ed1418c11e..fe3c25cea6 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -144,6 +144,7 @@ export { useRouteError, useRouteLoaderData, useRoutes, + useRoute as unstable_useRoute, } from "./lib/hooks"; // Expose old RR DOM API diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index d3f969ef02..259f75ace4 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -50,8 +50,13 @@ import { resolveTo, stripBasename, } from "./router/utils"; -import type { SerializeFrom } from "./types/route-data"; +import type { + GetActionData, + GetLoaderData, + SerializeFrom, +} from "./types/route-data"; import type { unstable_ClientOnErrorFunction } from "./components"; +import type { RouteModules } from "./types/register"; /** * Resolves a URL against the current {@link Location}. @@ -1282,6 +1287,7 @@ enum DataRouterStateHook { UseRevalidator = "useRevalidator", UseNavigateStable = "useNavigate", UseRouteId = "useRouteId", + UseRoute = "useRoute", } function getDataRouterConsoleError( @@ -1838,3 +1844,39 @@ function warningOnce(key: string, cond: boolean, message: string) { warning(false, message); } } + +type UseRouteArgs = [] | [routeId: keyof RouteModules]; + +// prettier-ignore +type UseRouteResult = + Args extends [] ? UseRoute : + Args extends ["root"] ? UseRoute<"root"> : + Args extends [infer RouteId extends keyof RouteModules] ? UseRoute | undefined : + never; + +type UseRoute = { + loaderData: RouteId extends keyof RouteModules + ? GetLoaderData | undefined + : unknown; + actionData: RouteId extends keyof RouteModules + ? GetActionData | undefined + : unknown; +}; + +export function useRoute( + ...args: Args +): UseRouteResult { + const currentRouteId: keyof RouteModules = useCurrentRouteId( + DataRouterStateHook.UseRoute, + ); + const id: keyof RouteModules = args[0] ?? currentRouteId; + + const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData); + const route = state.matches.find(({ route }) => route.id === id); + + if (route === undefined) return undefined as UseRouteResult; + return { + loaderData: state.loaderData[id], + actionData: state.actionData?.[id], + } as UseRouteResult; +} diff --git a/packages/react-router/lib/types/register.ts b/packages/react-router/lib/types/register.ts index a5bbd7f92b..51dbacf4a4 100644 --- a/packages/react-router/lib/types/register.ts +++ b/packages/react-router/lib/types/register.ts @@ -1,3 +1,5 @@ +import type { RouteModule } from "./route-module"; + /** * Apps can use this interface to "register" app-wide types for React Router via interface declaration merging and module augmentation. * React Router should handle this for you via type generation. @@ -7,6 +9,7 @@ export interface Register { // pages // routeFiles + // routeModules } // pages @@ -25,3 +28,10 @@ export type RouteFiles = Register extends { } ? Registered : AnyRouteFiles; + +type AnyRouteModules = Record; +export type RouteModules = Register extends { + routeModules: infer Registered extends AnyRouteModules; +} + ? Registered + : AnyRouteModules;