From 4523b8d3f53c8eb266bdbb70139c6350810d8d0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 13:05:21 +0000 Subject: [PATCH 1/3] fix: convert sync loader errors to rejected promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a loader throws a synchronous error, the exception previously propagated through the Router's useMemo, crashing the entire component. This converts sync errors to rejected promises so they behave identically to async loader errors — surfaced via React's use() hook and catchable by Error Boundaries. Also adds FAQ documentation recommending the root layout error boundary pattern for handling loader errors. https://claude.ai/code/session_01PDeppPqxy1CVE8oWJFiHLM --- packages/docs/src/pages/FaqPage.tsx | 68 +++++++++++++++ packages/router/src/__tests__/loader.test.tsx | 85 +++++++++++++++++++ packages/router/src/core/loaderCache.ts | 12 ++- 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/packages/docs/src/pages/FaqPage.tsx b/packages/docs/src/pages/FaqPage.tsx index 9074ec0..2c69d8f 100644 --- a/packages/docs/src/pages/FaqPage.tsx +++ b/packages/docs/src/pages/FaqPage.tsx @@ -79,6 +79,74 @@ hardReload(); // Full page navigation — bypasses the router and all blockers hardNavigate("/other-page");`} +
+

Handling loader errors

+

+ When a loader throws an error (or returns a rejected promise), the + error is surfaced as a rejected promise in the component's{" "} + data prop. When you call use(data), React + re-throws the rejection, which can be caught by an{" "} + + Error Boundary + + . +

+

+ The recommended pattern is to place an error boundary in your{" "} + root layout route. This catches errors from any loader in the + route tree without crashing the entire application: +

+ {`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(); + }, + }), + ], + }), +]; + +function App() { + return ; +}`}
+

+ This works for both synchronous and asynchronous loaders. The router + internally converts synchronous loader errors into rejected promises, + so the behavior is consistent regardless of whether a loader is sync + or async. +

+

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

