diff --git a/.changeset/blue-cycles-check.md b/.changeset/blue-cycles-check.md new file mode 100644 index 0000000000..c45b48f419 --- /dev/null +++ b/.changeset/blue-cycles-check.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Properly serialize/deserialize ErrorResponse instances when using built-in hydration diff --git a/package.json b/package.json index 46a937dd23..b29785bf16 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "none": "14.5 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "10 kB" + "none": "10.5 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { "none": "16 kB" diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index a8b68ecb82..6375b79954 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -23,6 +23,7 @@ import { Outlet, createBrowserRouter, createHashRouter, + isRouteErrorResponse, useLoaderData, useActionData, useRouteError, @@ -264,6 +265,40 @@ function testDomRouter( `); }); + it("deserializes ErrorResponse instances from the window", async () => { + window.__staticRouterHydrationData = { + loaderData: {}, + actionData: null, + errors: { + "0": { + status: 404, + statusText: "Not Found", + internal: false, + data: { not: "found" }, + __type: "RouteErrorResponse", + }, + }, + }; + let { container } = render( + + Nope} errorElement={} /> + + ); + + function Boundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ?

Yes!

:

No :(

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Yes! +

+
" + `); + }); + it("renders fallbackElement while first data fetch happens", async () => { let fooDefer = createDeferred(); let { container } = render( diff --git a/packages/react-router-dom/__tests__/data-static-router-test.tsx b/packages/react-router-dom/__tests__/data-static-router-test.tsx index fcaf04aec3..b6bc31c2be 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -1,7 +1,10 @@ import * as React from "react"; import * as ReactDOMServer from "react-dom/server"; import type { StaticHandlerContext } from "@remix-run/router"; -import { unstable_createStaticHandler as createStaticHandler } from "@remix-run/router"; +import { + json, + unstable_createStaticHandler as createStaticHandler, +} from "@remix-run/router"; import { Outlet, useLoaderData, @@ -71,7 +74,7 @@ describe("A ", () => { let { query } = createStaticHandler(routes); let context = (await query( - new Request("http:/localhost/the/path?the=query#the-hash", { + new Request("http://localhost/the/path?the=query#the-hash", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -179,7 +182,7 @@ describe("A ", () => { let { query } = createStaticHandler(routes); let context = (await query( - new Request("http:/localhost/the/path", { + new Request("http://localhost/the/path", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -209,6 +212,55 @@ describe("A ", () => { ); }); + it("serializes ErrorResponse instances", async () => { + let routes = [ + { + path: "/", + loader: () => { + throw json( + { not: "found" }, + { status: 404, statusText: "Not Found" } + ); + }, + }, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + + let expectedJsonString = JSON.stringify( + JSON.stringify({ + loaderData: {}, + actionData: null, + errors: { + "0": { + status: 404, + statusText: "Not Found", + internal: false, + data: { not: "found" }, + __type: "RouteErrorResponse", + }, + }, + }) + ); + expect(html).toMatch( + `` + ); + }); + it("supports a nonce prop", async () => { let routes = [ { @@ -225,7 +277,7 @@ describe("A ", () => { let { query } = createStaticHandler(routes); let context = (await query( - new Request("http:/localhost/the/path", { + new Request("http://localhost/the/path", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -275,7 +327,7 @@ describe("A ", () => { let { query } = createStaticHandler(routes); let context = (await query( - new Request("http:/localhost/the/path", { + new Request("http://localhost/the/path", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -305,7 +357,7 @@ describe("A ", () => { let { query } = createStaticHandler(routes); let context = (await query( - new Request("http:/localhost/the/path?the=query#the-hash", { + new Request("http://localhost/the/path?the=query#the-hash", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -351,7 +403,7 @@ describe("A ", () => { ]; let context = (await createStaticHandler(routes).query( - new Request("http:/localhost/", { + new Request("http://localhost/", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -385,7 +437,7 @@ describe("A ", () => { ]; let context = (await createStaticHandler(routes).query( - new Request("http:/localhost/", { + new Request("http://localhost/", { signal: new AbortController().signal, }) )) as StaticHandlerContext; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 6cd01dc86f..5afd942ae0 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -14,7 +14,6 @@ import { createPath, useHref, useLocation, - useMatch, useMatches, useNavigate, useNavigation, @@ -42,7 +41,7 @@ import { createHashHistory, invariant, joinPaths, - matchPath, + ErrorResponse, } from "@remix-run/router"; import type { @@ -205,7 +204,7 @@ export function createBrowserRouter( return createRouter({ basename: opts?.basename, history: createBrowserHistory({ window: opts?.window }), - hydrationData: opts?.hydrationData || window?.__staticRouterHydrationData, + hydrationData: opts?.hydrationData || parseHydrationData(), routes: enhanceManualRouteObjects(routes), }).initialize(); } @@ -221,10 +220,45 @@ export function createHashRouter( return createRouter({ basename: opts?.basename, history: createHashHistory({ window: opts?.window }), - hydrationData: opts?.hydrationData || window?.__staticRouterHydrationData, + hydrationData: opts?.hydrationData || parseHydrationData(), routes: enhanceManualRouteObjects(routes), }).initialize(); } + +function parseHydrationData(): HydrationState | undefined { + let state = window?.__staticRouterHydrationData; + if (state && state.errors) { + state = { + ...state, + errors: deserializeErrors(state.errors), + }; + } + return state; +} + +function deserializeErrors( + errors: RemixRouter["state"]["errors"] +): RemixRouter["state"]["errors"] { + if (!errors) return null; + let entries = Object.entries(errors); + let serialized: RemixRouter["state"]["errors"] = {}; + for (let [key, val] of entries) { + // Hey you! If you change this, please change the corresponding logic in + // serializeErrors in react-router-dom/server.tsx :) + if (val && val.__type === "RouteErrorResponse") { + serialized[key] = new ErrorResponse( + val.status, + val.statusText, + val.data, + val.internal === true + ); + } else { + serialized[key] = val; + } + } + return serialized; +} + //#endregion //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index e668894779..6177f0ac8e 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -9,6 +9,7 @@ import { IDLE_NAVIGATION, Action, invariant, + isRouteErrorResponse, UNSAFE_convertRoutesToDataRoutes as convertRoutesToDataRoutes, } from "@remix-run/router"; import type { Location, RouteObject, To } from "react-router-dom"; @@ -100,7 +101,7 @@ export function unstable_StaticRouterProvider({ let data = { loaderData: context.loaderData, actionData: context.actionData, - errors: context.errors, + errors: serializeErrors(context.errors), }; // Use JSON.parse here instead of embedding a raw JS object here to speed // up parsing on the client. Dual-stringify is needed to ensure all quotes @@ -139,6 +140,24 @@ export function unstable_StaticRouterProvider({ ); } +function serializeErrors( + errors: StaticHandlerContext["errors"] +): StaticHandlerContext["errors"] { + if (!errors) return null; + let entries = Object.entries(errors); + let serialized: StaticHandlerContext["errors"] = {}; + for (let [key, val] of entries) { + // Hey you! If you change this, please change the corresponding logic in + // deserializeErrors in react-router-dom/index.tsx :) + if (isRouteErrorResponse(val)) { + serialized[key] = { ...val, __type: "RouteErrorResponse" }; + } else { + serialized[key] = val; + } + } + return serialized; +} + function getStatelessNavigator() { return { createHref(to: To) {