Skip to content

Commit 2cd8246

Browse files
authored
fix: properly serialize/deserialize ErrorResponse instances (#9593)
* fix: properly serialize/deserialize ErrorResponse instances * add changeset * Bump bundle
1 parent f9652c6 commit 2cd8246

File tree

5 files changed

+157
-12
lines changed

5 files changed

+157
-12
lines changed

.changeset/blue-cycles-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router-dom": patch
3+
---
4+
5+
Properly serialize/deserialize ErrorResponse instances when using built-in hydration

packages/react-router-dom/__tests__/data-browser-router-test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
Outlet,
2424
createBrowserRouter,
2525
createHashRouter,
26+
isRouteErrorResponse,
2627
useLoaderData,
2728
useActionData,
2829
useRouteError,
@@ -264,6 +265,40 @@ function testDomRouter(
264265
`);
265266
});
266267

268+
it("deserializes ErrorResponse instances from the window", async () => {
269+
window.__staticRouterHydrationData = {
270+
loaderData: {},
271+
actionData: null,
272+
errors: {
273+
"0": {
274+
status: 404,
275+
statusText: "Not Found",
276+
internal: false,
277+
data: { not: "found" },
278+
__type: "RouteErrorResponse",
279+
},
280+
},
281+
};
282+
let { container } = render(
283+
<TestDataRouter window={getWindow("/")}>
284+
<Route path="/" element={<h1>Nope</h1>} errorElement={<Boundary />} />
285+
</TestDataRouter>
286+
);
287+
288+
function Boundary() {
289+
let error = useRouteError();
290+
return isRouteErrorResponse(error) ? <h1>Yes!</h1> : <h2>No :(</h2>;
291+
}
292+
293+
expect(getHtml(container)).toMatchInlineSnapshot(`
294+
"<div>
295+
<h1>
296+
Yes!
297+
</h1>
298+
</div>"
299+
`);
300+
});
301+
267302
it("renders fallbackElement while first data fetch happens", async () => {
268303
let fooDefer = createDeferred();
269304
let { container } = render(

packages/react-router-dom/__tests__/data-static-router-test.tsx

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as React from "react";
22
import * as ReactDOMServer from "react-dom/server";
33
import type { StaticHandlerContext } from "@remix-run/router";
4-
import { unstable_createStaticHandler as createStaticHandler } from "@remix-run/router";
4+
import {
5+
json,
6+
unstable_createStaticHandler as createStaticHandler,
7+
} from "@remix-run/router";
58
import {
69
Link,
710
Outlet,
@@ -239,7 +242,7 @@ describe("A <StaticRouterProvider>", () => {
239242
let { query } = createStaticHandler(routes);
240243

241244
let context = (await query(
242-
new Request("http:/localhost/the/path", {
245+
new Request("http://localhost/the/path", {
243246
signal: new AbortController().signal,
244247
})
245248
)) as StaticHandlerContext;
@@ -269,6 +272,55 @@ describe("A <StaticRouterProvider>", () => {
269272
);
270273
});
271274

275+
it("serializes ErrorResponse instances", async () => {
276+
let routes = [
277+
{
278+
path: "/",
279+
loader: () => {
280+
throw json(
281+
{ not: "found" },
282+
{ status: 404, statusText: "Not Found" }
283+
);
284+
},
285+
},
286+
];
287+
let { query } = createStaticHandler(routes);
288+
289+
let context = (await query(
290+
new Request("http://localhost/", {
291+
signal: new AbortController().signal,
292+
})
293+
)) as StaticHandlerContext;
294+
295+
let html = ReactDOMServer.renderToStaticMarkup(
296+
<React.StrictMode>
297+
<StaticRouterProvider
298+
router={createStaticRouter(routes, context)}
299+
context={context}
300+
/>
301+
</React.StrictMode>
302+
);
303+
304+
let expectedJsonString = JSON.stringify(
305+
JSON.stringify({
306+
loaderData: {},
307+
actionData: null,
308+
errors: {
309+
"0": {
310+
status: 404,
311+
statusText: "Not Found",
312+
internal: false,
313+
data: { not: "found" },
314+
__type: "RouteErrorResponse",
315+
},
316+
},
317+
})
318+
);
319+
expect(html).toMatch(
320+
`<script>window.__staticRouterHydrationData = JSON.parse(${expectedJsonString});</script>`
321+
);
322+
});
323+
272324
it("supports a nonce prop", async () => {
273325
let routes = [
274326
{
@@ -285,7 +337,7 @@ describe("A <StaticRouterProvider>", () => {
285337
let { query } = createStaticHandler(routes);
286338

287339
let context = (await query(
288-
new Request("http:/localhost/the/path", {
340+
new Request("http://localhost/the/path", {
289341
signal: new AbortController().signal,
290342
})
291343
)) as StaticHandlerContext;
@@ -335,7 +387,7 @@ describe("A <StaticRouterProvider>", () => {
335387
let { query } = createStaticHandler(routes);
336388

337389
let context = (await query(
338-
new Request("http:/localhost/the/path", {
390+
new Request("http://localhost/the/path", {
339391
signal: new AbortController().signal,
340392
})
341393
)) as StaticHandlerContext;
@@ -365,7 +417,7 @@ describe("A <StaticRouterProvider>", () => {
365417
let { query } = createStaticHandler(routes);
366418

367419
let context = (await query(
368-
new Request("http:/localhost/the/path?the=query#the-hash", {
420+
new Request("http://localhost/the/path?the=query#the-hash", {
369421
signal: new AbortController().signal,
370422
})
371423
)) as StaticHandlerContext;
@@ -411,7 +463,7 @@ describe("A <StaticRouterProvider>", () => {
411463
];
412464

413465
let context = (await createStaticHandler(routes).query(
414-
new Request("http:/localhost/", {
466+
new Request("http://localhost/", {
415467
signal: new AbortController().signal,
416468
})
417469
)) as StaticHandlerContext;
@@ -445,7 +497,7 @@ describe("A <StaticRouterProvider>", () => {
445497
];
446498

447499
let context = (await createStaticHandler(routes).query(
448-
new Request("http:/localhost/", {
500+
new Request("http://localhost/", {
449501
signal: new AbortController().signal,
450502
})
451503
)) as StaticHandlerContext;

packages/react-router-dom/index.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
createPath,
1515
useHref,
1616
useLocation,
17-
useMatch,
1817
useMatches,
1918
useNavigate,
2019
useNavigation,
@@ -42,7 +41,7 @@ import {
4241
createHashHistory,
4342
invariant,
4443
joinPaths,
45-
matchPath,
44+
ErrorResponse,
4645
} from "@remix-run/router";
4746

4847
import type {
@@ -205,7 +204,7 @@ export function createBrowserRouter(
205204
return createRouter({
206205
basename: opts?.basename,
207206
history: createBrowserHistory({ window: opts?.window }),
208-
hydrationData: opts?.hydrationData || window?.__staticRouterHydrationData,
207+
hydrationData: opts?.hydrationData || parseHydrationData(),
209208
routes: enhanceManualRouteObjects(routes),
210209
}).initialize();
211210
}
@@ -221,10 +220,45 @@ export function createHashRouter(
221220
return createRouter({
222221
basename: opts?.basename,
223222
history: createHashHistory({ window: opts?.window }),
224-
hydrationData: opts?.hydrationData || window?.__staticRouterHydrationData,
223+
hydrationData: opts?.hydrationData || parseHydrationData(),
225224
routes: enhanceManualRouteObjects(routes),
226225
}).initialize();
227226
}
227+
228+
function parseHydrationData(): HydrationState | undefined {
229+
let state = window?.__staticRouterHydrationData;
230+
if (state && state.errors) {
231+
state = {
232+
...state,
233+
errors: deserializeErrors(state.errors),
234+
};
235+
}
236+
return state;
237+
}
238+
239+
function deserializeErrors(
240+
errors: RemixRouter["state"]["errors"]
241+
): RemixRouter["state"]["errors"] {
242+
if (!errors) return null;
243+
let entries = Object.entries(errors);
244+
let serialized: RemixRouter["state"]["errors"] = {};
245+
for (let [key, val] of entries) {
246+
// Hey you! If you change this, please change the corresponding logic in
247+
// serializeErrors in react-router-dom/server.tsx :)
248+
if (val && val.__type === "RouteErrorResponse") {
249+
serialized[key] = new ErrorResponse(
250+
val.status,
251+
val.statusText,
252+
val.data,
253+
val.internal === true
254+
);
255+
} else {
256+
serialized[key] = val;
257+
}
258+
}
259+
return serialized;
260+
}
261+
228262
//#endregion
229263

230264
////////////////////////////////////////////////////////////////////////////////

packages/react-router-dom/server.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
IDLE_NAVIGATION,
1111
Action,
1212
invariant,
13+
isRouteErrorResponse,
1314
UNSAFE_convertRoutesToDataRoutes as convertRoutesToDataRoutes,
1415
} from "@remix-run/router";
1516
import type { Location, RouteObject, To } from "react-router-dom";
@@ -99,7 +100,7 @@ export function unstable_StaticRouterProvider({
99100
let data = {
100101
loaderData: context.loaderData,
101102
actionData: context.actionData,
102-
errors: context.errors,
103+
errors: serializeErrors(context.errors),
103104
};
104105
// Use JSON.parse here instead of embedding a raw JS object here to speed
105106
// up parsing on the client. Dual-stringify is needed to ensure all quotes
@@ -138,6 +139,24 @@ export function unstable_StaticRouterProvider({
138139
);
139140
}
140141

142+
function serializeErrors(
143+
errors: StaticHandlerContext["errors"]
144+
): StaticHandlerContext["errors"] {
145+
if (!errors) return null;
146+
let entries = Object.entries(errors);
147+
let serialized: StaticHandlerContext["errors"] = {};
148+
for (let [key, val] of entries) {
149+
// Hey you! If you change this, please change the corresponding logic in
150+
// deserializeErrors in react-router-dom/index.tsx :)
151+
if (isRouteErrorResponse(val)) {
152+
serialized[key] = { ...val, __type: "RouteErrorResponse" };
153+
} else {
154+
serialized[key] = val;
155+
}
156+
}
157+
return serialized;
158+
}
159+
141160
function getStatelessNavigator() {
142161
return {
143162
createHref,

0 commit comments

Comments
 (0)