Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/eighty-mangos-move.md
Original file line number Diff line number Diff line change
@@ -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 <pre data-testid="data">Message: {data.message}</pre>;
},
loader() {
return { message: "hello" };
},
},
]);

render(<RoutesStub />);

await waitFor(() => screen.findByText("Message: hello"));
```
3 changes: 3 additions & 0 deletions packages/react-router-dev/vite/with-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
91 changes: 91 additions & 0 deletions packages/react-router/__tests__/dom/stub-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
useMatches,
createRoutesStub,
type LoaderFunctionArgs,
useRouteError,
} from "../../index";
import { unstable_createContext } from "../../lib/router/utils";

Expand Down Expand Up @@ -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 <pre data-testid="data">Message: {data.message}</pre>;
},
loader() {
return { message: "hello" };
},
},
]);

render(<RoutesStub />);

await waitFor(() => screen.findByText("Message: hello"));
});

// eslint-disable-next-line jest/expect-expect
test("actions work", async () => {
let RoutesStub = createRoutesStub([
Expand All @@ -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 (
<Form method="post">
<button type="submit">Submit</button>
{data ? <pre>Message: {data.message}</pre> : null}
</Form>
);
},
action() {
return Response.json({ message: "hello" });
},
},
]);

render(<RoutesStub />);

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 <p>Error: {error.message}</p>;
},
},
]);

render(<RoutesStub />);

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 <p>Error: {(error as Error).message}</p>;
},
},
]);

render(<RoutesStub />);

await waitFor(() => screen.findByText("Error: Broken!"));
spy.mockRestore();
});

// eslint-disable-next-line jest/expect-expect
test("fetchers work", async () => {
let count = 0;
Expand Down
115 changes: 96 additions & 19 deletions packages/react-router/lib/dom/ssr/routes-test-stub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,66 @@ 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<typeof useParams>;
loaderData: ReturnType<typeof useLoaderData>;
actionData: ReturnType<typeof useActionData>;
matches: ReturnType<typeof useMatches>;
}>;
HydrateFallback?: React.ComponentType<{
params: ReturnType<typeof useParams>;
loaderData: ReturnType<typeof useLoaderData>;
actionData: ReturnType<typeof useActionData>;
}>;
ErrorBoundary?: React.ComponentType<{
params: ReturnType<typeof useParams>;
loaderData: ReturnType<typeof useLoaderData>;
actionData: ReturnType<typeof useActionData>;
error: ReturnType<typeof useRouteError>;
}>;
loader?: LoaderFunction;
action?: ActionFunction;
children?: StubRouteObject[];
meta?: MetaFunction;
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;

Expand Down Expand Up @@ -141,6 +177,41 @@ export function createRoutesStub(
};
}

// Implementations copied from packages/react-router-dev/vite/with-props.ts
function withComponentProps(Component: React.ComponentType<any>) {
return function Wrapped() {
return React.createElement(Component, {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
matches: useMatches(),
});
};
}

function withHydrateFallbackProps(HydrateFallback: React.ComponentType<any>) {
return function Wrapped() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
};
return React.createElement(HydrateFallback, props);
};
}

function withErrorBoundaryProps(ErrorBoundary: React.ComponentType<any>) {
return function Wrapped() {
const props = {
params: useParams(),
loaderData: useLoaderData(),
actionData: useActionData(),
error: useRouteError(),
};
return React.createElement(ErrorBoundary, props);
};
}

function processRoutes(
routes: StubRouteObject[],
manifest: AssetsManifest,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading