Skip to content

Commit fe066bd

Browse files
authored
Fix issues with useFormAction/useResolvedPath for dot paths in param/splat routes (#10983)
1 parent 4f6c454 commit fe066bd

File tree

6 files changed

+322
-10
lines changed

6 files changed

+322
-10
lines changed

.changeset/fix-resolve-path.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Fix bug in `useResolvedPath` that would cause `useResolvedPath(".")` in a splat route to lose the splat portion of the URL path.
6+
7+
- ⚠️ This fixes a quite long-standing bug specifically for `"."` paths inside a splat route which incorrectly dropped the splat portion of the URL. If you are relative routing via `"."` inside a splat route in your application you should double check that your logic is not relying on this buggy behavior and update accordingly.

packages/react-router-dom/__tests__/data-browser-router-test.tsx

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2653,6 +2653,46 @@ function testDomRouter(
26532653
"/foo/bar"
26542654
);
26552655
});
2656+
2657+
it("does not include dynamic parameters from a parent layout route", async () => {
2658+
let router = createTestRouter(
2659+
createRoutesFromElements(
2660+
<Route path="/">
2661+
<Route path="foo" element={<ActionEmptyComponent />}>
2662+
<Route path=":param" element={<h1>Param</h1>} />
2663+
</Route>
2664+
</Route>
2665+
),
2666+
{
2667+
window: getWindow("/foo/bar"),
2668+
}
2669+
);
2670+
let { container } = render(<RouterProvider router={router} />);
2671+
2672+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2673+
"/foo"
2674+
);
2675+
});
2676+
2677+
it("does not include splat parameters from a parent layout route", async () => {
2678+
let router = createTestRouter(
2679+
createRoutesFromElements(
2680+
<Route path="/">
2681+
<Route path="foo" element={<ActionEmptyComponent />}>
2682+
<Route path="*" element={<h1>Splat</h1>} />
2683+
</Route>
2684+
</Route>
2685+
),
2686+
{
2687+
window: getWindow("/foo/bar/baz/qux"),
2688+
}
2689+
);
2690+
let { container } = render(<RouterProvider router={router} />);
2691+
2692+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2693+
"/foo"
2694+
);
2695+
});
26562696
});
26572697

26582698
describe("index routes", () => {
@@ -2876,6 +2916,44 @@ function testDomRouter(
28762916
"/foo/bar"
28772917
);
28782918
});
2919+
2920+
it("includes param portion of path when no action is specified (inline splat)", async () => {
2921+
let router = createTestRouter(
2922+
createRoutesFromElements(
2923+
<Route path="/">
2924+
<Route path="foo">
2925+
<Route path=":param" element={<NoActionComponent />} />
2926+
</Route>
2927+
</Route>
2928+
),
2929+
{
2930+
window: getWindow("/foo/bar"),
2931+
}
2932+
);
2933+
let { container } = render(<RouterProvider router={router} />);
2934+
2935+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2936+
"/foo/bar"
2937+
);
2938+
});
2939+
2940+
it("includes splat portion of path when no action is specified (nested splat)", async () => {
2941+
let router = createTestRouter(
2942+
createRoutesFromElements(
2943+
<Route path="/">
2944+
<Route path="foo/:param" element={<NoActionComponent />} />
2945+
</Route>
2946+
),
2947+
{
2948+
window: getWindow("/foo/bar"),
2949+
}
2950+
);
2951+
let { container } = render(<RouterProvider router={router} />);
2952+
2953+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2954+
"/foo/bar"
2955+
);
2956+
});
28792957
});
28802958

28812959
describe("splat routes", () => {
@@ -2895,7 +2973,7 @@ function testDomRouter(
28952973
let { container } = render(<RouterProvider router={router} />);
28962974

28972975
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2898-
"/foo?a=1"
2976+
"/foo/bar?a=1"
28992977
);
29002978
});
29012979

@@ -2915,7 +2993,7 @@ function testDomRouter(
29152993
let { container } = render(<RouterProvider router={router} />);
29162994

29172995
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2918-
"/foo"
2996+
"/foo/bar"
29192997
);
29202998
});
29212999

