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
10 changes: 5 additions & 5 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 18 additions & 17 deletions src/_server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,11 +451,10 @@ async function renderDocument(

async function createErrorContext(
error: Error,
dataRoutes: DataRouteObject[],
query: ReturnType<typeof createStaticHandler>["query"],
request: Request,
requestContext: RouterContextProvider,
): Promise<StaticHandlerContext | Response> {
const { query } = createStaticHandler(dataRoutes as unknown as RouteObject[]);
const contextOrResponse = await query(request, { requestContext });

if (contextOrResponse instanceof Response) {
Expand All @@ -465,19 +464,13 @@ async function createErrorContext(
const context = contextOrResponse;
if (!context.errors) context.errors = {};

let errorRouteId = "/";
for (let i = context.matches.length - 1; i >= 0; i--) {
const match = context.matches[i];
const route = match.route;
if (route.hasErrorBoundary) {
errorRouteId = route.id ?? "/";
break;
}
}
context.errors[errorRouteId] = error;
const routeContext = getRouteContext();
if (routeContext?.routeId) {
context.errors[routeContext.routeId] ??= error;

if (context.loaderData) {
delete context.loaderData[errorRouteId];
if (context.loaderData) {
delete context.loaderData[routeContext.routeId];
}
}

return context;
Expand Down Expand Up @@ -813,7 +806,7 @@ export function createHandlers<
const requestContext = c.get("context");
const contextOrResponse = await createErrorContext(
error,
dataRoutes,
query,
c.req.raw,
requestContext,
);
Expand All @@ -828,7 +821,7 @@ export function createHandlers<
dataRoutes,
renderOptions,
request: c.req.raw,
waitForAllReady: true,
waitForAllReady: isbot(c.req.header("user-agent")),
presetError: error,
});
} catch (renderError) {
Expand Down Expand Up @@ -892,7 +885,15 @@ export function buildApp<
);
}

routeApp.use(async (_c, next) => {
routeApp.use(async (c, next) => {
// Index routes should only set context for exact path matches
if (currentRouteId.endsWith("/index")) {
const parentPath = currentRouteId.slice(0, -6) || "/";
const path = new URL(c.req.url).pathname;
if (path !== parentPath) {
return await next();
}
}
await routeContextStorage.run(
{ routeId: currentRouteId, isClientRoute: true },
async () => {
Expand Down
4 changes: 3 additions & 1 deletion src/client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ describe("Client", () => {

assertEquals(client.rootRoute.path, "/");
assertEquals(client.routeFileMap.size, 8);
assertEquals(client.routeObjectMap.size, 8);
// 12 = 8 explicit routes + 4 implicit catchalls for routes without their own catchall
// Implicit catchalls: /about/[...], /blog/[...], /blog/create/[...], /blog/[id]/[...]
assertEquals(client.routeObjectMap.size, 12);
assertEquals(client.routeObjects.length, 1);

const routeObject = client.routeObjects[0];
Expand Down
15 changes: 15 additions & 0 deletions src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RouterProvider,
} from "react-router";
import type { RouteObject } from "react-router";
import { HttpError } from "@udibo/http-error";
import type { HtmlProps, RootRouteModule, RouteModule } from "./mod.ts";

import {
Expand Down Expand Up @@ -213,6 +214,20 @@ export class Client {

this.routeFileMap.set(catchallRouteId, route.catchall);
this.routeObjectMap.set(catchallRouteId, catchallRouteObject);
} else {
// Add default catchall that throws 404 for unmatched routes
// This ensures errors bubble to the nearest ErrorBoundary
const catchallRouteId = generateRouteId(currentPath, "", "catchall");
const catchallRouteObject: RouteObject = {
id: catchallRouteId,
path: "*",
loader: () => {
throw new HttpError(404, "Not found");
},
Component: () => <div />,
};
routeObjectChildren.push(catchallRouteObject);
this.routeObjectMap.set(catchallRouteId, catchallRouteObject);
}

if (routeObjectChildren.length > 0) {
Expand Down
148 changes: 148 additions & 0 deletions src/server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,154 @@ describe("createServer", () => {
const html = await res.text();
assertStringIncludes(html, "<!DOCTYPE html>");
});

it("should use nested error boundary for 404s within nested routes", async () => {
const client = new Client({
path: "/",
main: {
default: () => <Outlet />,
ErrorBoundary: () => <div>Root Error Boundary</div>,
},
children: [
{
path: "admin",
main: {
default: () => <Outlet />,
ErrorBoundary: () => <div>Admin Error Boundary</div>,
},
children: [
{
path: "users",
main: () => Promise.resolve({ default: () => <div>Users</div> }),
},
],
},
],
});

const server = createServer(import.meta.url, client, {
path: "/",
children: [
{
path: "admin",
children: [
{
path: "users",
},
],
},
],
});

// Request to a non-existent path under /admin should use admin's error boundary
const res = await server.request("http://localhost/admin/non-existent");
assertEquals(res.status, 404);
const html = await res.text();
assertStringIncludes(html, "<!DOCTYPE html>");
assertStringIncludes(html, "<div>Admin Error Boundary</div>");
// Should NOT use root error boundary
assertEquals(html.includes("Root Error Boundary"), false);
});

it("should use root error boundary for 404s at root level", async () => {
const client = new Client({
path: "/",
main: {
default: () => <Outlet />,
ErrorBoundary: () => <div>Root Error Boundary</div>,
},
children: [
{
path: "admin",
main: {
default: () => <Outlet />,
ErrorBoundary: () => <div>Admin Error Boundary</div>,
},
},
],
});

const server = createServer(import.meta.url, client, {
path: "/",
children: [
{
path: "admin",
},
],
});

// Request to a non-existent path at root level should use root's error boundary
const res = await server.request("http://localhost/non-existent");
assertEquals(res.status, 404);
const html = await res.text();
assertStringIncludes(html, "<!DOCTYPE html>");
assertStringIncludes(html, "<div>Root Error Boundary</div>");
// Should NOT use admin error boundary
assertEquals(html.includes("Admin Error Boundary"), false);
});

it("should find nearest ancestor error boundary when route has no error boundary", async () => {
const client = new Client({
path: "/",
main: {
default: () => <Outlet />,
ErrorBoundary: () => <div>Root Error Boundary</div>,
},
children: [
{
path: "admin",
main: {
default: () => <Outlet />,
ErrorBoundary: () => <div>Admin Error Boundary</div>,
},
children: [
{
path: "settings",
// No error boundary on settings
main: {
default: () => <Outlet />,
},
children: [
{
path: "profile",
main: () =>
Promise.resolve({ default: () => <div>Profile</div> }),
},
],
},
],
},
],
});

const server = createServer(import.meta.url, client, {
path: "/",
children: [
{
path: "admin",
children: [
{
path: "settings",
children: [
{
path: "profile",
},
],
},
],
},
],
});

// Request to non-existent path under /admin/settings should bubble up to admin's error boundary
const res = await server.request(
"http://localhost/admin/settings/non-existent",
);
assertEquals(res.status, 404);
const html = await res.text();
assertStringIncludes(html, "<!DOCTYPE html>");
assertStringIncludes(html, "<div>Admin Error Boundary</div>");
});
});

describe("mergeServerRoutes", () => {
Expand Down