Skip to content

feat: add pending property to forms and commands #14137

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/purple-hotels-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add `pending` property to forms and commands
12 changes: 10 additions & 2 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1565,6 +1565,8 @@ export type RemoteForm<Result> = {
for(key: string | number | boolean): Omit<RemoteForm<Result>, 'for'>;
/** The result of the form submission */
get result(): Result | undefined;
/** The number of pending submissions */
get pending(): number;
/** Spread this onto a `<button>` or `<input type="submit">` */
buttonProps: {
type: 'submit';
Expand All @@ -1586,14 +1588,20 @@ export type RemoteForm<Result> = {
formaction: string;
onclick: (event: Event) => void;
};
/** The number of pending submissions */
get pending(): number;
};
};

/**
* The return value of a remote `command` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#command) for full documentation.
*/
export type RemoteCommand<Input, Output> = (arg: Input) => Promise<Awaited<Output>> & {
updates(...queries: Array<RemoteQuery<any> | RemoteQueryOverride>): Promise<Awaited<Output>>;
export type RemoteCommand<Input, Output> = {
(arg: Input): Promise<Awaited<Output>> & {
updates(...queries: Array<RemoteQuery<any> | RemoteQueryOverride>): Promise<Awaited<Output>>;
};
/** The number of pending command executions */
get pending(): number;
};

export type RemoteResource<T> = Promise<Awaited<T>> & {
Expand Down
5 changes: 5 additions & 0 deletions packages/kit/src/runtime/app/server/remote/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,10 @@ export function command(validate_or_fn, maybe_fn) {

Object.defineProperty(wrapper, '__', { value: __ });

// On the server, pending is always 0
Object.defineProperty(wrapper, 'pending', {
get: () => 0
});

return wrapper;
}
10 changes: 10 additions & 0 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ export function form(fn) {
}
});

// On the server, pending is always 0
Object.defineProperty(instance, 'pending', {
get: () => 0
});

// On the server, buttonProps.pending is always 0
Object.defineProperty(button_props, 'pending', {
get: () => 0
});

