Skip to content

Commit

Permalink
Encode URIs during server rendering of <a href>/<form action> to avoi… (
Browse files Browse the repository at this point in the history
#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
  • Loading branch information
brophdawg11 committed Aug 11, 2023
1 parent e11af30 commit 8b3559a
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/encode-uri-ssr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router-dom": patch
---

Proeprly encode rendered URIs in server rendering to avoid hydration errors
13 changes: 9 additions & 4 deletions packages/react-router-dom-v5-compat/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface StaticRouterProps {
location: Partial<Location> | string;
}

const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;

/**
* A <Router> that may not navigate to any other location. This is useful
* on the server where there is no stateful UI.
Expand Down Expand Up @@ -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) {
Expand Down
118 changes: 118 additions & 0 deletions packages/react-router-dom/__tests__/data-static-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -511,6 +512,123 @@ describe("A <StaticRouterProvider>", () => {
);
});

it("encodes auto-generated <a href> values to avoid hydration errors", async () => {
let routes = [{ path: "/path/:param", element: <Link to=".">👋</Link> }];
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(
<React.StrictMode>
<StaticRouterProvider
router={createStaticRouter(routes, context)}
context={context}
/>
</React.StrictMode>
);
expect(html).toContain('<a href="/path/with%20space">👋</a>');
});

it("does not encode user-specified <a href> values", async () => {
let routes = [
{ path: "/", element: <Link to="/path/with space">👋</Link> },
];
let { query } = createStaticHandler(routes);

let context = (await query(
new Request("http://localhost/", {
signal: new AbortController().signal,
})
)) as StaticHandlerContext;

let html = ReactDOMServer.renderToStaticMarkup(
<React.StrictMode>
<StaticRouterProvider
router={createStaticRouter(routes, context)}
context={context}
/>
</React.StrictMode>
);
expect(html).toContain('<a href="/path/with space">👋</a>');
});

it("encodes auto-generated <form action> values to avoid hydration errors (action=undefined)", async () => {
let routes = [{ path: "/path/:param", element: <Form>👋</Form> }];
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(
<React.StrictMode>
<StaticRouterProvider
router={createStaticRouter(routes, context)}
context={context}
/>
</React.StrictMode>
);
expect(html).toContain(
'<form method="get" action="/path/with%20space">👋</form>'
);
});

it('encodes auto-generated <form action> values to avoid hydration errors (action=".")', async () => {
let routes = [
{ path: "/path/:param", element: <Form action=".">👋</Form> },
];
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(
<React.StrictMode>
<StaticRouterProvider
router={createStaticRouter(routes, context)}
context={context}
/>
</React.StrictMode>
);
expect(html).toContain(
'<form method="get" action="/path/with%20space">👋</form>'
);
});

it("does not encode user-specified <form action> values", async () => {
let routes = [
{ path: "/", element: <Form action="/path/with space">👋</Form> },
];
let { query } = createStaticHandler(routes);

let context = (await query(
new Request("http://localhost/", {
signal: new AbortController().signal,
})
)) as StaticHandlerContext;

let html = ReactDOMServer.renderToStaticMarkup(
<React.StrictMode>
<StaticRouterProvider
router={createStaticRouter(routes, context)}
context={context}
/>
</React.StrictMode>
);
expect(html).toContain(
'<form method="get" action="/path/with space">👋</form>'
);
});

it("serializes ErrorResponse instances", async () => {
let routes = [
{
Expand Down
14 changes: 9 additions & 5 deletions packages/react-router-dom/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {
Expand Down

0 comments on commit 8b3559a

Please sign in to comment.