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: preserve method on 307/308 redirects #9597

Merged
merged 8 commits into from Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/stale-coats-smoke.md
@@ -0,0 +1,5 @@
---
"@remix-run/router": patch
---

Preserve the HTTP method on 307/208 redirects
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
172 changes: 172 additions & 0 deletions packages/router/__tests__/router-test.ts
Expand Up @@ -365,6 +365,11 @@ function setup({
let redirectNavigationId = ++guid;
activeLoaderType = "navigation";
activeLoaderNavigationId = redirectNavigationId;
if (status === 307 || status === 308) {
activeActionType = "navigation";
activeActionNavigationId = redirectNavigationId;
}

let helpers = getNavigationHelpers(href, redirectNavigationId);

// Since a redirect kicks off and awaits a new navigation we can't shim
Expand Down Expand Up @@ -5484,6 +5489,173 @@ describe("a router", () => {
errors: null,
});
});

describe("redirect status code handling", () => {
it("should not treat 300 as a redirect", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent");
await A.loaders.parent.redirectReturn("/idk", 300);
expect(t.router.state).toMatchObject({
loaderData: {},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});
});

it("should not preserve the method on 301 redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
// Triggers a GET redirect
let B = await A.actions.child.redirectReturn("/parent", 301);
await B.loaders.parent.resolve("PARENT");
expect(t.router.state).toMatchObject({
loaderData: {
parent: "PARENT",
},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});
});

it("should not preserve the method on 302 redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
// Triggers a GET redirect
let B = await A.actions.child.redirectReturn("/parent", 302);
await B.loaders.parent.resolve("PARENT");
expect(t.router.state).toMatchObject({
loaderData: {
parent: "PARENT",
},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});
});

it("should not preserve the method on 303 redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
// Triggers a GET redirect
let B = await A.actions.child.redirectReturn("/parent", 303);
await B.loaders.parent.resolve("PARENT");
expect(t.router.state).toMatchObject({
loaderData: {
parent: "PARENT",
},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});
});

it("should not treat 304 as a redirect", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent");
await A.loaders.parent.resolve(new Response(null, { status: 304 }));
expect(t.router.state).toMatchObject({
loaderData: {},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});
});

it("should preserve the method on 307 redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
// Triggers a POST redirect
let B = await A.actions.child.redirectReturn("/parent", 307);
await B.actions.parent.resolve("PARENT ACTION");
await B.loaders.parent.resolve("PARENT");
expect(t.router.state).toMatchObject({
actionData: {
parent: "PARENT ACTION",
},
loaderData: {
parent: "PARENT",
},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});

let request = B.actions.parent.stub.mock.calls[0][0].request;
expect(request.method).toBe("POST");
let fd = await request.formData();
expect(Array.from(fd.entries())).toEqual([["key", "value"]]);
});

it("should preserve the method on 308 redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
// Triggers a POST redirect
let B = await A.actions.child.redirectReturn("/parent", 308);
await B.actions.parent.resolve("PARENT ACTION");
await B.loaders.parent.resolve("PARENT");
expect(t.router.state).toMatchObject({
actionData: {
parent: "PARENT ACTION",
},
loaderData: {
parent: "PARENT",
},
location: {
pathname: "/parent",
},
navigation: {
state: "idle",
},
});

let request = B.actions.parent.stub.mock.calls[0][0].request;
expect(request.method).toBe("POST");
let fd = await request.formData();
expect(Array.from(fd.entries())).toEqual([["key", "value"]]);
});
});
});

