Skip to content

Commit

Permalink
feat: add relative=path option for url-relative routing (#9160)
Browse files Browse the repository at this point in the history
* feat: add relative=path option for url-relative routing

* add to native

* Change useFormActon to use an options object API

* add changeset
  • Loading branch information
brophdawg11 committed Aug 18, 2022
1 parent 4c190e3 commit 815e1d1
Show file tree
Hide file tree
Showing 12 changed files with 527 additions and 25 deletions.
27 changes: 27 additions & 0 deletions .changeset/big-bags-report.md
@@ -0,0 +1,27 @@
---
"react-router": patch
"react-router-dom": patch
"react-router-native": patch
---

feat: add `relative=path` option for url-relative routing (#9160)

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:

```jsx
// Contact and EditContact do not share UI layout
<Route path="contacts/:id" element={<Contact />} />
<Route path="contacts:id/edit" element={<EditContact />} />

function EditContact() {
return <Link to=".." relative="path">Cancel</Link>
}
```

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.

This applies to all path-related hooks and components:

- `react-router`: `useHref`, `useResolvedPath`, `useNavigate`, `Navigate`
- `react-router-dom`: `useLinkClickHandler`, `useFormAction`, `useSubmit`, `Link`, `Form`
- `react-router-native`: `useLinkPressHandler`, `Link`
113 changes: 113 additions & 0 deletions packages/react-router-dom/__tests__/data-browser-router-test.tsx
Expand Up @@ -1628,6 +1628,119 @@ function testDomRouter(
});
});

describe('<Form action relative="path">', () => {
it("navigates relative to the URL for static routes", async () => {
let { container } = render(
<TestDataRouter
window={getWindow("/inbox/messages/edit")}
hydrationData={{}}
>
<Route path="inbox">
<Route path="messages" />
<Route
path="messages/edit"
element={<Form action=".." relative="path" />}
/>
</Route>
</TestDataRouter>
);

expect(container.querySelector("form")?.getAttribute("action")).toBe(
"/inbox/messages"
);
});

it("navigates relative to the URL for dynamic routes", async () => {
let { container } = render(
<TestDataRouter
window={getWindow("/inbox/messages/1")}
hydrationData={{}}
>
<Route path="inbox">
<Route path="messages" />
<Route
path="messages/:id"
element={<Form action=".." relative="path" />}
/>
</Route>
</TestDataRouter>
);

expect(container.querySelector("form")?.getAttribute("action")).toBe(
"/inbox/messages"
);
});

it("navigates relative to the URL for layout routes", async () => {
let { container } = render(
<TestDataRouter
window={getWindow("/inbox/messages/1")}
hydrationData={{}}
>
<Route path="inbox">
<Route path="messages" />
<Route
path="messages/:id"
element={
<>
<Form action=".." relative="path" />
<Outlet />
</>
}
>
<Route index element={<h1>Form</h1>} />
</Route>
</Route>
</TestDataRouter>
);

expect(container.querySelector("form")?.getAttribute("action")).toBe(
"/inbox/messages"
);
});

it("navigates relative to the URL for index routes", async () => {
let { container } = render(
<TestDataRouter
window={getWindow("/inbox/messages/1")}
hydrationData={{}}
>
<Route path="inbox">
<Route path="messages" />
<Route path="messages/:id">
<Route index element={<Form action=".." relative="path" />} />
</Route>
</Route>
</TestDataRouter>
);

expect(container.querySelector("form")?.getAttribute("action")).toBe(
"/inbox/messages"
);
});

it("navigates relative to the URL for splat routes", async () => {
let { container } = render(
<TestDataRouter
window={getWindow("/inbox/messages/1/2/3")}
hydrationData={{}}
>
<Route path="inbox">
<Route path="messages" />
<Route
path="messages/*"
element={<Form action=".." relative="path" />}
/>
</Route>
</TestDataRouter>
);

expect(container.querySelector("form")?.getAttribute("action")).toBe(
"/inbox/messages/1/2"
);
});
});

describe("useSubmit/Form FormData", () => {
it("gathers form data on <Form> submissions", async () => {
let actionSpy = jest.fn();
Expand Down
85 changes: 85 additions & 0 deletions packages/react-router-dom/__tests__/link-href-test.tsx
Expand Up @@ -594,4 +594,89 @@ describe("<Link> href", () => {
);
});
});

describe("when using relative=path", () => {
test("absolute <Link to> resolves relative to the root URL", () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox"]}>
<Routes>
<Route
path="inbox"
element={<Link to="/about" relative="path" />}
/>
</Routes>
</MemoryRouter>
);
});

expect(renderer.root.findByType("a").props.href).toEqual("/about");
});

test('<Link to="."> resolves relative to the current route', () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox"]}>
<Routes>
<Route path="inbox" element={<Link to="." relative="path" />} />
</Routes>
</MemoryRouter>
);
});

expect(renderer.root.findByType("a").props.href).toEqual("/inbox");
});

test('<Link to=".."> resolves relative to the parent URL segment', () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox/messages/1"]}>
<Routes>
<Route path="inbox" />
<Route path="inbox/messages" />
<Route
path="inbox/messages/:id"
element={<Link to=".." relative="path" />}
/>
</Routes>
</MemoryRouter>
);
});

expect(renderer.root.findByType("a").props.href).toEqual(
"/inbox/messages"
);
});

test('<Link to=".."> with more .. segments than parent routes resolves to the root URL', () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox/messages"]}>
<Routes>
<Route path="inbox">
<Route
path="messages"
element={
<>
<Link to="../../about" relative="path" />
{/* traverse past the root */}
<Link to="../../../about" relative="path" />
</>
}
/>
</Route>
</Routes>
</MemoryRouter>
);
});

expect(renderer.root.findAllByType("a").map((a) => a.props.href)).toEqual(
["/about", "/about"]
);
});
});
});
8 changes: 8 additions & 0 deletions packages/react-router-dom/dom.ts
@@ -1,4 +1,5 @@
import type { FormEncType, FormMethod } from "@remix-run/router";
import { RelativeRoutingType } from "react-router";

export const defaultMethod = "get";
const defaultEncType = "application/x-www-form-urlencoded";
Expand Down Expand Up @@ -130,6 +131,13 @@ export interface SubmitOptions {
* to `false`.
*/
replace?: boolean;

/**
* Determines whether the form action is relative to the route hierarchy or
* the pathname. Use this if you want to opt out of navigating the route
* hierarchy and want to instead route based on /-delimited URL segments
*/
relative?: RelativeRoutingType;
}

export function getFormSubmissionInfo(
Expand Down

0 comments on commit 815e1d1

Please sign in to comment.