Skip to content

Commit

Permalink
feat(remix-react): emulate put/patch/delete when JS is unavailable
Browse files Browse the repository at this point in the history
References #4420

Ensure `<Form method=...>` behaves the same with and without JavaScript.
Although native forms don't support PUT/PATCH/DELETE, we can emulate them by
doing a POST and injecting a `_method` parameter into the action URL. The
Remix server runtime then provides action handlers a `Request` with the
overridden `method` so they are none the wiser.

This provides a more reliable way to use these methods, which can be more
ergonomic to write and handle than hidden inputs or buttons with values.

Note:
The emulation only works for action requests handled by Remix; if the form is
submitted to another endpoint, it would need to handle the `_method` URL
parameter accordingly.
  • Loading branch information
jenseng committed Nov 3, 2022
1 parent b380d5e commit 23d258a
Show file tree
Hide file tree
Showing 5 changed files with 50 additions and 24 deletions.
6 changes: 6 additions & 0 deletions .changeset/cuddly-bottles-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@remix-run/react": minor
"@remix-run/server-runtime": minor
---

emulate put/patch/delete when JS is unavailable
6 changes: 2 additions & 4 deletions docs/api/remix.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,9 @@ This determines the [HTTP verb][http-verb] to be used: get, post, put, patch, de
<Form method="post" />
```

Native `<form>` only supports get and post, so if you want your form to work with JavaScript on or off the page you'll need to stick with those two.
Although a native `<form>` only supports get and post, Remix can emulate put/patch/delete even when client-side JavaScript is unavailable. It does this by converting forms to "post" and including a `_method` parameter in the form action URL. On the server-side, your actions will see the appropriate method on the request.

Without JavaScript, Remix will turn non-get requests into "post", but you'll still need to instruct your server with a hidden input like `<input type="hidden" name="_method" value="delete" />`. If you always include JavaScript, you don't need to worry about this.

<docs-info>We generally recommend sticking with "get" and "post" because the other verbs are not supported by HTML</docs-info>
<docs-info>When overriding the form method via `<button formMethod=...>`, only "get" and "post" may be used because the other verbs are not supported by HTML.</docs-info>

#### `<Form encType>`

Expand Down
24 changes: 12 additions & 12 deletions integration/form-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,14 @@ test.describe("Forms", () => {
import { Form, useActionData, useLoaderData, useSearchParams } from "@remix-run/react";
import { json } from "@remix-run/node";
export function action({ request }) {
return json(request.method)
export async function action({ request }) {
let payload = (await request.formData()).get("payload");
return json(request.method + ": " + payload);
}
export function loader({ request }) {
return json(request.method)
export async function loader({ request }) {
let payload = new URL(request.url).searchParams.get("payload");
return json(request.method + ": " + payload);
}
export default function() {
Expand All @@ -329,6 +331,7 @@ test.describe("Forms", () => {
return (
<>
<Form method={formMethod}>
<input type="hidden" name="payload" value="data" />
<button>Submit</button>
<button formMethod={submitterFormMethod}>Submit with {submitterFormMethod}</button>
</Form>
Expand Down Expand Up @@ -939,16 +942,11 @@ test.describe("Forms", () => {

test.describe("uses the Form `method` attribute", () => {
FORM_METHODS.forEach((method) => {
test(`submits with ${method}`, async ({ page, javaScriptEnabled }) => {
test.fail(
!javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method),
`Native <form> doesn't support method ${method} #4420`
);

