Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encode URIs during server rendering of <a href>/<form action> to avoi… #10769

Merged
merged 7 commits into from Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/encode-uri-ssr.md
@@ -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
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
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
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bad assumption - while they're properly encoded in request.url, we decode paths for matching and this function is used to re-encode in external-facing paths.

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");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it have been possible to make that base configurable?

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