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
70 changes: 70 additions & 0 deletions packages/docs/src/pages/LearnLoadersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,76 @@ function UserDetail({ data }: { data: Promise<User> }) {
</p>
</section>

<section>
<h3>Error Handling</h3>
<p>
When a loader throws an error, the router catches it and re-throws it
during rendering of that route&rsquo;s component. This means the error
can be caught by a React{" "}
<a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary">
Error Boundary
</a>{" "}
placed above the route in the component tree. For async loaders that
return a rejected promise, the error is surfaced when{" "}
<code>use(data)</code> is called, which is also caught by Error
Boundaries.
</p>
<p>
The recommended pattern is to place an error boundary in your{" "}
<strong>root layout route</strong>, wrapping the{" "}
<code>{"<Outlet />"}</code>. This catches errors from any loader in
the route tree while keeping the root layout (header, navigation,
etc.) intact:
Comment on lines +231 to +233
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc implies a root layout Error Boundary wrapping will catch errors from "any loader in the route tree", but it will not catch errors from the root layout route's own loader (because that boundary renders only if RootLayout renders). Consider clarifying that it catches descendant route loader errors, and mention that to catch the root route loader error you need an Error Boundary above (or avoid a loader on the root layout).

Suggested change
<code>{"<Outlet />"}</code>. This catches errors from any loader in
the route tree while keeping the root layout (header, navigation,
etc.) intact:
<code>{"<Outlet />"}</code>. This lets you catch errors from
descendant route loaders while keeping the root layout (header,
navigation, etc.) intact. To catch errors from the root layout
route&rsquo;s own loader, place an error boundary above{" "}
<code>{"<Router />"}</code> (or avoid using a loader on the root
layout route):

Copilot uses AI. Check for mistakes.
</p>
<CodeBlock language="tsx">{`import { Router, route, Outlet } from "@funstack/router";
import { ErrorBoundary } from "./ErrorBoundary";

function RootLayout() {
return (
<div>
<header>My App</header>
<ErrorBoundary fallback={<div>Something went wrong.</div>}>
<Outlet />
</ErrorBoundary>
</div>
);
}

const routes = [
route({
path: "/",
component: RootLayout,
children: [
route({
path: "/",
component: HomePage,
}),
route({
path: "/users/:id",
component: UserPage,
loader: async ({ params }) => {
const res = await fetch(\`/api/users/\${params.id}\`);
if (!res.ok) throw new Error("Failed to load user");
return res.json();
},
}),
],
}),
];`}</CodeBlock>
<p>
This works for both synchronous and asynchronous loaders. For sync
loaders, the router catches the error and re-throws it during route
rendering. For async loaders, the rejected promise naturally surfaces
through <code>use()</code>. Either way, Error Boundaries catch the
error.
</p>
<p>
You can also place error boundaries at more granular levels (e.g.,
wrapping a specific route&rsquo;s <code>{"<Outlet />"}</code> or{" "}
<code>{"<Suspense>"}</code> boundary) for fine-grained error handling.
</p>
</section>

<section>
<h3>Summary</h3>
<table className="summary-table">
Expand Down
6 changes: 6 additions & 0 deletions packages/router/src/Router/RouteRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ReactNode, useContext, useMemo } from "react";
import { RouterContext } from "../context/RouterContext.js";
import { RouteContext } from "../context/RouteContext.js";
import { LoaderError } from "../core/loaderCache.js";
import type { MatchedRouteWithData, InternalRouteState } from "../types.js";
import { useRouteStateCallbacks } from "./useRouteStateCallbacks.js";