test(`submits with ${method}`, async ({ page }) => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto(`/form-method?method=${method}`);
await app.clickElement(`text=Submit`);
expect(await app.getHtml("pre")).toBe(`<pre>${method}</pre>`);
expect(await app.getHtml("pre")).toBe(`<pre>${method}: data</pre>`);
});
});
});
Expand All @@ -966,7 +964,9 @@ test.describe("Forms", () => {
`/form-method?method=${method}&submitterFormMethod=${overrideMethod}`
);
await app.clickElement(`text=Submit with ${overrideMethod}`);
expect(await app.getHtml("pre")).toBe(`<pre>${overrideMethod}</pre>`);
expect(await app.getHtml("pre")).toBe(
`<pre>${overrideMethod}: data</pre>`
);
});
});
});
Expand Down
25 changes: 18 additions & 7 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -909,11 +909,13 @@ function dedupe(array: any[]) {

export interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
/**
* The HTTP verb to use when the form is submit. Supports "get", "post",
* The HTTP verb to use when the form is submitted. Supports "get", "post",
* "put", "delete", "patch".
*
* Note: If JavaScript is disabled, you'll need to implement your own "method
* override" to support more than just GET and POST.
* Note: When JavaScript is unavailable, Remix emulates put/patch/delete by
* injecting a `_method` parameter into the form action URL and doing a POST.
* The server-side handles this transparently, so your actions can always
* count on `request.method` matching `<Form method=...>`.
*/
method?: FormMethod;

Expand Down Expand Up @@ -984,7 +986,7 @@ let FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
let submit = useSubmitImpl(fetchKey);
let formMethod: FormMethod =
method.toLowerCase() === "get" ? "get" : "post";
let formAction = useFormAction(action);
let formAction = useFormAction(action, method);

return (
<form
Expand Down Expand Up @@ -1035,7 +1037,6 @@ type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement;
*/
export function useFormAction(
action?: string,
// TODO: Remove method param in v2 as it's no longer needed and is a breaking change
method: FormMethod = "get"
): string {
let { id } = useRemixRouteContext();
Expand All @@ -1049,21 +1050,28 @@ export function useFormAction(
let location = useLocation();
let { search, hash } = resolvedPath;
let isIndexRoute = id.endsWith("/index");
let params = new URLSearchParams(search);

if (action == null) {
search = location.search;
hash = location.hash;
params = new URLSearchParams(search);

// When grabbing search params from the URL, remove the automatically
// inserted ?index param so we match the useResolvedPath search behavior
// which would not include ?index
if (isIndexRoute) {
let params = new URLSearchParams(search);
params.delete("index");
search = params.toString() ? `?${params.toString()}` : "";
}
}

// Emulate additional methods when JavaScript is missing/disabled/pending
method = method.toLowerCase() as FormMethod;
if (["put", "patch", "delete"].includes(method)) {
params.set("_method", method);
}

search = params.toString() ? `?${params.toString()}` : "";
if ((action == null || action === ".") && isIndexRoute) {
search = search ? search.replace(/^\?/, "?index&") : "?index";
}
Expand Down Expand Up @@ -1270,6 +1278,9 @@ export function useSubmitImpl(key?: string): SubmitFunction {
}

url.search = hasParams ? `?${params.toString()}` : "";
} else if (["put", "patch", "delete"].includes(method.toLowerCase())) {
// remove the method emulation param since it's only needed for non-JS form submissions
url.searchParams.delete("_method");
}

let submission: Submission = {
Expand Down
13 changes: 12 additions & 1 deletion packages/remix-server-runtime/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function callRouteAction({
let result;
try {
result = await action({
request: stripDataParam(stripIndexParam(request)),
request: applyMethodOverride(stripDataParam(stripIndexParam(request))),
context: loadContext,
params,
});
Expand Down Expand Up @@ -137,6 +137,17 @@ function stripDataParam(request: Request) {
return new Request(url.href, request);
}

function applyMethodOverride(request: Request) {
let url = new URL(request.url);
let overrideMethod = url.searchParams.get("_method") ?? "";
if (!["put", "patch", "delete"].includes(overrideMethod)) return request;

url.searchParams.delete("_method");
return new Request(new Request(url.href, request), {
method: overrideMethod,
});
}

export function extractData(response: Response): Promise<unknown> {
let contentType = response.headers.get("Content-Type");

Expand Down

0 comments on commit 23d258a

Please sign in to comment.