Skip to content

Commit 815e1d1

Browse files
authored
feat: add relative=path option for url-relative routing (#9160)
* feat: add relative=path option for url-relative routing * add to native * Change useFormActon to use an options object API * add changeset
1 parent 4c190e3 commit 815e1d1

File tree

12 files changed

+527
-25
lines changed

12 files changed

+527
-25
lines changed

.changeset/big-bags-report.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"react-router": patch
3+
"react-router-dom": patch
4+
"react-router-native": patch
5+
---
6+
7+
feat: add `relative=path` option for url-relative routing (#9160)
8+
9+
Adds a `relative=path` option to navigation aspects to allow users to opt-into paths behaving relative to the current URL instead of the current route hierarchy. This is useful if you're sharing route patterns in a non-nested for UI reasons:
10+
11+
```jsx
12+
// Contact and EditContact do not share UI layout
13+
<Route path="contacts/:id" element={<Contact />} />
14+
<Route path="contacts:id/edit" element={<EditContact />} />
15+
16+
function EditContact() {
17+
return <Link to=".." relative="path">Cancel</Link>
18+
}
19+
```
20+
21+
Without this, the user would need to reconstruct the contacts/:id url using useParams and either hardcoding the /contacts prefix or parsing it from useLocation.
22+
23+
This applies to all path-related hooks and components:
24+
25+
- `react-router`: `useHref`, `useResolvedPath`, `useNavigate`, `Navigate`
26+
- `react-router-dom`: `useLinkClickHandler`, `useFormAction`, `useSubmit`, `Link`, `Form`
27+
- `react-router-native`: `useLinkPressHandler`, `Link`

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1628,6 +1628,119 @@ function testDomRouter(
16281628
});
16291629
});
16301630

