Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 4 commits into from Aug 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -1537,6 +1537,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