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
55 changes: 50 additions & 5 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,58 @@ interface ErrorBoundaryProps<
resetErrorBoundary: () => void;
/** The route params */
params: Params;
/** The loader data (if available before the error) */
loaderData: LoaderData;
/** The action data (if available before the error) */
actionData: ActionData;
/** This route's loader data, or undefined when its own loader did not produce any */
loaderData: LoaderData | undefined;
/** The action data, or undefined when no submission to this route completed */
actionData: ActionData | undefined;
/** The router context */
context: RequestContext;
}
```

`loaderData` is the boundary's own route data. It is usually present when a
descendant route failed after this route's loader succeeded. It is `undefined`
when:

- this route's own loader threw
- the route has no loader
- middleware refused the request before loaders ran
- a document form submission, sent without JavaScript, failed at or below this
route. The server skips those routes' loaders when rendering the error.

A value from an earlier navigation is not carried into those cases. `actionData`
is `undefined` unless a submission to this route completed. A layout boundary
can use the difference in `loaderData` to keep its navigation when a child page
failed and its own data is available:

```tsx
import { HttpError } from "@udibo/juniper";
import type { AnyParams, ErrorBoundaryProps } from "@udibo/juniper";

interface TeamLoaderData {
team: { id: string; name: string };
}

export function ErrorBoundary(
{ error, loaderData }: ErrorBoundaryProps<AnyParams, TeamLoaderData>,
) {
const message = error instanceof HttpError
? error.exposedMessage
: "Something went wrong";
if (!loaderData) {
return <p role="alert">{message}</p>;
}
return (
<div>
<nav>
<a href={`/teams/${loaderData.team.id}`}>{loaderData.team.name}</a>
</nav>
<p role="alert">{message}</p>
</div>
);
}
```

Use typed params for better type safety:

```tsx
Expand Down Expand Up @@ -500,7 +543,9 @@ routes/
```

If the `[id]/index.tsx` error boundary isn't defined or doesn't handle an error,
it bubbles up to `blog/main.tsx`, then to `main.tsx`.
it bubbles up to `blog/main.tsx`, then to `main.tsx`. See
[Testing error boundaries](testing.md#testing-error-boundaries) to test a layout
boundary against a child failure.

```tsx
// routes/blog/main.tsx
Expand Down
91 changes: 86 additions & 5 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ your test project's import map if it is not already present.

### createRoutesStub

`createRoutesStub` accepts a flat list of Juniper route modules and returns a
`createRoutesStub` accepts a list of Juniper route modules and returns a
component backed by a memory router. Spread the real module and replace only the
loader or action whose dependency you want to control:

