diff --git a/.changeset/eighty-mangos-move.md b/.changeset/eighty-mangos-move.md new file mode 100644 index 0000000000..030007c2c4 --- /dev/null +++ b/.changeset/eighty-mangos-move.md @@ -0,0 +1,24 @@ +--- +"react-router": minor +--- + +Add support for route component props in `createRoutesStub`. This allows you to unit test your route components using the props instead of the hooks: + +```tsx +let RoutesStub = createRoutesStub([ + { + path: "/", + Component({ loaderData }) { + let data = loaderData as { message: string }; + return
Message: {data.message}
; + }, + loader() { + return { message: "hello" }; + }, + }, +]); + +render(); + +await waitFor(() => screen.findByText("Message: hello")); +``` diff --git a/packages/react-router-dev/vite/with-props.ts b/packages/react-router-dev/vite/with-props.ts index f4a8c0f8ff..3aabc89bdf 100644 --- a/packages/react-router-dev/vite/with-props.ts +++ b/packages/react-router-dev/vite/with-props.ts @@ -17,6 +17,9 @@ export const plugin: Plugin = { }, async load(id) { if (id !== vmod.resolvedId) return; + + // Note: If you make changes to these implementations, please also update + // the corresponding functions in packages/react-router/lib/dom/ssr/routes-test-stub.tsx return dedent` import { createElement as h } from "react"; import { useActionData, useLoaderData, useMatches, useParams, useRouteError } from "react-router"; diff --git a/packages/react-router/__tests__/dom/stub-test.tsx b/packages/react-router/__tests__/dom/stub-test.tsx index fe48fedd67..edb2098691 100644 --- a/packages/react-router/__tests__/dom/stub-test.tsx +++ b/packages/react-router/__tests__/dom/stub-test.tsx @@ -10,6 +10,7 @@ import { useMatches, createRoutesStub, type LoaderFunctionArgs, + useRouteError, } from "../../index"; import { unstable_createContext } from "../../lib/router/utils"; @@ -73,6 +74,27 @@ test("loaders work", async () => { await waitFor(() => screen.findByText("Message: hello")); }); +// eslint-disable-next-line jest/expect-expect +test("loaders work with props", async () => { + let RoutesStub = createRoutesStub([ + { + path: "/", + HydrateFallback: () => null, + Component({ loaderData }) { + let data = loaderData as { message: string }; + return
Message: {data.message}
; + }, + loader() { + return { message: "hello" }; + }, + }, + ]); + + render(); + + await waitFor(() => screen.findByText("Message: hello")); +}); + // eslint-disable-next-line jest/expect-expect test("actions work", async () => { let RoutesStub = createRoutesStub([ @@ -99,6 +121,75 @@ test("actions work", async () => { await waitFor(() => screen.findByText("Message: hello")); }); +// eslint-disable-next-line jest/expect-expect +test("actions work with props", async () => { + let RoutesStub = createRoutesStub([ + { + path: "/", + Component({ actionData }) { + let data = actionData as { message: string } | undefined; + return ( +
+ + {data ?
Message: {data.message}
: null} +
+ ); + }, + action() { + return Response.json({ message: "hello" }); + }, + }, + ]); + + render(); + + user.click(screen.getByText("Submit")); + await waitFor(() => screen.findByText("Message: hello")); +}); + +// eslint-disable-next-line jest/expect-expect +test("errors work", async () => { + let spy = jest.spyOn(console, "error").mockImplementation(() => {}); + let RoutesStub = createRoutesStub([ + { + path: "/", + Component() { + throw new Error("Broken!"); + }, + ErrorBoundary() { + let error = useRouteError() as Error; + return

Error: {error.message}

; + }, + }, + ]); + + render(); + + await waitFor(() => screen.findByText("Error: Broken!")); + spy.mockRestore(); +}); + +// eslint-disable-next-line jest/expect-expect +test("errors work with prop", async () => { + let spy = jest.spyOn(console, "error").mockImplementation(() => {}); + let RoutesStub = createRoutesStub([ + { + path: "/", + Component() { + throw new Error("Broken!"); + }, + ErrorBoundary({ error }) { + return

