diff --git a/.changeset/decode-uri-component.md b/.changeset/decode-uri-component.md new file mode 100644 index 0000000000..ddeb357724 --- /dev/null +++ b/.changeset/decode-uri-component.md @@ -0,0 +1,6 @@ +--- +"react-router": patch +"@remix-run/router": patch +--- + +Use `safelyDecodeURIComponent` to ensure proper URL decoding of full string in `matchRoutes`. \ No newline at end of file diff --git a/contributors.yml b/contributors.yml index 4bdf225d92..7cd09f270d 100644 --- a/contributors.yml +++ b/contributors.yml @@ -133,6 +133,7 @@ - KubasuIvanSakwa - KutnerUri - kylegirard +- labkey-nicka - landisdesign - latin-1 - lequangdongg diff --git a/packages/react-router-dom/__tests__/special-characters-test.tsx b/packages/react-router-dom/__tests__/special-characters-test.tsx index 0f6a5ddb6e..1c816eb0b0 100644 --- a/packages/react-router-dom/__tests__/special-characters-test.tsx +++ b/packages/react-router-dom/__tests__/special-characters-test.tsx @@ -763,15 +763,15 @@ describe("special character tests", () => { describe("memory routers", () => { it("does not encode characters in MemoryRouter", () => { let ctx = render( - + - } /> + } /> ); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with space","search":"","hash":""}
"` + `"
{"pathname":"/with space&encoded:characters","search":"","hash":""}
"` ); }); @@ -779,32 +779,32 @@ describe("special character tests", () => { function Start() { let navigate = useNavigate(); // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => navigate("/with space"), []); + React.useEffect(() => navigate("/with space&encoded:characters"), []); return null; } let ctx = render( } /> - } /> + } /> ); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with space","search":"","hash":""}
"` + `"
{"pathname":"/with space&encoded:characters","search":"","hash":""}
"` ); }); it("does not encode characters in createMemoryRouter", () => { let router = createMemoryRouter( - [{ path: "/with space", element: }], - { initialEntries: ["/with space"] } + [{ path: "/with space&encoded:characters", element: }], + { initialEntries: ["/with space&encoded:characters"] } ); let ctx = render(); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with space","search":"","hash":""}
"` + `"
{"pathname":"/with space&encoded:characters","search":"","hash":""}
"` ); }); @@ -812,36 +812,36 @@ describe("special character tests", () => { function Start() { let navigate = useNavigate(); // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => navigate("/with space"), []); + React.useEffect(() => navigate("/with space&encoded:characters"), []); return null; } let router = createMemoryRouter([ { path: "/", element: }, - { path: "/with space", element: }, + { path: "/with space&encoded:characters", element: }, ]); let ctx = render(); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with space","search":"","hash":""}
"` + `"
{"pathname":"/with space&encoded:characters","search":"","hash":""}
"` ); }); }); describe("browser routers", () => { it("encodes characters in BrowserRouter", () => { - let testWindow = getWindow("/with space"); + let testWindow = getWindow("/with%20space%26encoded%3Acharacters"); let ctx = render( - } /> + } /> ); - expect(testWindow.location.pathname).toBe("/with%20space"); + expect(testWindow.location.pathname).toBe("/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); @@ -851,7 +851,7 @@ describe("special character tests", () => { function Start() { let navigate = useNavigate(); // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => navigate("/with space"), []); + React.useEffect(() => navigate("/with%20space%26encoded%3Acharacters"), []); return null; } @@ -859,29 +859,29 @@ describe("special character tests", () => { } /> - } /> + } /> ); - expect(testWindow.location.pathname).toBe("/with%20space"); + expect(testWindow.location.pathname).toBe("/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); it("encodes characters in createBrowserRouter", () => { - let testWindow = getWindow("/with space"); + let testWindow = getWindow("/with%20space%26encoded%3Acharacters"); let router = createBrowserRouter( - [{ path: "/with space", element: }], + [{ path: "/with space&encoded:characters", element: }], { window: testWindow } ); let ctx = render(); - expect(testWindow.location.pathname).toBe("/with%20space"); + expect(testWindow.location.pathname).toBe("/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); @@ -891,42 +891,42 @@ describe("special character tests", () => { function Start() { let navigate = useNavigate(); // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => navigate("/with space"), []); + React.useEffect(() => navigate("/with%20space%26encoded%3Acharacters"), []); return null; } let router = createBrowserRouter( [ { path: "/", element: }, - { path: "/with space", element: }, + { path: "/with space&encoded:characters", element: }, ], { window: testWindow } ); let ctx = render(); - expect(testWindow.location.pathname).toBe("/with%20space"); + expect(testWindow.location.pathname).toBe("/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); }); describe("hash routers", () => { it("encodes characters in HashRouter", () => { - let testWindow = getWindow("/#/with space"); + let testWindow = getWindow("/#/with%20space%26encoded%3Acharacters"); let ctx = render( - } /> + } /> ); expect(testWindow.location.pathname).toBe("/"); - expect(testWindow.location.hash).toBe("#/with%20space"); + expect(testWindow.location.hash).toBe("#/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); @@ -936,7 +936,7 @@ describe("special character tests", () => { function Start() { let navigate = useNavigate(); // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => navigate("/with space"), []); + React.useEffect(() => navigate("/with%20space%26encoded%3Acharacters"), []); return null; } @@ -944,31 +944,31 @@ describe("special character tests", () => { } /> - } /> + } /> ); expect(testWindow.location.pathname).toBe("/"); - expect(testWindow.location.hash).toBe("#/with%20space"); + expect(testWindow.location.hash).toBe("#/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); it("encodes characters in createHashRouter", () => { - let testWindow = getWindow("/#/with space"); + let testWindow = getWindow("/#/with%20space%26encoded%3Acharacters"); let router = createHashRouter( - [{ path: "/with space", element: }], + [{ path: "/with space&encoded:characters", element: }], { window: testWindow } ); let ctx = render(); expect(testWindow.location.pathname).toBe("/"); - expect(testWindow.location.hash).toBe("#/with%20space"); + expect(testWindow.location.hash).toBe("#/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); @@ -978,23 +978,23 @@ describe("special character tests", () => { function Start() { let navigate = useNavigate(); // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => navigate("/with space"), []); + React.useEffect(() => navigate("/with%20space%26encoded%3Acharacters"), []); return null; } let router = createHashRouter( [ { path: "/", element: }, - { path: "/with space", element: }, + { path: "/with space&encoded:characters", element: }, ], { window: testWindow } ); let ctx = render(); expect(testWindow.location.pathname).toBe("/"); - expect(testWindow.location.hash).toBe("#/with%20space"); + expect(testWindow.location.hash).toBe("#/with%20space%26encoded%3Acharacters"); expect(ctx.container.innerHTML).toMatchInlineSnapshot( - `"
{"pathname":"/with%20space","search":"","hash":""}
"` + `"
{"pathname":"/with%20space%26encoded%3Acharacters","search":"","hash":""}
"` ); }); }); diff --git a/packages/react-router/__tests__/matchPath-test.tsx b/packages/react-router/__tests__/matchPath-test.tsx index 5d4a95b0aa..fb897ace8a 100644 --- a/packages/react-router/__tests__/matchPath-test.tsx +++ b/packages/react-router/__tests__/matchPath-test.tsx @@ -108,6 +108,14 @@ describe("matchPath", () => { pathnameBase: "/users/mj", }); }); + + it("matches second consecutive slash as a parameter", () => { + expect(matchPath("/:id", "//")).toMatchObject({ + params: { id: "/" }, + pathname: "//", + pathnameBase: "//", + }); + }); }); describe("with { end: false }", () => { @@ -164,6 +172,10 @@ describe("matchPath", () => { matchPath({ path: "/users/mj", end: false }, "/users/mj2") ).toBeNull(); }); + + it("does not match second consecutive slash as a parameter", () => { + expect(matchPath({ path: "/:id", end: false }, "//")).toBeNull(); + }); }); describe("with { end: false } and a / pattern", () => { diff --git a/packages/router/utils.ts b/packages/router/utils.ts index a8c35d9595..d798b9edd7 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -483,18 +483,17 @@ export function matchRoutes< let branches = flattenRoutes(routes); rankRouteBranches(branches); + // Incoming pathnames are generally encoded from either window.location + // or from router.navigate, but we want to match against the decoded + // paths in the route definitions. Memory router locations won't be + // encoded here but there also shouldn't be anything to decode so this + // should be a safe operation. This avoids needing matchRoutes to be + // history-aware. + const decodedPathname = safelyDecodeURIComponent(pathname); + let matches = null; for (let i = 0; matches == null && i < branches.length; ++i) { - matches = matchRouteBranch( - branches[i], - // Incoming pathnames are generally encoded from either window.location - // or from router.navigate, but we want to match against the unencoded - // paths in the route definitions. Memory router locations won't be - // encoded here but there also shouldn't be anything to decode so this - // should be a safe operation. This avoids needing matchRoutes to be - // history-aware. - safelyDecodeURI(pathname) - ); + matches = matchRouteBranch(branches[i], decodedPathname); } return matches; @@ -641,7 +640,7 @@ function explodeOptionalSegments(path: string): string[] { let required = first.replace(/\?$/, ""); if (rest.length === 0) { - // Intepret empty string as omitting an optional segment + // Interpret empty string as omitting an optional segment // `["one", "", "three"]` corresponds to omitting `:two` from `/one/:two?/three` -> `/one/three` return isOptional ? [required, ""] : [required]; } @@ -903,6 +902,15 @@ export function matchPath< pattern = { path: pattern, caseSensitive: false, end: true }; } + // GH Issue #8072: Support matching against a standalone "/" as a parameter + // Here we encode the second slash to allow for pattern matching against the second slash. + // The encoded variant can be thought of as a placeholder during this matching process. + // When this process is complete we undo this encoding iff it was applied. + const encodeDoubleSlash = pattern.end && pathname.startsWith("//"); + if (encodeDoubleSlash) { + pathname = pathname.replace("//", "/%2F"); + } + let [matcher, compiledParams] = compilePath( pattern.path, pattern.caseSensitive, @@ -939,8 +947,8 @@ export function matchPath< return { params, - pathname: matchedPathname, - pathnameBase, + pathname: encodeDoubleSlash ? matchedPathname.replace("/%2F", "//") : matchedPathname, + pathnameBase: encodeDoubleSlash ? pathnameBase.replace("/%2F", "//") : pathnameBase, pattern, }; } @@ -999,28 +1007,13 @@ function compilePath( return [matcher, params]; } -function safelyDecodeURI(value: string) { - try { - return decodeURI(value); - } catch (error) { - warning( - false, - `The URL path "${value}" could not be decoded because it is is a ` + - `malformed URL segment. This is probably due to a bad percent ` + - `encoding (${error}).` - ); - - return value; - } -} - -function safelyDecodeURIComponent(value: string, paramName: string) { +function safelyDecodeURIComponent(value: string, paramName?: string) { try { return decodeURIComponent(value); } catch (error) { warning( false, - `The value for the URL param "${paramName}" will not be decoded because` + + `The value for the URL "${paramName ? `param ${paramName}` : ''}" will not be decoded because` + ` the string "${value}" is a malformed URL segment. This is probably` + ` due to a bad percent encoding (${error}).` );