diff --git a/packages/docs/src/pages/LearnLoadersPage.tsx b/packages/docs/src/pages/LearnLoadersPage.tsx index 12c6c2c..198ea85 100644 --- a/packages/docs/src/pages/LearnLoadersPage.tsx +++ b/packages/docs/src/pages/LearnLoadersPage.tsx @@ -211,6 +211,76 @@ function UserDetail({ data }: { data: Promise }) {

+
+

Error Handling

+

+ When a loader throws an error, the router catches it and re-throws it + during rendering of that route’s component. This means the error + can be caught by a React{" "} + + Error Boundary + {" "} + placed above the route in the component tree. For async loaders that + return a rejected promise, the error is surfaced when{" "} + use(data) is called, which is also caught by Error + Boundaries. +

+

+ The recommended pattern is to place an error boundary in your{" "} + root layout route, wrapping the{" "} + {""}. This catches errors from any loader in + the route tree while keeping the root layout (header, navigation, + etc.) intact: +

+ {`import { Router, route, Outlet } from "@funstack/router"; +import { ErrorBoundary } from "./ErrorBoundary"; + +function RootLayout() { + return ( +
+
My App
+ Something went wrong.
}> + + + + ); +} + +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(); + }, + }), + ], + }), +];`}
+

+ 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 use(). Either way, Error Boundaries catch the + error. +

+

+ You can also place error boundaries at more granular levels (e.g., + wrapping a specific route’s {""} or{" "} + {""} boundary) for fine-grained error handling. +

+
+

Summary

diff --git a/packages/router/src/Router/RouteRenderer.tsx b/packages/router/src/Router/RouteRenderer.tsx index 11b519b..15d3315 100644 --- a/packages/router/src/Router/RouteRenderer.tsx +++ b/packages/router/src/Router/RouteRenderer.tsx @@ -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"; @@ -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; diff --git a/packages/router/src/__tests__/loader.test.tsx b/packages/router/src/__tests__/loader.test.tsx index 327dd35..e0aac22 100644 --- a/packages/router/src/__tests__/loader.test.tsx +++ b/packages/router/src/__tests__/loader.test.tsx @@ -1,5 +1,6 @@ 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"; @@ -7,6 +8,28 @@ 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; @@ -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
{data}
; + } + + const routes = [ + route({ + path: "/", + component: Page, + loader: (): never => { + throw error; + }, + }), + ]; + + // The error should be caught by the Error Boundary, not crash Router + render( +
Caught: {(e as Error).message}
} + > + +
, + ); + 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
{data}
; + } + + const routes = [ + route({ + path: "/", + component: Page, + loader: (): never => { + throw error; + }, + }), + ]; + + render( + { + caughtError = e; + return
Error caught
; + }} + > + +
, + ); + + 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 ( +
+

Layout

+
Caught: {(e as Error).message}
} + > + +
+
+ ); + } + + function ChildPage({ data }: { data: string }) { + return
{data}
; + } + + const routes = [ + route({ + path: "/", + component: Layout, + children: [ + route({ + path: "/child", + component: ChildPage, + loader: (): never => { + throw new Error("Child loader failed"); + }, + }), + ], + }), + ]; + + render(); + + // 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 diff --git a/packages/router/src/core/loaderCache.ts b/packages/router/src/core/loaderCache.ts index 6ecd3a7..1e9f7bf 100644 --- a/packages/router/src/core/loaderCache.ts +++ b/packages/router/src/core/loaderCache.ts @@ -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}` @@ -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)); + } } return loaderCache.get(cacheKey);