diff --git a/.changeset/two-mice-grin.md b/.changeset/two-mice-grin.md new file mode 100644 index 0000000000..3c8383d48e --- /dev/null +++ b/.changeset/two-mice-grin.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Handle encoded question mark and hash characters in ancestor splat routes diff --git a/packages/react-router/__tests__/dom/special-characters-test.tsx b/packages/react-router/__tests__/dom/special-characters-test.tsx index 48e9cd2669..e37a24f9d1 100644 --- a/packages/react-router/__tests__/dom/special-characters-test.tsx +++ b/packages/react-router/__tests__/dom/special-characters-test.tsx @@ -603,6 +603,164 @@ describe("special character tests", () => { ); } }); + + it("handles encoded question marks in ancestor splat route segments", async () => { + let ctx = render( + + + , + ); + + expect(getHtml(ctx.container)).toMatchInlineSnapshot(` + "
+ + Link to grandchild + +
" + `); + + await fireEvent.click(screen.getByText("Link to grandchild")); + await waitFor(() => screen.getByText("Grandchild")); + + expect(getHtml(ctx.container)).toMatchInlineSnapshot(` + "
+ + Link to grandchild + +

+ Grandchild +

+
+            {"*":"grandchild","param":"question-?-mark"}
+          
+
" + `); + + function App() { + return ( + + } /> + + ); + } + + function Parent() { + return ( + + } /> + + ); + } + + function Child() { + let location = useLocation(); + let to = location.pathname.endsWith("grandchild") + ? "." + : "./grandchild"; + return ( + <> + Link to grandchild + + } /> + + + ); + } + + function Grandchild() { + return ( + <> +

Grandchild

+
{JSON.stringify(useParams())}
+ + ); + } + }); + + it("handles encoded hashes in ancestor splat route segments", async () => { + let ctx = render( + + + , + ); + + expect(getHtml(ctx.container)).toMatchInlineSnapshot(` + "
+ + Link to grandchild + +
" + `); + + await fireEvent.click(screen.getByText("Link to grandchild")); + await waitFor(() => screen.getByText("Grandchild")); + + expect(getHtml(ctx.container)).toMatchInlineSnapshot(` + "
+ + Link to grandchild + +

+ Grandchild +

+
+            {"*":"grandchild","param":"hash-#-char"}
+          
+
" + `); + + function App() { + return ( + + } /> + + ); + } + + function Parent() { + return ( + + } /> + + ); + } + + function Child() { + let location = useLocation(); + let to = location.pathname.endsWith("grandchild") + ? "." + : "./grandchild"; + return ( + <> + Link to grandchild + + } /> + + + ); + } + + function Grandchild() { + return ( + <> +

Grandchild

+
{JSON.stringify(useParams())}
+ + ); + } + }); }); describe("when matching as part of the defined route path", () => { diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 4356cd4ee6..c877445f9e 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -869,9 +869,14 @@ export function useRoutesImpl( params: Object.assign({}, parentParams, match.params), pathname: joinPaths([ parentPathnameBase, - // Re-encode pathnames that were decoded inside matchRoutes + // Re-encode pathnames that were decoded inside matchRoutes. + // Pre-encode `?` and `#` ahead of `encodeLocation` because it uses + // `new URL()` internally and we need to prevent it from treating + // them as separators navigator.encodeLocation - ? navigator.encodeLocation(match.pathname).pathname + ? navigator.encodeLocation( + match.pathname.replace(/\?/g, "%3F").replace(/#/g, "%23"), + ).pathname : match.pathname, ]), pathnameBase: @@ -880,8 +885,15 @@ export function useRoutesImpl( : joinPaths([ parentPathnameBase, // Re-encode pathnames that were decoded inside matchRoutes + // Pre-encode `?` and `#` ahead of `encodeLocation` because it uses + // `new URL()` internally and we need to prevent it from treating + // them as separators navigator.encodeLocation - ? navigator.encodeLocation(match.pathnameBase).pathname + ? navigator.encodeLocation( + match.pathnameBase + .replace(/\?/g, "%3F") + .replace(/#/g, "%23"), + ).pathname : match.pathnameBase, ]), }),