Expand All @@ -63,14 +63,37 @@ const Stub = createRoutesStub([{
render(<Stub initialEntries={["/profile"]} />);
```

Top-level routes are siblings. Give a route `children` to render them in its
`Outlet`, the way a `main.tsx` layout wraps the routes beside it. A child's
`path` is relative to its parent, `index: true` renders a child at the parent's
own URL like an `index.tsx` route, and a child with neither is a pathless
layout. An index route cannot have children.

```tsx
const Stub = createRoutesStub([{
...teamLayout,
path: "/teams/:teamId",
loader: () => ({ team: { id: "1", name: "Core" } }),
children: [
{ ...teamOverview, index: true },
{ ...teamMembers, path: "members" },
],
}]);
render(<Stub initialEntries={["/teams/1/members"]} />);
```

The default initial entry is the first route's `path`, or `/`. Pass
`hydrationData` for data already available before the initial render. React
Router assigns the memory-router IDs; a single unnamed route uses `"0"`.
Router assigns the memory-router IDs: top-level routes use `"0"`, `"1"`, and so
on, and the first child of `"0"` is `"0-0"`.

The adapter exercises route props, loaders, actions, `HydrateFallback`, and
error boundaries. It does **not** run the route's `middleware`, discover a
matching `.ts` module, or model nested layout routes. Seed middleware-provided
context explicitly:
error boundaries, including a failure that bubbles from a child to its layout.
It does **not** run the route's `middleware`, discover a matching `.ts` module,
or add the catch-all route that answers an unmatched URL with `HttpError(404)`
in production. In a stub, an unmatched URL still reaches the nearest boundary as
an `HttpError` with status 404, but its message is React Router's "No route
matches URL" text. Seed middleware-provided context explicitly:

```tsx
const Stub = createRoutesStub([profileRoute], {
Expand Down Expand Up @@ -278,6 +301,64 @@ Use `fetcher.data` for a `fetcher.Form` result; the route's `actionData` prop is
for navigational submissions. Add a rejection case to verify the error boundary,
and test the real server action separately.

### Testing Error Boundaries

A failure renders the nearest `ErrorBoundary` at or above the route that failed.
Nest the page under its layout to test the layout's boundary against a child
failure. The stub uses the same boundary adapter as production, so a thrown
`data(...)` response reaches the boundary as an `HttpError`:

```tsx
import "@udibo/juniper/utils/global-jsdom";

import { afterEach, it } from "@std/testing/bdd";
import { cleanup, render, screen } from "@testing-library/react";
import { Outlet } from "react-router";
import { HttpError } from "@udibo/juniper";
import type { AnyParams, ErrorBoundaryProps } from "@udibo/juniper";
import { createRoutesStub } from "@udibo/juniper/utils/testing";

interface TeamLoaderData {
name: string;
}

afterEach(cleanup);

it("keeps the team name when a member page fails", async () => {
const Stub = createRoutesStub([{
path: "/teams/:teamId",
loader: () => ({ name: "Core" }),
default: () => <Outlet />,
ErrorBoundary: (
{ error, loaderData }: ErrorBoundaryProps<AnyParams, TeamLoaderData>,
) => (
<div>
{loaderData && <h1>{loaderData.name}</h1>}
<p role="alert">
{error instanceof HttpError ? error.exposedMessage : "Unknown error"}
</p>
</div>
),
children: [{
path: "members/:memberId",
loader: () => {
throw new HttpError(404, "Not found", {
exposedMessage: "Member not found",
});
},
default: () => <p>Member</p>,
}],
}]);
render(<Stub initialEntries={["/teams/1/members/2"]} />);

await screen.findByText("Member not found");
await screen.findByRole("heading", { name: "Core" });
});
```

Make the layout's own loader throw instead to test the boundary without
`loaderData`.

## Integration Testing

Import the generated `server` from `main.ts` and call `server.request()` to
Expand Down
196 changes: 196 additions & 0 deletions src/mod.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import "./utils/global-jsdom.ts";

import { assertStringIncludes } from "@std/assert";
import { afterEach, describe, it } from "@std/testing/bdd";
import { stub } from "@std/testing/mock";
import { assertType } from "@std/testing/types";
import type { IsExact } from "@std/testing/types";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Link, Outlet } from "react-router";

import { Client } from "@udibo/juniper/client";
import { HttpError } from "@udibo/juniper";
import type { AnyParams, ErrorBoundaryProps } from "@udibo/juniper";
import { createServer } from "@udibo/juniper/server";
import { createRoutesStub } from "@udibo/juniper/utils/testing";

interface LayoutData {
name: string;
}

function LayoutBoundary(
{ loaderData, error }: ErrorBoundaryProps<AnyParams, LayoutData>,
) {
const message = error instanceof HttpError
? error.exposedMessage
: "Unexpected error";
return (
<div>
<p>{loaderData ? `Layout data: ${loaderData.name}` : "No layout data"}</p>
<p>{message}</p>
</div>
);
}

describe("ErrorBoundaryProps", () => {
it("types loaderData and actionData as possibly undefined", () => {
type Props = ErrorBoundaryProps<AnyParams, LayoutData, { saved: true }>;
assertType<IsExact<Props["loaderData"], LayoutData | undefined>>(true);
assertType<IsExact<Props["actionData"], { saved: true } | undefined>>(
true,
);
});

it("rejects reading loader data without checking that it is present", () => {
const unguarded = (
{ loaderData }: ErrorBoundaryProps<AnyParams, LayoutData>,
) =>
// @ts-expect-error loaderData is undefined when this route's loader threw
loaderData.name;
assertType<IsExact<ReturnType<typeof unguarded>, string>>(true);
});
});

describe("ErrorBoundary loaderData in the browser", () => {
afterEach(cleanup);

function createLayoutStub(
{ layoutFails, childFails }: { layoutFails: boolean; childFails: boolean },
) {
return createRoutesStub([{
path: "/:tenant",
loader: ({ params }) => {
if (layoutFails || params.tenant === "refused") {
throw new HttpError(404, "Not found", {
exposedMessage: "Tenant not found",
});
}
return { name: params.tenant! } satisfies LayoutData;
},
default: () => (
<div>
<Link to="/refused">Switch tenant</Link>
<Outlet />
</div>
),
ErrorBoundary: LayoutBoundary,
children: [
{ index: true, default: () => <p>Overview</p> },
{
path: "users",
loader: () => {
if (childFails) {
throw new HttpError(404, "Not found", {
exposedMessage: "User not found",
});
}
return null;
},
default: () => <p>Users</p>,
},
],
}]);
}

it("is undefined when the boundary's own loader threw", async () => {
const Stub = createLayoutStub({ layoutFails: true, childFails: false });
render(<Stub initialEntries={["/acme/users"]} />);

await screen.findByText("Tenant not found");
screen.getByText("No layout data");
});

it("is the boundary's loader data when a descendant's loader threw", async () => {
const Stub = createLayoutStub({ layoutFails: false, childFails: true });
render(<Stub initialEntries={["/acme/users"]} />);

await screen.findByText("User not found");
screen.getByText("Layout data: acme");
});

it("is not carried forward from a previous navigation when the boundary's own loader throws", async () => {
const Stub = createLayoutStub({ layoutFails: false, childFails: false });
render(<Stub initialEntries={["/acme"]} />);

await screen.findByText("Overview");
fireEvent.click(screen.getByRole("link", { name: "Switch tenant" }));

await screen.findByText("Tenant not found");
screen.getByText("No layout data");
});
});

describe("ErrorBoundary loaderData during server rendering", () => {
function createLayoutServer() {
const client = new Client({
path: "/",
main: {
default: () => <Outlet />,
ErrorBoundary: LayoutBoundary,
},
children: [{
path: "users",
main: { default: () => <p>Users</p> },
}],
});
return createServer(import.meta.url, client, {
path: "/",
main: {
loader: ({ request }) => {
if (new URL(request.url).searchParams.has("refuse")) {
throw new HttpError(403, "Forbidden", {
exposedMessage: "Tenant refused",
});
}
return { name: "acme" } satisfies LayoutData;
},
},
children: [{
path: "users",
main: {
loader: () => {
throw new HttpError(404, "Not found", {
exposedMessage: "User not found",
});
},
action: () => {
throw new HttpError(400, "Bad request", {
exposedMessage: "Could not save user",
});
},
},
}],
});
}

it("is undefined when the boundary's own loader threw", async () => {
using _console = stub(console, "error");
const response = await createLayoutServer().request(
"http://localhost/users?refuse",
);
const html = await response.text();
assertStringIncludes(html, "Tenant refused");
assertStringIncludes(html, "No layout data");
});

it("is the boundary's loader data when a descendant's loader threw", async () => {
using _console = stub(console, "error");
const response = await createLayoutServer().request(
"http://localhost/users",
);
const html = await response.text();
assertStringIncludes(html, "User not found");
assertStringIncludes(html, "Layout data: acme");
});

it("is undefined when a document form submission below the boundary failed", async () => {
using _console = stub(console, "error");
const response = await createLayoutServer().request(
"http://localhost/users",
{ method: "POST", body: new URLSearchParams({ name: "Ada" }) },
);
const html = await response.text();
assertStringIncludes(html, "Could not save user");
assertStringIncludes(html, "No layout data");
});
});
Loading
Loading