Revert "breaking: remove `buttonProps` from experimental remote form function…" ...
authorRich Harris <richard.a.harris@gmail.com>
Fri, 9 Jan 2026 16:02:42 +0000 (11:02 -0500)
committerGitHub <noreply@github.com>
Fri, 9 Jan 2026 16:02:42 +0000 (11:02 -0500)
This reverts commit 4f9870dd9d8bca40352f647863b9a79e2488295e.

.changeset/honest-actors-arrive.md [deleted file]
documentation/docs/20-core-concepts/60-remote-functions.md
packages/kit/src/exports/public.d.ts
packages/kit/src/runtime/app/server/remote/form.js
packages/kit/src/runtime/client/remote-functions/form.svelte.js
packages/kit/test/apps/async/src/routes/remote/form/[test_name]/+page.svelte
packages/kit/test/apps/async/src/routes/remote/form/[test_name]/form.remote.ts
packages/kit/test/apps/async/test/test.js
packages/kit/types/index.d.ts

diff --git a/.changeset/honest-actors-arrive.md b/.changeset/honest-actors-arrive.md
deleted file mode 100644 (file)
index 17412ab..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': minor
----
-
-breaking: remove `buttonProps` from experimental remote form functions; use e.g. `<button {...myForm.fields.action.as('submit', 'register')}>Register</button>` button instead
index 7c44424dcb6fd020f515a9a18c395c657c9662fb..6bdc4e30d779b1a455ef9e333acf8e89caef5998 100644 (file)
@@ -818,56 +818,37 @@ Some forms may be repeated as part of a list. In this case you can create separa
 {/each}
 ```
 
-### Multiple submit buttons
+### buttonProps
 
-It's possible for a `<form>` to have multiple submit buttons. For example, you might have a single form that allows you to log in or register depending on which button was clicked.
+By default, submitting a form will send a request to the URL indicated by the `<form>` element's [`action`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form#attributes_for_form_submission) attribute, which in the case of a remote function is a property on the form object generated by SvelteKit.
 
-To accomplish this, add a field to your schema for the button value, and use `as('submit', value)` to bind it:
+It's possible for a `<button>` inside the `<form>` to send the request to a _different_ URL, using the [`formaction`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#formaction) attribute. For example, you might have a single form that allows you to log in or register depending on which button was clicked.
+
+This attribute exists on the `buttonProps` property of a form object:
 
 ```svelte
 <!--- file: src/routes/login/+page.svelte --->
 <script>
-       import { loginOrRegister } from '$lib/auth';
+       import { login, register } from '$lib/auth.remote';
 </script>
 
-<form {...loginOrRegister}>
+<form {...login}>
        <label>
                Your username
-               <input {...loginOrRegister.fields.username.as('text')} />
+               <input {...login.fields.username.as('text')} />
        </label>
 
        <label>
                Your password
-               <input {...loginOrRegister.fields._password.as('password')} />
+               <input {...login.fields._password.as('password')} />
        </label>
 