Expand Down Expand Up @@ -106,6 +107,11 @@ export function RouteRenderer({
// When loader exists, data is defined and component expects data prop
// When loader doesn't exist, data is undefined and component doesn't expect data prop
// TypeScript can't narrow this union, so we use runtime check with type assertion
if (data instanceof LoaderError) {
// Re-throw synchronous loader errors during rendering so that
// the nearest Error Boundary can catch them.
throw data.error;
}
if (route.loader) {
const ComponentWithData = Component as React.ComponentType<{
data: unknown;
Expand Down
151 changes: 151 additions & 0 deletions packages/router/src/__tests__/loader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { Component, type ReactNode } from "react";
import { Router } from "../Router/index.js";
import { Outlet } from "../Outlet.js";
import { route, type LoaderArgs } from "../route.js";
import { setupNavigationMock, cleanupNavigationMock } from "./setup.js";
import { internalRoutes } from "../types.js";
import { clearLoaderCache } from "../core/loaderCache.js";

class ErrorBoundary extends Component<
{ children: ReactNode; fallback: (error: unknown) => ReactNode },
{ error: unknown; hasError: boolean }
> {
constructor(props: {
children: ReactNode;
fallback: (error: unknown) => ReactNode;
}) {
super(props);
this.state = { error: undefined, hasError: false };
}
static getDerivedStateFromError(error: unknown) {
return { error, hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}

describe("Data Loader", () => {
let mockNavigation: ReturnType<typeof setupNavigationMock>;

Expand Down Expand Up @@ -560,6 +583,134 @@ describe("Data Loader", () => {
});
});

describe("loader errors", () => {
it("sync loader error is caught by Error Boundary instead of crashing Router", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});

const error = new Error("Sync loader failure");

function Page({ data }: { data: string }) {
return <div>{data}</div>;
}

const routes = [
route({
path: "/",
component: Page,
loader: (): never => {
throw error;
},
}),
];

// The error should be caught by the Error Boundary, not crash Router
render(
<ErrorBoundary
fallback={(e) => <div>Caught: {(e as Error).message}</div>}
>
<Router routes={routes} />
</ErrorBoundary>,
);
expect(
screen.getByText("Caught: Sync loader failure"),
).toBeInTheDocument();

consoleErrorSpy.mockRestore();
});

it("sync loader error preserves the original error object", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});

const error = new Error("Original error");
let caughtError: unknown;

function Page({ data }: { data: string }) {
return <div>{data}</div>;
}

const routes = [
route({
path: "/",
component: Page,
loader: (): never => {
throw error;
},
}),
];

render(
<ErrorBoundary
fallback={(e) => {
caughtError = e;
return <div>Error caught</div>;
}}
>
<Router routes={routes} />
</ErrorBoundary>,
);

expect(caughtError).toBe(error);

consoleErrorSpy.mockRestore();
});

it("sync loader error in nested route is caught by Error Boundary around Outlet", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});

mockNavigation = setupNavigationMock("http://localhost/child");

function Layout() {
return (
<div>
<h1>Layout</h1>
<ErrorBoundary
fallback={(e) => <div>Caught: {(e as Error).message}</div>}
>
<Outlet />
</ErrorBoundary>
</div>
);
}

function ChildPage({ data }: { data: string }) {
return <div>{data}</div>;
}

const routes = [
route({
path: "/",
component: Layout,
children: [
route({
path: "/child",
component: ChildPage,
loader: (): never => {
throw new Error("Child loader failed");
},
}),
],
}),
];

render(<Router routes={routes} />);

// Layout should still render
expect(screen.getByText("Layout")).toBeInTheDocument();
// Child error should be caught by the Error Boundary in the layout
expect(
screen.getByText("Caught: Child loader failed"),
).toBeInTheDocument();

consoleErrorSpy.mockRestore();
});
});

describe("route helper type inference", () => {
it("infers loader return type for component data prop", () => {
// This test mainly verifies that TypeScript compiles correctly
Expand Down
19 changes: 18 additions & 1 deletion packages/router/src/core/loaderCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import type {
InternalRouteDefinition,
} from "../types.js";

/**
* Wrapper for synchronous errors thrown by loaders.
* Cached instead of the raw error so the Router's useMemo doesn't throw.
* RouteRenderer checks for this class and re-throws the original error
* during rendering, where Error Boundaries can catch it.
*/
export class LoaderError {
constructor(public readonly error: unknown) {}
}

/**
* Cache for loader results.
* Key format: `${entryId}:${matchIndex}`
Expand All @@ -28,7 +38,14 @@ function getOrCreateLoaderResult(
const cacheKey = `${entryId}:${matchIndex}`;

if (!loaderCache.has(cacheKey)) {
loaderCache.set(cacheKey, route.loader(args));
try {
loaderCache.set(cacheKey, route.loader(args));
} catch (error) {
// Wrap synchronous loader errors so they don't crash the Router
// during useMemo. RouteRenderer will unwrap and re-throw during
// rendering, where Error Boundaries can catch them.
loaderCache.set(cacheKey, new LoaderError(error));
}
Comment on lines +41 to +48
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR title/description say sync loader errors are converted to rejected promises, but this change instead caches a LoaderError sentinel and relies on RouteRenderer to throw during render. To avoid confusion for reviewers/users, either update the PR metadata (and any docs wording) to match this implementation, or change the cache value here to a rejected Promise (and handle it consistently downstream).

Copilot uses AI. Check for mistakes.
}

return loaderCache.get(cacheKey);
Expand Down
Loading