Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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/red-waves-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add new remote function `query.batch`
54 changes: 54 additions & 0 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,60 @@ Any query can be updated via its `refresh` method:

> [!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 refresh the query.

## query.batch

`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem: rather than each query resulting in a separate database call (for example), simultaneous queries are grouped together.

On the server, the callback receives an array of the arguments the function was called with. It must return a function of the form `(input: Input, index: number) => Output`. SvelteKit will then call this with each of the input arguments to resolve the individual calls with their results.

```js
/// file: weather.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import * as v from 'valibot';
import { query } from '$app/server';
import * as db from '$lib/server/database';

export const getWeather = query.batch(v.string(), async (cities) => {
const weather = await db.sql`
SELECT * FROM weather
WHERE city = ANY(${cities})
`;
const weatherMap = new Map(weather.map(w => [w.city, w]));

return (city) => weatherMap.get(city);
});
```

```svelte
<!--- file: Weather.svelte --->
<script>
import CityWeather from './CityWeather.svelte';
import { getWeather } from './weather.remote.js';

let { cities } = $props();
let limit = $state(5);
</script>

<h2>Weather</h2>

{#each cities.slice(0, limit) as city}
<h3>{city.name}</h3>
<CityWeather weather={await getWeather(city.id)} />
{/each}

<button
onclick={() => limit +=5}
disabled={limit >= cities.length}
>
Load more
</button>
```

## form

The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...
Expand Down
10 changes: 8 additions & 2 deletions packages/kit/src/exports/internal/remote-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ export function validate_remote_functions(module, file) {
}

for (const name in module) {
const type = module[name]?.__?.type;
const type = /** @type {import('types').RemoteInfo['type']} */ (module[name]?.__?.type);

if (type !== 'form' && type !== 'command' && type !== 'query' && type !== 'prerender') {
if (
type !== 'form' &&
type !== 'command' &&
type !== 'query' &&
type !== 'query_batch' &&
type !== 'prerender'
) {
throw new Error(
`\`${name}\` exported from ${file} is invalid — all exports from this file must be remote functions`
);
Expand Down
142 changes: 142 additions & 0 deletions packages/kit/src/runtime/app/server/remote/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,145 @@ export function query(validate_or_fn, maybe_fn) {

return wrapper;
}

/**
* Creates a batch query function that collects multiple calls and executes them in a single request
*
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation.
*
* @template Input
* @template Output
* @overload
* @param {'unchecked'} validate
* @param {(args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} fn
* @returns {RemoteQueryFunction<Input, Output>}
* @since 2.35
*/
/**
* Creates a batch query function that collects multiple calls and executes them in a single request
*
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation.
*
* @template {StandardSchemaV1} Schema
* @template Output
* @overload
* @param {Schema} schema
* @param {(args: StandardSchemaV1.InferOutput<Schema>[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput<Schema>, idx: number) => Output>} fn
* @returns {RemoteQueryFunction<StandardSchemaV1.InferInput<Schema>, Output>}
* @since 2.35
*/
/**
* @template Input
* @template Output
* @param {any} validate_or_fn
* @param {(args?: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} [maybe_fn]
* @returns {RemoteQueryFunction<Input, Output>}
* @since 2.35
*/
/*@__NO_SIDE_EFFECTS__*/
function batch(validate_or_fn, maybe_fn) {
/** @type {(args?: Input[]) => (arg: Input, idx: number) => Output} */
const fn = maybe_fn ?? validate_or_fn;

/** @type {(arg?: any) => MaybePromise<Input>} */
const validate = create_validator(validate_or_fn, maybe_fn);

/** @type {RemoteInfo & { type: 'query_batch' }} */
const __ = {
type: 'query_batch',
id: '',
name: '',
run: (args) => {
const { event, state } = get_request_store();

return run_remote_function(
event,
state,
false,
args,
(array) => Promise.all(array.map(validate)),
fn
);
}
};

/** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */
let batching = { args: [], resolvers: [] };

/** @type {RemoteQueryFunction<Input, Output> & { __: RemoteInfo }} */
const wrapper = (arg) => {
if (prerendering) {
throw new Error(
`Cannot call query.batch '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead`
);
}

const { event, state } = get_request_store();

/** @type {Promise<any> & Partial<RemoteQuery<any>>} */
const promise = get_response(__.id, arg, state, () => {
// Collect all the calls to the same query in the same macrotask,
// then execute them as one backend request.
return new Promise((resolve, reject) => {
// We don't need to deduplicate args here, because get_response already caches/reuses identical calls
batching.args.push(arg);
batching.resolvers.push({ resolve, reject });

if (batching.args.length > 1) return;

setTimeout(async () => {
const batched = batching;
batching = { args: [], resolvers: [] };

try {
const get_result = await run_remote_function(
event,
state,
false,
batched.args,
(array) => Promise.all(array.map(validate)),
fn
);

for (let i = 0; i < batched.resolvers.length; i++) {
batched.resolvers[i].resolve(get_result(batched.args[i], i));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is quite right — if you have 10 items you might resolve the first 3, then the 4th errors, and then you call resolver.reject on all of them in the catch clause, the first 3 rejections will be no-ops but the remaining 7 will all reject.

If the call to run_remote_function throws then we need to reject everything, but otherwise surely it needs to be this?

for (let i = 0; i < batched.resolvers.length; i += 1) {
  const resolver = batched.resolvers[i];
  try {
    resolver.resolve(get_result(batched.args[i], i));
  } catch (error) {
    resolver.reject(error);
  }
}

Copy link
Member Author

@dummdidumm dummdidumm Aug 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

started implementing this but stumbled into an error handling question: Right now, we do call the handleError hook when it's a client->server invocation but don't when it's happening server->server. I'll gloss over it for now but there may be a more general mismatch with error handling and remote functions.

Update: The mismatch AFAICT ends up being:

  • on calls on the server the error is just thrown as-is, no "handleError" hook handling in place
  • on remote calls from the client the error is routed through handleError directly

Right now this wouldn't make a difference, because on the server the error bubbles up all the way to the root, and will be handled there by our root-handleError catcher. But once we we have async SSR in place, and a boundary catches an error on the server (if we want that to happen; maybe a separate discussion), then it gets the raw error on the server but a transformed-to-HttpError error on the client.

Update: We concluded that we won't change the behavior of boundaries in SSR, i.e. they still won't catch errors, so the behavior will be the same, so it's fine as is.

} catch (error) {
for (const resolver of batched.resolvers) {
resolver.reject(error);
}
}
}, 0);
});
});

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

promise.refresh = async () => {
const { state } = get_request_store();
const refreshes = state.refreshes;

if (!refreshes) {
throw new Error(
`Cannot call refresh on query.batch '${__.name}' because it is not executed in the context of a command/form remote function`
);
}

const cache_key = create_remote_cache_key(__.id, stringify_remote_arg(arg, state.transport));
refreshes[cache_key] = await /** @type {Promise<any>} */ (promise);
};

promise.withOverride = () => {
throw new Error(`Cannot call '${__.name}.withOverride()' on the server`);
};

return /** @type {RemoteQuery<Output>} */ (promise);
};

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

return wrapper;
}

// Add batch as a property to the query function
Object.defineProperty(query, 'batch', { value: batch, enumerable: true });
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.svelte.js';
export { form } from './form.svelte.js';
export { prerender } from './prerender.svelte.js';
export { query } from './query.svelte.js';
export { query, query_batch } from './query.svelte.js';
92 changes: 91 additions & 1 deletion packages/kit/src/runtime/client/remote-functions/query.svelte.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/** @import { RemoteQueryFunction } from '@sveltejs/kit' */
/** @import { RemoteFunctionResponse } from 'types' */
import { app_dir, base } from '__sveltekit/paths';
import { remote_responses, started } from '../client.js';
import { app, goto, remote_responses, started } from '../client.js';
import { tick } from 'svelte';
import { create_remote_function, remote_request } from './shared.svelte.js';
import * as devalue from 'devalue';
import { HttpError, Redirect } from '@sveltejs/kit/internal';

/**
* @param {string} id
Expand All @@ -25,6 +28,93 @@ export function query(id) {
});
}

/**
* @param {string} id
* @returns {(arg: any) => Query<any>}
*/
export function query_batch(id) {
/** @type {Map<string, Array<{resolve: (value: any) => void, reject: (error: any) => void}>>} */
let batching = new Map();

return create_remote_function(id, (cache_key, payload) => {
return new Query(cache_key, () => {
if (!started) {
const result = remote_responses[cache_key];
if (result) {
return result;
}
}

// Collect all the calls to the same query in the same macrotask,
// then execute them as one backend request.
return new Promise((resolve, reject) => {
// create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function
// is invoked multiple times with the same payload, so we need to deduplicate here
const entry = batching.get(payload) ?? [];
entry.push({ resolve, reject });
batching.set(payload, entry);

if (batching.size > 1) return;

// Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them,
// and flushes could reveal more queries that should be batched.
setTimeout(async () => {
const batched = batching;
batching = new Map();

try {
const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
method: 'POST',
body: JSON.stringify({
payloads: Array.from(batched.keys())
}),
headers: {
'Content-Type': 'application/json'
}
});

if (!response.ok) {
throw new Error('Failed to execute batch query');
}

const result = /** @type {RemoteFunctionResponse} */ (await response.json());
if (result.type === 'error') {
throw new HttpError(result.status ?? 500, result.error);
}

if (result.type === 'redirect') {
// TODO double-check this
await goto(result.location);
await new Promise((r) => setTimeout(r, 100));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this about?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from the existing query code. Last time I specifically tested this I had to give the async function a bit of time before I rejected it to not cause problems with async Svelte. Not sure if that's still the case hence the TODO

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tried deleting it, no tests failed. Not sure if that means it's safe to discard but my inclination is to do so, at most we should be using tick() I think but it might not be necessary at all

throw new Redirect(307, result.location);
}

const results = devalue.parse(result.result, app.decoders);

// Resolve individual queries
// Maps guarantee insertion order so we can do it like this
let i = 0;

for (const resolvers of batched.values()) {
for (const { resolve } of resolvers) {
resolve(results[i]);
}
i++;
}
} catch (error) {
// Reject all queries in the batch
for (const resolver of batched.values()) {
for (const { reject } of resolver) {
reject(error);
}
}
}
}, 0);
});
});
});
}

