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 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@
"none": "16.3 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "12.8 kB"
"none": "13.0 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "18.9 kB"
"none": "19.1 kB"
}
}
}
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
37 changes: 36 additions & 1 deletion packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
ErrorResponse,
UNSAFE_invariant as invariant,
UNSAFE_warning as warning,
parsePath,
} from "@remix-run/router";

import type {
Expand Down Expand Up @@ -526,7 +527,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
},
ref
) {
let { basename } = React.useContext(NavigationContext);
let { basename, static: isStatic } = React.useContext(NavigationContext);

// Rendered into <a href> for absolute URLs
let absoluteHref;
Expand Down Expand Up @@ -565,6 +566,12 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
// Rendered into <a href> for relative URLs
let href = useHref(to, { relative });

// When <a href> 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,
Expand Down Expand Up @@ -823,9 +830,17 @@ const FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
},
forwardedRef
) => {
let { static: isStatic } = React.useContext(NavigationContext);
let formMethod: HTMLFormMethod =
method.toLowerCase() === "get" ? "get" : "post";
let formAction = useFormAction(action, { relative });

// When <form action> 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<HTMLFormElement> = (event) => {
onSubmit && onSubmit(event);
if (event.defaultPrevented) return;
Expand Down Expand Up @@ -1483,4 +1498,24 @@ 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 - don't change href if we aren't sure it needs encoding
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
}
}
return href;
}
//#endregion