Skip to content

Commit 69f4e5f

Browse files
feat: add pending property to forms and commands (#14137)
* feat: add `pending` property to forms and commands * reinstate comment * tests * prettier * fix * wat * Apply suggestions from code review Co-authored-by: Simon H <[email protected]> --------- Co-authored-by: Simon H <[email protected]>
1 parent f67ba09 commit 69f4e5f

File tree

15 files changed

+288
-82
lines changed

15 files changed

+288
-82
lines changed

.changeset/purple-hotels-prove.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: add `pending` property to forms and commands

packages/kit/src/exports/public.d.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,8 @@ export type RemoteForm<Result> = {
15651565
for(key: string | number | boolean): Omit<RemoteForm<Result>, 'for'>;
15661566
/** The result of the form submission */
15671567
get result(): Result | undefined;
1568+
/** The number of pending submissions */
1569+
get pending(): number;
15681570
/** Spread this onto a `<button>` or `<input type="submit">` */
15691571
buttonProps: {
15701572
type: 'submit';
@@ -1586,14 +1588,20 @@ export type RemoteForm<Result> = {
15861588
formaction: string;
15871589
onclick: (event: Event) => void;
15881590
};
1591+
/** The number of pending submissions */
1592+
get pending(): number;
15891593
};
15901594
};
15911595

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

15991607
export type RemoteResource<T> = Promise<Awaited<T>> & {

packages/kit/src/runtime/app/server/remote/command.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,10 @@ export function command(validate_or_fn, maybe_fn) {
8787

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

90+
// On the server, pending is always 0
91+
Object.defineProperty(wrapper, 'pending', {
92+
get: () => 0
93+
});
94+
9095
return wrapper;
9196
}

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ export function form(fn) {
9797
}
9898
});
9999