describe("scroll restoration", () => {
Expand Down
93 changes: 51 additions & 42 deletions packages/router/router.ts
Expand Up @@ -1000,15 +1000,10 @@ export function createRouter(init: RouterInit): Router {
}

if (isRedirectResult(result)) {
let redirectNavigation: NavigationStates["Loading"] = {
state: "loading",
location: createLocation(state.location, result.location),
...submission,
};
MichaelDeBoey marked this conversation as resolved.
Show resolved Hide resolved
await startRedirectNavigation(
state,
result,
redirectNavigation,
opts && opts.replace
opts && opts.replace === true
);
return { shortCircuited: true };
}
Expand Down Expand Up @@ -1152,8 +1147,7 @@ export function createRouter(init: RouterInit): Router {
// If any loaders returned a redirect Response, start a new REPLACE navigation
let redirect = findRedirect(results);
if (redirect) {
let redirectNavigation = getLoaderRedirect(state, redirect);
await startRedirectNavigation(redirect, redirectNavigation, replace);
await startRedirectNavigation(state, redirect, replace);
return { shortCircuited: true };
}

Expand Down Expand Up @@ -1304,12 +1298,7 @@ export function createRouter(init: RouterInit): Router {
state.fetchers.set(key, loadingFetcher);
updateState({ fetchers: new Map(state.fetchers) });

let redirectNavigation: NavigationStates["Loading"] = {
state: "loading",
location: createLocation(state.location, actionResult.location),
...submission,
};
await startRedirectNavigation(actionResult, redirectNavigation);
await startRedirectNavigation(state, actionResult);
return;
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
}

Expand Down Expand Up @@ -1402,8 +1391,7 @@ export function createRouter(init: RouterInit): Router {

let redirect = findRedirect(results);
if (redirect) {
let redirectNavigation = getLoaderRedirect(state, redirect);
await startRedirectNavigation(redirect, redirectNavigation);
await startRedirectNavigation(state, redirect);
return;
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
}

Expand Down Expand Up @@ -1515,8 +1503,7 @@ export function createRouter(init: RouterInit): Router {

// If the loader threw a redirect Response, start a new REPLACE navigation
if (isRedirectResult(result)) {
let redirectNavigation = getLoaderRedirect(state, result);
await startRedirectNavigation(result, redirectNavigation);
await startRedirectNavigation(state, result);
return;
}

Expand Down Expand Up @@ -1571,15 +1558,17 @@ export function createRouter(init: RouterInit): Router {
* the history action from the original navigation (PUSH or REPLACE).
*/
async function startRedirectNavigation(
state: RouterState,
redirect: RedirectResult,
navigation: Navigation,
replace?: boolean
) {
if (redirect.revalidate) {
isRevalidationRequired = true;
}

let redirectLocation = createLocation(state.location, redirect.location);
invariant(
navigation.location,
redirectLocation,
"Expected a location on the redirect navigation"
);
// There's no need to abort on redirects, since we don't detect the
Expand All @@ -1589,9 +1578,41 @@ export function createRouter(init: RouterInit): Router {
let redirectHistoryAction =
replace === true ? HistoryAction.Replace : HistoryAction.Push;

await startNavigation(redirectHistoryAction, navigation.location, {
overrideNavigation: navigation,
});
let { formMethod, formAction, formEncType, formData } = state.navigation;

// If this was a 307/308 submission we want to preserve the HTTP method and
// re-submit the POST/PUT/PATCH/DELETE as a submission navigation to the
// redirected location
if (
[307, 308].includes(redirect.status) &&
formMethod &&
formMethod !== "get" &&
formMethod !== "head" &&
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
formEncType &&
formData
) {
await startNavigation(redirectHistoryAction, redirectLocation, {
submission: {
formMethod,
formAction: redirect.location,
formEncType,
formData,
},
});
MichaelDeBoey marked this conversation as resolved.
Show resolved Hide resolved
} else {
// Otherwise, we kick off a new loading navigation, preserving the
// submission info for the duration of this navigation
await startNavigation(redirectHistoryAction, redirectLocation, {
overrideNavigation: {
state: "loading",
location: redirectLocation,
formMethod: formMethod || undefined,
formAction: formAction || undefined,
formEncType: formEncType || undefined,
formData: formData || undefined,
},
});
MichaelDeBoey marked this conversation as resolved.
Show resolved Hide resolved
}
}

async function callLoadersAndMaybeResolveData(
Expand Down Expand Up @@ -2279,7 +2300,11 @@ function normalizeNavigateOptions(
}

// Create a Submission on non-GET navigations
if (opts.formMethod != null && opts.formMethod !== "get") {
if (
opts.formMethod != null &&
opts.formMethod !== "get" &&
opts.formMethod !== "head"
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
) {
return {
path,
submission: {
Expand Down Expand Up @@ -2322,22 +2347,6 @@ function normalizeNavigateOptions(
return { path: createPath(parsedPath) };
}

function getLoaderRedirect(
state: RouterState,
redirect: RedirectResult
): Navigation {
let { formMethod, formAction, formEncType, formData } = state.navigation;
let navigation: NavigationStates["Loading"] = {
state: "loading",
location: createLocation(state.location, redirect.location),
formMethod: formMethod || undefined,
formAction: formAction || undefined,
formEncType: formEncType || undefined,
formData: formData || undefined,
};
return navigation;
}

// Filter out all routes below any caught error as they aren't going to
// render so we don't need to load them
function getLoaderMatchesUntilBoundary(
Expand Down Expand Up @@ -2547,7 +2556,7 @@ async function callLoaderOrAction(
let status = result.status;

// Process redirects
if (status >= 300 && status <= 399) {
if ([301, 302, 303, 307, 308].includes(status)) {
let location = result.headers.get("Location");
invariant(
location,
Expand Down
4 changes: 2 additions & 2 deletions packages/router/utils.ts
Expand Up @@ -61,7 +61,7 @@ export type DataResult =
| RedirectResult
| ErrorResult;

export type FormMethod = "get" | "post" | "put" | "patch" | "delete";
export type FormMethod = "get" | "head" | "post" | "put" | "patch" | "delete";
export type FormEncType =
| "application/x-www-form-urlencoded"
| "multipart/form-data";
Expand All @@ -72,7 +72,7 @@ export type FormEncType =
* external consumption
*/
export interface Submission {
formMethod: Exclude<FormMethod, "get">;
formMethod: Exclude<FormMethod, "get" | "head">;
formAction: string;
formEncType: FormEncType;
formData: FormData;
Expand Down