Skip to content

Commit 3778ea5

Browse files
authored
Handle encoded question mark and hash chars in ancestor splat routes (#14249)
1 parent f99fec6 commit 3778ea5

File tree

3 files changed

+178
-3
lines changed

3 files changed

+178
-3
lines changed

.changeset/two-mice-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Handle encoded question mark and hash characters in ancestor splat routes

packages/react-router/__tests__/dom/special-characters-test.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,164 @@ describe("special character tests", () => {
603603
);
604604
}
605605
});
606+
607+
it("handles encoded question marks in ancestor splat route segments", async () => {
608+
let ctx = render(
609+
<BrowserRouter window={getWindow("/parent/child/question-%3F-mark")}>
610+
<App />
611+
</BrowserRouter>,
612+
);
613+
614+
expect(getHtml(ctx.container)).toMatchInlineSnapshot(`
615+
"<div>
616+
<a
617+
data-discover="true"
618+
href="/parent/child/question-%3F-mark/grandchild"
619+
>
620+
Link to grandchild
621+
</a>
622+
</div>"
623+
`);
624+
625+
await fireEvent.click(screen.getByText("Link to grandchild"));
626+
await waitFor(() => screen.getByText("Grandchild"));
627+
628+
expect(getHtml(ctx.container)).toMatchInlineSnapshot(`
629+
"<div>
630+
<a
631+
data-discover="true"
632+
href="/parent/child/question-%3F-mark/grandchild"
633+
>
634+
Link to grandchild
635+
</a>
636+
<h1>
637+
Grandchild
638+
</h1>
639+
<pre>
640+
{"*":"grandchild","param":"question-?-mark"}
641+
</pre>
642+
</div>"
643+
`);
644+
645+
function App() {
646+
return (
647+
<Routes>
648+
<Route path="/parent/*" element={<Parent />} />
649+
</Routes>
650+
);
651+
}
652+
653+
function Parent() {
654+
return (
655+
<Routes>
656+
<Route path="child/:param/*" element={<Child />} />
657+
</Routes>
658+
);
659+
}
660+
661+
function Child() {
662+
let location = useLocation();
663+
let to = location.pathname.endsWith("grandchild")
664+
? "."
665+
: "./grandchild";
666+
return (
667+
<>
668+
<Link to={to}>Link to grandchild</Link>
669+
<Routes>
670+
<Route path="grandchild" element={<Grandchild />} />
671+
</Routes>
672+
</>
673+
);
674+
}
675+
676+
function Grandchild() {
677+
return (
678+
<>
679+
<h1>Grandchild</h1>
680+
<pre>{JSON.stringify(useParams())}</pre>
681+
</>
682+
);
683+
}
684+
});
685+
686+
it("handles encoded hashes in ancestor splat route segments", async () => {
687+
let ctx = render(
688+
<BrowserRouter window={getWindow("/parent/child/hash-%23-char")}>
689+
<App />
690+
</BrowserRouter>,
691+
);
692+
693+
expect(getHtml(ctx.container)).toMatchInlineSnapshot(`
694+
"<div>
695+
<a
696+
data-discover="true"
697+
href="/parent/child/hash-%23-char/grandchild"
698+
>
699+
Link to grandchild
700+
</a>
701+
</div>"
702+
`);
703+
704+
await fireEvent.click(screen.getByText("Link to grandchild"));
705+
await waitFor(() => screen.getByText("Grandchild"));
706+
707+
expect(getHtml(ctx.container)).toMatchInlineSnapshot(`
708+
"<div>
709+
<a
710+
data-discover="true"
711+
href="/parent/child/hash-%23-char/grandchild"
712+
>
713+
Link to grandchild
714+
</a>
715+
<h1>
716+
Grandchild
717+
</h1>
718+
<pre>
719+
{"*":"grandchild","param":"hash-#-char"}
720+
</pre>
721+
</div>"
722+
`);
723+
724+
function App() {
725+
return (
726+
<Routes>
727+
<Route path="/parent/*" element={<Parent />} />
728+
</Routes>
729+
);
730+
}
731+
732+
function Parent() {
733+
return (
734+
<Routes>
735+
<Route path="child/:param/*" element={<Child />} />
736+
</Routes>
737+
);
738+
}
739+
740+
function Child() {
741+
let location = useLocation();
742+
let to = location.pathname.endsWith("grandchild")
743+
? "."
744+
: "./grandchild";
745+
return (
746+
<>
747+
<Link to={to}>Link to grandchild</Link>
748+
<Routes>
749+
<Route path="grandchild" element={<Grandchild />} />
750+
</Routes>
751+
</>
752+
);
753+
}
754+
755+
function Grandchild() {
756+
return (
757+
<>
758+
<h1>Grandchild</h1>
759+
<pre>{JSON.stringify(useParams())}</pre>
760+
</>
761+
);
762+
}
763+
});
606764
});
607765

608766
describe("when matching as part of the defined route path", () => {

packages/react-router/lib/hooks.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -869,9 +869,14 @@ export function useRoutesImpl(
869869
params: Object.assign({}, parentParams, match.params),
870870
pathname: joinPaths([
871871
parentPathnameBase,
872-
// Re-encode pathnames that were decoded inside matchRoutes
872+
// Re-encode pathnames that were decoded inside matchRoutes.
873+
// Pre-encode `?` and `#` ahead of `encodeLocation` because it uses
874+
// `new URL()` internally and we need to prevent it from treating
875+
// them as separators
873876
navigator.encodeLocation
874-
? navigator.encodeLocation(match.pathname).pathname
877+
? navigator.encodeLocation(
878+
match.pathname.replace(/\?/g, "%3F").replace(/#/g, "%23"),
879+
).pathname
875880
: match.pathname,
876881
]),
877882
pathnameBase:
@@ -880,8 +885,15 @@ export function useRoutesImpl(
880885
: joinPaths([
881886
parentPathnameBase,
882887
// Re-encode pathnames that were decoded inside matchRoutes
888+
// Pre-encode `?` and `#` ahead of `encodeLocation` because it uses
889+
// `new URL()` internally and we need to prevent it from treating
890+
// them as separators
883891
navigator.encodeLocation
884-
? navigator.encodeLocation(match.pathnameBase).pathname
892+
? navigator.encodeLocation(
893+
match.pathnameBase
894+
.replace(/\?/g, "%3F")
895+
.replace(/#/g, "%23"),
896+
).pathname
885897
: match.pathnameBase,
886898
]),
887899
}),

0 commit comments

Comments
 (0)