/**
* @template T
* @implements {Partial<Promise<T>>}
Expand Down
34 changes: 33 additions & 1 deletion packages/kit/src/runtime/server/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,44 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
let form_client_refreshes;

try {
if (info.type === 'query_batch') {
if (event.request.method !== 'POST') {
throw new SvelteKitError(
405,
'Method Not Allowed',
`\`query.batch\` functions must be invoked via POST request, not ${event.request.method}`
);
}

/** @type {{ payloads: string[] }} */
const { payloads } = await event.request.json();

const args = payloads.map((payload) => parse_remote_arg(payload, transport));
const get_result = await with_request_store({ event, state }, () => info.run(args));
const results = args.map((arg, i) => get_result(arg, i));

return json(
/** @type {RemoteFunctionResponse} */ ({
type: 'result',
result: stringify(results, transport)
})
);
}

if (info.type === 'form') {
if (event.request.method !== 'POST') {
throw new SvelteKitError(
405,
'Method Not Allowed',
`\`form\` functions must be invoked via POST request, not ${event.request.method}`
);
}

if (!is_form_content_type(event.request)) {
throw new SvelteKitError(
415,
'Unsupported Media Type',
`Form actions expect form-encoded data — received ${event.request.headers.get(
`\`form\` functions expect form-encoded data — received ${event.request.headers.get(
'content-type'
)}`
);
Expand Down
Loading
Loading