Skip to content

Commit c03f1a6

Browse files
committed
Merge branch 'rsc' into chore-vite-rsc-css-export-transform
2 parents 775bb5d + 4303fcb commit c03f1a6

File tree

5 files changed

+219
-14
lines changed

5 files changed

+219
-14
lines changed

packages/react-router/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ export {
296296
RSCStaticRouter as unstable_RSCStaticRouter,
297297
} from "./lib/rsc/server.ssr";
298298
export { getServerStream as unstable_getServerStream } from "./lib/rsc/html-stream/browser";
299+
export { RSCDefaultRootErrorBoundary as UNSAFE_RSCDefaultRootErrorBoundary } from "./lib/rsc/errorBoundaries";
299300

300301
///////////////////////////////////////////////////////////////////////////////
301302
// DANGER! PLEASE READ ME!

packages/react-router/lib/dom/ssr/routes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ function preventInvalidServerHandlerCall(
211211
}
212212
}
213213

214-
function noActionDefinedError(
214+
export function noActionDefinedError(
215215
type: "action" | "clientAction",
216216
routeId: string
217217
) {

packages/react-router/lib/rsc/browser.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ import {
3434
} from "../dom/ssr/single-fetch";
3535
import { createRequestInit } from "../dom/ssr/data";
3636
import { getHydrationData } from "../dom/ssr/hydration";
37-
import { shouldHydrateRouteLoader } from "../dom/ssr/routes";
37+
import {
38+
noActionDefinedError,
39+
shouldHydrateRouteLoader,
40+
} from "../dom/ssr/routes";
41+
import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";
3842

3943
export type DecodeServerResponseFunction = (
4044
body: ReadableStream<Uint8Array>
@@ -446,6 +450,18 @@ export function RSCHydratedRouter({
446450
}
447451
}, []);
448452

453+
let [location, setLocation] = React.useState(router.state.location);
454+
455+
React.useLayoutEffect(
456+
() =>
457+
router.subscribe((newState) => {
458+
if (newState.location !== location) {
459+
setLocation(newState.location);
460+
}
461+
}),
462+
[router, location]
463+
);
464+
449465
React.useEffect(() => {
450466
if (
451467
routeDiscovery === "lazy" ||
@@ -540,9 +556,11 @@ export function RSCHydratedRouter({
540556

541557
return (
542558
<RSCRouterContext.Provider value={true}>
543-
<FrameworkContext.Provider value={frameworkContext}>
544-
<RouterProvider router={router} flushSync={ReactDOM.flushSync} />
545-
</FrameworkContext.Provider>
559+
<RSCRouterGlobalErrorBoundary location={location}>
560+
<FrameworkContext.Provider value={frameworkContext}>
561+
<RouterProvider router={router} flushSync={ReactDOM.flushSync} />
562+
</FrameworkContext.Provider>
563+
</RSCRouterGlobalErrorBoundary>
546564
</RSCRouterContext.Provider>
547565
);
548566
}
@@ -625,7 +643,9 @@ function createRouteFromServerManifest(
625643
})
626644
: match.hasAction
627645
? (_, singleFetch) => callSingleFetch(singleFetch)
628-
: undefined,
646+
: () => {
647+
throw noActionDefinedError("action", match.id);
648+
},
629649
path: match.path,
630650
shouldRevalidate: match.shouldRevalidate,
631651
// We always have a "loader" in this RSC world since even if we don't
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import React from "react";
2+
import { useRouteError } from "../hooks";
3+
import type { Location } from "../router/history";
4+
import { isRouteErrorResponse } from "../router/utils";
5+
import { ENABLE_DEV_WARNINGS } from "../context";
6+
7+
type RSCRouterGlobalErrorBoundaryProps = React.PropsWithChildren<{
8+
location: Location;
9+
}>;
10+
11+
type RSCRouterGlobalErrorBoundaryState = {
12+
error: null | Error;
13+
location: Location;
14+
};
15+
16+
export class RSCRouterGlobalErrorBoundary extends React.Component<
17+
RSCRouterGlobalErrorBoundaryProps,
18+
RSCRouterGlobalErrorBoundaryState
19+
> {
20+
constructor(props: RSCRouterGlobalErrorBoundaryProps) {
21+
super(props);
22+
this.state = { error: null, location: props.location };
23+
}
24+
25+
static getDerivedStateFromError(error: Error) {
26+
return { error };
27+
}
28+
29+
static getDerivedStateFromProps(
30+
props: RSCRouterGlobalErrorBoundaryProps,
31+
state: RSCRouterGlobalErrorBoundaryState
32+
) {
33+
// When we get into an error state, the user will likely click "back" to the
34+
// previous page that didn't have an error. Because this wraps the entire
35+
// application (even the HTML!) that will have no effect--the error page
36+
// continues to display. This gives us a mechanism to recover from the error
37+
// when the location changes.
38+
//
39+
// Whether we're in an error state or not, we update the location in state
40+
// so that when we are in an error state, it gets reset when a new location
41+
// comes in and the user recovers from the error.
42+
if (state.location !== props.location) {
43+
return { error: null, location: props.location };
44+
}
45+
46+
// If we're not changing locations, preserve the location but still surface
47+
// any new errors that may come through. We retain the existing error, we do
48+
// this because the error provided from the app state may be cleared without
49+
// the location changing.
50+
return { error: state.error, location: state.location };
51+
}
52+
53+
render() {
54+
if (this.state.error) {
55+
return (
56+
<RSCDefaultRootErrorBoundaryImpl
57+
error={this.state.error}
58+
renderAppShell={true}
59+
/>
60+
);
61+
} else {
62+
return this.props.children;
63+
}
64+
}
65+
}
66+
67+
function ErrorWrapper({
68+
renderAppShell,
69+
title,
70+
children,
71+
}: {
72+
renderAppShell: boolean;
73+
title: string;
74+
children: React.ReactNode;
75+
}) {
76+
if (!renderAppShell) {
77+
return children;
78+
}
79+
80+
return (
81+
<html lang="en">
82+
<head>
83+
<meta charSet="utf-8" />
84+
<meta
85+
name="viewport"
86+
content="width=device-width,initial-scale=1,viewport-fit=cover"
87+
/>
88+
<title>{title}</title>
89+
</head>
90+
<body>
91+
<main style={{ fontFamily: "system-ui, sans-serif", padding: "2rem" }}>
92+
{children}
93+
</main>
94+
</body>
95+
</html>
96+
);
97+
}
98+
99+
function RSCDefaultRootErrorBoundaryImpl({
100+
error,
101+
renderAppShell,
102+
}: {
103+
error: unknown;
104+
renderAppShell: boolean;
105+
}) {
106+
console.error(error);
107+
108+
let heyDeveloper = (
109+
<script
110+
dangerouslySetInnerHTML={{
111+
__html: `
112+
console.log(
113+
"💿 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."
114+
);
115+
`,
116+
}}
117+
/>
118+
);
119+
120+
if (isRouteErrorResponse(error)) {
121+
return (
122+
<ErrorWrapper
123+
renderAppShell={renderAppShell}
124+
title="Unhandled Thrown Response!"
125+
>
126+
<h1 style={{ fontSize: "24px" }}>
127+
{error.status} {error.statusText}
128+
</h1>
129+
{ENABLE_DEV_WARNINGS ? heyDeveloper : null}
130+
</ErrorWrapper>
131+
);
132+
}
133+
134+
let errorInstance: Error;
135+
if (error instanceof Error) {
136+
errorInstance = error;
137+
} else {
138+
let errorString =
139+
error == null
140+
? "Unknown Error"
141+
: typeof error === "object" && "toString" in error
142+
? error.toString()
143+
: JSON.stringify(error);
144+
errorInstance = new Error(errorString);
145+
}
146+
147+
return (
148+
<ErrorWrapper renderAppShell={renderAppShell} title="Application Error!">
149+
<h1 style={{ fontSize: "24px" }}>Application Error</h1>
150+
<pre
151+
style={{
152+
padding: "2rem",
153+
background: "hsla(10, 50%, 50%, 0.1)",
154+
color: "red",
155+
overflow: "auto",
156+
}}
157+
>
158+
{errorInstance.stack}
159+
</pre>
160+
{heyDeveloper}
161+
</ErrorWrapper>
162+
);
163+
}
164+
165+
export function RSCDefaultRootErrorBoundary({
166+
hasRootLayout,
167+
}: {
168+
hasRootLayout: boolean;
169+
}) {
170+
let error = useRouteError();
171+
172+
if (hasRootLayout === undefined) {
173+
throw new Error("Missing 'hasRootLayout' prop");
174+
}
175+
return (
176+
<RSCDefaultRootErrorBoundaryImpl
177+
renderAppShell={!hasRootLayout}
178+
error={error}
179+
/>
180+
);
181+
}

packages/react-router/lib/rsc/server.ssr.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FrameworkContext } from "../dom/ssr/components";
44
import type { FrameworkContextObject } from "../dom/ssr/entry";
55
import { createStaticRouter, StaticRouterProvider } from "../dom/server";
66
import { injectRSCPayload } from "./html-stream/server";
7+
import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";
78
import type { ServerPayload } from "./server.rsc";
89

910
export async function routeRSCServerRequest({
@@ -181,14 +182,16 @@ export function RSCStaticRouter({
181182

182183
return (
183184
<RSCRouterContext.Provider value={true}>
184-
<FrameworkContext.Provider value={frameworkContext}>
185-
<StaticRouterProvider
186-
context={context}
187-
router={router}
188-
hydrate={false}
189-
nonce={payload.nonce}
190-
/>
191-
</FrameworkContext.Provider>
185+
<RSCRouterGlobalErrorBoundary location={payload.location}>
186+
<FrameworkContext.Provider value={frameworkContext}>
187+
<StaticRouterProvider
188+
context={context}
189+
router={router}
190+
hydrate={false}
191+
nonce={payload.nonce}
192+
/>
193+
</FrameworkContext.Provider>
194+
</RSCRouterGlobalErrorBoundary>
192195
</RSCRouterContext.Provider>
193196
);
194197
}

0 commit comments

Comments
 (0)