@@ -2935,7 +3013,25 @@ function testDomRouter(
29353013
let { container } = render(<RouterProvider router={router} />);
29363014

29373015
expect(container.querySelector("form")?.getAttribute("action")).toBe(
2938-
"/foo"
3016+
"/foo/bar"
3017+
);
3018+
});
3019+
3020+
it("includes splat portion of path when no action is specified (inline splat)", async () => {
3021+
let router = createTestRouter(
3022+
createRoutesFromElements(
3023+
<Route path="/">
3024+
<Route path="foo/*" element={<NoActionComponent />} />
3025+
</Route>
3026+
),
3027+
{
3028+
window: getWindow("/foo/bar/baz"),
3029+
}
3030+
);
3031+
let { container } = render(<RouterProvider router={router} />);
3032+
3033+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
3034+
"/foo/bar/baz"
29393035
);
29403036
});
29413037
});

packages/react-router-dom/__tests__/link-href-test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ describe("<Link> href", () => {
530530
});
531531

532532
expect(renderer.root.findByType("a").props.href).toEqual(
533-
"/inbox/messages"
533+
"/inbox/messages/abc"
534534
);
535535
});
536536

packages/react-router-dom/index.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,10 +1478,8 @@ export function useFormAction(
14781478
// object referenced by useMemo inside useResolvedPath
14791479
let path = { ...useResolvedPath(action ? action : ".", { relative }) };
14801480

1481-
// Previously we set the default action to ".". The problem with this is that
1482-
// `useResolvedPath(".")` excludes search params of the resolved URL. This is
1483-
// the intended behavior of when "." is specifically provided as
1484-
// the form action, but inconsistent w/ browsers when the action is omitted.
1481+
// If no action was specified, browsers will persist current search params
1482+
// when determining the path, so match that behavior
14851483
// https://github.com/remix-run/remix/issues/927
14861484
let location = useLocation();
14871485
if (action == null) {

packages/react-router/__tests__/useResolvedPath-test.tsx

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe("useResolvedPath", () => {
8585
});
8686

8787
describe("in a splat route", () => {
88-
it("resolves . to the route path", () => {
88+
it("resolves . to the route path (nested splat)", () => {
8989
let renderer: TestRenderer.ReactTestRenderer;
9090
TestRenderer.act(() => {
9191
renderer = TestRenderer.create(
@@ -99,6 +99,213 @@ describe("useResolvedPath", () => {
9999
);
100100
});
101101

102+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
103+
<pre>
104+
{"pathname":"/users/mj","search":"","hash":""}
105+
</pre>
106+
`);
107+
});
108+
109+
it("resolves .. to the parent route path (nested splat)", () => {
110+
let renderer: TestRenderer.ReactTestRenderer;
111+
TestRenderer.act(() => {
112+
renderer = TestRenderer.create(
113+
<MemoryRouter initialEntries={["/users/mj"]}>
114+
<Routes>
115+
<Route path="/users">
116+
<Route path="*" element={<ShowResolvedPath path=".." />} />
117+
</Route>
118+
</Routes>
119+
</MemoryRouter>
120+
);
121+
});
122+
123+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
124+
<pre>
125+
{"pathname":"/users","search":"","hash":""}
126+
</pre>
127+
`);
128+
});
129+
130+
it("resolves . to the route path (inline splat)", () => {
131+
let renderer: TestRenderer.ReactTestRenderer;
132+
TestRenderer.act(() => {
133+
renderer = TestRenderer.create(
134+
<MemoryRouter initialEntries={["/users/name/mj"]}>
135+
<Routes>
136+
<Route path="/users">
137+
<Route path="name/*" element={<ShowResolvedPath path="." />} />
138+
</Route>
139+
</Routes>
140+
</MemoryRouter>
141+
);
142+
});
143+
144+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
145+
<pre>
146+
{"pathname":"/users/name/mj","search":"","hash":""}
147+
</pre>
148+
`);
149+
});
150+
151+
it("resolves .. to the parent route path (inline splat)", () => {
152+
let renderer: TestRenderer.ReactTestRenderer;
153+
TestRenderer.act(() => {
154+
renderer = TestRenderer.create(
155+
<MemoryRouter initialEntries={["/users/name/mj"]}>
156+
<Routes>
157+
<Route path="/users">
158+
<Route path="name/*" element={<ShowResolvedPath path=".." />} />
159+
</Route>
160+
</Routes>
161+
</MemoryRouter>
162+
);
163+
});
164+
165+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
166+
<pre>
167+
{"pathname":"/users","search":"","hash":""}
168+
</pre>
169+
`);
170+
});
171+
172+
it("resolves . to the route path (descendant route)", () => {
173+
let renderer: TestRenderer.ReactTestRenderer;
174+
TestRenderer.act(() => {
175+
renderer = TestRenderer.create(
176+
<MemoryRouter initialEntries={["/users/mj"]}>
177+
<Routes>
178+
<Route
179+
path="/users/*"
180+
element={
181+
<Routes>
182+
<Route path="mj" element={<ShowResolvedPath path="." />} />
183+
</Routes>
184+
}
185+
/>
186+
</Routes>
187+
</MemoryRouter>
188+
);
189+
});
190+
191+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
192+
<pre>
193+
{"pathname":"/users/mj","search":"","hash":""}
194+
</pre>
195+
`);
196+
});
197+
198+
it("resolves .. to the parent route path (descendant route)", () => {
199+
let renderer: TestRenderer.ReactTestRenderer;
200+
TestRenderer.act(() => {
201+
renderer = TestRenderer.create(
202+
<MemoryRouter initialEntries={["/users/mj"]}>
203+
<Routes>
204+
<Route
205+
path="/users/*"
206+
element={
207+
<Routes>
208+
<Route path="mj" element={<ShowResolvedPath path=".." />} />
209+
</Routes>
210+
}
211+
/>
212+
</Routes>
213+
</MemoryRouter>
214+
);
215+
});
216+
217+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
218+
<pre>
219+
{"pathname":"/users","search":"","hash":""}
220+
</pre>
221+
`);
222+
});
223+
});
224+
225+
describe("in a param route", () => {
226+
it("resolves . to the route path (nested param)", () => {
227+
let renderer: TestRenderer.ReactTestRenderer;
228+
TestRenderer.act(() => {
229+
renderer = TestRenderer.create(
230+
<MemoryRouter initialEntries={["/users/mj"]}>
231+
<Routes>
232+
<Route path="/users">
233+
<Route path=":name" element={<ShowResolvedPath path="." />} />
234+
</Route>
235+
</Routes>
236+
</MemoryRouter>
237+
);
238+
});
239+
240+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
241+
<pre>
242+
{"pathname":"/users/mj","search":"","hash":""}
243+
</pre>
244+
`);
245+
});
246+
247+
it("resolves .. to the parent route (nested param)", () => {
248+
let renderer: TestRenderer.ReactTestRenderer;
249+
TestRenderer.act(() => {
250+
renderer = TestRenderer.create(
251+
<MemoryRouter initialEntries={["/users/mj"]}>
252+
<Routes>
253+
<Route path="/users">
254+
<Route path=":name" element={<ShowResolvedPath path=".." />} />
255+
</Route>
256+
</Routes>
257+
</MemoryRouter>
258+
);
259+
});
260+
261+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
262+
<pre>
263+
{"pathname":"/users","search":"","hash":""}
264+
</pre>
265+
`);
266+
});
267+
268+
it("resolves . to the route path (inline param)", () => {
269+
let renderer: TestRenderer.ReactTestRenderer;
270+
TestRenderer.act(() => {
271+
renderer = TestRenderer.create(
272+
<MemoryRouter initialEntries={["/users/name/mj"]}>
273+
<Routes>
274+
<Route path="/users">
275+
<Route
276+
path="name/:name"
277+
element={<ShowResolvedPath path="." />}
278+
/>
279+
</Route>
280+
</Routes>
281+
</MemoryRouter>
282+
);
283+
});
284+
285+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
286+
<pre>
287+
{"pathname":"/users/name/mj","search":"","hash":""}
288+
</pre>
289+
`);
290+
});
291+
292+
it("resolves .. to the parent route (inline param)", () => {
293+
let renderer: TestRenderer.ReactTestRenderer;
294+
TestRenderer.act(() => {
295+
renderer = TestRenderer.create(
296+
<MemoryRouter initialEntries={["/users/name/mj"]}>
297+
<Routes>
298+
<Route path="/users">
299+
<Route
300+
path="name/:name"
301+
element={<ShowResolvedPath path=".." />}
302+
/>
303+
</Route>
304+
</Routes>
305+
</MemoryRouter>
306+
);
307+
});
308+
102309
expect(renderer.toJSON()).toMatchInlineSnapshot(`
103310
<pre>
104311
{"pathname":"/users","search":"","hash":""}

0 commit comments

Comments
 (0)