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
15 changes: 15 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ A loader or action can also throw a `Response` other than a redirect, or throw
`HttpError` with that status and those headers. A status outside 400–599 becomes
`500`.

A data request that no loader or action can handle also gets an `HttpError`:

- `404` when its `X-Juniper-Route-Id` doesn't name a route that matches the URL.
- `405` when the route has no handler for the method, such as a `POST` to a
route without an action. The `Allow` header lists the methods the route
accepts. It's left off while a lazily loaded route's module hasn't loaded on
the server yet.
- `400` for a `GET` to a route without a loader. Until a lazily loaded route's
module has loaded on the server, React Router answers that request with
`undefined` data instead.

These errors carry the generic message for their status. React Router's own
message, which names the route, is logged on the server. Outside development, it
isn't sent to the browser.

## Error Boundaries

Error boundaries catch errors thrown during rendering, in loaders, actions, or
Expand Down
56 changes: 55 additions & 1 deletion src/_server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import {
createStaticHandler,
createStaticRouter,
isRouteErrorResponse,
matchRoutes,
StaticRouterProvider,
} from "react-router";
import type {
DataRouteObject,
DataStrategyFunctionArgs,
DataStrategyResult,
ErrorResponse,
RouterContextProvider,
StaticHandlerContext,
} from "react-router";
Expand Down Expand Up @@ -350,6 +353,22 @@ async function responseToHttpError(response: Response): Promise<HttpError> {
});
}

function isRouterRejection(error: ErrorResponse): boolean {
return (error as { internal?: unknown }).internal === true;
}

function routeErrorResponseToHttpError(error: ErrorResponse): HttpError {
const fromRouter = isRouterRejection(error);
return new HttpError(
fromRouter && error.status === 403 ? 404 : errorStatus(error.status),
{
message: typeof error.data === "string" ? error.data : error.statusText,
expose: fromRouter ? false : undefined,
cause: error,
},
);
}

async function convertToHttpError(cause: unknown): Promise<HttpError> {
if (
cause !== null &&
Expand All @@ -368,6 +387,7 @@ async function convertToHttpError(cause: unknown): Promise<HttpError> {
headers: new Headers(init?.headers),
});
}
if (isRouteErrorResponse(cause)) return routeErrorResponseToHttpError(cause);
return HttpError.from(cause);
}

Expand Down Expand Up @@ -1047,6 +1067,33 @@ async function dataRequestStrategy(
return results;
}

function allowedDataMethods(route: DataRouteObject): string {
return [
...(route.loader ? ["GET", "HEAD"] : []),
...(route.action ? ["POST", "PUT", "PATCH", "DELETE"] : []),
].join(", ");
}

async function toDataRequestError(
cause: unknown,
request: Request,
dataRoutes: DataRouteObject[],
routeId: string,
): Promise<HttpError> {
const error = await convertToHttpError(cause);
if (
error.status === 405 && isRouteErrorResponse(cause) &&
isRouterRejection(cause)
) {
const route = matchRoutes(dataRoutes, new URL(request.url).pathname)
?.find((match) => match.route.id === routeId)?.route;
if (route && !route.lazy) {
error.headers.set("Allow", allowedDataMethods(route));
}
}
return error;
}

/**
* Builds the Hono handlers for the client routes — server-rendered documents
* and data requests — plus the error handler `createServer` installs alongside
Expand Down Expand Up @@ -1107,13 +1154,20 @@ export function createHandlers<
},
async function handleDataRequest(c) {
return await startActiveSpan("handleDataRequest", async (_span) => {
const routeId = c.req.header("X-Juniper-Route-Id");
const routeId = c.req.header("X-Juniper-Route-Id") ?? "";

const requestContext = c.get("context");
const dataOrResponse = await queryRoute(c.req.raw, {
requestContext,
routeId,
dataStrategy: dataRequestStrategy,
}).catch(async (cause: unknown) => {
throw await toDataRequestError(
cause,
c.req.raw,
dataRoutes,
routeId,
);
});

if (dataOrResponse instanceof Response) {
Expand Down
Loading
Loading