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

Update replace logic for form submissions/redirects #9734

Merged
merged 5 commits into from
Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Update replace logic for form submissions/redirects
  • Loading branch information
brophdawg11 committed Dec 15, 2022
commit 60ea5502aba66f6f95be911388bf6d7489580f9d
96 changes: 95 additions & 1 deletion packages/react-router-dom/__tests__/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1311,7 +1311,7 @@ function testDomRouter(
`);
});

it('defaults useSubmit({ method: "post" }) to be a REPLACE navigation', async () => {
it('defaults useSubmit({ method: "post" }) to a new location to be a PUSH navigation', async () => {
let { container } = render(
<TestDataRouter window={getWindow("/")} hydrationData={{}}>
<Route element={<Layout />}>
Expand Down Expand Up @@ -1387,6 +1387,100 @@ function testDomRouter(
</div>"
`);

fireEvent.click(screen.getByText("Go back"));
await waitFor(() => screen.getByText("Page 1"));
expect(getHtml(container.querySelector(".output")))
.toMatchInlineSnapshot(`
"<div
class=\\"output\\"
>
<h1>
Page 1
</h1>
</div>"
`);
});

it('defaults useSubmit({ method: "post" }) to the same location to be a REPLACE navigation', async () => {
let { container } = render(
<TestDataRouter window={getWindow("/")} hydrationData={{}}>
<Route element={<Layout />}>
<Route index loader={() => "index"} element={<h1>index</h1>} />
<Route
path="1"
action={() => "action"}
loader={() => "1"}
element={<h1>Page 1</h1>}
/>
</Route>
</TestDataRouter>
);

function Layout() {
let navigate = useNavigate();
let submit = useSubmit();
let actionData = useActionData();
let formData = new FormData();
formData.append("test", "value");
return (
<>
<Link to="1">Go to 1</Link>
<button
onClick={() => {
submit(formData, { action: "1", method: "post" });
}}
>
Submit
</button>
<button onClick={() => navigate(-1)}>Go back</button>
<div className="output">
{actionData ? <p>{actionData}</p> : null}
<Outlet />
</div>
</>
);
}

expect(getHtml(container.querySelector(".output")))
.toMatchInlineSnapshot(`
"<div
class=\\"output\\"
>
<h1>
index
</h1>
</div>"
`);

fireEvent.click(screen.getByText("Go to 1"));
await waitFor(() => screen.getByText("Page 1"));
expect(getHtml(container.querySelector(".output")))
.toMatchInlineSnapshot(`
"<div
class=\\"output\\"
>
<h1>
Page 1
</h1>
</div>"
`);

fireEvent.click(screen.getByText("Submit"));
await waitFor(() => screen.getByText("action"));
expect(getHtml(container.querySelector(".output")))
.toMatchInlineSnapshot(`
"<div
class=\\"output\\"
>
<p>
action
</p>
<h1>
Page 1
</h1>
</div>"
`);

fireEvent.click(screen.getByText("Go back"));
await waitFor(() => screen.getByText("index"));
expect(getHtml(container.querySelector(".output")))
Expand Down
144 changes: 135 additions & 9 deletions packages/router/__tests__/router-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2519,6 +2519,35 @@ describe("a router", () => {
expect(t.router.state.location.pathname).toEqual("/foo");
});

it("navigates correctly using POP navigations across actions to new locations", async () => {
let t = initializeTmTest();

// Navigate to /foo
let A = await t.navigate("/foo");
await A.loaders.foo.resolve("FOO");
expect(t.router.state.location.pathname).toEqual("/foo");

// Navigate to /bar
let B = await t.navigate("/bar");
await B.loaders.bar.resolve("BAR");
expect(t.router.state.location.pathname).toEqual("/bar");

// Post to /baz (should not replace)
let C = await t.navigate("/baz", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
await C.actions.baz.resolve("BAZ ACTION");
await C.loaders.root.resolve("ROOT");
await C.loaders.baz.resolve("BAZ");
expect(t.router.state.location.pathname).toEqual("/baz");

// POP to /bar
let D = await t.navigate(-1);
await D.loaders.bar.resolve("BAR");
expect(t.router.state.location.pathname).toEqual("/bar");
});

it("navigates correctly using POP navigations across action errors", async () => {
let t = initializeTmTest();

Expand Down Expand Up @@ -2635,6 +2664,42 @@ describe("a router", () => {
expect(t.router.state.location.key).not.toBe(postBarKey);
});

it("navigates correctly using POP navigations across action redirects to the same location", async () => {
let t = initializeTmTest();

// Navigate to /foo
let A = await t.navigate("/foo");
let fooKey = t.router.state.navigation.location?.key;
await A.loaders.foo.resolve("FOO");
expect(t.router.state.location.pathname).toEqual("/foo");

// Navigate to /bar
let B = await t.navigate("/bar");
await B.loaders.bar.resolve("BAR");
expect(t.router.state.historyAction).toEqual("PUSH");
expect(t.router.state.location.pathname).toEqual("/bar");

// Post to /bar, redirect to /bar
let C = await t.navigate("/bar", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
let postBarKey = t.router.state.navigation.location?.key;
let D = await C.actions.bar.redirect("/bar");
await D.loaders.root.resolve("ROOT");
await D.loaders.bar.resolve("BAR");
expect(t.router.state.historyAction).toEqual("REPLACE");
expect(t.router.state.location.pathname).toEqual("/bar");

// POP to /foo
let E = await t.navigate(-1);
await E.loaders.foo.resolve("FOO");
expect(t.router.state.historyAction).toEqual("POP");
expect(t.router.state.location.pathname).toEqual("/foo");
expect(t.router.state.location.key).toBe(fooKey);
expect(t.router.state.location.key).not.toBe(postBarKey);
});

it("navigates correctly using POP navigations across <Form replace> redirects", async () => {
let t = initializeTmTest();

Expand Down Expand Up @@ -2667,6 +2732,67 @@ describe("a router", () => {
expect(t.router.state.historyAction).toEqual("POP");
expect(t.router.state.location.pathname).toEqual("/foo");
});

it("should respect explicit replace:false on non-redirected actions to new locations", async () => {
// start at / (history stack: [/])
let t = initializeTmTest();

// Link to /foo (history stack: [/, /foo])
let A = await t.navigate("/foo");
await A.loaders.root.resolve("ROOT");
await A.loaders.foo.resolve("FOO");
expect(t.router.state.historyAction).toEqual("PUSH");
expect(t.router.state.location.pathname).toEqual("/foo");

// POST /bar (history stack: [/, /foo, /bar])
let B = await t.navigate("/bar", {
formMethod: "post",
formData: createFormData({ gosh: "dang" }),
replace: false,
});
await B.actions.bar.resolve("BAR");
await B.loaders.root.resolve("ROOT");
await B.loaders.bar.resolve("BAR");
expect(t.router.state.historyAction).toEqual("PUSH");
expect(t.router.state.location.pathname).toEqual("/bar");

// POP /foo (history stack: [GET /, GET /foo])
let C = await t.navigate(-1);
await C.loaders.foo.resolve("FOO");
expect(t.router.state.historyAction).toEqual("POP");
expect(t.router.state.location.pathname).toEqual("/foo");
});

it("should respect explicit replace:false on non-redirected actions to the same location", async () => {
// start at / (history stack: [/])
let t = initializeTmTest();

// Link to /foo (history stack: [/, /foo])
let A = await t.navigate("/foo");
await A.loaders.root.resolve("ROOT");
await A.loaders.foo.resolve("FOO");
expect(t.router.state.historyAction).toEqual("PUSH");
expect(t.router.state.location.pathname).toEqual("/foo");

// POST /foo (history stack: [/, /foo, /foo])
let B = await t.navigate("/foo", {
formMethod: "post",
formData: createFormData({ gosh: "dang" }),
replace: false,
});
await B.actions.foo.resolve("FOO2 ACTION");
await B.loaders.root.resolve("ROOT2");
await B.loaders.foo.resolve("FOO2");
expect(t.router.state.historyAction).toEqual("PUSH");
expect(t.router.state.location.pathname).toEqual("/foo");

// POP /foo (history stack: [/, /foo])
let C = await t.navigate(-1);
await C.loaders.root.resolve("ROOT3");
await C.loaders.foo.resolve("FOO3");
expect(t.router.state.historyAction).toEqual("POP");
expect(t.router.state.location.pathname).toEqual("/foo");
});
});

describe("submission navigations", () => {
Expand Down Expand Up @@ -6384,7 +6510,7 @@ describe("a router", () => {
await N.loaders.root.resolve("ROOT_DATA*");
await N.loaders.tasks.resolve("TASKS_DATA");
expect(t.router.state).toMatchObject({
historyAction: "REPLACE",
historyAction: "PUSH",
location: { pathname: "/tasks" },
navigation: IDLE_NAVIGATION,
revalidation: "idle",
Expand All @@ -6396,7 +6522,7 @@ describe("a router", () => {
tasks: "TASKS_ACTION",
},
});
expect(t.history.replace).toHaveBeenCalledWith(
expect(t.history.push).toHaveBeenCalledWith(
t.router.state.location,
t.router.state.location.state
);
Expand Down Expand Up @@ -6596,7 +6722,7 @@ describe("a router", () => {
await R.loaders.root.resolve("ROOT_DATA*");
await R.loaders.tasks.resolve("TASKS_DATA*");
expect(t.router.state).toMatchObject({
historyAction: "REPLACE",
historyAction: "PUSH",
location: { pathname: "/tasks" },
navigation: IDLE_NAVIGATION,
revalidation: "idle",
Expand All @@ -6605,7 +6731,7 @@ describe("a router", () => {
tasks: "TASKS_DATA*",
},
});
expect(t.history.replace).toHaveBeenCalledWith(
expect(t.history.push).toHaveBeenCalledWith(
t.router.state.location,
t.router.state.location.state
);
Expand Down Expand Up @@ -6683,7 +6809,7 @@ describe("a router", () => {
await R.loaders.root.resolve("ROOT_DATA*");
await R.loaders.tasks.resolve("TASKS_DATA*");
expect(t.router.state).toMatchObject({
historyAction: "REPLACE",
historyAction: "PUSH",
location: { pathname: "/tasks" },
navigation: IDLE_NAVIGATION,
revalidation: "idle",
Expand All @@ -6695,7 +6821,7 @@ describe("a router", () => {
tasks: "TASKS_ACTION",
},
});
expect(t.history.replace).toHaveBeenCalledWith(
expect(t.history.push).toHaveBeenCalledWith(
t.router.state.location,
t.router.state.location.state
);
Expand Down Expand Up @@ -6998,7 +7124,7 @@ describe("a router", () => {

let key = "key";
router.fetch(key, "root", "/");
expect(router.state.fetchers.get(key)).toEqual({
expect(router.state.fetchers.get(key)).toMatchObject({
state: "loading",
formMethod: undefined,
formEncType: undefined,
Expand All @@ -7008,7 +7134,7 @@ describe("a router", () => {
});

await dfd.resolve("DATA");
expect(router.state.fetchers.get(key)).toEqual({
expect(router.state.fetchers.get(key)).toMatchObject({
state: "idle",
formMethod: undefined,
formEncType: undefined,
Expand Down Expand Up @@ -7500,7 +7626,7 @@ describe("a router", () => {
expect(t.router.state.navigation.location?.pathname).toBe("/bar");
await AR.loaders.root.resolve("ROOT*");
await AR.loaders.bar.resolve("stuff");
expect(A.fetcher).toEqual({
expect(A.fetcher).toMatchObject({
data: undefined,
state: "idle",
formMethod: undefined,
Expand Down
42 changes: 32 additions & 10 deletions packages/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,11 +821,27 @@ export function createRouter(init: RouterInit): Router {
...init.history.encodeLocation(location),
};

let historyAction =
(opts && opts.replace) === true ||
(submission != null && isMutationMethod(submission.formMethod))
? HistoryAction.Replace
: HistoryAction.Push;
let userReplace = opts && opts.replace != null ? opts.replace : undefined;

let historyAction = HistoryAction.Push;

if (userReplace === true) {
historyAction = HistoryAction.Replace;
} else if (userReplace === false) {
// no-op
Comment on lines +828 to +831
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Always respect the user value if provided

} else if (submission != null && isMutationMethod(submission.formMethod)) {
// By default on submissions to the current location we REPLACE so that
// users don't have to double-click the back button to get to the prior
// location. If the user redirects from the action/loader this will be
// ignored and the redirect will be a PUSH
if (
submission.formAction ===
state.location.pathname + state.location.search
) {
historyAction = HistoryAction.Replace;
}
}

let preventScrollReset =
opts && "preventScrollReset" in opts
? opts.preventScrollReset === true
Expand Down Expand Up @@ -1054,11 +1070,17 @@ export function createRouter(init: RouterInit): Router {
}

if (isRedirectResult(result)) {
await startRedirectNavigation(
state,
result,
opts && opts.replace === true
);
let replace: boolean;
if (opts && opts.replace != null) {
replace = opts.replace;
} else {
// If the user didn't explicity indicate replace behavior, replace if
// we redirected to the exact same location we're currently at to avoid
// double back-buttons
replace =
result.location === state.location.pathname + state.location.search;
}
await startRedirectNavigation(state, result, replace);
return { shortCircuited: true };
}

Expand Down