-       <button {...loginOrRegister.fields.action.as('submit', 'login')}>login</button>
-       <button {...loginOrRegister.fields.action.as('submit', 'register')}>register</button>
+       <button>login</button>
+       <button {...register.buttonProps}>register</button>
 </form>
 ```
 
-In your form handler, you can check which button was clicked:
-
-```js
-/// file: $lib/auth.js
-import * as v from 'valibot';
-import { form } from '$app/server';
-
-export const loginOrRegister = form(
-       v.object({
-               username: v.string(),
-               _password: v.string(),
-               action: v.picklist(['login', 'register'])
-       }),
-       async ({ username, _password, action }) => {
-               if (action === 'login') {
-                       // handle login
-               } else {
-                       // handle registration
-               }
-       }
-);
-```
+Like the form object itself, `buttonProps` has an `enhance` method for customizing submission behaviour.
 
 ## command
 
index fd41c98481d5b57961ce99649f319b0f8cae1746..5aa821429dbef2178326cc2e6789661d3d56de3a 100644 (file)
@@ -2078,6 +2078,30 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
        get pending(): number;
        /** Access form fields using object notation */
        fields: RemoteFormFields<Input>;
+       /** Spread this onto a `<button>` or `<input type="submit">` */
+       buttonProps: {
+               type: 'submit';
+               formmethod: 'POST';
+               formaction: string;
+               onclick: (event: Event) => void;
+               /** Use the `enhance` method to influence what happens when the form is submitted. */
+               enhance(
+                       callback: (opts: {
+                               form: HTMLFormElement;
+                               data: Input;
+                               submit: () => Promise<void> & {
+                                       updates: (...queries: Array<RemoteQuery<any> | RemoteQueryOverride>) => Promise<void>;
+                               };
+                       }) => void | Promise<void>
+               ): {
+                       type: 'submit';
+                       formmethod: 'POST';
+                       formaction: string;
+                       onclick: (event: Event) => void;
+               };
+               /** The number of pending submissions */
+               get pending(): number;
+       };
 };
 
 /**
index 9f71d6e7e126524af65fd3ec9d5b470ec3e4c6f4..bf69e0200bce275d64dc0b068ea972b935a2efff 100644 (file)
@@ -84,6 +84,21 @@ export function form(validate_or_fn, maybe_fn) {
                        }
                });
 
+               const button_props = {
+                       type: 'submit',
+                       onclick: () => {}
+               };
+
+               Object.defineProperty(button_props, 'enhance', {
+                       value: () => {
+                               return { type: 'submit', formaction: instance.buttonProps.formaction, onclick: () => {} };
+                       }
+               });
+
+               Object.defineProperty(instance, 'buttonProps', {
+                       value: button_props
+               });
+
                /** @type {RemoteInfo} */
                const __ = {
                        type: 'form',
@@ -175,6 +190,11 @@ export function form(validate_or_fn, maybe_fn) {
                        enumerable: true
                });
 
+               Object.defineProperty(button_props, 'formaction', {
+                       get: () => `?/remote=${__.id}`,
+                       enumerable: true
+               });
+
                Object.defineProperty(instance, 'fields', {
                        get() {
                                const data = get_cache(__)?.[''];
@@ -202,15 +222,6 @@ export function form(validate_or_fn, maybe_fn) {
                // TODO 3.0 remove
                if (DEV) {
                        throw_on_old_property_access(instance);
-
-                       Object.defineProperty(instance, 'buttonProps', {
-                               get() {
-                                       throw new Error(
-                                               '`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
-                                                       ' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
-                                       );
-                               }
-                       });
                }
 
                Object.defineProperty(instance, 'result', {
@@ -228,6 +239,11 @@ export function form(validate_or_fn, maybe_fn) {
                        get: () => 0
                });
 
+               // On the server, buttonProps.pending is always 0
+               Object.defineProperty(button_props, 'pending', {
+                       get: () => 0
+               });
+
                Object.defineProperty(instance, 'preflight', {
                        // preflight is a noop on the server
                        value: () => instance
index c7461e64894167a87f735feaeffc1f42cc73c9d1..ba47e7ff0099f3f151c0483c91a0d06f9b3b849a 100644 (file)
@@ -425,23 +425,74 @@ export function form(id) {
                        )
                );
 
+               /** @param {Parameters<RemoteForm<any, any>['buttonProps']['enhance']>[0]} callback */
+               const form_action_onclick = (callback) => {
+                       /** @param {Event} event */
+                       return async (event) => {
+                               const target = /** @type {HTMLButtonElement} */ (event.currentTarget);
+                               const form = target.form;
+                               if (!form) return;
+
+                               // Prevent this from firing the form's submit event
+                               event.stopPropagation();
+                               event.preventDefault();
+
+                               const form_data = new FormData(form, target);
+
+                               if (DEV) {
+                                       const enctype = target.hasAttribute('formenctype')
+                                               ? target.formEnctype
+                                               : clone(form).enctype;
+
+                                       validate_form_data(form_data, enctype);
+                               }
+
+                               await handle_submit(form, form_data, callback);
+                       };
+               };
+
+               /** @type {RemoteForm<any, any>['buttonProps']} */
+               // @ts-expect-error we gotta set enhance as a non-enumerable property
+               const button_props = {
+                       type: 'submit',
+                       formmethod: 'POST',
+                       formaction: action,
+                       onclick: form_action_onclick(({ submit, form }) =>
+                               submit().then(() => {
+                                       if (!issues.$) {
+                                               form.reset();
+                                       }
+                               })
+                       )
+               };
+
+               Object.defineProperty(button_props, 'enhance', {
+                       /** @type {RemoteForm<any, any>['buttonProps']['enhance']} */
+                       value: (callback) => {
+                               return {
+                                       type: 'submit',
+                                       formmethod: 'POST',
+                                       formaction: action,
+                                       onclick: form_action_onclick(callback)
+                               };
+                       }
+               });
+
+               Object.defineProperty(button_props, 'pending', {
+                       get: () => pending_count
+               });
+
                let validate_id = 0;
 
                // TODO 3.0 remove
                if (DEV) {
                        throw_on_old_property_access(instance);
-
-                       Object.defineProperty(instance, 'buttonProps', {
-                               get() {
-                                       throw new Error(
-                                               '`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
-                                                       ' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
-                                       );
-                               }
-                       });
                }
 
                Object.defineProperties(instance, {
+                       buttonProps: {
+                               value: button_props
+                       },
                        fields: {
                                get: () =>
                                        create_field_proxy(
index 7686db39006cc49ec13d756565127e49e07bd23f..485cfab34a113fae5ae3ffa73f41f6753f95fac4 100644 (file)
@@ -1,5 +1,10 @@
 <script>
-       import { get_message, set_message, resolve_deferreds } from './form.remote.js';
+       import {
+               get_message,
+               set_message,
+               resolve_deferreds,
+               set_reverse_message
+       } from './form.remote.js';
 
        const { params } = $props();
 
 
        <input {...set_message.fields.message.as('text')} />
        <input {...set_message.fields.test_name.as('hidden', params.test_name)} />
-
-       <button {...set_message.fields.action.as('submit', 'normal')}>set message</button>
-       <button {...set_message.fields.action.as('submit', 'reverse')}>set reverse message</button>
+       <!--
+        NOTE: there really probably should be a `set_reverse_message' test_name hidden field here, but it collides with the one above.
+        This kind of lines up with our discussions from earlier where we were talking about needing to include the RF hash in the field name.
+        If we do that and this test starts failing, all we'll need to do is add the hidden field back in.
+       -->
+       <button>set message</button>
+       <button {...set_reverse_message.buttonProps}>set reverse message</button>
 </form>
 
 <p>set_message.input.message: {set_message.fields.message.value()}</p>
 <p>set_message.pending: {set_message.pending}</p>
 <p>set_message.result: {set_message.result}</p>
+<p>set_reverse_message.result: {set_reverse_message.result}</p>
 
 <hr />
 
index 993962048e1bc2c8aa5820dccb5c03b4667a6147..8a0d860f0a2f8f9ffd3bfd74dbcdaa88930a7d20 100644 (file)
@@ -16,11 +16,10 @@ export const set_message = form(
                test_name: v.string(),
                id: v.optional(v.string()),
                message: v.picklist(
-                       ['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect', 'backwards'],
+                       ['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect'],
                        'message is invalid'
                ),
-               uppercase: v.optional(v.string()),
-               action: v.optional(v.picklist(['normal', 'reverse']))
+               uppercase: v.optional(v.string())
        }),
        async (data) => {
                if (data.message === 'unexpected error') {
@@ -38,11 +37,7 @@ export const set_message = form(
                const instance = instances.get(data.test_name) ?? { message: 'initial', deferreds: [] };
                instances.set(data.test_name, instance);
 
-               if (data.action === 'reverse') {
-                       instance.message = data.message.split('').reverse().join('');
-               } else {
-                       instance.message = data.uppercase === 'true' ? data.message.toUpperCase() : data.message;
-               }
+               instance.message = data.uppercase === 'true' ? data.message.toUpperCase() : data.message;
 
                if (getRequestEvent().isRemoteRequest) {
                        const deferred = Promise.withResolvers<void>();
index 01b2f937d3c2c6dd82e11e9622d177f2ce5ee1d1..bf647f6e4e38ae9ce2bbcca4be6d720ef0ea5505 100644 (file)
@@ -135,21 +135,22 @@ test.describe('remote functions', () => {
                await page.waitForURL('/remote');
        });
 
-       test('form multiple submit buttons work', async ({ page, javaScriptEnabled }) => {
-               await page.goto('/remote/form/multiple-submit');
+       test('form.buttonProps works', async ({ page, javaScriptEnabled }) => {
+               await page.goto('/remote/form/button-props');
 
                await page.fill('[data-unscoped] input', 'backwards');
                await page.getByText('set reverse message').click();
 
                if (javaScriptEnabled) {
-                       await page.getByText('resolve deferreds').click();
                        await page.getByText('message.current: sdrawkcab').waitFor();
                        await expect(page.getByText('await get_message():')).toHaveText(
                                'await get_message(): sdrawkcab'
                        );
                }
 
-               await expect(page.getByText('set_message.result')).toHaveText('set_message.result: sdrawkcab');
+               await expect(page.getByText('set_reverse_message.result')).toHaveText(
+                       'set_reverse_message.result: sdrawkcab'
+               );
        });
 
        test('form scoping with for(...) works', async ({ page, javaScriptEnabled }) => {
index 2009117d5e80795c12a56dc738ee91a34d5e2a03..0cd1dc26a3e81910880e13680e213266bb5be10e 100644 (file)
@@ -2054,6 +2054,30 @@ declare module '@sveltejs/kit' {
                get pending(): number;
                /** Access form fields using object notation */
                fields: RemoteFormFields<Input>;
+               /** Spread this onto a `<button>` or `<input type="submit">` */
+               buttonProps: {
+                       type: 'submit';
+                       formmethod: 'POST';
+                       formaction: string;
+                       onclick: (event: Event) => void;
+                       /** Use the `enhance` method to influence what happens when the form is submitted. */
+                       enhance(
+                               callback: (opts: {
+                                       form: HTMLFormElement;
+                                       data: Input;
+                                       submit: () => Promise<void> & {
+                                               updates: (...queries: Array<RemoteQuery<any> | RemoteQueryOverride>) => Promise<void>;
+                                       };
+                               }) => void | Promise<void>
+                       ): {
+                               type: 'submit';
+                               formmethod: 'POST';
+                               formaction: string;
+                               onclick: (event: Event) => void;
+                       };
+                       /** The number of pending submissions */
+                       get pending(): number;
+               };
        };
 
        /**