diff --git a/.changeset/violet-wolves-reflect.md b/.changeset/violet-wolves-reflect.md new file mode 100644 index 000000000000..d12f71b28060 --- /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 c137a08d754c..d4822f24ea14 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/test/apps/basics/src/routes/actions/enhance/+page.server.js b/packages/kit/test/apps/basics/src/routes/actions/enhance/+page.server.js index cb69d5781690..7a97721c284e 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 db5e38b63891..748b0986e2c4 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;
{JSON.stringify(form)}
-
+ - + + +
+ +{count} +
{ + previous?.abort(); + previous = controller; + return () => count++; + }} +> +
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index e9161307f234..16c81f3d7704 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' })); + }); }); diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts index e3ce20d1159f..dd1c45222429 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 | undefined = Record, Invalid extends Record | undefined = Record > = (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; }) => 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 | undefined = Record, Invalid extends Record | undefined = Record >( - 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 + submit?: SubmitFunction ): { destroy: () => void }; /**