Repository: sveltejs/kit (js)

Issue:
`use:enhance` does not properly handle `<button formaction="...">`
### Describe the bug

Adding `use:enhance` to a form like:

```html
<script lang="ts">
	import { enhance } from '$app/forms';
</script>


<form method="POST" action="?/a" use:enhance>
	<button>Submit A</button>
	<button formaction="?/b">Submit B</button>
</form>
```

Always calls `?/a` action. Removing it calls `?/b` when clicking "Submit B".

### Reproduction

https://github.com/danawoodman/sveltekit-formaction-use-enhance-bug

### Logs

_No response_

### System Info

```shell
System:
    OS: macOS 12.5.1
    CPU: (8) x64 Apple M2
    Memory: 23.95 MB / 24.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 18.4.0 - ~/.asdf/installs/nodejs/18.4.0/bin/node
    npm: 8.12.1 - ~/.asdf/plugins/nodejs/shims/npm
  Browsers:
    Brave Browser: 105.1.43.89
    Chrome: 105.0.5195.102
    Firefox: 103.0
    Safari: 15.6.1
  npmPackages:
    @sveltejs/adapter-auto: next => 1.0.0-next.71 
    @sveltejs/kit: next => 1.0.0-next.476 
    svelte: ^3.44.0 => 3.50.0 
    vite: ^3.1.0 => 3.1.0
```


### Severity

blocking an upgrade

### Additional Information



Pull request:
[breaking] only allow enhance on form; pass abort controller
This is a breaking change because the `element` property was removed from `enhance` and the action only is possible to apply to forms again; also the interface generic has been removed accordingly.

Fixes #6660
Superseedes/closes #6658 
Reverts #6633, because we can achieve the same with `event.submitter`

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/violet-wolves-reflect.md b/.changeset/violet-wolves-reflect.md
new file mode 100644
index 000000000..d12f71b28
--- /dev/null
+++ b/.changeset/violet-wolves-reflect.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] remove element property; enhance can only be used on form elements
diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js
index c137a08d7..d4822f24e 100644
--- a/packages/kit/src/runtime/app/forms.js
+++ b/packages/kit/src/runtime/app/forms.js
@@ -16,46 +16,43 @@ const ssr = import.meta.env.SSR;
 export const applyAction = ssr ? guard('applyAction') : client.apply_action;
 
 /** @type {import('$app/forms').enhance} */
