Skip to content

Commit 5af3eaa

Browse files
authored
Add component props support to createRoutesStub (#13528)
1 parent 2c87a07 commit 5af3eaa

File tree

4 files changed

+214
-19
lines changed

4 files changed

+214
-19
lines changed

.changeset/eighty-mangos-move.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"react-router": minor
3+
---
4+
5+
Add support for route component props in `createRoutesStub`. This allows you to unit test your route components using the props instead of the hooks:
6+
7+
```tsx
8+
let RoutesStub = createRoutesStub([
9+
{
10+
path: "/",
11+
Component({ loaderData }) {
12+
let data = loaderData as { message: string };
13+
return <pre data-testid="data">Message: {data.message}</pre>;
14+
},
15+
loader() {
16+
return { message: "hello" };
17+
},
18+
},
19+
]);
20+
21+
render(<RoutesStub />);
22+
23+
await waitFor(() => screen.findByText("Message: hello"));
24+
```

packages/react-router-dev/vite/with-props.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const plugin: Plugin = {
1717
},
1818
async load(id) {
1919
if (id !== vmod.resolvedId) return;
20+
21+
// Note: If you make changes to these implementations, please also update
22+
// the corresponding functions in packages/react-router/lib/dom/ssr/routes-test-stub.tsx
2023
return dedent`
2124
import { createElement as h } from "react";
2225
import { useActionData, useLoaderData, useMatches, useParams, useRouteError } from "react-router";

packages/react-router/__tests__/dom/stub-test.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useMatches,
1111
createRoutesStub,
1212
type LoaderFunctionArgs,
13+
useRouteError,
1314
} from "../../index";
1415
import { unstable_createContext } from "../../lib/router/utils";
1516

@@ -73,6 +74,27 @@ test("loaders work", async () => {
7374
await waitFor(() => screen.findByText("Message: hello"));
7475
});
7576

77+
// eslint-disable-next-line jest/expect-expect
78+
test("loaders work with props", async () => {
79+
let RoutesStub = createRoutesStub([
80+
{
81+
path: "/",
82+
HydrateFallback: () => null,
83+
Component({ loaderData }) {
84+
let data = loaderData as { message: string };
85+
return <pre data-testid="data">Message: {data.message}</pre>;
86+
},
87+
loader() {
88+
return { message: "hello" };
89+
},
90+
},
91+
]);
92+
93+
render(<RoutesStub />);
94+
95+
await waitFor(() => screen.findByText("Message: hello"));
96+
});
97+
7698
// eslint-disable-next-line jest/expect-expect
7799
test("actions work", async () => {
78100
let RoutesStub = createRoutesStub([
@@ -99,6 +121,75 @@ test("actions work", async () => {
99121
await waitFor(() => screen.findByText("Message: hello"));
100122
});
101123

124+
// eslint-disable-next-line jest/expect-expect
125+
test("actions work with props", async () => {
126+
let RoutesStub = createRoutesStub([
127+
{
128+
path: "/",
129+
Component({ actionData }) {
130+
let data = actionData as { message: string } | undefined;
131+
return (
132+
<Form method="post">
133+
<button type="submit">Submit</button>
134+
{data ? <pre>Message: {data.message}</pre> : null}
135+
</Form>
136+
);
137+
},
138+
action() {
139+
return Response.json({ message: "hello" });
140+
},
141+
},
142+
]);
143+
144+
render(<RoutesStub />);
145+
146+
user.click(screen.getByText("Submit"));
147+
await waitFor(() => screen.findByText("Message: hello"));
148+
});
149+
150+
// eslint-disable-next-line jest/expect-expect
151+
test("errors work", async () => {
152+
let spy = jest.spyOn(console, "error").mockImplementation(() => {});
153+
let RoutesStub = createRoutesStub([
154+
{
155+
path: "/",
156+
Component() {
157+
throw new Error("Broken!");
158+
},
159+
ErrorBoundary() {
160+
let error = useRouteError() as Error;
161+
return <p>Error: {error.message}</p>;
162+
},
163+
},
164+
]);
165+
166+
render(<RoutesStub />);
167+
168+
await waitFor(() => screen.findByText("Error: Broken!"));
169+
spy.mockRestore();
170+
});
171+
172+
// eslint-disable-next-line jest/expect-expect
173+
test("errors work with prop", async () => {
174+
let spy = jest.spyOn(console, "error").mockImplementation(() => {});
175+
let RoutesStub = createRoutesStub([
176+
{
177+
path: "/",
178+
Component() {
179+
throw new Error("Broken!");
180+
},
181+
ErrorBoundary({ error }) {
182+
return <p>Error: {(error as Error).message}</p>;
183+
},
184+
},
185+
]);
186+
187+
render(<RoutesStub />);
188+
189+
await waitFor(() => screen.findByText("Error: Broken!"));
190+
spy.mockRestore();
191+
});
192+
102193
// eslint-disable-next-line jest/expect-expect
103194
test("fetchers work", async () => {
104195
let count = 0;

packages/react-router/lib/dom/ssr/routes-test-stub.tsx

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,66 @@ import type {
2121
import { Outlet, RouterProvider, createMemoryRouter } from "../../components";
2222
import type { EntryRoute } from "./routes";
2323
import { FrameworkContext } from "./components";
24+
import {
25+
useParams,
26+
useLoaderData,
27+
useActionData,
28+
useMatches,
29+
useRouteError,
30+
} from "../../hooks";
2431

25-
interface StubIndexRouteObject
26-
extends Omit<
27-
IndexRouteObject,
28-
"loader" | "action" | "element" | "errorElement" | "children"
29-
> {
32+
interface StubRouteExtensions {
33+
Component?: React.ComponentType<{
34+
params: ReturnType<typeof useParams>;
35+
loaderData: ReturnType<typeof useLoaderData>;
36+
actionData: ReturnType<typeof useActionData>;
37+
matches: ReturnType<typeof useMatches>;
38+
}>;
39+
HydrateFallback?: React.ComponentType<{
40+
params: ReturnType<typeof useParams>;
41+
loaderData: ReturnType<typeof useLoaderData>;
42+
actionData: ReturnType<typeof useActionData>;
43+
}>;
44+
ErrorBoundary?: React.ComponentType<{
45+
params: ReturnType<typeof useParams>;
46+
loaderData: ReturnType<typeof useLoaderData>;
47+
actionData: ReturnType<typeof useActionData>;
48+
error: ReturnType<typeof useRouteError>;
49+
}>;
3050
loader?: LoaderFunction;
3151
action?: ActionFunction;
3252
children?: StubRouteObject[];
3353
meta?: MetaFunction;
3454
links?: LinksFunction;
3555
}
3656

57+
interface StubIndexRouteObject
58+
extends Omit<
59+
IndexRouteObject,
60+
| "Component"
61+
| "HydrateFallback"
62+
| "ErrorBoundary"
63+
| "loader"
64+
| "action"
65+
| "element"
66+
| "errorElement"
67+
| "children"
68+
>,
69+
StubRouteExtensions {}
70+
3771
interface StubNonIndexRouteObject
3872
extends Omit<
39-
NonIndexRouteObject,
40-
"loader" | "action" | "element" | "errorElement" | "children"
41-
> {
42-
loader?: LoaderFunction;
43-
action?: ActionFunction;
44-
children?: StubRouteObject[];
45-
meta?: MetaFunction;
46-
links?: LinksFunction;
47-
}
73+
NonIndexRouteObject,
74+
| "Component"
75+
| "HydrateFallback"
76+
| "ErrorBoundary"
77+
| "loader"
78+
| "action"
79+
| "element"
80+
| "errorElement"
81+
| "children"
82+
>,
83+
StubRouteExtensions {}
4884

4985
type StubRouteObject = StubIndexRouteObject | StubNonIndexRouteObject;
5086

@@ -141,6 +177,41 @@ export function createRoutesStub(
141177
};
142178
}
143179

180+
// Implementations copied from packages/react-router-dev/vite/with-props.ts
181+
function withComponentProps(Component: React.ComponentType<any>) {
182+
return function Wrapped() {
183+
return React.createElement(Component, {
184+
params: useParams(),
185+
loaderData: useLoaderData(),
186+
actionData: useActionData(),
187+
matches: useMatches(),
188+
});
189+
};
190+
}
191+
192+
function withHydrateFallbackProps(HydrateFallback: React.ComponentType<any>) {
193+
return function Wrapped() {
194+
const props = {
195+
params: useParams(),
196+
loaderData: useLoaderData(),
197+
actionData: useActionData(),
198+
};
199+
return React.createElement(HydrateFallback, props);
200+
};
201+
}
202+
203+
function withErrorBoundaryProps(ErrorBoundary: React.ComponentType<any>) {
204+
return function Wrapped() {
205+
const props = {
206+
params: useParams(),
207+
loaderData: useLoaderData(),
208+
actionData: useActionData(),
209+
error: useRouteError(),
210+
};
211+
return React.createElement(ErrorBoundary, props);
212+
};
213+
}
214+
144215
function processRoutes(
145216
routes: StubRouteObject[],
146217
manifest: AssetsManifest,
@@ -158,9 +229,15 @@ function processRoutes(
158229
id: route.id,
159230
path: route.path,
160231
index: route.index,
161-
Component: route.Component,
162-
HydrateFallback: route.HydrateFallback,
163-
ErrorBoundary: route.ErrorBoundary,
232+
Component: route.Component
233+
? withComponentProps(route.Component)
234+
: undefined,
235+
HydrateFallback: route.HydrateFallback
236+
? withHydrateFallbackProps(route.HydrateFallback)
237+
: undefined,
238+
ErrorBoundary: route.ErrorBoundary
239+
? withErrorBoundaryProps(route.ErrorBoundary)
240+
: undefined,
164241
action: route.action,
165242
loader: route.loader,
166243
handle: route.handle,
@@ -193,8 +270,8 @@ function processRoutes(
193270

194271
// Add the route to routeModules
195272
routeModules[route.id] = {
196-
default: route.Component || Outlet,
197-
ErrorBoundary: route.ErrorBoundary || undefined,
273+
default: newRoute.Component || Outlet,
274+
ErrorBoundary: newRoute.ErrorBoundary || undefined,
198275
handle: route.handle,
199276
links: route.links,
200277
meta: route.meta,

0 commit comments

Comments
 (0)