From 8b3559a50de61a29972ca161a2fccd007441ac58 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 11 Aug 2023 13:38:02 -0400 Subject: [PATCH] =?UTF-8?q?Encode=20URIs=20during=20server=20rendering=20o?= =?UTF-8?q?f=20/=20to=20avoi=E2=80=A6=20(#10769)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Encode URIs during server rendering of /
to avoid hydration issues * Bump bundle * Update approach * bump bundle * Remove absolute url check * Update packages/react-router-dom/index.tsx * Change approach to properly encode in encodeLocation --- .changeset/encode-uri-ssr.md | 5 + .../lib/components.tsx | 13 +- .../__tests__/data-static-router-test.tsx | 118 ++++++++++++++++++ packages/react-router-dom/server.tsx | 14 ++- 4 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 .changeset/encode-uri-ssr.md diff --git a/.changeset/encode-uri-ssr.md b/.changeset/encode-uri-ssr.md new file mode 100644 index 0000000000..39529243c3 --- /dev/null +++ b/.changeset/encode-uri-ssr.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Proeprly encode rendered URIs in server rendering to avoid hydration errors diff --git a/packages/react-router-dom-v5-compat/lib/components.tsx b/packages/react-router-dom-v5-compat/lib/components.tsx index de8591eb2a..840434f3ba 100644 --- a/packages/react-router-dom-v5-compat/lib/components.tsx +++ b/packages/react-router-dom-v5-compat/lib/components.tsx @@ -66,6 +66,8 @@ export interface StaticRouterProps { location: Partial | string; } +const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; + /** * A that may not navigate to any other location. This is useful * on the server where there is no stateful UI. @@ -93,11 +95,14 @@ export function StaticRouter({ return typeof to === "string" ? to : createPath(to); }, encodeLocation(to: To) { - let path = typeof to === "string" ? parsePath(to) : to; + let href = typeof to === "string" ? to : createPath(to); + let encoded = ABSOLUTE_URL_REGEX.test(href) + ? new URL(href) + : new URL(href, "http://localhost"); return { - pathname: path.pathname || "", - search: path.search || "", - hash: path.hash || "", + pathname: encoded.pathname, + search: encoded.search, + hash: encoded.hash, }; }, push(to: To) { 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 53a1c62737..5a4050d48e 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -6,6 +6,7 @@ import * as React from "react"; import * as ReactDOMServer from "react-dom/server"; import { json } from "@remix-run/router"; import { + Form, Link, Outlet, useLoaderData, @@ -511,6 +512,123 @@ describe("A ", () => { ); }); + it("encodes auto-generated values to avoid hydration errors", async () => { + let routes = [{ path: "/path/:param", element: 👋 }]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/path/with space", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toContain('👋'); + }); + + it("does not encode user-specified values", async () => { + let routes = [ + { path: "/", element: 👋 }, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toContain('👋'); + }); + + it("encodes auto-generated values to avoid hydration errors (action=undefined)", async () => { + let routes = [{ path: "/path/:param", element: 👋
}]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/path/with space", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toContain( + '
👋
' + ); + }); + + it('encodes auto-generated
values to avoid hydration errors (action=".")', async () => { + let routes = [ + { path: "/path/:param", element: 👋
}, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/path/with space", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toContain( + '
👋
' + ); + }); + + it("does not encode user-specified
values", async () => { + let routes = [ + { path: "/", element: 👋
}, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toContain( + '
👋
' + ); + }); + it("serializes ErrorResponse instances", async () => { let routes = [ { diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 2271e51e48..51b22e1822 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -348,15 +348,19 @@ function createHref(to: To) { } function encodeLocation(to: To): Path { - // Locations should already be encoded on the server, so just return as-is - let path = typeof to === "string" ? parsePath(to) : to; + let href = typeof to === "string" ? to : createPath(to); + let encoded = ABSOLUTE_URL_REGEX.test(href) + ? new URL(href) + : new URL(href, "http://localhost"); return { - pathname: path.pathname || "", - search: path.search || "", - hash: path.hash || "", + pathname: encoded.pathname, + search: encoded.search, + hash: encoded.hash, }; } +const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; + // This utility is based on https://github.com/zertosh/htmlescape // License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE const ESCAPE_LOOKUP: { [match: string]: string } = {