-export function enhance(element, submit = () => {}) {
+export function enhance(form, submit = () => {}) {
 	/**
 	 * @param {{
-	 *   element: HTMLFormElement | HTMLButtonElement | HTMLInputElement;
-	 *   form: HTMLFormElement;
+	 *   action: string;
 	 *   result: import('types').ActionResult;
 	 * }} opts
 	 */
-	const fallback_callback = async ({ element, form, result }) => {
+	const fallback_callback = async ({ action, result }) => {
 		if (result.type === 'success') {
 			await invalidateAll();
 		}
 
-		const action = element.formAction ?? form.action;
-
 		if (location.origin + location.pathname === action.split('?')[0]) {
 			applyAction(result);
 		}
 	};
 
-	const form =
-		element instanceof HTMLFormElement ? element : /** @type {HTMLFormElement} */ (element.form);
-	if (!form) throw new Error('Element is not associated with a form');
-
 	/** @param {SubmitEvent} event */
 	async function handle_submit(event) {
 		event.preventDefault();
 
-		const action = element.formAction ?? form.action;
-
+		// We can't do submitter.formAction directly because that property is always set
+		const action = event.submitter?.hasAttribute('formaction')
+			? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formAction
+			: form.action;
 		const data = new FormData(form);
+		const controller = new AbortController();
 
 		let cancelled = false;
 		const cancel = () => (cancelled = true);
 
 		const callback =
 			submit({
-				element,
-				data,
+				action,
 				cancel,
+				controller,
+				data,
 				form
 			}) ?? fallback_callback;
 		if (cancelled) return;
@@ -69,16 +66,18 @@ export function enhance(element, submit = () => {}) {
 				headers: {
 					accept: 'application/json'
 				},
-				body: data
+				body: data,
+				signal: controller.signal
 			});
 
 			result = await response.json();
 		} catch (error) {
+			if (/** @type {any} */ (error)?.name === 'AbortError') return;
 			result = { type: 'error', error };
 		}
 
 		callback({
-			element,
+			action,
 			data,
 			form,
 			// @ts-expect-error generic constraints stuff we don't care about
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index e3ce20d11..dd1c45222 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -96,20 +96,19 @@ declare module '$app/forms' {
 	import type { ActionResult } from '@sveltejs/kit';
 
 	export type SubmitFunction<
-		Element extends HTMLFormElement | HTMLInputElement | HTMLButtonElement = HTMLFormElement,
 		Success extends Record<string, unknown> | undefined = Record<string, any>,
 		Invalid extends Record<string, unknown> | undefined = Record<string, any>
 	> = (input: {
+		action: string;
 		data: FormData;
 		form: HTMLFormElement;
-		element: Element;
+		controller: AbortController;
 		cancel: () => void;
 	}) =>
 		| void
 		| ((opts: {
-				data: FormData;
 				form: HTMLFormElement;
-				element: Element;
+				action: string;
 				result: ActionResult<Success, Invalid>;
 		  }) => void);
 
@@ -119,14 +118,14 @@ declare module '$app/forms' {
 	 * @param options Callbacks for different states of the form lifecycle
 	 */
 	export function enhance<
-		Element extends HTMLFormElement | HTMLInputElement | HTMLButtonElement = HTMLFormElement,
 		Success extends Record<string, unknown> | undefined = Record<string, any>,
 		Invalid extends Record<string, unknown> | undefined = Record<string, any>
 	>(
-		element: Element,
+		form: HTMLFormElement,
 		/**
-		 * Called upon submission with the given FormData.
+		 * Called upon submission with the given FormData and the `action` that should be triggered.
 		 * If `cancel` is called, the form will not be submitted.
+		 * You can use the abort `controller` to cancel the submission in case another one starts.
 		 * If a function is returned, that function is called with the response from the server.
 		 * If nothing is returned, the fallback will be used.
 		 *
@@ -137,7 +136,7 @@ declare module '$app/forms' {
 		 * - redirects in case of a redirect response
 		 * - redirects to the nearest error page in case of an unexpected error
 		 */
-		submit?: SubmitFunction<Element, Success, Invalid>
+		submit?: SubmitFunction<Success, Invalid>
 	): { destroy: () => void };
 
 	/**


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.server.js b/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.server.js
index cb69d5781..7a97721c2 100644
--- a/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.server.js
+++ b/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.server.js
@@ -7,10 +7,19 @@ export function load() {
 
 /** @type {import('./$types').Actions} */
 export const actions = {
-	default: async ({ request }) => {
+	login: async ({ request }) => {
 		const fields = await request.formData();
 		return {
 			result: fields.get('username')
 		};
+	},
+	register: async ({ request }) => {
+		const fields = await request.formData();
+		return {
+			result: 'register: ' + fields.get('username')
+		};
+	},
+	slow: async () => {
+		await new Promise((resolve) => setTimeout(resolve, 500));
 	}
 };
diff --git a/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.svelte b/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.svelte
index db5e38b63..748b0986e 100644
--- a/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.svelte
@@ -3,11 +3,29 @@
 
 	/** @type {import('./$types').ActionData} */
 	export let form;
+
+	/** @type {AbortController | undefined} */
+	let previous;
+	let count = 0;
 </script>
 
 <pre>{JSON.stringify(form)}</pre>
 
-<form method="post" use:enhance>
+<form method="post" action="?/login" use:enhance>
 	<input name="username" type="text" />
-	<button>Submit</button>
+	<button class="form1">Submit</button>
+	<button class="form1-register" formAction="?/register">Submit</button>
+</form>
+
+<span class="count">{count}</span>
+<form
+	method="post"
+	action="?/slow"
+	use:enhance={({ controller }) => {
+		previous?.abort();
+		previous = controller;
+		return () => count++;
+	}}
+>
+	<button class="form2">Submit</button>
 </form>
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index e9161307f..16c81f3d7 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -1792,9 +1792,40 @@ test.describe('Actions', () => {
 		await page.type('input[name="username"]', 'foo');
 		await Promise.all([
 			page.waitForRequest((request) => request.url().includes('/actions/enhance')),
-			page.click('button')
+			page.click('button.form1')
 		]);
 
 		await expect(page.locator('pre')).toHaveText(JSON.stringify({ result: 'foo' }));
 	});
+
+	test('use:enhance abort controller', async ({ page, javaScriptEnabled }) => {
+		await page.goto('/actions/enhance');
+
+		expect(await page.textContent('span.count')).toBe('0');
+
+		if (javaScriptEnabled) {
+			await Promise.all([
+				page.waitForRequest((request) => request.url().includes('/actions/enhance')),
+				page.click('button.form2'),
+				page.click('button.form2')
+			]);
+			await page.waitForTimeout(500); // to make sure locator doesn't run exactly between submission 1 and 2
+
+			await expect(page.locator('span.count')).toHaveText('1');
+		}
+	});
+
+	test('use:enhance button with formAction', async ({ page, app }) => {
+		await page.goto('/actions/enhance');
+
+		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
+
+		await page.type('input[name="username"]', 'foo');
+		await Promise.all([
+			page.waitForRequest((request) => request.url().includes('/actions/enhance')),
+			page.click('button.form1-register')
+		]);
+
+		await expect(page.locator('pre')).toHaveText(JSON.stringify({ result: 'register: foo' }));
+	});
 });