From e074a8f4db9aeb41f590d503f931f3c34c4f853c Mon Sep 17 00:00:00 2001 From: Kyle June Date: Thu, 24 Sep 2026 02:41:26 -0400 Subject: [PATCH] fix: type boundary loaderData as optional and nest route stubs ErrorBoundaryProps now types loaderData and actionData as possibly undefined, matching React Router, and createRoutesStub accepts children and index so a layout boundary can be tested against a child failure. Co-Authored-By: Claude Opus 5.5 --- docs/error-handling.md | 55 ++++++++- docs/testing.md | 91 ++++++++++++++- src/mod.test.tsx | 196 +++++++++++++++++++++++++++++++ src/mod.ts | 22 +++- src/utils/testing.test.tsx | 231 ++++++++++++++++++++++++++++++++++++- src/utils/testing.ts | 73 ++++++++---- 6 files changed, 630 insertions(+), 38 deletions(-) create mode 100644 src/mod.test.tsx diff --git a/docs/error-handling.md b/docs/error-handling.md index 530ad72..6d3ee7d 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -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, +) { + const message = error instanceof HttpError + ? error.exposedMessage + : "Something went wrong"; + if (!loaderData) { + return

{message}

; + } + return ( +
+ +

{message}

+
+ ); +} +``` + Use typed params for better type safety: ```tsx @@ -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 diff --git a/docs/testing.md b/docs/testing.md index e0b22eb..dcf8fa3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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: @@ -63,14 +63,37 @@ const Stub = createRoutesStub([{ render(); ``` +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(); +``` + 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], { @@ -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: () => , + ErrorBoundary: ( + { error, loaderData }: ErrorBoundaryProps, + ) => ( +
+ {loaderData &&

{loaderData.name}

} +

+ {error instanceof HttpError ? error.exposedMessage : "Unknown error"} +

+
+ ), + children: [{ + path: "members/:memberId", + loader: () => { + throw new HttpError(404, "Not found", { + exposedMessage: "Member not found", + }); + }, + default: () =>

Member

, + }], + }]); + render(); + + 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 diff --git a/src/mod.test.tsx b/src/mod.test.tsx new file mode 100644 index 0000000..be2b1fd --- /dev/null +++ b/src/mod.test.tsx @@ -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, +) { + const message = error instanceof HttpError + ? error.exposedMessage + : "Unexpected error"; + return ( +
+

{loaderData ? `Layout data: ${loaderData.name}` : "No layout data"}

+

{message}

+
+ ); +} + +describe("ErrorBoundaryProps", () => { + it("types loaderData and actionData as possibly undefined", () => { + type Props = ErrorBoundaryProps; + assertType>(true); + assertType>( + true, + ); + }); + + it("rejects reading loader data without checking that it is present", () => { + const unguarded = ( + { loaderData }: ErrorBoundaryProps, + ) => + // @ts-expect-error loaderData is undefined when this route's loader threw + loaderData.name; + assertType, 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: () => ( +
+ Switch tenant + +
+ ), + ErrorBoundary: LayoutBoundary, + children: [ + { index: true, default: () =>

Overview

}, + { + path: "users", + loader: () => { + if (childFails) { + throw new HttpError(404, "Not found", { + exposedMessage: "User not found", + }); + } + return null; + }, + default: () =>

Users

, + }, + ], + }]); + } + + it("is undefined when the boundary's own loader threw", async () => { + const Stub = createLayoutStub({ layoutFails: true, childFails: false }); + render(); + + 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(); + + 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(); + + 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: () => , + ErrorBoundary: LayoutBoundary, + }, + children: [{ + path: "users", + main: { default: () =>

Users

}, + }], + }); + 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"); + }); +}); diff --git a/src/mod.ts b/src/mod.ts index 32e3419..fbffda5 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -455,9 +455,15 @@ export interface RouteProps< /** * Props for the nearest route `ErrorBoundary` handling a failure. * - * Loader and action data may be absent even when the normal component requires - * them. A middleware denial renders without executing loaders. Preserve useful - * navigation and display a safe message rather than an arbitrary exception. + * `loaderData` is this route's own data. It is usually present when a + * descendant failed after this route's loader succeeded. It is `undefined` when + * this route's own loader threw, it has no loader, middleware refused the + * request before loaders ran, or a document (no-JS) form submission failed at + * or below this route, because the server then skips those loaders. Data from + * an earlier navigation is not carried into those cases. `actionData` is + * `undefined` unless a submission to this route completed. Check both before + * reading them, and keep useful navigation with a safe message rather than an + * arbitrary exception. * Outside development, unexpected built-in or unregistered server `Error` * failures become generic 500 errors. `HttpError` uses its exposure policy; * custom serializers and explicitly returned error data remain application-owned. @@ -478,7 +484,15 @@ export interface ErrorBoundaryProps< Params extends AnyParams = AnyParams, LoaderData = unknown, ActionData = unknown, -> extends RouteProps { +> extends + Omit< + RouteProps, + "loaderData" | "actionData" + > { + /** This route's loader data, or `undefined` when its loader did not produce any for this request. */ + loaderData: LoaderData | undefined; + /** Result of a navigation submission to this route, or `undefined` when none completed. */ + actionData: ActionData | undefined; /** The failure, with server error details sanitized outside development. */ error: unknown; /** Retries the current URL, including query and fragment; failed imports require document navigation. */ diff --git a/src/utils/testing.test.tsx b/src/utils/testing.test.tsx index 724b7ee..b4a5619 100644 --- a/src/utils/testing.test.tsx +++ b/src/utils/testing.test.tsx @@ -1,8 +1,14 @@ import "./global-jsdom.ts"; -import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "@std/assert"; import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { delay } from "@std/async/delay"; +import { stub } from "@std/testing/mock"; import { cleanup, fireEvent, @@ -10,15 +16,22 @@ import { screen, waitFor, } from "@testing-library/react"; -import { Link, useLocation } from "react-router"; +import { data, Link, Outlet, useLocation } from "react-router"; + +import { HttpError } from "@udibo/juniper"; +import type { AnyParams, ErrorBoundaryProps } from "@udibo/juniper"; import { getEnv, isBrowser, isProduction, isServer, isTest } from "./env.ts"; -import { createRoutesStub, simulateEnvironment } from "./testing.ts"; +import { createRoutesStub, simulateEnvironment, stubFetch } from "./testing.ts"; +import type { RouteStub } from "./testing.ts"; import { simulateBrowser } from "./testing.internal.ts"; import type { HydrationData } from "../_client.tsx"; -import { serializeHydrationData } from "../_serialization.ts"; +import { + createLoaderDataResponse, + serializeHydrationData, +} from "../_serialization.ts"; import { env } from "./_env.ts"; describe("simulateEnvironment", () => { @@ -702,3 +715,213 @@ describe("createRoutesStub", () => { ); } }); + +describe("createRoutesStub with nested routes", () => { + afterEach(cleanup); + + function LayoutBoundary( + { error, loaderData }: ErrorBoundaryProps, + ) { + return ( +
+

