Skip to content

Commit 7a057e1

Browse files
authored
fix: do not REPLACE if action redirected (#8979)
* fix: do not REPLACE if action redirected * add changeset * Enhance test to assert correct location key
1 parent a924347 commit 7a057e1

File tree

5 files changed

+250
-78
lines changed

5 files changed

+250
-78
lines changed

.changeset/odd-yaks-kneel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
fix: don't default to a `REPLACE` navigation on form submissions if the action redirected. The redirect takes care of avoiding the back-button-resubmit scenario, so by using a `PUSH` we allow the back button to go back to the pre-submission form page (#8979)

packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx

Lines changed: 96 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -772,14 +772,13 @@ function testDomRouter(
772772
let { container } = render(
773773
<TestDataRouter window={getWindow("/")} hydrationData={{}}>
774774
<Route element={<Layout />}>
775-
<Route index loader={() => "index"} element={<h1>index</h1>} />
776-
<Route path="1" loader={() => "1"} element={<h1>Page 1</h1>} />
775+
<Route index loader={() => "index"} element={<h1>Index Page</h1>} />
777776
<Route
778-
path="2"
779-
action={() => "action"}
780-
loader={() => "2"}
781-
element={<h1>Page 2</h1>}
777+
path="form"
778+
action={() => "action data"}
779+
element={<FormPage />}
782780
/>
781+
<Route path="result" element={<h1>Result Page</h1>} />
783782
</Route>
784783
</TestDataRouter>
785784
);
@@ -788,11 +787,7 @@ function testDomRouter(
788787
let navigate = useNavigate();
789788
return (
790789
<>
791-
<Link to="1">Go to 1</Link>
792-
<Form action="2" method="post">
793-
<input name="test" defaultValue="value" />
794-
<button type="submit">Submit Form</button>
795-
</Form>
790+
<Link to="form">Go to Form</Link>
796791
<button onClick={() => navigate(-1)}>Go back</button>
797792
<div className="output">
798793
<Outlet />
@@ -801,55 +796,100 @@ function testDomRouter(
801796
);
802797
}
803798

804-
expect(getHtml(container.querySelector(".output")))
805-
.toMatchInlineSnapshot(`
806-
"<div
807-
class=\\"output\\"
808-
>
809-
<h1>
810-
index
811-
</h1>
812-
</div>"
813-
`);
799+
function FormPage() {
800+
let data = useActionData();
801+
return (
802+
<Form method="post">
803+
<p>Form Page</p>
804+
<p>{data}</p>
805+
<input name="test" defaultValue="value" />
806+
<button type="submit">Submit</button>
807+
</Form>
808+
);
809+
}
814810

815-
fireEvent.click(screen.getByText("Go to 1"));
816-
await waitFor(() => screen.getByText("Page 1"));
817-
expect(getHtml(container.querySelector(".output")))
818-
.toMatchInlineSnapshot(`
819-
"<div
820-
class=\\"output\\"
821-
>
822-
<h1>
823-
Page 1
824-
</h1>
825-
</div>"
826-
`);
811+
let html = () => getHtml(container.querySelector(".output"));
827812

828-
fireEvent.click(screen.getByText("Submit Form"));
829-
await waitFor(() => screen.getByText("Page 2"));
830-
expect(getHtml(container.querySelector(".output")))
831-
.toMatchInlineSnapshot(`
832-
"<div
833-
class=\\"output\\"
834-
>
835-
<h1>
836-
Page 2
837-
</h1>
838-
</div>"
839-
`);
813+
// Start on index page
814+
expect(html()).toMatch("Index Page");
815+
816+
// Navigate to form page
817+
fireEvent.click(screen.getByText("Go to Form"));
818+
await waitFor(() => screen.getByText("Form Page"));
819+
expect(html()).not.toMatch("action result");
840820

821+
// Submit without redirect does a replace
822+
fireEvent.click(screen.getByText("Submit"));
823+
await waitFor(() => screen.getByText("action data"));
824+
expect(html()).toMatch("Form Page");
825+
expect(html()).toMatch("action data");
826+
827+
// Back navigate to index page
841828
fireEvent.click(screen.getByText("Go back"));
842-
await waitFor(() => screen.getByText("index"));
843-
expect(getHtml(container.querySelector(".output")))
844-
.toMatchInlineSnapshot(`
845-
"<div
846-
class=\\"output\\"
847-
>
848-
<h1>
849-
index
850-
</h1>
851-
</div>"
852-
`);
829+
await waitFor(() => screen.getByText("Index Page"));
830+
});
831+
832+
it('Uses a PUSH navigation on <Form method="post"> if it redirects', async () => {
833+
let { container } = render(
834+
<TestDataRouter window={getWindow("/")} hydrationData={{}}>
835+
<Route element={<Layout />}>
836+
<Route index loader={() => "index"} element={<h1>Index Page</h1>} />
837+
<Route
838+
path="form"
839+
action={() =>
840+
new Response(null, {
841+
status: 302,
842+
headers: { Location: "/result" },
843+
})
844+
}
845+
element={<FormPage />}
846+
/>
847+
<Route path="result" element={<h1>Result Page</h1>} />
848+
</Route>
849+
</TestDataRouter>
850+
);
851+
852+
function Layout() {
853+
let navigate = useNavigate();
854+
return (
855+
<>
856+
<Link to="form">Go to Form</Link>
857+
<button onClick={() => navigate(-1)}>Go back</button>
858+
<div className="output">
859+
<Outlet />
860+
</div>
861+
</>
862+
);
863+
}
864+
865+
function FormPage() {
866+
let data = useActionData();
867+
return (
868+
<Form method="post">
869+
<p>Form Page</p>
870+
<p>{data}</p>
871+
<input name="test" defaultValue="value" />
872+
<button type="submit">Submit</button>
873+
</Form>
874+
);
875+
}
876+
877+
let html = () => getHtml(container.querySelector(".output"));
878+
879+
// Start on index page
880+
expect(html()).toMatch("Index Page");
881+
882+
// Navigate to form page
883+
fireEvent.click(screen.getByText("Go to Form"));
884+
await waitFor(() => screen.getByText("Form Page"));
885+
886+
// Submit with redirect
887+
fireEvent.click(screen.getByText("Submit"));
888+
await waitFor(() => screen.getByText("Result Page"));
889+
890+
// Back navigate to form page
891+
fireEvent.click(screen.getByText("Go back"));
892+
await waitFor(() => screen.getByText("Form Page"));
853893
});
854894

855895
it('defaults useSubmit({ method: "get" }) to be a PUSH navigation', async () => {

packages/react-router-dom/index.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -775,10 +775,7 @@ function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction {
775775

776776
let href = url.pathname + url.search;
777777
let opts = {
778-
// If replace is not specified, we'll default to false for GET and
779-
// true otherwise
780-
replace:
781-
options.replace != null ? options.replace === true : method !== "get",
778+
replace: options.replace,
782779
formData,
783780
formMethod: method as FormMethod,
784781
formEncType: encType as FormEncType,

packages/router/__tests__/router-test.ts

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,7 +1955,7 @@ describe("a router", () => {
19551955
});
19561956
});
19571957

1958-
describe("POP navigations after action redirect", () => {
1958+
describe("POP navigations", () => {
19591959
it("does a normal load when backing into an action redirect", async () => {
19601960
let t = initializeTmTest();
19611961
let A = await t.navigate("/foo", {
@@ -1979,14 +1979,131 @@ describe("a router", () => {
19791979
baz: "C LOADER",
19801980
});
19811981

1982-
let D = await t.navigate(-2);
1982+
let D = await t.navigate(-1);
19831983
await D.loaders.bar.resolve("D LOADER");
19841984
expect(D.loaders.root.stub.mock.calls.length).toBe(0);
19851985
expect(t.router.state.loaderData).toMatchObject({
19861986
root: "ROOT DATA",
19871987
bar: "D LOADER",
19881988
});
19891989
});
1990+
1991+
it("navigates correctly using POP navigations", async () => {
1992+
let t = initializeTmTest();
1993+
1994+
let A = await t.navigate("/foo");
1995+
await A.loaders.foo.resolve("FOO");
1996+
expect(t.router.state.location.pathname).toEqual("/foo");
1997+
1998+
let B = await t.navigate("/bar");
1999+
await B.loaders.bar.resolve("BAR");
2000+
expect(t.router.state.location.pathname).toEqual("/bar");
2001+
2002+
let C = await t.navigate(-1);
2003+
await C.loaders.foo.resolve("FOO*");
2004+
expect(t.router.state.location.pathname).toEqual("/foo");
2005+
2006+
let D = await t.navigate("/baz", { replace: true });
2007+
await D.loaders.baz.resolve("BAZ");
2008+
expect(t.router.state.location.pathname).toEqual("/baz");
2009+
2010+
// POP to /
2011+
let E = await t.navigate(-1);
2012+
await E.loaders.index.resolve("INDEX*");
2013+
expect(t.router.state.location.pathname).toEqual("/");
2014+
});
2015+
2016+
it("navigates correctly using POP navigations across actions", async () => {
2017+
let t = initializeTmTest();
2018+
2019+
// Navigate to /foo
2020+
let A = await t.navigate("/foo");
2021+
await A.loaders.foo.resolve("FOO");
2022+
expect(t.router.state.location.pathname).toEqual("/foo");
2023+
2024+
// Navigate to /bar
2025+
let B = await t.navigate("/bar");
2026+
await B.loaders.bar.resolve("BAR");
2027+
expect(t.router.state.location.pathname).toEqual("/bar");
2028+
2029+
// Post to /bar (should replace)
2030+
let C = await t.navigate("/bar", {
2031+
formMethod: "post",
2032+
formData: createFormData({ key: "value" }),
2033+
});
2034+
await C.actions.bar.resolve("BAR ACTION");
2035+
await C.loaders.root.resolve("ROOT");
2036+
await C.loaders.bar.resolve("BAR");
2037+
expect(t.router.state.location.pathname).toEqual("/bar");
2038+
2039+
// POP to /foo
2040+
let D = await t.navigate(-1);
2041+
await D.loaders.foo.resolve("FOO");
2042+
expect(t.router.state.location.pathname).toEqual("/foo");
2043+
});
2044+
2045+
it("navigates correctly using POP navigations across action redirects", async () => {
2046+
let t = initializeTmTest();
2047+
2048+
// Navigate to /foo
2049+
let A = await t.navigate("/foo");
2050+
await A.loaders.foo.resolve("FOO");
2051+
expect(t.router.state.location.pathname).toEqual("/foo");
2052+
2053+
// Navigate to /bar
2054+
let B = await t.navigate("/bar");
2055+
let getBarKey = t.router.state.navigation.location?.key;
2056+
await B.loaders.bar.resolve("BAR");
2057+
expect(t.router.state.location.pathname).toEqual("/bar");
2058+
2059+
// Post to /bar, redirect to /baz
2060+
let C = await t.navigate("/bar", {
2061+
formMethod: "post",
2062+
formData: createFormData({ key: "value" }),
2063+
});
2064+
let postBarKey = t.router.state.navigation.location?.key;
2065+
let D = await C.actions.bar.redirect("/baz");
2066+
await D.loaders.root.resolve("ROOT");
2067+
await D.loaders.baz.resolve("BAZ");
2068+
expect(t.router.state.location.pathname).toEqual("/baz");
2069+
2070+
// POP to /bar
2071+
let E = await t.navigate(-1);
2072+
await E.loaders.bar.resolve("BAR");
2073+
expect(t.router.state.location.pathname).toEqual("/bar");
2074+
expect(t.router.state.location.key).toBe(getBarKey);
2075+
expect(t.router.state.location.key).not.toBe(postBarKey);
2076+
});
2077+
2078+
it("navigates correctly using POP navigations across <Form replace> redirects", async () => {
2079+
let t = initializeTmTest();
2080+
2081+
// Navigate to /foo
2082+
let A = await t.navigate("/foo");
2083+
await A.loaders.foo.resolve("FOO");
2084+
expect(t.router.state.location.pathname).toEqual("/foo");
2085+
2086+
// Navigate to /bar
2087+
let B = await t.navigate("/bar");
2088+
await B.loaders.bar.resolve("BAR");
2089+
expect(t.router.state.location.pathname).toEqual("/bar");
2090+
2091+
// Post to /bar, redirect to /baz
2092+
let C = await t.navigate("/bar", {
2093+
formMethod: "post",
2094+
formData: createFormData({ key: "value" }),
2095+
replace: true,
2096+
});
2097+
let D = await C.actions.bar.redirect("/baz");
2098+
await D.loaders.root.resolve("ROOT");
2099+
await D.loaders.baz.resolve("BAZ");
2100+
expect(t.router.state.location.pathname).toEqual("/baz");
2101+
2102+
// POP to /foo
2103+
let E = await t.navigate(-1);
2104+
await E.loaders.foo.resolve("FOO");
2105+
expect(t.router.state.location.pathname).toEqual("/foo");
2106+
});
19902107
});
19912108

19922109
describe("submission navigations", () => {
@@ -4602,7 +4719,7 @@ describe("a router", () => {
46024719
await N.loaders.root.resolve("ROOT_DATA*");
46034720
await N.loaders.tasks.resolve("TASKS_DATA");
46044721
expect(t.router.state).toMatchObject({
4605-
historyAction: "PUSH",
4722+
historyAction: "REPLACE",
46064723
location: { pathname: "/tasks" },
46074724
navigation: IDLE_NAVIGATION,
46084725
revalidation: "idle",
@@ -4614,7 +4731,7 @@ describe("a router", () => {
46144731
tasks: "TASKS_ACTION",
46154732
},
46164733
});
4617-
expect(t.history.push).toHaveBeenCalledWith(
4734+
expect(t.history.replace).toHaveBeenCalledWith(
46184735
t.router.state.location,
46194736
t.router.state.location.state
46204737
);
@@ -4814,7 +4931,7 @@ describe("a router", () => {
48144931
await R.loaders.root.resolve("ROOT_DATA*");
48154932
await R.loaders.tasks.resolve("TASKS_DATA*");
48164933
expect(t.router.state).toMatchObject({
4817-
historyAction: "PUSH",
4934+
historyAction: "REPLACE",
48184935
location: { pathname: "/tasks" },
48194936
navigation: IDLE_NAVIGATION,
48204937
revalidation: "idle",
@@ -4823,7 +4940,7 @@ describe("a router", () => {
48234940
tasks: "TASKS_DATA*",
48244941
},
48254942
});
4826-
expect(t.history.push).toHaveBeenCalledWith(
4943+
expect(t.history.replace).toHaveBeenCalledWith(
48274944
t.router.state.location,
48284945
t.router.state.location.state
48294946
);
@@ -4901,7 +5018,7 @@ describe("a router", () => {
49015018
await R.loaders.root.resolve("ROOT_DATA*");
49025019
await R.loaders.tasks.resolve("TASKS_DATA*");
49035020
expect(t.router.state).toMatchObject({
4904-
historyAction: "PUSH",
5021+
historyAction: "REPLACE",
49055022
location: { pathname: "/tasks" },
49065023
navigation: IDLE_NAVIGATION,
49075024
revalidation: "idle",
@@ -4913,7 +5030,7 @@ describe("a router", () => {
49135030
tasks: "TASKS_ACTION",
49145031
},
49155032
});
4916-
expect(t.history.push).toHaveBeenCalledWith(
5033+
expect(t.history.replace).toHaveBeenCalledWith(
49175034
t.router.state.location,
49185035
t.router.state.location.state
49195036
);

0 commit comments

Comments
 (0)