1631+
describe('<Form action relative="path">', () => {
1632+
it("navigates relative to the URL for static routes", async () => {
1633+
let { container } = render(
1634+
<TestDataRouter
1635+
window={getWindow("/inbox/messages/edit")}
1636+
hydrationData={{}}
1637+
>
1638+
<Route path="inbox">
1639+
<Route path="messages" />
1640+
<Route
1641+
path="messages/edit"
1642+
element={<Form action=".." relative="path" />}
1643+
/>
1644+
</Route>
1645+
</TestDataRouter>
1646+
);
1647+
1648+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
1649+
"/inbox/messages"
1650+
);
1651+
});
1652+
1653+
it("navigates relative to the URL for dynamic routes", async () => {
1654+
let { container } = render(
1655+
<TestDataRouter
1656+
window={getWindow("/inbox/messages/1")}
1657+
hydrationData={{}}
1658+
>
1659+
<Route path="inbox">
1660+
<Route path="messages" />
1661+
<Route
1662+
path="messages/:id"
1663+
element={<Form action=".." relative="path" />}
1664+
/>
1665+
</Route>
1666+
</TestDataRouter>
1667+
);
1668+
1669+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
1670+
"/inbox/messages"
1671+
);
1672+
});
1673+
1674+
it("navigates relative to the URL for layout routes", async () => {
1675+
let { container } = render(
1676+
<TestDataRouter
1677+
window={getWindow("/inbox/messages/1")}
1678+
hydrationData={{}}
1679+
>
1680+
<Route path="inbox">
1681+
<Route path="messages" />
1682+
<Route
1683+
path="messages/:id"
1684+
element={
1685+
<>
1686+
<Form action=".." relative="path" />
1687+
<Outlet />
1688+
</>
1689+
}
1690+
>
1691+
<Route index element={<h1>Form</h1>} />
1692+
</Route>
1693+
</Route>
1694+
</TestDataRouter>
1695+
);
1696+
1697+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
1698+
"/inbox/messages"
1699+
);
1700+
});
1701+
1702+
it("navigates relative to the URL for index routes", async () => {
1703+
let { container } = render(
1704+
<TestDataRouter
1705+
window={getWindow("/inbox/messages/1")}
1706+
hydrationData={{}}
1707+
>
1708+
<Route path="inbox">
1709+
<Route path="messages" />
1710+
<Route path="messages/:id">
1711+
<Route index element={<Form action=".." relative="path" />} />
1712+
</Route>
1713+
</Route>
1714+
</TestDataRouter>
1715+
);
1716+
1717+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
1718+
"/inbox/messages"
1719+
);
1720+
});
1721+
1722+
it("navigates relative to the URL for splat routes", async () => {
1723+
let { container } = render(
1724+
<TestDataRouter
1725+
window={getWindow("/inbox/messages/1/2/3")}
1726+
hydrationData={{}}
1727+
>
1728+
<Route path="inbox">
1729+
<Route path="messages" />
1730+
<Route
1731+
path="messages/*"
1732+
element={<Form action=".." relative="path" />}
1733+
/>
1734+
</Route>
1735+
</TestDataRouter>
1736+
);
1737+
1738+
expect(container.querySelector("form")?.getAttribute("action")).toBe(
1739+
"/inbox/messages/1/2"
1740+
);
1741+
});
1742+
});
1743+
16311744
describe("useSubmit/Form FormData", () => {
16321745
it("gathers form data on <Form> submissions", async () => {
16331746
let actionSpy = jest.fn();

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,4 +594,89 @@ describe("<Link> href", () => {
594594
);
595595
});
596596
});
597+
598+
describe("when using relative=path", () => {
599+
test("absolute <Link to> resolves relative to the root URL", () => {
600+
let renderer: TestRenderer.ReactTestRenderer;
601+
TestRenderer.act(() => {
602+
renderer = TestRenderer.create(
603+
<MemoryRouter initialEntries={["/inbox"]}>
604+
<Routes>
605+
<Route
606+
path="inbox"
607+
element={<Link to="/about" relative="path" />}
608+
/>
609+
</Routes>
610+
</MemoryRouter>
611+
);
612+
});
613+
614+
expect(renderer.root.findByType("a").props.href).toEqual("/about");
615+
});
616+
617+
test('<Link to="."> resolves relative to the current route', () => {
618+
let renderer: TestRenderer.ReactTestRenderer;
619+
TestRenderer.act(() => {
620+
renderer = TestRenderer.create(
621+
<MemoryRouter initialEntries={["/inbox"]}>
622+
<Routes>
623+
<Route path="inbox" element={<Link to="." relative="path" />} />
624+
</Routes>
625+
</MemoryRouter>
626+
);
627+
});
628+
629+
expect(renderer.root.findByType("a").props.href).toEqual("/inbox");
630+
});
631+
632+
test('<Link to=".."> resolves relative to the parent URL segment', () => {
633+
let renderer: TestRenderer.ReactTestRenderer;
634+
TestRenderer.act(() => {
635+
renderer = TestRenderer.create(
636+
<MemoryRouter initialEntries={["/inbox/messages/1"]}>
637+
<Routes>
638+
<Route path="inbox" />
639+
<Route path="inbox/messages" />
640+
<Route
641+
path="inbox/messages/:id"
642+
element={<Link to=".." relative="path" />}
643+
/>
644+
</Routes>
645+
</MemoryRouter>
646+
);
647+
});
648+
649+
expect(renderer.root.findByType("a").props.href).toEqual(
650+
"/inbox/messages"
651+
);
652+
});
653+
654+
test('<Link to=".."> with more .. segments than parent routes resolves to the root URL', () => {
655+
let renderer: TestRenderer.ReactTestRenderer;
656+
TestRenderer.act(() => {
657+
renderer = TestRenderer.create(
658+
<MemoryRouter initialEntries={["/inbox/messages"]}>
659+
<Routes>
660+
<Route path="inbox">
661+
<Route
662+
path="messages"
663+
element={
664+
<>
665+
<Link to="../../about" relative="path" />
666+
{/* traverse past the root */}
667+
<Link to="../../../about" relative="path" />
668+
</>
669+
}
670+
/>
671+
</Route>
672+
</Routes>
673+
</MemoryRouter>
674+
);
675+
});
676+
677+
expect(renderer.root.findAllByType("a").map((a) => a.props.href)).toEqual(
678+
["/about", "/about"]
679+
);
680+
});
681+
});
597682
});

packages/react-router-dom/dom.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FormEncType, FormMethod } from "@remix-run/router";
2+
import { RelativeRoutingType } from "react-router";
23

34
export const defaultMethod = "get";
45
const defaultEncType = "application/x-www-form-urlencoded";
@@ -130,6 +131,13 @@ export interface SubmitOptions {
130131
* to `false`.
131132
*/
132133
replace?: boolean;
134+
135+
/**
136+
* Determines whether the form action is relative to the route hierarchy or
137+
* the pathname. Use this if you want to opt out of navigating the route
138+
* hierarchy and want to instead route based on /-delimited URL segments
139+
*/
140+
relative?: RelativeRoutingType;
133141
}
134142

135143
export function getFormSubmissionInfo(

0 commit comments

Comments
 (0)