Skip to content

Commit f457eca

Browse files
feat: Server-side version of query.set() to avoid re-calling the query function when unnecessary (#14304)
* expose query.set * changeset * Make query.set() work on server * changeset * Update .changeset/social-hats-hope.md Co-authored-by: Simon H <[email protected]> * docs * simplify * tweaks * docs * Update documentation/docs/20-core-concepts/60-remote-functions.md * remove mentions from docs * fix --------- Co-authored-by: Simon H <[email protected]> Co-authored-by: Simon Holthausen <[email protected]>
1 parent 7f4ab16 commit f457eca

File tree

9 files changed

+98
-37
lines changed

9 files changed

+98
-37
lines changed

.changeset/quick-zebras-know.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: allow query.set() to be called on the server

documentation/docs/20-core-concepts/60-remote-functions.md

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export const getPost = query(v.string(), async (slug) => {
160160
161161
Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue), which handles types like `Date` and `Map` (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)) in addition to JSON.
162162
163-
### Updating queries
163+
### Refreshing queries
164164
165165
Any query can be re-fetched via its `refresh` method, which retrieves the latest value from the server:
166166
@@ -170,29 +170,6 @@ Any query can be re-fetched via its `refresh` method, which retrieves the latest
170170
</button>
171171
```
172172
173-
Alternatively, if you need to update its value manually, you can use the `set` method:
174-
175-
```svelte
176-
<script>
177-
import { getPosts } from './data.remote';
178-
import { onMount } from 'svelte';
179-
180-
onMount(() => {
181-
const ws = new WebSocket('/ws');
182-
ws.addEventListener('message', (ev) => {
183-
const message = JSON.parse(ev.data);
184-
if (message.type === 'new-post') {
185-
getPosts().set([
186-
message.post,
187-
...getPosts().current,
188-
]);
189-
}
190-
});
191-
return () => ws.close();
192-
});
193-
</script>
194-
```
195-
196173
> [!NOTE] Queries are cached while they're on the page, meaning `getPosts() === getPosts()`. This means you don't need a reference like `const posts = getPosts()` in order to update the query.
197174
198175
## form
@@ -293,6 +270,9 @@ import * as v from 'valibot';
293270
import { error, redirect } from '@sveltejs/kit';
294271
import { query, form } from '$app/server';
295272
const slug = '';
273+
const post = { id: '' };
274+
/** @type {any} */
275+
const externalApi = '';
296276
// ---cut---
297277
export const getPosts = query(async () => { /* ... */ });
298278

@@ -308,6 +288,15 @@ export const createPost = form(async (data) => {
308288
// Redirect to the newly created page
309289
redirect(303, `/blog/${slug}`);
310290
});
291+
292+
export const updatePost = form(async (data) => {
293+
// form logic goes here...
294+
const result = externalApi.update(post);
295+
296+
// The API already gives us the updated post,
297+
// no need to refresh it, we can set it directly
298+
+++await getPost(post.id).set(result);+++
299+
});
311300
```
312301
313302
The second is to drive the single-flight mutation from the client, which we'll see in the section on [`enhance`](#form-enhance).
@@ -564,6 +553,9 @@ export const addLike = command(v.string(), async (id) => {
564553
`;
565554

566555
+++getLikes(id).refresh();+++
556+
// Just like within form functions you can also do
557+
// getLikes(id).set(...)
558+
// in case you have the result already
567559
});
568560
```
569561

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1807,7 +1807,8 @@ export type RemoteQuery<T> = RemoteResource<T> & {
18071807
/**
18081808
* On the client, this function will update the value of the query without re-fetching it.
18091809
*
1810-
* On the server, this throws an error.
1810+
* On the server, this can be called in the context of a `command` or `form` and the specified data will accompany the action response back to the client.
1811+
* This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip.
18111812
*/
18121813
set(value: T): void;
18131814
/**

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,32 @@ export function query(validate_or_fn, maybe_fn) {
7272

7373
const { event, state } = get_request_store();
7474

75+
const abort_controller = new AbortController();
7576
/** @type {Promise<any> & Partial<RemoteQuery<any>>} */
76-
const promise = get_response(__.id, arg, state, () =>
77-
run_remote_function(event, state, false, arg, validate, fn)
77+
const promise = get_response(
78+
__.id,
79+
arg,
80+
state,
81+
() => run_remote_function(event, state, false, arg, validate, fn),
82+
abort_controller.signal
7883
);
7984

8085
promise.catch(() => {});
8186

82-
promise.set = () => {
83-
throw new Error(`Cannot call '${__.name}.set()' on the server`);
87+
/** @param {Output} value */
88+
promise.set = (value) => {
89+
abort_controller.abort();
90+
const { state } = get_request_store();
91+
const refreshes = state.refreshes;
92+
93+
if (!refreshes) {
94+
throw new Error(
95+
`Cannot call set on query '${__.name}' because it is not executed in the context of a command/form remote function`
96+
);
97+
}
98+
99+
const cache_key = create_remote_cache_key(__.id, stringify_remote_arg(arg, state.transport));
100+
refreshes[cache_key] = Promise.resolve(value);
84101
};
85102

86103
promise.refresh = () => {
@@ -101,6 +118,7 @@ export function query(validate_or_fn, maybe_fn) {
101118
};
102119

103120
promise.withOverride = () => {
121+
abort_controller.abort();
104122
throw new Error(`Cannot call '${__.name}.withOverride()' on the server`);
105123
};
106124

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,14 @@ export function create_validator(validate_or_fn, maybe_fn) {
6666
* @param {any} arg
6767
* @param {RequestState} state
6868
* @param {() => Promise<T>} get_result
69+
* @param {AbortSignal | undefined=} signal
6970
* @returns {Promise<T>}
7071
*/
71-
export function get_response(id, arg, state, get_result) {
72+
export async function get_response(id, arg, state, get_result, signal) {
73+
if (signal) {
74+
await new Promise((r) => setTimeout(r, 0));
75+
if (signal.aborted) throw new DOMException('The operation was aborted.', 'AbortError');
76+
}
7277
const cache_key = create_remote_cache_key(id, stringify_remote_arg(arg, state.transport));
7378

7479
return ((state.remote_data ??= {})[cache_key] ??= get_result());

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
add,
66
get_count,
77
set_count,
8-
set_count_server,
8+
set_count_server_refresh,
9+
set_count_server_set,
910
resolve_deferreds
1011
} from './query-command.remote.js';
1112
@@ -33,7 +34,7 @@
3334
<p id="command-pending">Command pending: {set_count.pending}</p>
3435
{/if}
3536

36-
<button onclick={() => set_count_server(0)} id="reset-btn">reset</button>
37+
<button onclick={() => set_count_server_refresh(0)} id="reset-btn">reset</button>
3738

3839
<button onclick={() => count.refresh()} id="refresh-btn">Refresh</button>
3940

@@ -57,7 +58,7 @@
5758
</button>
5859
<button
5960
onclick={async () => {
60-
command_result = await set_count_server(4);
61+
command_result = await set_count_server_refresh(4);
6162
}}
6263
id="multiply-server-refresh-btn"
6364
>
@@ -82,6 +83,14 @@
8283
>
8384
command (deferred)
8485
</button>
86+
<button
87+
onclick={async () => {
88+
command_result = await set_count_server_set(8);
89+
}}
90+
id="multiply-server-set-btn"
91+
>
92+
command (query server set)
93+
</button>
8594

8695
<button id="refresh-all" onclick={() => refreshAll()}>refreshAll</button>
8796
<button id="refresh-remote-only" onclick={() => refreshAll({ includeLoadFunctions: false })}>

packages/kit/test/apps/basics/src/routes/remote/query-command.remote.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ export const add = query('unchecked', ({ a, b }) => a + b);
66
let count = 0;
77
const deferreds = [];
88

9-
export const get_count = query(() => count);
9+
let get_count_called = false;
10+
export const get_count = query(() => {
11+
get_count_called = true;
12+
return count;
13+
});
1014

1115
export const set_count = command('unchecked', async ({ c, slow = false, deferred = false }) => {
1216
if (deferred) {
@@ -26,8 +30,19 @@ export const resolve_deferreds = command(() => {
2630
deferreds.length = 0;
2731
});
2832

29-
export const set_count_server = command('unchecked', (c) => {
33+
export const set_count_server_refresh = command('unchecked', (c) => {
3034
count = c;
3135
get_count().refresh();
3236
return c;
3337
});
38+
39+
export const set_count_server_set = command('unchecked', async (c) => {
40+
get_count_called = false;
41+
count = c;
42+
get_count().set(c);
43+
await new Promise((resolve) => setTimeout(resolve, 100));
44+
if (get_count_called) {
45+
throw new Error('get_count should not have been called');
46+
}
47+
return c;
48+
});

packages/kit/test/apps/basics/test/client.test.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,7 @@ test.describe('remote functions', () => {
16671667

16681668
await page.click('#set-btn');
16691669
await expect(page.locator('#count-result')).toHaveText('999 / 999 (false)');
1670+
await page.waitForTimeout(100); // allow all requests to finish (in case there are query refreshes which shouldn't happen)
16701671
expect(request_count).toBe(0);
16711672
});
16721673

@@ -1710,7 +1711,7 @@ test.describe('remote functions', () => {
17101711
expect(request_count).toBe(1); // no query refreshes, since that happens as part of the command response
17111712
});
17121713

1713-
test('command does server-initiated single flight mutation', async ({ page }) => {
1714+
test('command does server-initiated single flight mutation (refresh)', async ({ page }) => {
17141715
await page.goto('/remote');
17151716
await expect(page.locator('#count-result')).toHaveText('0 / 0 (false)');
17161717

@@ -1724,6 +1725,20 @@ test.describe('remote functions', () => {
17241725
expect(request_count).toBe(1); // no query refreshes, since that happens as part of the command response
17251726
});
17261727

1728+
test('command does server-initiated single flight mutation (set)', async ({ page }) => {
1729+
await page.goto('/remote');
1730+
await expect(page.locator('#count-result')).toHaveText('0 / 0 (false)');
1731+
1732+
let request_count = 0;
1733+
page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0));
1734+
1735+
await page.click('#multiply-server-set-btn');
1736+
await expect(page.locator('#command-result')).toHaveText('8');
1737+
await expect(page.locator('#count-result')).toHaveText('8 / 8 (false)');
1738+
await page.waitForTimeout(100); // allow all requests to finish (in case there are query refreshes which shouldn't happen)
1739+
expect(request_count).toBe(1); // no query refreshes, since that happens as part of the command response
1740+
});
1741+
17271742
test('command does client-initiated single flight mutation with override', async ({ page }) => {
17281743
await page.goto('/remote');
17291744
await expect(page.locator('#count-result')).toHaveText('0 / 0 (false)');

packages/kit/types/index.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1783,7 +1783,8 @@ declare module '@sveltejs/kit' {
17831783
/**
17841784
* On the client, this function will update the value of the query without re-fetching it.
17851785
*
1786-
* On the server, this throws an error.
1786+
* On the server, this can be called in the context of a `command` or `form` and the specified data will accompany the action response back to the client.
1787+
* This prevents SvelteKit needing to refresh all queries on the page in a second server round-trip.
17871788
*/
17881789
set(value: T): void;
17891790
/**

0 commit comments

Comments
 (0)