From e24606d8e11c676da3d75cc7bd5601326883de60 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 8 Aug 2023 18:22:18 -0400 Subject: [PATCH 1/7] Encode URIs during server rendering of /
to avoid hydration issues --- .changeset/encode-uri-ssr.md | 5 + .../__tests__/data-static-router-test.tsx | 93 +++++++++++++++++++ packages/react-router-dom/index.tsx | 16 +++- 3 files changed, 113 insertions(+), 1 deletion(-) 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/__tests__/data-static-router-test.tsx b/packages/react-router-dom/__tests__/data-static-router-test.tsx index 53a1c62737..0ddf7db0a6 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,98 @@ describe("A ", () => { ); }); + it("encodes absolute values to avoid hydration errors", 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 self 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("encodes absolute values to avoid hydration errors", 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 self
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("serializes ErrorResponse instances", async () => { let routes = [ { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 283c5e364f..8979c698a0 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -526,7 +526,7 @@ export const Link = React.forwardRef( }, ref ) { - let { basename } = React.useContext(NavigationContext); + let { basename, static: isStatic } = React.useContext(NavigationContext); // Rendered into for absolute URLs let absoluteHref; @@ -565,6 +565,12 @@ export const Link = React.forwardRef( // Rendered into for relative URLs let href = useHref(to, { relative }); + // Render encoded URIs on the server to avoid hydration issues on the + // client when pathnames contain spaces or other URL-encoded chars + if (isStatic) { + href = encodeURI(href); + } + let internalOnClick = useLinkClickHandler(to, { replace, state, @@ -823,9 +829,17 @@ const FormImpl = React.forwardRef( }, forwardedRef ) => { + let { static: isStatic } = React.useContext(NavigationContext); let formMethod: HTMLFormMethod = method.toLowerCase() === "get" ? "get" : "post"; let formAction = useFormAction(action, { relative }); + + // Render encoded URIs on the server to avoid hydration issues on the + // client when pathnames contain spaces or other URL-encoded chars + if (isStatic) { + formAction = encodeURI(formAction); + } + let submitHandler: React.FormEventHandler = (event) => { onSubmit && onSubmit(event); if (event.defaultPrevented) return; From 3626fef9bee008364ba3103a03f1c551cbcb6d81 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Aug 2023 10:28:34 -0400 Subject: [PATCH 2/7] Bump bundle --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d53976bc8b..d2bcea17a3 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "12.8 kB" + "none": "12.9 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { "none": "18.9 kB" From 34495545082c79e761eb3f639e88816bbecf7573 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Aug 2023 15:06:03 -0400 Subject: [PATCH 3/7] Update approach --- .../__tests__/data-static-router-test.tsx | 49 ++++++++++++++----- packages/react-router-dom/index.tsx | 35 ++++++++++--- 2 files changed, 66 insertions(+), 18 deletions(-) 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 0ddf7db0a6..5a4050d48e 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -512,7 +512,28 @@ describe("A ", () => { ); }); - it("encodes absolute values to avoid hydration errors", async () => { + 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: 👋 }, ]; @@ -532,11 +553,11 @@ describe("A ", () => { /> ); - expect(html).toContain('👋'); + expect(html).toContain('👋'); }); - it("encodes self values to avoid hydration errors", async () => { - let routes = [{ path: "/path/:param", element: 👋 }]; + 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( @@ -553,17 +574,19 @@ describe("A ", () => { /> ); - expect(html).toContain('
👋'); + expect(html).toContain( + '
👋
' + ); }); - it("encodes absolute
values to avoid hydration errors", async () => { + it('encodes auto-generated values to avoid hydration errors (action=".")', async () => { let routes = [ - { path: "/", element: 👋
}, + { path: "/path/:param", element:
👋
}, ]; let { query } = createStaticHandler(routes); let context = (await query( - new Request("http://localhost/", { + new Request("http://localhost/path/with space", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -581,12 +604,14 @@ describe("A ", () => { ); }); - it("encodes self
values to avoid hydration errors", async () => { - let routes = [{ path: "/path/:param", element: 👋
}]; + it("does not encode user-specified
values", async () => { + let routes = [ + { path: "/", element: 👋
}, + ]; let { query } = createStaticHandler(routes); let context = (await query( - new Request("http://localhost/path/with space", { + new Request("http://localhost/", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -600,7 +625,7 @@ describe("A ", () => { ); expect(html).toContain( - '
👋
' + '
👋
' ); }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 8979c698a0..620926c4d7 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -52,6 +52,7 @@ import { ErrorResponse, UNSAFE_invariant as invariant, UNSAFE_warning as warning, + parsePath, } from "@remix-run/router"; import type { @@ -565,10 +566,10 @@ export const Link = React.forwardRef( // Rendered into for relative URLs let href = useHref(to, { relative }); - // Render encoded URIs on the server to avoid hydration issues on the - // client when pathnames contain spaces or other URL-encoded chars + // When URLs contain characters that require encoding (such as + // spaces) - encode them on the server to avoid hydration issues if (isStatic) { - href = encodeURI(href); + href = safelyEncodeSsrHref(to, href); } let internalOnClick = useLinkClickHandler(to, { @@ -834,10 +835,10 @@ const FormImpl = React.forwardRef( method.toLowerCase() === "get" ? "get" : "post"; let formAction = useFormAction(action, { relative }); - // Render encoded URIs on the server to avoid hydration issues on the - // client when pathnames contain spaces or other URL-encoded chars + // When
URLs contain characters that require encoding (such as + // spaces) - encode them on the server to avoid hydration issues if (isStatic) { - formAction = encodeURI(formAction); + formAction = safelyEncodeSsrHref(action || ".", formAction); } let submitHandler: React.FormEventHandler = (event) => { @@ -1497,4 +1498,26 @@ function usePrompt({ when, message }: { when: boolean; message: string }) { export { usePrompt as unstable_usePrompt }; +/** + * @private + * Avoid hydration issues for auto-generated hrefs (i.e., to=".") on the server + * since when we auto-generate on the client we'll take our current location + * from window.location which will have encoded any special characters and + * we'll get a hydration mismatch on the SSR attribute and the client attribute. + */ +function safelyEncodeSsrHref(to: To, href: string): string { + let path = typeof to === "string" ? parsePath(to).pathname : to.pathname; + // Only touch the href for auto-generated paths + if (path === null || path === "" || path === ".") { + try { + let encoded = ABSOLUTE_URL_REGEX.test(href) + ? new URL(href) + : new URL(href, "http://localhost"); + return encoded.pathname + encoded.search; + } catch (e) { + // no-op - don't change href if we aren't sure it needs encoding + } + } + return href; +} //#endregion From 142b2b0f819a1c2beb6b0d3a6dff807941682260 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Aug 2023 15:14:49 -0400 Subject: [PATCH 4/7] bump bundle --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d2bcea17a3..531d15c50e 100644 --- a/package.json +++ b/package.json @@ -118,10 +118,10 @@ "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "12.9 kB" + "none": "13.1 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "18.9 kB" + "none": "19.1 kB" } } } From 7ecb16500ddf5b892ea53316ac2c627de565672d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 9 Aug 2023 15:18:29 -0400 Subject: [PATCH 5/7] Remove absolute url check --- package.json | 2 +- packages/react-router-dom/index.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 531d15c50e..f82413bc68 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "13.1 kB" + "none": "13.0 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { "none": "19.1 kB" diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 620926c4d7..26e7ddb9bf 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1508,11 +1508,9 @@ export { usePrompt as unstable_usePrompt }; function safelyEncodeSsrHref(to: To, href: string): string { let path = typeof to === "string" ? parsePath(to).pathname : to.pathname; // Only touch the href for auto-generated paths - if (path === null || path === "" || path === ".") { + if (!path || path === ".") { try { - let encoded = ABSOLUTE_URL_REGEX.test(href) - ? new URL(href) - : new URL(href, "http://localhost"); + let encoded = new URL(href, "http://localhost"); return encoded.pathname + encoded.search; } catch (e) { // no-op - don't change href if we aren't sure it needs encoding From b9dd99116b0ac9e61d86fcf11e0b0b9c7a43a13a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 10 Aug 2023 12:19:24 -0400 Subject: [PATCH 6/7] Update packages/react-router-dom/index.tsx --- packages/react-router-dom/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 26e7ddb9bf..984451af64 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1513,7 +1513,7 @@ function safelyEncodeSsrHref(to: To, href: string): string { let encoded = new URL(href, "http://localhost"); return encoded.pathname + encoded.search; } catch (e) { - // no-op - don't change href if we aren't sure it needs encoding + // no-op - no changes if we can't construct a valid URL } } return href; From f1350589096e89aa597bd5563bbc2617ffdc2b25 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 11 Aug 2023 11:50:19 -0400 Subject: [PATCH 7/7] Change approach to properly encode in encodeLocation --- package.json | 4 +- .../lib/components.tsx | 13 +++++-- packages/react-router-dom/index.tsx | 37 +------------------ packages/react-router-dom/server.tsx | 14 ++++--- 4 files changed, 21 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index f82413bc68..d53976bc8b 100644 --- a/package.json +++ b/package.json @@ -118,10 +118,10 @@ "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "13.0 kB" + "none": "12.8 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "19.1 kB" + "none": "18.9 kB" } } } 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/index.tsx b/packages/react-router-dom/index.tsx index 984451af64..283c5e364f 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -52,7 +52,6 @@ import { ErrorResponse, UNSAFE_invariant as invariant, UNSAFE_warning as warning, - parsePath, } from "@remix-run/router"; import type { @@ -527,7 +526,7 @@ export const Link = React.forwardRef( }, ref ) { - let { basename, static: isStatic } = React.useContext(NavigationContext); + let { basename } = React.useContext(NavigationContext); // Rendered into for absolute URLs let absoluteHref; @@ -566,12 +565,6 @@ export const Link = React.forwardRef( // Rendered into for relative URLs let href = useHref(to, { relative }); - // When URLs contain characters that require encoding (such as - // spaces) - encode them on the server to avoid hydration issues - if (isStatic) { - href = safelyEncodeSsrHref(to, href); - } - let internalOnClick = useLinkClickHandler(to, { replace, state, @@ -830,17 +823,9 @@ const FormImpl = React.forwardRef( }, forwardedRef ) => { - let { static: isStatic } = React.useContext(NavigationContext); let formMethod: HTMLFormMethod = method.toLowerCase() === "get" ? "get" : "post"; let formAction = useFormAction(action, { relative }); - - // When URLs contain characters that require encoding (such as - // spaces) - encode them on the server to avoid hydration issues - if (isStatic) { - formAction = safelyEncodeSsrHref(action || ".", formAction); - } - let submitHandler: React.FormEventHandler = (event) => { onSubmit && onSubmit(event); if (event.defaultPrevented) return; @@ -1498,24 +1483,4 @@ function usePrompt({ when, message }: { when: boolean; message: string }) { export { usePrompt as unstable_usePrompt }; -/** - * @private - * Avoid hydration issues for auto-generated hrefs (i.e., to=".") on the server - * since when we auto-generate on the client we'll take our current location - * from window.location which will have encoded any special characters and - * we'll get a hydration mismatch on the SSR attribute and the client attribute. - */ -function safelyEncodeSsrHref(to: To, href: string): string { - let path = typeof to === "string" ? parsePath(to).pathname : to.pathname; - // Only touch the href for auto-generated paths - if (!path || path === ".") { - try { - let encoded = new URL(href, "http://localhost"); - return encoded.pathname + encoded.search; - } catch (e) { - // no-op - no changes if we can't construct a valid URL - } - } - return href; -} //#endregion 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 } = {