Skip to content

Commit 8b3559a

Browse files
authoredAug 11, 2023
Encode URIs during server rendering of <a href>/<form action> to avoi… (#10769)
* Encode URIs during server rendering of <a href>/<form action> 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
1 parent e11af30 commit 8b3559a

File tree

4 files changed

+141
-9
lines changed

4 files changed

+141
-9
lines changed
 

‎.changeset/encode-uri-ssr.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router-dom": patch
3+
---
4+
5+
Proeprly encode rendered URIs in server rendering to avoid hydration errors

‎packages/react-router-dom-v5-compat/lib/components.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface StaticRouterProps {
6666
location: Partial<Location> | string;
6767
}
6868

69+
const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
70+
6971
/**
7072
* A <Router> that may not navigate to any other location. This is useful
7173
* on the server where there is no stateful UI.
@@ -93,11 +95,14 @@ export function StaticRouter({
9395
return typeof to === "string" ? to : createPath(to);
9496
},
9597
encodeLocation(to: To) {
96-
let path = typeof to === "string" ? parsePath(to) : to;
98+
let href = typeof to === "string" ? to : createPath(to);
99+
let encoded = ABSOLUTE_URL_REGEX.test(href)
100+
? new URL(href)
101+
: new URL(href, "http://localhost");
97102
return {
98-
pathname: path.pathname || "",
99-
search: path.search || "",
100-
hash: path.hash || "",
103+
pathname: encoded.pathname,
104+
search: encoded.search,
105+
hash: encoded.hash,
101106
};
102107
},
103108
push(to: To) {

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

+118
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as React from "react";
66
import * as ReactDOMServer from "react-dom/server";
77
import { json } from "@remix-run/router";
88
import {
9+
Form,
910
Link,
1011
Outlet,
1112
useLoaderData,
@@ -511,6 +512,123 @@ describe("A <StaticRouterProvider>", () => {
511512
);
512513
});
513514

515+
it("encodes auto-generated <a href> values to avoid hydration errors", async () => {
516+
let routes = [{ path: "/path/:param", element: <Link to=".">👋</Link> }];
517+
let { query } = createStaticHandler(routes);
518+
519+
let context = (await query(
520+
new Request("http://localhost/path/with space", {
521+
signal: new AbortController().signal,
522+
})
523+
)) as StaticHandlerContext;
524+
525+
let html = ReactDOMServer.renderToStaticMarkup(
526+
<React.StrictMode>
527+
<StaticRouterProvider
528+
router={createStaticRouter(routes, context)}
529+
context={context}
530+
/>
531+
</React.StrictMode>
532+
);
533+
expect(html).toContain('<a href="/path/with%20space">👋</a>');
534+
});
535+
536+
it("does not encode user-specified <a href> values", async () => {
537+
let routes = [
538+
{ path: "/", element: <Link to="/path/with space">👋</Link> },
539+
];
540+
let { query } = createStaticHandler(routes);
541+
542+
let context = (await query(
543+
new Request("http://localhost/", {
544+
signal: new AbortController().signal,
545+
})
546+
)) as StaticHandlerContext;
547+
548+
let html = ReactDOMServer.renderToStaticMarkup(
549+
<React.StrictMode>
550+
<StaticRouterProvider
551+
router={createStaticRouter(routes, context)}
552+
context={context}
553+
/>
554+
</React.StrictMode>
555+
);
556+
expect(html).toContain('<a href="/path/with space">👋</a>');
557+
});
558+
559+
it("encodes auto-generated <form action> values to avoid hydration errors (action=undefined)", async () => {
560+
let routes = [{ path: "/path/:param", element: <Form>👋</Form> }];
561+
let { query } = createStaticHandler(routes);
562+
563+
let context = (await query(
564+
new Request("http://localhost/path/with space", {
565+
signal: new AbortController().signal,
566+
})
567+
)) as StaticHandlerContext;
568+
569+
let html = ReactDOMServer.renderToStaticMarkup(
570+
<React.StrictMode>
571+
<StaticRouterProvider
572+
router={createStaticRouter(routes, context)}
573+
context={context}
574+
/>
575+
</React.StrictMode>
576+
);
577+
expect(html).toContain(
578+
'<form method="get" action="/path/with%20space">👋</form>'
579+
);
580+
});
581+
582+
it('encodes auto-generated <form action> values to avoid hydration errors (action=".")', async () => {
583+
let routes = [
584+
{ path: "/path/:param", element: <Form action=".">👋</Form> },
585+
];
586+
let { query } = createStaticHandler(routes);
587+
588+
let context = (await query(
589+
new Request("http://localhost/path/with space", {
590+
signal: new AbortController().signal,
591+
})
592+
)) as StaticHandlerContext;
593+
594+
let html = ReactDOMServer.renderToStaticMarkup(
595+
<React.StrictMode>
596+
<StaticRouterProvider
597+
router={createStaticRouter(routes, context)}
598+
context={context}
599+
/>
600+
</React.StrictMode>
601+
);
602+
expect(html).toContain(
603+
'<form method="get" action="/path/with%20space">👋</form>'
604+
);
605+
});
606+
607+
it("does not encode user-specified <form action> values", async () => {
608+
let routes = [
609+
{ path: "/", element: <Form action="/path/with space">👋</Form> },
610+
];
611+
let { query } = createStaticHandler(routes);
612+
613+
let context = (await query(
614+
new Request("http://localhost/", {
615+
signal: new AbortController().signal,
616+
})
617+
)) as StaticHandlerContext;
618+
619+
let html = ReactDOMServer.renderToStaticMarkup(
620+
<React.StrictMode>
621+
<StaticRouterProvider
622+
router={createStaticRouter(routes, context)}
623+
context={context}
624+
/>
625+
</React.StrictMode>
626+
);
627+
expect(html).toContain(
628+
'<form method="get" action="/path/with space">👋</form>'
629+
);
630+
});
631+
514632
it("serializes ErrorResponse instances", async () => {
515633
let routes = [
516634
{

‎packages/react-router-dom/server.tsx

+9-5
Original file line numberDiff line numberDiff line change
@@ -348,15 +348,19 @@ function createHref(to: To) {
348348
}
349349

350350
function encodeLocation(to: To): Path {
351-
// Locations should already be encoded on the server, so just return as-is
352-
let path = typeof to === "string" ? parsePath(to) : to;
351+
let href = typeof to === "string" ? to : createPath(to);
352+
let encoded = ABSOLUTE_URL_REGEX.test(href)
353+
? new URL(href)
354+
: new URL(href, "http://localhost");
353355
return {
354-
pathname: path.pathname || "",
355-
search: path.search || "",
356-
hash: path.hash || "",
356+
pathname: encoded.pathname,
357+
search: encoded.search,
358+
hash: encoded.hash,
357359
};
358360
}
359361

362+
const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
363+
360364
// This utility is based on https://github.com/zertosh/htmlescape
361365
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
362366
const ESCAPE_LOOKUP: { [match: string]: string } = {

0 commit comments

Comments
 (0)
Please sign in to comment.