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 (
+
+ );
+ },
+ 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,