+
); } diff --git a/packages/router/src/__tests__/loader.test.tsx b/packages/router/src/__tests__/loader.test.tsx index 327dd35..8cd088f 100644 --- a/packages/router/src/__tests__/loader.test.tsx +++ b/packages/router/src/__tests__/loader.test.tsx @@ -560,6 +560,91 @@ describe("Data Loader", () => { }); }); + describe("loader errors", () => { + it("converts sync loader error to rejected promise instead of crashing Router", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const error = new Error("Sync loader failure"); + + function ErrorPage({ data }: { data: Promise }) { + // Don't use() the data — just verify the component receives a rejected promise + expect(data).toBeInstanceOf(Promise); + return
Error page rendered
; + } + + const routes = [ + route({ + path: "/", + component: ErrorPage, + loader: (): never => { + throw error; + }, + }), + ]; + + // Router should NOT throw during render + render(); + expect(screen.getByText("Error page rendered")).toBeInTheDocument(); + + consoleErrorSpy.mockRestore(); + }); + + it("sync loader error results in a rejected promise with the original error", async () => { + const error = new Error("Original error"); + let receivedData: unknown; + + function Page({ data }: { data: unknown }) { + receivedData = data; + return
Page
; + } + + const routes = [ + route({ + path: "/", + component: Page, + loader: (): never => { + throw error; + }, + }), + ]; + + render(); + + // The rejected promise should contain the original error + await expect(receivedData).rejects.toBe(error); + }); + + it("caches the rejected promise from sync loader error", () => { + const promises: unknown[] = []; + + function Page({ data }: { data: unknown }) { + promises.push(data); + return
Page
; + } + + const routes = [ + route({ + path: "/", + component: Page, + loader: (): never => { + throw new Error("fail"); + }, + }), + ]; + + const { rerender } = render(); + rerender(); + + // Both renders should receive the same Promise instance (from cache) + expect(promises.length).toBeGreaterThanOrEqual(1); + if (promises.length > 1) { + expect(promises[0]).toBe(promises[1]); + } + }); + }); + 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..8f3a050 100644 --- a/packages/router/src/core/loaderCache.ts +++ b/packages/router/src/core/loaderCache.ts @@ -28,7 +28,17 @@ 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) { + // Convert synchronous loader errors to rejected promises + // so they are handled uniformly via React's use() + Error Boundary + const rejected = Promise.reject(error); + // Prevent unhandled rejection warnings; the rejection will be + // consumed by React's use() which surfaces it to Error Boundaries. + rejected.catch(() => {}); + loaderCache.set(cacheKey, rejected); + } } return loaderCache.get(cacheKey); From b14ba8852ffdf2e0663ef8dc3962a0e2535e406b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 13:50:46 +0000 Subject: [PATCH 2/3] fix: use LoaderError class to handle sync loader errors Instead of converting sync loader errors to rejected promises (which changes the type contract), cache them as LoaderError instances. RouteRenderer checks for LoaderError and re-throws the original error during rendering, where Error Boundaries can catch it. This preserves the expected data types for components while ensuring sync loader errors don't crash the entire . https://claude.ai/code/session_01PDeppPqxy1CVE8oWJFiHLM --- packages/docs/src/pages/FaqPage.tsx | 23 +-- packages/router/src/Router/RouteRenderer.tsx | 6 + packages/router/src/__tests__/loader.test.tsx | 132 +++++++++++++----- packages/router/src/core/loaderCache.ts | 21 ++- 4 files changed, 132 insertions(+), 50 deletions(-) diff --git a/packages/docs/src/pages/FaqPage.tsx b/packages/docs/src/pages/FaqPage.tsx index 2c69d8f..1ba3bf6 100644 --- a/packages/docs/src/pages/FaqPage.tsx +++ b/packages/docs/src/pages/FaqPage.tsx @@ -82,14 +82,16 @@ hardNavigate("/other-page");`}

Handling loader errors

- When a loader throws an error (or returns a rejected promise), the - error is surfaced as a rejected promise in the component's{" "} - data prop. When you call use(data), React - re-throws the rejection, which can be caught by an{" "} + 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 an{" "} 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{" "} @@ -136,10 +138,11 @@ function App() { return ; }`}

- This works for both synchronous and asynchronous loaders. The router - internally converts synchronous loader errors into rejected promises, - so the behavior is consistent regardless of whether a loader is sync - or async. + 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., 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 8cd088f..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; @@ -561,43 +584,52 @@ describe("Data Loader", () => { }); describe("loader errors", () => { - it("converts sync loader error to rejected promise instead of crashing Router", () => { + 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 ErrorPage({ data }: { data: Promise }) { - // Don't use() the data — just verify the component receives a rejected promise - expect(data).toBeInstanceOf(Promise); - return

Error page rendered
; + function Page({ data }: { data: string }) { + return
{data}
; } const routes = [ route({ path: "/", - component: ErrorPage, + component: Page, loader: (): never => { throw error; }, }), ]; - // Router should NOT throw during render - render(); - expect(screen.getByText("Error page rendered")).toBeInTheDocument(); + // 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 results in a rejected promise with the original error", async () => { + it("sync loader error preserves the original error object", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const error = new Error("Original error"); - let receivedData: unknown; + let caughtError: unknown; - function Page({ data }: { data: unknown }) { - receivedData = data; - return
Page
; + function Page({ data }: { data: string }) { + return
{data}
; } const routes = [ @@ -610,38 +642,72 @@ describe("Data Loader", () => { }), ]; - render(); + render( + { + caughtError = e; + return
Error caught
; + }} + > + +
, + ); - // The rejected promise should contain the original error - await expect(receivedData).rejects.toBe(error); + expect(caughtError).toBe(error); + + consoleErrorSpy.mockRestore(); }); - it("caches the rejected promise from sync loader error", () => { - const promises: unknown[] = []; + 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 Page({ data }: { data: unknown }) { - promises.push(data); - return
Page
; + function Layout() { + return ( +
+

Layout

+
Caught: {(e as Error).message}
} + > + +
+
+ ); + } + + function ChildPage({ data }: { data: string }) { + return
{data}
; } const routes = [ route({ path: "/", - component: Page, - loader: (): never => { - throw new Error("fail"); - }, + component: Layout, + children: [ + route({ + path: "/child", + component: ChildPage, + loader: (): never => { + throw new Error("Child loader failed"); + }, + }), + ], }), ]; - const { rerender } = render(); - rerender(); + render(); - // Both renders should receive the same Promise instance (from cache) - expect(promises.length).toBeGreaterThanOrEqual(1); - if (promises.length > 1) { - expect(promises[0]).toBe(promises[1]); - } + // 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(); }); }); diff --git a/packages/router/src/core/loaderCache.ts b/packages/router/src/core/loaderCache.ts index 8f3a050..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}` @@ -31,13 +41,10 @@ function getOrCreateLoaderResult( try { loaderCache.set(cacheKey, route.loader(args)); } catch (error) { - // Convert synchronous loader errors to rejected promises - // so they are handled uniformly via React's use() + Error Boundary - const rejected = Promise.reject(error); - // Prevent unhandled rejection warnings; the rejection will be - // consumed by React's use() which surfaces it to Error Boundaries. - rejected.catch(() => {}); - loaderCache.set(cacheKey, rejected); + // 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)); } } From 2cf4eba16608a5e9c85974d51e50548ad9d592cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 13:59:13 +0000 Subject: [PATCH 3/3] docs: move loader error handling to How Loaders Run page Move the error boundary recommendation from the FAQ to the "How Loaders Run" learn page where it fits naturally alongside other loader behavior documentation. https://claude.ai/code/session_01PDeppPqxy1CVE8oWJFiHLM --- packages/docs/src/pages/FaqPage.tsx | 71 -------------------- packages/docs/src/pages/LearnLoadersPage.tsx | 70 +++++++++++++++++++ 2 files changed, 70 insertions(+), 71 deletions(-) diff --git a/packages/docs/src/pages/FaqPage.tsx b/packages/docs/src/pages/FaqPage.tsx index 1ba3bf6..9074ec0 100644 --- a/packages/docs/src/pages/FaqPage.tsx +++ b/packages/docs/src/pages/FaqPage.tsx @@ -79,77 +79,6 @@ hardReload(); // Full page navigation — bypasses the router and all blockers hardNavigate("/other-page");`}
-
-

Handling loader errors

-

- 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 an{" "} - - 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. This catches errors from any loader in the - route tree without crashing the entire application: -

- {`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(); - }, - }), - ], - }), -]; - -function App() { - return ; -}`}
-

- 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 individual route components or {""}{" "} - boundaries) for fine-grained error handling. -

-
); } 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