Skip to content

fix(rsc): export default root ErrorBoundary #13838

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 20, 2025
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
1 change: 1 addition & 0 deletions packages/react-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export {
RSCStaticRouter as unstable_RSCStaticRouter,
} from "./lib/rsc/server.ssr";
export { getServerStream as unstable_getServerStream } from "./lib/rsc/html-stream/browser";
export { RSCDefaultRootErrorBoundary as UNSAFE_RSCDefaultRootErrorBoundary } from "./lib/rsc/errorBoundaries";

///////////////////////////////////////////////////////////////////////////////
// DANGER! PLEASE READ ME!
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/lib/dom/ssr/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ function preventInvalidServerHandlerCall(
}
}

function noActionDefinedError(
export function noActionDefinedError(
type: "action" | "clientAction",
routeId: string
) {
Expand Down
30 changes: 25 additions & 5 deletions packages/react-router/lib/rsc/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ import {
} from "../dom/ssr/single-fetch";
import { createRequestInit } from "../dom/ssr/data";
import { getHydrationData } from "../dom/ssr/hydration";
import { shouldHydrateRouteLoader } from "../dom/ssr/routes";
import {
noActionDefinedError,
shouldHydrateRouteLoader,
} from "../dom/ssr/routes";
import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";

export type DecodeServerResponseFunction = (
body: ReadableStream<Uint8Array>
Expand Down Expand Up @@ -446,6 +450,18 @@ export function RSCHydratedRouter({
}
}, []);

let [location, setLocation] = React.useState(router.state.location);

React.useLayoutEffect(
() =>
router.subscribe((newState) => {
if (newState.location !== location) {
setLocation(newState.location);
}
}),
[router, location]
);

React.useEffect(() => {
if (
routeDiscovery === "lazy" ||
Expand Down Expand Up @@ -540,9 +556,11 @@ export function RSCHydratedRouter({

return (
<RSCRouterContext.Provider value={true}>
<FrameworkContext.Provider value={frameworkContext}>
<RouterProvider router={router} flushSync={ReactDOM.flushSync} />
</FrameworkContext.Provider>
<RSCRouterGlobalErrorBoundary location={location}>
<FrameworkContext.Provider value={frameworkContext}>
<RouterProvider router={router} flushSync={ReactDOM.flushSync} />
</FrameworkContext.Provider>
</RSCRouterGlobalErrorBoundary>
</RSCRouterContext.Provider>
);
}
Expand Down Expand Up @@ -625,7 +643,9 @@ function createRouteFromServerManifest(
})
: match.hasAction
? (_, singleFetch) => callSingleFetch(singleFetch)
: undefined,
: () => {
throw noActionDefinedError("action", match.id);
},
path: match.path,
shouldRevalidate: match.shouldRevalidate,
// We always have a "loader" in this RSC world since even if we don't
Expand Down
181 changes: 181 additions & 0 deletions packages/react-router/lib/rsc/errorBoundaries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React from "react";
import { useRouteError } from "../hooks";
import type { Location } from "../router/history";
import { isRouteErrorResponse } from "../router/utils";
import { ENABLE_DEV_WARNINGS } from "../context";

type RSCRouterGlobalErrorBoundaryProps = React.PropsWithChildren<{
location: Location;
}>;

type RSCRouterGlobalErrorBoundaryState = {
error: null | Error;
location: Location;
};

export class RSCRouterGlobalErrorBoundary extends React.Component<
RSCRouterGlobalErrorBoundaryProps,
RSCRouterGlobalErrorBoundaryState
> {
constructor(props: RSCRouterGlobalErrorBoundaryProps) {
super(props);
this.state = { error: null, location: props.location };
}

static getDerivedStateFromError(error: Error) {
return { error };
}

static getDerivedStateFromProps(
props: RSCRouterGlobalErrorBoundaryProps,
state: RSCRouterGlobalErrorBoundaryState
) {
// When we get into an error state, the user will likely click "back" to the
// previous page that didn't have an error. Because this wraps the entire
// application (even the HTML!) that will have no effect--the error page
// continues to display. This gives us a mechanism to recover from the error
// when the location changes.
//
// Whether we're in an error state or not, we update the location in state
// so that when we are in an error state, it gets reset when a new location
// comes in and the user recovers from the error.
if (state.location !== props.location) {
return { error: null, location: props.location };
}

// If we're not changing locations, preserve the location but still surface
// any new errors that may come through. We retain the existing error, we do
// this because the error provided from the app state may be cleared without
// the location changing.
return { error: state.error, location: state.location };
}

render() {
if (this.state.error) {
return (
<RSCDefaultRootErrorBoundaryImpl
error={this.state.error}
renderAppShell={true}
/>
);
} else {
return this.props.children;
}
}
}

function ErrorWrapper({
renderAppShell,
title,
children,
}: {
renderAppShell: boolean;
title: string;
children: React.ReactNode;
}) {
if (!renderAppShell) {
return children;
}

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<title>{title}</title>
</head>
<body>
<main style={{ fontFamily: "system-ui, sans-serif", padding: "2rem" }}>
{children}
</main>
</body>
</html>
);
}

function RSCDefaultRootErrorBoundaryImpl({
error,
renderAppShell,
}: {
error: unknown;
renderAppShell: boolean;
}) {
console.error(error);

let heyDeveloper = (
<script
dangerouslySetInnerHTML={{
__html: `
console.log(
"💿 Hey developer 👋. You can provide a way better UX than this when your app throws errors. Check out https://reactrouter.com/how-to/error-boundary for more information."
);
`,
}}
/>
);

if (isRouteErrorResponse(error)) {
return (
<ErrorWrapper
renderAppShell={renderAppShell}
title="Unhandled Thrown Response!"
>
<h1 style={{ fontSize: "24px" }}>
{error.status} {error.statusText}
</h1>
{ENABLE_DEV_WARNINGS ? heyDeveloper : null}
</ErrorWrapper>
);
}

let errorInstance: Error;
if (error instanceof Error) {
errorInstance = error;
} else {
let errorString =
error == null
? "Unknown Error"
: typeof error === "object" && "toString" in error
? error.toString()
: JSON.stringify(error);
errorInstance = new Error(errorString);
}

return (
<ErrorWrapper renderAppShell={renderAppShell} title="Application Error!">
<h1 style={{ fontSize: "24px" }}>Application Error</h1>
<pre
style={{
padding: "2rem",
background: "hsla(10, 50%, 50%, 0.1)",
color: "red",
overflow: "auto",
}}
>
{errorInstance.stack}
</pre>
{heyDeveloper}
</ErrorWrapper>
);
}

export function RSCDefaultRootErrorBoundary({
hasRootLayout,
}: {
hasRootLayout: boolean;
}) {
let error = useRouteError();

if (hasRootLayout === undefined) {
throw new Error("Missing 'hasRootLayout' prop");
}
return (
<RSCDefaultRootErrorBoundaryImpl
renderAppShell={!hasRootLayout}
error={error}
/>
);
}
19 changes: 11 additions & 8 deletions packages/react-router/lib/rsc/server.ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FrameworkContext } from "../dom/ssr/components";
import type { FrameworkContextObject } from "../dom/ssr/entry";
import { createStaticRouter, StaticRouterProvider } from "../dom/server";
import { injectRSCPayload } from "./html-stream/server";
import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";
import type { ServerPayload } from "./server.rsc";

export async function routeRSCServerRequest({
Expand Down Expand Up @@ -181,14 +182,16 @@ export function RSCStaticRouter({

return (
<RSCRouterContext.Provider value={true}>
<FrameworkContext.Provider value={frameworkContext}>
<StaticRouterProvider
context={context}
router={router}
hydrate={false}
nonce={payload.nonce}
/>
</FrameworkContext.Provider>
<RSCRouterGlobalErrorBoundary location={payload.location}>
<FrameworkContext.Provider value={frameworkContext}>
<StaticRouterProvider
context={context}
router={router}
hydrate={false}
nonce={payload.nonce}
/>
</FrameworkContext.Provider>
</RSCRouterGlobalErrorBoundary>
</RSCRouterContext.Provider>
);
}
Expand Down