Skip to content

Commit

Permalink
[breaking] only allow enhance on form; pass abort controller (#6662)
Browse files Browse the repository at this point in the history
* Use abort controller in enhance action

* make abort controller an input param

* use event.submit for formAction, revert element change

* changeset

* handle formAction weirdness, handle abort correctly

* thanks Rich

Co-authored-by: f-elix <guerinfelix08@gmail.com>
  • Loading branch information
dummdidumm and f-elix committed Sep 8, 2022
1 parent e20425f commit 842f69b
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 28 deletions.
5 changes: 5 additions & 0 deletions .changeset/violet-wolves-reflect.md
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[breaking] remove element property; enhance can only be used on form elements
31 changes: 15 additions & 16 deletions packages/kit/src/runtime/app/forms.js
Expand Up @@ -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;
Expand All @@ -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
Expand Down
Expand Up @@ -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));
}
};
Expand Up @@ -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>
33 changes: 32 additions & 1 deletion packages/kit/test/apps/basics/test/test.js
Expand Up @@ -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' }));
});
});
15 changes: 7 additions & 8 deletions packages/kit/types/ambient.d.ts
Expand Up @@ -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);

Expand All @@ -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.
*
Expand All @@ -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 };

/**
Expand Down

0 comments on commit 842f69b

Please sign in to comment.