Layout boundary

+

+ {error instanceof HttpError + ? `${error.status}: ${error.exposedMessage}` + : "Unexpected error"} +

+

{loaderData ? `Layout ${loaderData.name}` : "No layout data"}

+
+ ); + } + + function layout(children: RouteStub[]): RouteStub { + return { + path: "/tenants/:tenant", + loader: ({ params }) => ({ name: params.tenant! }), + default: ({ loaderData }) => ( +
+

Layout {(loaderData as { name: string }).name}

+ +
+ ), + ErrorBoundary: LayoutBoundary, + children, + }; + } + + it("renders an index child inside its layout's Outlet", async () => { + const Stub = createRoutesStub([ + layout([{ index: true, default: () =>

Overview

}]), + ]); + render(); + + await screen.findByText("Overview"); + screen.getByText("Layout acme"); + }); + + it("resolves a child path relative to its layout", async () => { + const Stub = createRoutesStub([ + layout([{ path: "users/:user", default: () =>

User page

}]), + ]); + render(); + + await screen.findByText("User page"); + screen.getByText("Layout acme"); + }); + + it("treats a child without a path or index as a pathless layout", async () => { + const Stub = createRoutesStub([ + layout([{ + default: () => ( +
+

Pathless shell

+ +
+ ), + children: [{ path: "users", default: () =>

Users

}], + }]), + ]); + render(); + + await screen.findByText("Users"); + screen.getByText("Pathless shell"); + }); + + it("lets a layout ErrorBoundary catch a child loader failure", async () => { + const Stub = createRoutesStub([ + layout([{ + path: "users", + loader: () => { + throw new HttpError(404, "Not found", { + exposedMessage: "User not found", + }); + }, + default: () =>

Users

, + }]), + ]); + render(); + + await screen.findByText("404: User not found"); + screen.getByText("Layout acme"); + assertEquals(screen.queryByText("Users"), null); + }); + + it("lets a layout ErrorBoundary catch a child render failure", async () => { + using _console = stub(console, "error"); + const Stub = createRoutesStub([ + layout([{ + path: "users", + default: () => { + throw new HttpError(500, "Render failed", { + exposedMessage: "Could not show users", + }); + }, + }]), + ]); + render(); + + await screen.findByText("500: Could not show users"); + screen.getByText("Layout acme"); + }); + + it("normalizes a child's thrown route error response the way production does", async () => { + const Stub = createRoutesStub([ + layout([{ + path: "users", + loader: () => { + throw data("Users moved away", { status: 410 }); + }, + default: () =>

Users

, + }]), + ]); + render(); + + await screen.findByText("410: Users moved away"); + }); + + it("renders a child's own ErrorBoundary inside the layout", async () => { + const Stub = createRoutesStub([ + layout([{ + path: "users", + loader: () => { + throw new HttpError(404, "Not found"); + }, + default: () =>

Users

, + ErrorBoundary: () =>

Child boundary

, + }]), + ]); + render(); + + await screen.findByText("Child boundary"); + screen.getByText("Layout acme"); + assertEquals(screen.queryByText("Layout boundary"), null); + }); + + it("keys hydrationData by React Router's nested route ids", async () => { + const Stub = createRoutesStub([{ + path: "/tenants/:tenant", + loader: () => ({ name: "from loader" }), + default: ({ loaderData }) => ( +
+

Layout {(loaderData as { name: string }).name}

+ +
+ ), + children: [{ + path: "users", + loader: () => ({ count: -1 }), + default: ({ loaderData }) => ( +

Users {(loaderData as { count: number }).count}

+ ), + }], + }]); + render( + , + ); + + await screen.findByText("Users 3"); + screen.getByText("Layout hydrated"); + }); + + it("sends a child's routeId with its server loader request", async () => { + using fetchStub = stubFetch((_input, init) => + createLoaderDataResponse({ + from: new Headers(init?.headers).get("X-Juniper-Route-Id"), + }) + ); + const Stub = createRoutesStub([ + layout([{ + path: "users", + serverFlags: { loader: true }, + routeId: "/tenants/[tenant]/users", + default: ({ loaderData }) => ( +

Users from {(loaderData as { from: string }).from}

+ ), + }]), + ]); + render(); + + await screen.findByText("Users from /tenants/[tenant]/users"); + assertEquals(fetchStub.calls.length, 1); + }); + + it("refuses an index route with children", () => { + assertThrows( + () => + createRoutesStub([ + layout([{ + index: true, + children: [{ path: "users", default: () =>

Users

}], + }]), + ]), + TypeError, + "An index route cannot have children", + ); + }); +}); diff --git a/src/utils/testing.ts b/src/utils/testing.ts index ef3339c..fa1cac5 100644 --- a/src/utils/testing.ts +++ b/src/utils/testing.ts @@ -133,11 +133,19 @@ export function simulateEnvironment>( * A route definition for {@linkcode createRoutesStub}. * * Extends {@linkcode RouteModule} with the routing metadata the stub needs to - * place the route in the test router. + * place the route in the test router, including nested child routes. */ export interface RouteStub extends RouteModule { - /** The route's URL path segment. */ + /** + * The route's URL path. A top-level route defaults to `/`; a child's path is + * relative to its parent, and a child with neither `path` nor `index` is a + * pathless layout. + */ path?: string; + /** Renders this child at its parent's own URL, like an `index.tsx` route; cannot have `children`. */ + index?: boolean; + /** Routes rendered in this route's `Outlet`; their failures bubble to this route's `ErrorBoundary`. */ + children?: RouteStub[]; /** Flags marking which server-side handlers the route simulates. */ serverFlags?: ServerFlags; /** Server data-request id sent in X-Juniper-Route-Id; does not set the memory router's id. */ @@ -176,6 +184,40 @@ export interface CreateRoutesStubOptions { getContext?: (context: RouterContextProvider) => void; } +function createStubRouteObject( + routeStub: RouteStub, + defaultPath?: string, +): RouteObject { + const { + path = defaultPath, + index, + children, + serverFlags, + routeId, + ...routeModule + } = routeStub; + const route = createRoute(routeModule, serverFlags, routeId); + const routeObject = { + path, + Component: route.Component, + ErrorBoundary: route.ErrorBoundary, + HydrateFallback: route.HydrateFallback, + loader: route.loader, + action: route.action, + shouldRevalidate: route.shouldRevalidate, + }; + if (index) { + if (children) { + throw new TypeError("An index route cannot have children"); + } + return { ...routeObject, index: true }; + } + return { + ...routeObject, + children: children?.map((child) => createStubRouteObject(child)), + }; +} + /** * Creates a memory-router component from Juniper route modules. * @@ -184,11 +226,13 @@ export interface CreateRoutesStubOptions { * use `serverFlags`, `routeId`, and a controlled fetch to exercise a server bridge. * This does not execute Hono or route middleware. Seed context with `getContext`. * - * Each call creates sibling routes, not a nested route tree. `hydrationData` uses - * React Router's generated ids (`"0"`, `"1"`, …), not the server `routeId` values. - * Create the stub outside your component's render and unmount it with `cleanup`. + * Top-level routes are siblings; nest routes under a layout with `children` so + * a child's loader or render failure reaches the layout's `ErrorBoundary`. + * `hydrationData` uses React Router's generated ids (`"0"`, `"1"`, and `"0-0"` + * for the first child of `"0"`), not the server `routeId` values. Create the + * stub outside your component's render and unmount it with `cleanup`. * - * @param routes - Route modules with optional path and server-request metadata. + * @param routes - Route modules with optional path, children, and server-request metadata. * @param options - Initial context setup. * @returns A component; initial history defaults to the first route's path. * @example @@ -224,20 +268,9 @@ export function createRoutesStub( options?: CreateRoutesStubOptions, ): React.ComponentType { const firstPath = routes[0]?.path ?? "/"; - const routeObjects: RouteObject[] = routes.map((routeStub) => { - const { path = "/", serverFlags, routeId, ...routeModule } = routeStub; - const route = createRoute(routeModule, serverFlags, routeId); - - return { - path, - Component: route.Component, - ErrorBoundary: route.ErrorBoundary, - HydrateFallback: route.HydrateFallback, - loader: route.loader, - action: route.action, - shouldRevalidate: route.shouldRevalidate, - }; - }); + const routeObjects = routes.map((routeStub) => + createStubRouteObject(routeStub, "/") + ); return function RoutesStub( { initialEntries, hydrationData }: RoutesStubProps,