Error: {(error as Error).message}

; + }, + }, + ]); + + render(); + + await waitFor(() => screen.findByText("Error: Broken!")); + spy.mockRestore(); +}); + // eslint-disable-next-line jest/expect-expect test("fetchers work", async () => { let count = 0; diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index 5307012aa7..f21f5e250d 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -21,12 +21,32 @@ import type { import { Outlet, RouterProvider, createMemoryRouter } from "../../components"; import type { EntryRoute } from "./routes"; import { FrameworkContext } from "./components"; +import { + useParams, + useLoaderData, + useActionData, + useMatches, + useRouteError, +} from "../../hooks"; -interface StubIndexRouteObject - extends Omit< - IndexRouteObject, - "loader" | "action" | "element" | "errorElement" | "children" - > { +interface StubRouteExtensions { + Component?: React.ComponentType<{ + params: ReturnType; + loaderData: ReturnType; + actionData: ReturnType; + matches: ReturnType; + }>; + HydrateFallback?: React.ComponentType<{ + params: ReturnType; + loaderData: ReturnType; + actionData: ReturnType; + }>; + ErrorBoundary?: React.ComponentType<{ + params: ReturnType; + loaderData: ReturnType; + actionData: ReturnType; + error: ReturnType; + }>; loader?: LoaderFunction; action?: ActionFunction; children?: StubRouteObject[]; @@ -34,17 +54,33 @@ interface StubIndexRouteObject links?: LinksFunction; } +interface StubIndexRouteObject + extends Omit< + IndexRouteObject, + | "Component" + | "HydrateFallback" + | "ErrorBoundary" + | "loader" + | "action" + | "element" + | "errorElement" + | "children" + >, + StubRouteExtensions {} + interface StubNonIndexRouteObject extends Omit< - NonIndexRouteObject, - "loader" | "action" | "element" | "errorElement" | "children" - > { - loader?: LoaderFunction; - action?: ActionFunction; - children?: StubRouteObject[]; - meta?: MetaFunction; - links?: LinksFunction; -} + NonIndexRouteObject, + | "Component" + | "HydrateFallback" + | "ErrorBoundary" + | "loader" + | "action" + | "element" + | "errorElement" + | "children" + >, + StubRouteExtensions {} type StubRouteObject = StubIndexRouteObject | StubNonIndexRouteObject; @@ -141,6 +177,41 @@ export function createRoutesStub( }; } +// Implementations copied from packages/react-router-dev/vite/with-props.ts +function withComponentProps(Component: React.ComponentType) { + return function Wrapped() { + return React.createElement(Component, { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + matches: useMatches(), + }); + }; +} + +function withHydrateFallbackProps(HydrateFallback: React.ComponentType) { + return function Wrapped() { + const props = { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + }; + return React.createElement(HydrateFallback, props); + }; +} + +function withErrorBoundaryProps(ErrorBoundary: React.ComponentType) { + return function Wrapped() { + const props = { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + error: useRouteError(), + }; + return React.createElement(ErrorBoundary, props); + }; +} + function processRoutes( routes: StubRouteObject[], manifest: AssetsManifest, @@ -158,9 +229,15 @@ function processRoutes( id: route.id, path: route.path, index: route.index, - Component: route.Component, - HydrateFallback: route.HydrateFallback, - ErrorBoundary: route.ErrorBoundary, + Component: route.Component + ? withComponentProps(route.Component) + : undefined, + HydrateFallback: route.HydrateFallback + ? withHydrateFallbackProps(route.HydrateFallback) + : undefined, + ErrorBoundary: route.ErrorBoundary + ? withErrorBoundaryProps(route.ErrorBoundary) + : undefined, action: route.action, loader: route.loader, handle: route.handle, @@ -193,8 +270,8 @@ function processRoutes( // Add the route to routeModules routeModules[route.id] = { - default: route.Component || Outlet, - ErrorBoundary: route.ErrorBoundary || undefined, + default: newRoute.Component || Outlet, + ErrorBoundary: newRoute.ErrorBoundary || undefined, handle: route.handle, links: route.links, meta: route.meta,