if (key == undefined) {
Object.defineProperty(instance, 'for', {
/** @type {RemoteForm<any>['for']} */
Expand Down
71 changes: 0 additions & 71 deletions packages/kit/src/runtime/client/remote-functions/command.js

This file was deleted.

89 changes: 89 additions & 0 deletions packages/kit/src/runtime/client/remote-functions/command.svelte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** @import { RemoteCommand, RemoteQueryOverride } from '@sveltejs/kit' */
/** @import { RemoteFunctionResponse } from 'types' */
/** @import { Query } from './query.svelte.js' */
import { app_dir, base } from '__sveltekit/paths';
import * as devalue from 'devalue';
import { HttpError } from '@sveltejs/kit/internal';
import { app } from '../client.js';
import { stringify_remote_arg } from '../../shared.js';
import { refresh_queries, release_overrides } from './shared.svelte.js';

/**
* Client-version of the `command` function from `$app/server`.
* @param {string} id
* @returns {RemoteCommand<any, any>}
*/
export function command(id) {
/** @type {number} */
let pending_count = $state(0);

// Careful: This function MUST be synchronous (can't use the async keyword) because the return type has to be a promise with an updates() method.
// If we make it async, the return type will be a promise that resolves to a promise with an updates() method, which is not what we want.
/** @type {RemoteCommand<any, any>} */
const command_function = (arg) => {
/** @type {Array<Query<any> | RemoteQueryOverride>} */
let updates = [];

// Increment pending count when command starts
pending_count++;

/** @type {Promise<any> & { updates: (...args: any[]) => any }} */
const promise = (async () => {
try {
// Wait a tick to give room for the `updates` method to be called
await Promise.resolve();

const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
method: 'POST',
body: JSON.stringify({
payload: stringify_remote_arg(arg, app.hooks.transport),
refreshes: updates.map((u) => u._key)
}),
headers: {
'Content-Type': 'application/json'
}
});

if (!response.ok) {
release_overrides(updates);
// We only end up here in case of a network error or if the server has an internal error
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
throw new Error('Failed to execute remote function');
}

const result = /** @type {RemoteFunctionResponse} */ (await response.json());
if (result.type === 'redirect') {
release_overrides(updates);
throw new Error(
'Redirects are not allowed in commands. Return a result instead and use goto on the client'
);
} else if (result.type === 'error') {
release_overrides(updates);
throw new HttpError(result.status ?? 500, result.error);
} else {
refresh_queries(result.refreshes, updates);

return devalue.parse(result.result, app.decoders);
}
} finally {
// Decrement pending count when command completes
pending_count--;
}
})();

promise.updates = (/** @type {any} */ ...args) => {
updates = args;
// @ts-expect-error Don't allow updates to be called multiple times
delete promise.updates;
return promise;
};

return promise;
};

Object.defineProperty(command_function, 'pending', {
get: () => pending_count
});

return command_function;
}
16 changes: 16 additions & 0 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export function form(id) {
/** @type {any} */
let result = $state(started ? undefined : remote_responses[action_id]);

/** @type {number} */
let pending_count = $state(0);

/**
* @param {FormData} data
* @returns {Promise<any> & { updates: (...args: any[]) => any }}
Expand All @@ -42,6 +45,9 @@ export function form(id) {
entry.count++;
}

// Increment pending count when submission starts
pending_count++;

/** @type {Array<Query<any> | RemoteQueryOverride>} */
let updates = [];

Expand Down Expand Up @@ -94,6 +100,9 @@ export function form(id) {
release_overrides(updates);
throw e;
} finally {
// Decrement pending count when submission completes
pending_count--;

void tick().then(() => {
if (entry) {
entry.count--;
Expand Down Expand Up @@ -242,13 +251,20 @@ export function form(id) {
}
});

Object.defineProperty(button_props, 'pending', {
get: () => pending_count
});

Object.defineProperties(instance, {
buttonProps: {
value: button_props
},
result: {
get: () => result
},
pending: {
get: () => pending_count
},
enhance: {
/** @type {RemoteForm<any>['enhance']} */
value: (callback) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/client/remote-functions/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { command } from './command.js';
export { command } from './command.svelte.js';
export { form } from './form.svelte.js';
export { prerender } from './prerender.svelte.js';
export { query } from './query.svelte.js';
10 changes: 10 additions & 0 deletions packages/kit/test/apps/basics/src/hooks.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import { COOKIE_NAME } from './routes/cookies/shared';
import { _set_from_init } from './routes/init-hooks/+page.server';
import { getRequestEvent } from '$app/server';

// @ts-ignore this doesn't exist in old Node
Promise.withResolvers ??= () => {
const d = {};
d.promise = new Promise((resolve, reject) => {
d.resolve = resolve;
d.reject = reject;
});
return d;
};

/**
* Transform an error into a POJO, by copying its `name`, `message`
* and (in dev) `stack`, plus any custom properties, plus recursively
Expand Down
23 changes: 22 additions & 1 deletion packages/kit/test/apps/basics/src/routes/remote/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
<script>
import { browser } from '$app/environment';
import { refreshAll } from '$app/navigation';
import { add, get_count, set_count, set_count_server } from './query-command.remote.js';
import {
add,
get_count,
set_count,
set_count_server,
resolve_deferreds
} from './query-command.remote.js';

let { data } = $props();

Expand All @@ -22,6 +28,11 @@
{/if}
<p id="command-result">{command_result}</p>

<!-- Test pending state for commands -->
{#if browser}
<p id="command-pending">Command pending: {set_count.pending}</p>
{/if}

<button onclick={() => set_count_server(0)} id="reset-btn">reset</button>

<button onclick={() => count.refresh()} id="refresh-btn">Refresh</button>
Expand Down Expand Up @@ -60,8 +71,18 @@
>
command (override + refresh)
</button>
<button
onclick={async () => {
// deferred for pending state testing
command_result = await set_count({ c: 7, deferred: true });
}}
id="command-deferred-btn"
>
command (deferred)
</button>

<button id="refresh-all" onclick={() => refreshAll()}>refreshAll</button>
<button id="refresh-remote-only" onclick={() => refreshAll({ includeLoadFunctions: false })}>
refreshAll (remote functions only)
</button>
<button id="resolve-deferreds" onclick={() => resolve_deferreds()}>Resolve Deferreds</button>
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<script>
import { get_task, task_one, task_two } from './form.remote.js';
import { get_task, task_one, task_two, resolve_deferreds } from './form.remote.js';
const current_task = get_task();
</script>

<!-- TODO use await here once async lands -->
<p id="get-task">{#await current_task then task}{task}{/await}</p>

<!-- Test pending state for forms -->
<p id="form-pending">Form pending: {task_one.pending}</p>
<p id="form-button-pending">Button pending: {task_two.buttonProps.pending}</p>

<form {...task_one}>
<input id="input-task" name="task" />
<button id="submit-btn-one">Task One</button>
Expand Down Expand Up @@ -51,6 +55,10 @@
<p id="form-result-1">{task_one.result}</p>
<p id="form-result-2">{task_two.result}</p>

<form {...resolve_deferreds}>
<button id="resolve-deferreds" type="submit">Resolve Deferreds</button>
</form>

{#each ['foo', 'bar'] as item}
<form {...task_one.for(item)}>
<span id="form-result-{item}">{task_one.for(item).result}</span>
Expand Down
Loading
Loading