100+
// On the server, pending is always 0
101+
Object.defineProperty(instance, 'pending', {
102+
get: () => 0
103+
});
104+
105+
// On the server, buttonProps.pending is always 0
106+
Object.defineProperty(button_props, 'pending', {
107+
get: () => 0
108+
});
109+
100110
if (key == undefined) {
101111
Object.defineProperty(instance, 'for', {
102112
/** @type {RemoteForm<any>['for']} */

packages/kit/src/runtime/client/remote-functions/command.js

Lines changed: 0 additions & 71 deletions
This file was deleted.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/** @import { RemoteCommand, RemoteQueryOverride } from '@sveltejs/kit' */
2+
/** @import { RemoteFunctionResponse } from 'types' */
3+
/** @import { Query } from './query.svelte.js' */
4+
import { app_dir, base } from '__sveltekit/paths';
5+
import * as devalue from 'devalue';
6+
import { HttpError } from '@sveltejs/kit/internal';
7+
import { app } from '../client.js';
8+
import { stringify_remote_arg } from '../../shared.js';
9+
import { refresh_queries, release_overrides } from './shared.svelte.js';
10+
11+
/**
12+
* Client-version of the `command` function from `$app/server`.
13+
* @param {string} id
14+
* @returns {RemoteCommand<any, any>}
15+
*/
16+
export function command(id) {
17+
/** @type {number} */
18+
let pending_count = $state(0);
19+
20+
// 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.
21+
// 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.
22+
/** @type {RemoteCommand<any, any>} */
23+
const command_function = (arg) => {
24+
/** @type {Array<Query<any> | RemoteQueryOverride>} */
25+
let updates = [];
26+
27+
// Increment pending count when command starts
28+
pending_count++;
29+
30+
/** @type {Promise<any> & { updates: (...args: any[]) => any }} */
31+
const promise = (async () => {
32+
try {
33+
// Wait a tick to give room for the `updates` method to be called
34+
await Promise.resolve();
35+
36+
const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
37+
method: 'POST',
38+
body: JSON.stringify({
39+
payload: stringify_remote_arg(arg, app.hooks.transport),
40+
refreshes: updates.map((u) => u._key)
41+
}),
42+
headers: {
43+
'Content-Type': 'application/json'
44+
}
45+
});
46+
47+
if (!response.ok) {
48+
release_overrides(updates);
49+
// We only end up here in case of a network error or if the server has an internal error
50+
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
51+
throw new Error('Failed to execute remote function');
52+
}
53+
54+
const result = /** @type {RemoteFunctionResponse} */ (await response.json());
55+
if (result.type === 'redirect') {
56+
release_overrides(updates);
57+
throw new Error(
58+
'Redirects are not allowed in commands. Return a result instead and use goto on the client'
59+
);
60+
} else if (result.type === 'error') {
61+
release_overrides(updates);
62+
throw new HttpError(result.status ?? 500, result.error);
63+
} else {
64+
refresh_queries(result.refreshes, updates);
65+
66+
return devalue.parse(result.result, app.decoders);
67+
}
68+
} finally {
69+
// Decrement pending count when command completes
70+
pending_count--;
71+
}
72+
})();
73+
74+
promise.updates = (/** @type {any} */ ...args) => {
75+
updates = args;
76+
// @ts-expect-error Don't allow updates to be called multiple times
77+
delete promise.updates;
78+
return promise;
79+
};
80+
81+
return promise;
82+
};
83+
84+
Object.defineProperty(command_function, 'pending', {
85+
get: () => pending_count
86+
});
87+
88+
return command_function;
89+
}

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function form(id) {
2727
/** @type {any} */
2828
let result = $state(started ? undefined : remote_responses[action_id]);
2929

30+
/** @type {number} */
31+
let pending_count = $state(0);
32+
3033
/**
3134
* @param {FormData} data
3235
* @returns {Promise<any> & { updates: (...args: any[]) => any }}
@@ -42,6 +45,9 @@ export function form(id) {
4245
entry.count++;
4346
}
4447

48+
// Increment pending count when submission starts
49+
pending_count++;
50+
4551
/** @type {Array<Query<any> | RemoteQueryOverride>} */
4652
let updates = [];
4753

@@ -94,6 +100,9 @@ export function form(id) {
94100
release_overrides(updates);
95101
throw e;
96102
} finally {
103+
// Decrement pending count when submission completes
104+
pending_count--;
105+
97106
void tick().then(() => {
98107
if (entry) {
99108
entry.count--;
@@ -242,13 +251,20 @@ export function form(id) {
242251
}
243252
});
244253

254+
Object.defineProperty(button_props, 'pending', {
255+
get: () => pending_count
256+
});
257+
245258
Object.defineProperties(instance, {
246259
buttonProps: {
247260
value: button_props
248261
},
249262
result: {
250263
get: () => result
251264
},
265+
pending: {
266+
get: () => pending_count
267+
},
252268
enhance: {
253269
/** @type {RemoteForm<any>['enhance']} */
254270
value: (callback) => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { command } from './command.js';
1+
export { command } from './command.svelte.js';
22
export { form } from './form.svelte.js';
33
export { prerender } from './prerender.svelte.js';
44
export { query } from './query.svelte.js';

packages/kit/test/apps/basics/src/hooks.server.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import { COOKIE_NAME } from './routes/cookies/shared';
66
import { _set_from_init } from './routes/init-hooks/+page.server';
77
import { getRequestEvent } from '$app/server';
88

9+
// @ts-ignore this doesn't exist in old Node
10+
Promise.withResolvers ??= () => {
11+
const d = {};
12+
d.promise = new Promise((resolve, reject) => {
13+
d.resolve = resolve;
14+
d.reject = reject;
15+
});
16+
return d;
17+
};
18+
919
/**
1020
* Transform an error into a POJO, by copying its `name`, `message`
1121
* and (in dev) `stack`, plus any custom properties, plus recursively

packages/kit/test/apps/basics/src/routes/remote/+page.svelte

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
<script>
22
import { browser } from '$app/environment';
33
import { refreshAll } from '$app/navigation';
4-
import { add, get_count, set_count, set_count_server } from './query-command.remote.js';
4+
import {
5+
add,
6+
get_count,
7+
set_count,
8+
set_count_server,
9+
resolve_deferreds
10+
} from './query-command.remote.js';
511
612
let { data } = $props();
713
@@ -22,6 +28,11 @@
2228
{/if}
2329
<p id="command-result">{command_result}</p>
2430

31+
<!-- Test pending state for commands -->
32+
{#if browser}
33+
<p id="command-pending">Command pending: {set_count.pending}</p>
34+
{/if}
35+
2536
<button onclick={() => set_count_server(0)} id="reset-btn">reset</button>
2637

2738
<button onclick={() => count.refresh()} id="refresh-btn">Refresh</button>
@@ -60,8 +71,18 @@
6071
>
6172
command (override + refresh)
6273
</button>
74+
<button
75+
onclick={async () => {
76+
// deferred for pending state testing
77+
command_result = await set_count({ c: 7, deferred: true });
78+
}}
79+
id="command-deferred-btn"
80+
>
81+
command (deferred)
82+
</button>
6383

6484
<button id="refresh-all" onclick={() => refreshAll()}>refreshAll</button>
6585
<button id="refresh-remote-only" onclick={() => refreshAll({ includeLoadFunctions: false })}>
6686
refreshAll (remote functions only)
6787
</button>
88+
<button id="resolve-deferreds" onclick={() => resolve_deferreds()}>Resolve Deferreds</button>

0 commit comments

Comments
 (0)