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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issues with contextual routing and index params #12003

Merged
merged 1 commit into from
Sep 17, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/calm-wombats-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"react-router-dom": patch
"react-router": patch
"@remix-run/router": patch
---

- Fix bug when submitting to the current contextual route (parent route with an index child) when an `?index` param already exists from a prior submission
- Fix `useFormAction` bug - when removing `?index` param it would not keep other non-Remix `index` params
280 changes: 277 additions & 3 deletions packages/react-router-dom/__tests__/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3000,6 +3000,280 @@ function testDomRouter(
});
});

describe("submitting to self from parent/index when ?index param exists", () => {
it("useSubmit", async () => {
let router = createTestRouter(
createRoutesFromElements(
<Route
id="parent"
path="/parent"
element={<Parent />}
action={({ request }) => "PARENT ACTION: " + request.url}
>
<Route
id="index"
index
element={<Index />}
action={({ request }) => "INDEX ACTION: " + request.url}
/>
</Route>
),
{
window: getWindow("/parent?index&index=keep"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we have an ?index around from a prior submission, we were incorrectly letting that apply to a submit(data, { method: 'post' }) in the parent component and then it would try to submit to the child index route action and not the proper contextual parent route action

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was handled properly in useFormAction (so Form/fetcher.Form didn't have the bug) but when using useSubmit it goes straight to the router which does the path resolving, so we were missing the logic in router.navigate/router.fetch

}
);
let { container } = render(<RouterProvider router={router} />);

function Parent() {
let actionData = useActionData();
let submit = useSubmit();
return (
<>
<p id="parent">{actionData}</p>
<button onClick={() => submit({}, { method: "post" })}>
Submit from parent
</button>
<Outlet />
</>
);
}

function Index() {
let actionData = useActionData();
let submit = useSubmit();
return (
<>
<p id="index">{actionData}</p>
<button onClick={() => submit({}, { method: "post" })}>
Submit from index
</button>
</>
);
}

fireEvent.click(screen.getByText("Submit from parent"));
await tick();
await waitFor(() => screen.getByText(new RegExp("PARENT ACTION")));
expect(getHtml(container.querySelector("#parent")!)).toContain(
"PARENT ACTION: http://localhost/parent?index=keep"
);

fireEvent.click(screen.getByText("Submit from index"));
await tick();
await waitFor(() => screen.getByText(new RegExp("INDEX ACTION")));
expect(getHtml(container.querySelector("#index")!)).toContain(
"INDEX ACTION: http://localhost/parent?index&index=keep"
);
});

it("Form", async () => {
let router = createTestRouter(
createRoutesFromElements(
<Route
id="parent"
path="/parent"
element={<Parent />}
action={({ request }) => "PARENT ACTION: " + request.url}
>
<Route
id="index"
index
element={<Index />}
action={({ request }) => "INDEX ACTION: " + request.url}
/>
</Route>
),
{
window: getWindow("/parent?index&index=keep"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Form/fetcher.Form did not have this issue since useFormAction handled it - but the logic in useFormActionto remove the ?index param was naive and removed all index params, not preserving user-specified ones. This PR also fixes that issue

}
);
let { container } = render(<RouterProvider router={router} />);

function Parent() {
let actionData = useActionData();
return (
<>
<p id="parent">{actionData}</p>
<Form method="post" id="parent-form">
<button type="submit">Submit from parent</button>
</Form>
<Outlet />
</>
);
}

function Index() {
let actionData = useActionData();
return (
<>
<p id="index">{actionData}</p>
<Form method="post" id="index-form">
<button type="submit">Submit from index</button>
</Form>
</>
);
}

expect(
container.querySelector("#parent-form")?.getAttribute("action")
).toBe("/parent?index=keep");
expect(
container.querySelector("#index-form")?.getAttribute("action")
).toBe("/parent?index&index=keep");

fireEvent.click(screen.getByText("Submit from parent"));
await tick();
await waitFor(() => screen.getByText(new RegExp("PARENT ACTION")));
expect(getHtml(container.querySelector("#parent")!)).toContain(
"PARENT ACTION: http://localhost/parent?index=keep"
);

fireEvent.click(screen.getByText("Submit from index"));
await tick();
await waitFor(() => screen.getByText(new RegExp("INDEX ACTION")));
expect(getHtml(container.querySelector("#index")!)).toContain(
"INDEX ACTION: http://localhost/parent?index&index=keep"
);
});

it("fetcher.submit", async () => {
let router = createTestRouter(
createRoutesFromElements(
<Route
id="parent"
path="/parent"
element={<Parent />}
action={({ request }) => "PARENT ACTION: " + request.url}
>
<Route
id="index"
index
element={<Index />}
action={({ request }) => "INDEX ACTION: " + request.url}
/>
</Route>
),
{
window: getWindow("/parent?index&index=keep"),
}
);
let { container } = render(<RouterProvider router={router} />);

function Parent() {
let fetcher = useFetcher();

return (
<>
<p id="parent">{fetcher.data}</p>
<button onClick={() => fetcher.submit({}, { method: "post" })}>
Submit from parent
</button>
<Outlet />
</>
);
}

function Index() {
let fetcher = useFetcher();

return (
<>
<p id="index">{fetcher.data}</p>
<button onClick={() => fetcher.submit({}, { method: "post" })}>
Submit from index
</button>
</>
);
}

fireEvent.click(screen.getByText("Submit from parent"));
await tick();
await waitFor(() => screen.getByText(new RegExp("PARENT ACTION")));
expect(getHtml(container.querySelector("#parent")!)).toContain(
"PARENT ACTION: http://localhost/parent?index=keep"
);

fireEvent.click(screen.getByText("Submit from index"));
await tick();
await waitFor(() => screen.getByText(new RegExp("INDEX ACTION")));
expect(getHtml(container.querySelector("#index")!)).toContain(
"INDEX ACTION: http://localhost/parent?index&index=keep"
);
});

it("fetcher.Form", async () => {
let router = createTestRouter(
createRoutesFromElements(
<Route
id="parent"
path="/parent"
element={<Parent />}
action={({ request }) => "PARENT ACTION: " + request.url}
>
<Route
id="index"
index
element={<Index />}
action={({ request }) => "INDEX ACTION: " + request.url}
/>
</Route>
),
{
window: getWindow("/parent?index&index=keep"),
}
);
let { container } = render(<RouterProvider router={router} />);

function Parent() {
let fetcher = useFetcher();

return (
<>
<p id="parent">{fetcher.data}</p>
<fetcher.Form method="post" id="parent-form">
<button type="submit">Submit from parent</button>
</fetcher.Form>
<Outlet />
</>
);
}

function Index() {
let fetcher = useFetcher();

return (
<>
<p id="index">{fetcher.data}</p>
<fetcher.Form method="post" id="index-form">
<button type="submit">Submit from index</button>
</fetcher.Form>
</>
);
}

expect(
container.querySelector("#parent-form")?.getAttribute("action")
).toBe("/parent?index=keep");
expect(
container.querySelector("#index-form")?.getAttribute("action")
).toBe("/parent?index&index=keep");

fireEvent.click(screen.getByText("Submit from parent"));
await tick();
await waitFor(() => screen.getByText(new RegExp("PARENT ACTION")));
expect(getHtml(container.querySelector("#parent")!)).toContain(
"PARENT ACTION: http://localhost/parent?index=keep"
);

fireEvent.click(screen.getByText("Submit from index"));
await tick();
await waitFor(() => screen.getByText(new RegExp("INDEX ACTION")));
expect(getHtml(container.querySelector("#index")!)).toContain(
"INDEX ACTION: http://localhost/parent?index&index=keep"
);
});
});

it("allows user to specify search params and hash", async () => {
let router = createTestRouter(
createRoutesFromElements(
Expand Down Expand Up @@ -5370,9 +5644,9 @@ function testDomRouter(
let { container } = render(<RouterProvider router={router} />);
expect(container.innerHTML).not.toMatch(/my-key/);
await waitFor(() =>
// React `useId()` results in either `:r2a:` or `:rp:` depending on
// `DataBrowserRouter`/`DataHashRouter`
expect(container.innerHTML).toMatch(/(:r2a:|:rp:),my-key/)
// React `useId()` results in something such as `:r2a:`, `:r2i:`,
// `:rt:`, or `:rp:` depending on `DataBrowserRouter`/`DataHashRouter`
expect(container.innerHTML).toMatch(/(:r[0-9]?[a-z]:),my-key/)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These internal react IDs seem to have changed with the new tests

);
});
});
Expand Down
8 changes: 6 additions & 2 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1635,9 +1635,13 @@ export function useFormAction(
// since it might not apply to our contextual route. We add it back based
// on match.route.index below
let params = new URLSearchParams(path.search);
if (params.has("index") && params.get("index") === "") {
let indexValues = params.getAll("index");
let hasNakedIndexParam = indexValues.some((v) => v === "");
if (hasNakedIndexParam) {
params.delete("index");
path.search = params.toString() ? `?${params.toString()}` : "";
indexValues.filter((v) => v).forEach((v) => params.append("index", v));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preserve user-specified index params inside useFormAction

let qs = params.toString();
path.search = qs ? `?${qs}` : "";
}
}

Expand Down
27 changes: 17 additions & 10 deletions packages/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4169,16 +4169,23 @@ function normalizeTo(
path.hash = location.hash;
}

// Add an ?index param for matched index routes if we don't already have one
if (
(to == null || to === "" || to === ".") &&
activeRouteMatch &&
activeRouteMatch.route.index &&
!hasNakedIndexQuery(path.search)
) {
path.search = path.search
? path.search.replace(/^\?/, "?index&")
: "?index";
// Account for `?index` params when routing to the current location
if ((to == null || to === "" || to === ".") && activeRouteMatch) {
let nakedIndex = hasNakedIndexQuery(path.search);
if (activeRouteMatch.route.index && !nakedIndex) {
// Add one when we're targeting an index route
path.search = path.search
? path.search.replace(/^\?/, "?index&")
: "?index";
} else if (!activeRouteMatch.route.index && nakedIndex) {
// Remove existing ones when we're not
let params = new URLSearchParams(path.search);
let indexValues = params.getAll("index");
params.delete("index");
indexValues.filter((v) => v).forEach((v) => params.append("index", v));
let qs = params.toString();
path.search = qs ? `?${qs}` : "";
Comment on lines +4181 to +4187
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove ?index param when submitting to a non-index parent route

}
}

// If we're operating within a basename, prepend it to the pathname. If
Expand Down