From aaebc03291d504839b68050e57dd2fc59ef374a1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Mar 2026 18:33:21 -0400 Subject: [PATCH 01/36] feat: add query.live remote function support --- .../20-core-concepts/60-remote-functions.md | 41 +- .../src/exports/internal/remote-functions.js | 2 +- packages/kit/src/exports/public.d.ts | 20 + .../src/runtime/app/server/remote/query.js | 172 ++++- packages/kit/src/runtime/client/client.js | 8 +- .../runtime/client/remote-functions/index.js | 2 +- .../client/remote-functions/query.svelte.js | 603 +++++++++++++++++- .../client/remote-functions/shared.svelte.js | 2 +- packages/kit/src/runtime/server/remote.js | 134 +++- packages/kit/src/types/internal.d.ts | 2 +- .../async/src/routes/remote/live/+page.svelte | 25 + .../src/routes/remote/live/LiveView.svelte | 11 + .../src/routes/remote/live/live.remote.js | 62 ++ .../src/routes/remote/validation/+page.svelte | 60 +- .../remote/validation/validation.remote.js | 6 + .../kit/test/apps/async/test/client.test.js | 29 + packages/kit/test/apps/async/test/test.js | 5 + packages/kit/test/types/remote.test.ts | 55 ++ packages/kit/types/index.d.ts | 414 ++++++++---- query-live-plan.md | 34 + 20 files changed, 1549 insertions(+), 138 deletions(-) create mode 100644 packages/kit/test/apps/async/src/routes/remote/live/+page.svelte create mode 100644 packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte create mode 100644 packages/kit/test/apps/async/src/routes/remote/live/live.remote.js create mode 100644 query-live-plan.md diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index dedfc0f06e17..f7890b5037ba 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -33,7 +33,7 @@ export default config; ## Overview -Remote functions are exported from a `.remote.js` or `.remote.ts` file, and come in four flavours: `query`, `form`, `command` and `prerender`. On the client, the exported functions are transformed to `fetch` wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote files can be placed anywhere in your `src` directory (except inside the `src/lib/server` directory), and third party libraries can provide them, too. +Remote functions are exported from a `.remote.js` or `.remote.ts` file, and come in five flavours: `query`, `query.live`, `form`, `command` and `prerender`. On the client, the exported functions are transformed to `fetch` wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote files can be placed anywhere in your `src` directory (except inside the `src/lib/server` directory), and third party libraries can provide them, too. ## query @@ -225,6 +225,45 @@ export const getWeather = query.batch(v.string(), async (cityIds) => { {/if} ``` +## query.live + +`query.live` is for streaming updates from the server. It works like `query`, including argument validation, but the callback returns an `AsyncIterator` (typically an async generator): + +```js +import { query } from '$app/server'; + +export const getCount = query.live(async function* () { + yield 0; + + while (true) { + await wait_for_count_change(); + yield get_current_count(); + } +}); +``` + +On the server, `await getCount()` reads the first yielded value and then closes the iterator. This allows SSR to serialize the initial value and reuse it during hydration. + +On the client, the query stays connected while it's actively used in a component. When there are no active uses left, the stream disconnects and server-side iteration is stopped. + +Live queries expose a `connected` property and `reconnect()` method: + +```svelte + + +

{count.current}

+

connected: {String(count.connected)}

+ +``` + +Unlike `query`, live queries do not have a `refresh()` method. + +As with `query` and `query.batch`, call `.run()` outside render when you need imperative access. For live queries, `run()` returns a `Promise>`. + ## form The `form` function makes it easy to write data to the server. It takes a callback that receives `data` constructed from the submitted [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)... diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index ae83ed5d3d2b..f6dd1c4d794c 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -1,7 +1,7 @@ /** @import { RemoteInfo } from 'types' */ /** @type {RemoteInfo['type'][]} */ -const types = ['command', 'form', 'prerender', 'query', 'query_batch']; +const types = ['command', 'form', 'prerender', 'query', 'query_batch', 'query_live']; /** * @param {Record} module diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index aaad6eef8c7d..ee4d7539d99d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2170,6 +2170,19 @@ export type RemoteQuery = RemoteResource & { withOverride(update: (current: T) => T): RemoteQueryOverride; }; +export type RemoteLiveQuery = RemoteResource & { + /** + * Returns an async iterator with live updates. + * Unlike awaiting the resource directly, this can only be used _outside_ render + * (i.e. in load functions, event handlers and so on) + */ + run(): Promise>; + /** `true` if the live stream is currently connected. */ + readonly connected: boolean; + /** Reconnects the live stream immediately. */ + reconnect(): void; +}; + export interface RemoteQueryOverride { _key: string; release(): void; @@ -2189,4 +2202,11 @@ export type RemoteQueryFunction = ( arg: undefined extends Input ? Input | void : Input ) => RemoteQuery; +/** + * The return value of a remote `query.live` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. + */ +export type RemoteLiveQueryFunction = ( + arg: undefined extends Input ? Input | void : Input +) => RemoteLiveQuery; + export * from './index.js'; diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 03dc4a550f92..6fd2802394b9 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -1,4 +1,4 @@ -/** @import { RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteLiveQuery, RemoteLiveQueryFunction, RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */ /** @import { RemoteInfo, MaybePromise, RequestState } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; @@ -84,6 +84,103 @@ export function query(validate_or_fn, maybe_fn) { return wrapper; } +/** + * Creates a live remote query. When called from the browser, the function will be invoked on the server via a streaming `fetch` call. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. + * + * @template Output + * @overload + * @param {() => MaybePromise | AsyncIterable>} fn + * @returns {RemoteLiveQueryFunction} + */ +/** + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(arg: Input) => MaybePromise | AsyncIterable>} fn + * @returns {RemoteLiveQueryFunction} + */ +/** + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} schema + * @param {(arg: StandardSchemaV1.InferOutput) => MaybePromise | AsyncIterable>} fn + * @returns {RemoteLiveQueryFunction, Output>} + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(args?: Input) => MaybePromise | AsyncIterable>} [maybe_fn] + * @returns {RemoteLiveQueryFunction} + */ +/*@__NO_SIDE_EFFECTS__*/ +function live(validate_or_fn, maybe_fn) { + /** @type {(arg?: Input) => MaybePromise | AsyncIterable>} */ + const fn = maybe_fn ?? validate_or_fn; + + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** + * @param {any} event + * @param {any} state + * @param {any} arg + */ + const run = (event, state, arg) => + run_remote_function( + event, + state, + false, + () => validate(arg), + async (input) => to_async_iterator(await fn(input), __.name) + ); + + /** @type {RemoteInfo & { type: 'query_live'; run: typeof run }} */ + const __ = { type: 'query_live', id: '', name: '', run }; + + /** @type {RemoteLiveQueryFunction & { __: RemoteInfo }} */ + const wrapper = (arg) => { + if (prerendering) { + throw new Error( + `Cannot call query.live '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead` + ); + } + + const { event, state } = get_request_store(); + + return create_live_query_resource( + __, + arg, + state, + async () => { + const iterator = await run(event, state, arg); + + try { + const { value, done } = await iterator.next(); + + if (done) { + throw new Error(`query.live '${__.name}' did not yield a value`); + } + + return value; + } finally { + await iterator.return?.(); + } + }, + () => + /** @type {Promise>} */ (/** @type {unknown} */ (run(event, state, arg))) + ); + }; + + Object.defineProperty(wrapper, '__', { value: __ }); + + return wrapper; +} + /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -305,8 +402,81 @@ function create_query_resource(__, arg, state, fn) { }; } +/** + * @param {RemoteInfo} __ + * @param {any} arg + * @param {RequestState} state + * @param {() => Promise} get_first_value + * @param {() => Promise>} get_iterator + * @returns {RemoteLiveQuery} + */ +function create_live_query_resource(__, arg, state, get_first_value, get_iterator) { + /** @type {Promise | null} */ + let promise = null; + + const get_promise = () => { + return (promise ??= get_response(__, arg, state, get_first_value)); + }; + + return { + /** @type {Promise['catch']} */ + catch(onrejected) { + return get_promise().catch(onrejected); + }, + current: undefined, + error: undefined, + /** @type {Promise['finally']} */ + finally(onfinally) { + return get_promise().finally(onfinally); + }, + loading: true, + ready: false, + connected: false, + reconnect() { + throw new Error(`Cannot call '${__.name}.reconnect()' on the server`); + }, + run() { + if (!state.is_in_universal_load) { + throw new Error( + 'On the server, .run() can only be called in universal `load` functions. Anywhere else, just await the query directly' + ); + } + + return get_iterator(); + }, + /** @type {Promise['then']} */ + then(onfulfilled, onrejected) { + return get_promise().then(onfulfilled, onrejected); + }, + get [Symbol.toStringTag]() { + return 'LiveQueryResource'; + } + }; +} + // Add batch as a property to the query function Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); +Object.defineProperty(query, 'live', { value: live, enumerable: true }); + +/** + * @template T + * @param {AsyncIterator | AsyncIterable} source + * @param {string} name + * @returns {AsyncIterator} + */ +function to_async_iterator(source, name) { + const maybe = /** @type {any} */ (source); + + if (maybe && typeof maybe[Symbol.asyncIterator] === 'function') { + return maybe[Symbol.asyncIterator](); + } + + if (maybe && typeof maybe.next === 'function') { + return maybe; + } + + throw new Error(`query.live '${name}' must return an AsyncIterator or AsyncIterable`); +} /** * @param {RemoteInfo} __ diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 125b2c6571f5..b4b3c7a01a0d 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1,4 +1,4 @@ -/** @import { RemoteQueryCacheEntry } from './remote-functions/query.svelte.js' */ +/** @import { RemoteLiveQueryCacheEntry, RemoteQueryCacheEntry } from './remote-functions/query.svelte.js' */ import { BROWSER, DEV } from 'esm-env'; import * as svelte from 'svelte'; import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; @@ -301,7 +301,7 @@ const preload_tokens = new Set(); export let pending_invalidate; /** - * @type {Map>} + * @type {Map | RemoteLiveQueryCacheEntry>} * A map of id -> query info with all queries that currently exist in the app. */ export const query_map = new Map(); @@ -412,7 +412,7 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru // Rerun queries if (force_invalidation) { query_map.forEach(({ resource }) => { - void resource.refresh?.(); + void (/** @type {any} */ (resource).refresh?.()); }); } @@ -520,7 +520,7 @@ export async function _goto(url, options, redirect_count, nav_token) { query_map.forEach(({ resource }, key) => { // Only refresh those that already existed on the old page if (query_keys?.includes(key)) { - void resource.refresh?.(); + void (/** @type {any} */ (resource).refresh?.()); } }); }); diff --git a/packages/kit/src/runtime/client/remote-functions/index.js b/packages/kit/src/runtime/client/remote-functions/index.js index 4b20cabddd92..6f71dd028e12 100644 --- a/packages/kit/src/runtime/client/remote-functions/index.js +++ b/packages/kit/src/runtime/client/remote-functions/index.js @@ -1,4 +1,4 @@ export { command } from './command.svelte.js'; export { form } from './form.svelte.js'; export { prerender } from './prerender.svelte.js'; -export { query, query_batch } from './query.svelte.js'; +export { query, query_batch, query_live } from './query.svelte.js'; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index dba4b16707c0..842935a46bfd 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -1,4 +1,4 @@ -/** @import { RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteLiveQueryFunction, RemoteQueryFunction } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '$app/paths/internal/client'; import { app, goto, query_map, query_responses } from '../client.js'; @@ -19,6 +19,15 @@ import { create_remote_key, stringify_remote_arg, unfriendly_hydratable } from ' * }} RemoteQueryCacheEntry */ +/** + * @template T + * @typedef {{ + * count: number; + * resource: LiveQuery; + * cleanup: () => void; + * }} RemoteLiveQueryCacheEntry + */ + /** * @returns {boolean} Returns `true` if we are in an effect */ @@ -40,8 +49,8 @@ export function query(id) { // If this reruns as part of HMR, refresh the query for (const [key, entry] of query_map) { if (key === id || key.startsWith(id + '/')) { - // use optional chaining in case a prerender function was turned into a query - void entry.resource.refresh?.(); + void (/** @type {any} */ (entry.resource).refresh?.()); + void (/** @type {any} */ (entry.resource).reconnect?.()); } } } @@ -57,6 +66,22 @@ export function query(id) { }); } +/** + * @param {string} id + * @returns {RemoteLiveQueryFunction} + */ +export function query_live(id) { + if (DEV) { + for (const [key, entry] of query_map) { + if (key === id || key.startsWith(id + '/')) { + void (/** @type {any} */ (entry.resource).reconnect?.()); + } + } + } + + return create_live_query_function(id); +} + /** * @param {string} id * @returns {RemoteQueryFunction} @@ -158,7 +183,17 @@ export function query_batch(id) { * @returns {RemoteQueryFunction} */ function create_query_function(id, fn) { - return (arg) => new QueryProxy(id, arg, fn); + return (arg) => /** @type {any} */ (new QueryProxy(id, arg, fn)); +} + +/** + * @template Input + * @template Output + * @param {string} id + * @returns {RemoteLiveQueryFunction} + */ +function create_live_query_function(id) { + return (arg) => /** @type {any} */ (new LiveQueryProxy(id, arg)); } /** @@ -366,6 +401,399 @@ export class Query { } } +/** + * @param {Response} response + * @returns {Promise>} + */ +async function get_stream_reader(response) { + const content_type = response.headers.get('content-type') ?? ''; + + if (response.ok && content_type.includes('application/json')) { + const result = await response.json(); + + if (result.type === 'redirect') { + await goto(result.location); + throw new Redirect(307, result.location); + } + + if (result.type === 'error') { + throw new HttpError(result.status ?? 500, result.error); + } + + throw new Error('Invalid query.live response'); + } + + if (!response.ok) { + let result; + + try { + result = await response.json(); + } catch { + throw new HttpError(response.status, response.statusText); + } + + if (result.type === 'redirect') { + await goto(result.location); + throw new Redirect(307, result.location); + } + + if (result.type === 'error') { + throw new HttpError(result.status ?? response.status ?? 500, result.error); + } + + throw new HttpError(response.status, response.statusText); + } + + if (!response.body) { + throw new Error('Expected query.live response body to be a ReadableStream'); + } + + return response.body.getReader(); +} + +/** + * @param {ReadableStreamDefaultReader} reader + */ +function create_stream_reader(reader) { + let done = false; + let buffer = ''; + const text_decoder = new TextDecoder(); + + return async () => { + while (true) { + const split = buffer.indexOf('\n'); + if (split !== -1) { + const line = buffer.slice(0, split).trim(); + buffer = buffer.slice(split + 1); + + if (!line) continue; + + const node = JSON.parse(line); + + if (node.type === 'result') { + return devalue.parse(node.result, app.decoders); + } + + if (node.type === 'redirect') { + await goto(node.location); + throw new Redirect(307, node.location); + } + + if (node.type === 'error') { + throw new HttpError(node.status ?? 500, node.error); + } + + throw new Error('Invalid query.live response'); + } + + if (done) { + if (buffer.trim()) { + const node = JSON.parse(buffer.trim()); + buffer = ''; + + if (node.type === 'result') { + return devalue.parse(node.result, app.decoders); + } + + if (node.type === 'redirect') { + await goto(node.location); + throw new Redirect(307, node.location); + } + + if (node.type === 'error') { + throw new HttpError(node.status ?? 500, node.error); + } + } + + return undefined; + } + + const chunk = await reader.read(); + done = chunk.done; + if (chunk.value) { + buffer += text_decoder.decode(chunk.value, { stream: true }); + } + } + }; +} + +/** + * @template T + * @param {string} id + * @param {string} payload + * @returns {Promise>} + */ +async function create_live_iterator(id, payload) { + const controller = new AbortController(); + const url = `${base}/${app_dir}/remote/${id}${payload ? `?payload=${payload}` : ''}`; + const response = await fetch(url, { + headers: get_remote_request_headers(), + signal: controller.signal + }); + const reader = await get_stream_reader(response); + const next_value = create_stream_reader(reader); + + let closed = false; + + /** @type {AsyncIterableIterator} */ + const iterator = { + [Symbol.asyncIterator]() { + return iterator; + }, + async next() { + if (closed) { + return { value: undefined, done: true }; + } + + const value = await next_value(); + if (value === undefined) { + closed = true; + return { value: undefined, done: true }; + } + + return { value, done: false }; + }, + async return(value) { + closed = true; + controller.abort(); + try { + await reader.cancel(); + } catch { + // already closed + } + return { value, done: true }; + } + }; + + return iterator; +} + +/** + * @template T + * @implements {Promise} + */ +export class LiveQuery { + _key; + #id; + #payload; + #loading = $state(true); + #ready = $state(false); + #connected = $state(false); + /** @type {T | undefined} */ + #raw = $state.raw(); + /** @type {any} */ + #error = $state.raw(undefined); + /** @type {Promise} */ + #promise; + /** @type {(value: T | PromiseLike) => void} */ + #resolve_first; + /** @type {(reason?: any) => void} */ + #reject_first; + #active = false; + #destroyed = false; + #attempt = 0; + /** @type {ReturnType | null} */ + #retry_timer = null; + /** @type {AbortController | null} */ + #controller = null; + #connection = 0; + + /** + * @param {string} id + * @param {string} key + * @param {string} payload + */ + constructor(id, key, payload) { + this.#id = id; + this._key = key; + this.#payload = payload; + + const { promise, resolve, reject } = with_resolvers(); + this.#promise = promise; + this.#resolve_first = resolve; + this.#reject_first = reject; + + if (Object.hasOwn(query_responses, key)) { + this.#set_value(query_responses[key]); + this.#resolve_first(query_responses[key]); + } + } + + #clear_retry() { + if (this.#retry_timer) { + clearTimeout(this.#retry_timer); + this.#retry_timer = null; + } + } + + /** @param {T} value */ + #set_value(value) { + this.#ready = true; + this.#loading = false; + this.#error = undefined; + this.#raw = value; + } + + #disconnect_current() { + this.#controller?.abort(); + this.#controller = null; + this.#connected = false; + } + + #schedule_reconnect() { + if (!this.#active || this.#destroyed || this.#retry_timer) return; + + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + return; + } + + const base_delay = Math.min(250 * 2 ** this.#attempt, 10_000); + const jitter = base_delay * (Math.random() * 0.4 - 0.2); + const delay = Math.max(0, Math.round(base_delay + jitter)); + this.#attempt += 1; + + this.#retry_timer = setTimeout(() => { + this.#retry_timer = null; + void this.#connect_stream(); + }, delay); + } + + async #connect_stream() { + if (!this.#active || this.#destroyed) return; + + const connection = ++this.#connection; + const controller = new AbortController(); + this.#controller = controller; + + const url = `${base}/${app_dir}/remote/${this.#id}${this.#payload ? `?payload=${this.#payload}` : ''}`; + + try { + const response = await fetch(url, { + headers: get_remote_request_headers(), + signal: controller.signal + }); + + if (connection !== this.#connection || !this.#active || this.#destroyed) { + return; + } + + const reader = await get_stream_reader(response); + const next_value = create_stream_reader(reader); + this.#connected = true; + this.#attempt = 0; + + while (this.#active && !this.#destroyed && connection === this.#connection) { + const value = await next_value(); + if (value === undefined) break; + + if (!this.#ready) { + this.#resolve_first(value); + } + + this.#set_value(value); + } + } catch (error) { + if (controller.signal.aborted || connection !== this.#connection) { + return; + } + + this.#connected = false; + this.#error = /** @type {any} */ (error); + if (!this.#ready) { + this.#loading = false; + this.#reject_first(error); + } + } finally { + if (connection === this.#connection) { + this.#connected = false; + this.#controller = null; + + if (this.#active && !this.#destroyed) { + this.#schedule_reconnect(); + } + } + } + } + + #on_online = () => { + if (!this.#active || this.#destroyed) return; + this.#clear_retry(); + void this.#connect_stream(); + }; + + connect() { + this.#active = true; + + if (typeof window !== 'undefined') { + window.addEventListener('online', this.#on_online); + } + + this.#clear_retry(); + if (!this.#controller) { + void this.#connect_stream(); + } + } + + disconnect() { + this.#active = false; + this.#clear_retry(); + this.#disconnect_current(); + + if (typeof window !== 'undefined') { + window.removeEventListener('online', this.#on_online); + } + } + + destroy() { + this.#destroyed = true; + this.disconnect(); + } + + get then() { + return this.#promise.then.bind(this.#promise); + } + + get catch() { + return this.#promise.catch.bind(this.#promise); + } + + get finally() { + return this.#promise.finally.bind(this.#promise); + } + + get current() { + return this.#raw; + } + + get error() { + return this.#error; + } + + get loading() { + return this.#loading; + } + + get ready() { + return this.#ready; + } + + get connected() { + return this.#connected; + } + + reconnect() { + if (!this.#active || this.#destroyed) return; + this.#attempt = 0; + this.#clear_retry(); + this.#disconnect_current(); + void this.#connect_stream(); + } + + get [Symbol.toStringTag]() { + return 'LiveQuery'; + } +} + /** * Manages the caching layer between the user and the actual {@link Query} instance. * @@ -426,7 +854,7 @@ class QueryProxy { cached.count += 1; - return cached; + return /** @type {RemoteQueryCacheEntry} */ (cached); } /** @@ -475,11 +903,11 @@ class QueryProxy { ); } - return cached.resource; + return /** @type {Query} */ (cached.resource); } #safe_get_cached_query() { - return query_map.get(this._key)?.resource; + return /** @type {Query | undefined} */ (query_map.get(this._key)?.resource); } get current() { @@ -523,7 +951,7 @@ class QueryProxy { /** @type {Query['withOverride']} */ withOverride(fn) { const entry = this.#get_or_create_cache_entry(); - const override = entry.resource.withOverride(fn); + const override = /** @type {Query} */ (entry.resource).withOverride(fn); return { _key: override._key, @@ -556,3 +984,162 @@ class QueryProxy { return 'QueryProxy'; } } + +/** + * @template T + * @implements {Promise} + */ +class LiveQueryProxy { + _key; + #id; + #payload; + #active = true; + #tracking = is_in_effect(); + + /** + * @param {string} id + * @param {any} arg + */ + constructor(id, arg) { + this.#id = id; + this.#payload = stringify_remote_arg(arg, app.hooks.transport); + this._key = create_remote_key(id, this.#payload); + + if (!this.#tracking) { + this.#active = false; + return; + } + + const entry = this.#get_or_create_cache_entry(); + entry.resource.connect(); + + $effect.pre(() => () => { + const die = this.#release(entry); + void tick().then(die); + }); + } + + /** @returns {RemoteLiveQueryCacheEntry} */ + #get_or_create_cache_entry() { + let cached = query_map.get(this._key); + + if (!cached) { + const c = (cached = { + count: 0, + resource: /** @type {LiveQuery} */ (/** @type {unknown} */ (null)), + cleanup: /** @type {() => void} */ (/** @type {unknown} */ (null)) + }); + + c.cleanup = $effect.root(() => { + c.resource = new LiveQuery(this.#id, this._key, this.#payload); + }); + + query_map.set(this._key, cached); + } + + cached.count += 1; + + return /** @type {RemoteLiveQueryCacheEntry} */ (cached); + } + + /** + * @param {RemoteLiveQueryCacheEntry} entry + * @param {boolean} [deactivate] + */ + #release(entry, deactivate = true) { + this.#active &&= !deactivate; + entry.count -= 1; + + return () => { + const cached = query_map.get(this._key); + if (cached?.count === 0) { + /** @type {any} */ (cached.resource).disconnect?.(); + /** @type {any} */ (cached.resource).destroy?.(); + cached.cleanup(); + query_map.delete(this._key); + } + }; + } + + #get_cached_query() { + if (!this.#tracking) { + throw new Error( + 'This live query was not created in a reactive context and is limited to calling `.run` and `.reconnect`.' + ); + } + + if (!this.#active) { + throw new Error( + 'This query instance is no longer active and can no longer be used for reactive state access. ' + + 'This typically means you created the query in a tracking context and stashed it somewhere outside of a tracking context.' + ); + } + + const cached = query_map.get(this._key); + + if (!cached) { + throw new Error( + 'No cached query found. This should be impossible. Please file a bug report.' + ); + } + + return /** @type {LiveQuery} */ (cached.resource); + } + + #safe_get_cached_query() { + return /** @type {LiveQuery | undefined} */ (query_map.get(this._key)?.resource); + } + + get current() { + return this.#get_cached_query().current; + } + + get error() { + return this.#get_cached_query().error; + } + + get loading() { + return this.#get_cached_query().loading; + } + + get ready() { + return this.#get_cached_query().ready; + } + + get connected() { + return this.#get_cached_query().connected; + } + + run() { + if (is_in_effect()) { + throw new Error( + 'On the client, .run() can only be called outside render, e.g. in universal `load` functions and event handlers. In render, await the query directly' + ); + } + + return create_live_iterator(this.#id, this.#payload); + } + + reconnect() { + this.#safe_get_cached_query()?.reconnect(); + } + + get then() { + const cached = this.#get_cached_query(); + return cached.then.bind(cached); + } + + get catch() { + const cached = this.#get_cached_query(); + return cached.catch.bind(cached); + } + + get finally() { + const cached = this.#get_cached_query(); + return cached.finally.bind(cached); + } + + get [Symbol.toStringTag]() { + return 'LiveQueryProxy'; + } +} diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index eff2cfe00400..91127828c03e 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -81,6 +81,6 @@ export function refresh_queries(stringified_refreshes, updates = []) { } // Update the query with the new value const entry = query_map.get(key); - entry?.resource.set(value); + /** @type {any} */ (entry?.resource)?.set?.(value); } } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 18bbc379064f..1d76dfc6212c 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -143,6 +143,118 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } + if (info.type === 'query_live') { + const live_info = + /** @type {RemoteInfo & { type: 'query_live'; run: (event: RequestEvent, state: RequestState, arg: any) => Promise> }} */ ( + info + ); + + if (event.request.method !== 'GET') { + throw new SvelteKitError( + 405, + 'Method Not Allowed', + `\`query.live\` functions must be invoked via GET request, not ${event.request.method}` + ); + } + + const payload = /** @type {string} */ ( + new URL(event.request.url).searchParams.get('payload') + ); + + const iterator = to_async_iterator( + await live_info.run(event, state, parse_remote_arg(payload, transport)), + info.name + ); + + const encoder = new TextEncoder(); + let closed = false; + + const close = async () => { + if (closed) return; + closed = true; + await iterator.return?.(); + }; + + event.request.signal.addEventListener( + 'abort', + () => { + void close(); + }, + { once: true } + ); + + return new Response( + new ReadableStream({ + async pull(controller) { + if (event.request.signal.aborted) { + await close(); + controller.close(); + return; + } + + try { + const { value, done } = await iterator.next(); + + if (done) { + await close(); + controller.close(); + return; + } + + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: 'result', + result: stringify(value, transport) + }) + '\n' + ) + ); + } catch (error) { + if (!event.request.signal.aborted) { + if (error instanceof Redirect) { + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: 'redirect', + location: error.location + }) + '\n' + ) + ); + } else { + const status = + error instanceof HttpError || error instanceof SvelteKitError + ? error.status + : 500; + + controller.enqueue( + encoder.encode( + JSON.stringify({ + type: 'error', + error: await handle_error_and_jsonify(event, state, options, error), + status + }) + '\n' + ) + ); + } + } + + await close(); + controller.close(); + } + }, + async cancel() { + await close(); + } + }), + { + headers: { + 'cache-control': 'private, no-store', + 'content-type': 'application/x-ndjson' + } + } + ); + } + const payload = info.type === 'prerender' ? additional_args @@ -184,7 +296,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) { // By setting a non-200 during prerendering we fail the prerender process (unless handleHttpError handles it). // Errors at runtime will be passed to the client and are handled there - status: state.prerendering ? status : undefined, + status: state.prerendering || info.type === 'query_live' ? status : undefined, headers: { 'cache-control': 'private, no-store' } @@ -230,6 +342,26 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } } +/** + * @template T + * @param {AsyncIterator | AsyncIterable} source + * @param {string} name + * @returns {AsyncIterator} + */ +function to_async_iterator(source, name) { + const maybe = /** @type {any} */ (source); + + if (maybe && typeof maybe[Symbol.asyncIterator] === 'function') { + return maybe[Symbol.asyncIterator](); + } + + if (maybe && typeof maybe.next === 'function') { + return maybe; + } + + throw new Error(`query.live '${name}' must return an AsyncIterator or AsyncIterable`); +} + /** @type {typeof handle_remote_form_post_internal} */ export async function handle_remote_form_post(event, state, manifest, id) { return record_span({ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 3f54b262e891..9c784040b4fa 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -571,7 +571,7 @@ export type BinaryFormMeta = { export type RemoteInfo = | { - type: 'query' | 'command'; + type: 'query' | 'query_live' | 'command'; id: string; name: string; } diff --git a/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte new file mode 100644 index 000000000000..450ec3addce1 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte @@ -0,0 +1,25 @@ + + + + + + + + +{#if show_live} + +{:else} +

detached

+{/if} +

{stats}

diff --git a/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte b/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte new file mode 100644 index 000000000000..ad1cbe97c2fc --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte @@ -0,0 +1,11 @@ + + +

{String(live.ready)}

+

{String(live.connected)}

+

{live.current}

+

{await live}

+ diff --git a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js new file mode 100644 index 000000000000..36a890b954c3 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js @@ -0,0 +1,62 @@ +import { command, query } from '$app/server'; + +let count = 0; +let drop_next = false; +let active_connections = 0; +let cleanup_count = 0; + +/** @type {Set<() => void>} */ +const listeners = new Set(); + +function notify() { + for (const listener of listeners) { + listener(); + } + + listeners.clear(); +} + +export const get_count = query.live(async function* () { + active_connections += 1; + + try { + yield count; + + while (true) { + await new Promise((resolve) => listeners.add(resolve)); + + if (drop_next) { + drop_next = false; + return; + } + + yield count; + } + } finally { + active_connections -= 1; + cleanup_count += 1; + } +}); + +export const increment = command(() => { + count += 1; + notify(); +}); + +export const reset = command(() => { + count = 0; + notify(); +}); + +export const drop = command(() => { + drop_next = true; + notify(); +}); + +export const get_stats = query(() => { + return { + active_connections, + cleanup_count, + count + }; +}); diff --git a/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte index 44f9bb70e574..13583c271553 100644 --- a/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte @@ -3,6 +3,8 @@ import { validated_query_no_args, validated_query_with_arg, + validated_live_query_no_args, + validated_live_query_with_arg, validated_prerendered_query_no_args, validated_prerendered_query_with_arg, validated_command_no_args, @@ -17,6 +19,21 @@ } } + async function read_live(resource) { + const iterator = await resource.run(); + + try { + const result = await iterator.next(); + if (result.done) { + throw new Error('query.live did not yield a value'); + } + + return result.value; + } finally { + await iterator.return?.(); + } + } + let status = $state('pending'); @@ -27,10 +44,12 @@ status = 'pending'; try { validate_result(await validated_query_no_args().run()); + validate_result(await read_live(validated_live_query_no_args())); validate_result(await validated_prerendered_query_no_args()); validate_result(await validated_command_no_args()); validate_result(await validated_query_with_arg('valid').run()); + validate_result(await read_live(validated_live_query_with_arg('valid'))); validate_result(await validated_prerendered_query_with_arg('valid')); validate_result(await validated_command_with_arg('valid')); @@ -38,7 +57,7 @@ validate_result(await validated_batch_query_with_validation('valid').run()); status = 'success'; - } catch (e) { + } catch { status = 'error'; } }} @@ -55,16 +74,21 @@ status = 'error'; } catch { try { - // @ts-expect-error - await validated_prerendered_query_no_args('invalid'); + await validated_live_query_no_args('invalid').run(); status = 'error'; } catch { try { // @ts-expect-error - await validated_command_no_args('invalid'); + await validated_prerendered_query_no_args('invalid'); status = 'error'; } catch { - status = 'success'; + try { + // @ts-expect-error + await validated_command_no_args('invalid'); + status = 'error'; + } catch { + status = 'success'; + } } } } @@ -85,18 +109,19 @@ status = 'wrong error message'; return; } + try { - // @ts-expect-error - await validated_prerendered_query_with_arg(1); + await validated_live_query_with_arg(1).run(); status = 'error'; } catch (e) { if (!isHttpError(e) || e.body.message !== 'Input must be a string') { status = 'wrong error message'; return; } + try { // @ts-expect-error - await validated_command_with_arg(1); + await validated_prerendered_query_with_arg(1); status = 'error'; } catch (e) { if (!isHttpError(e) || e.body.message !== 'Input must be a string') { @@ -106,14 +131,26 @@ try { // @ts-expect-error - await validated_batch_query_with_validation(123).run(); + await validated_command_with_arg(1); status = 'error'; } catch (e) { if (!isHttpError(e) || e.body.message !== 'Input must be a string') { status = 'wrong error message'; return; } - status = 'success'; + + try { + // @ts-expect-error + await validated_batch_query_with_validation(123).run(); + status = 'error'; + } catch (e) { + if (!isHttpError(e) || e.body.message !== 'Input must be a string') { + status = 'wrong error message'; + return; + } + + status = 'success'; + } } } } @@ -129,6 +166,7 @@ try { // @ts-expect-error validate_result(await validated_query_with_arg('valid', 'ignored').run()); + validate_result(await read_live(validated_live_query_with_arg('valid', 'ignored'))); // @ts-expect-error validate_result(await validated_prerendered_query_with_arg('valid', 'ignored')); // @ts-expect-error @@ -137,7 +175,7 @@ validate_result(await validated_batch_query_no_validation('valid', 'ignored').run()); status = 'success'; - } catch (e) { + } catch { status = 'error'; } }} diff --git a/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js index c4050a7bc471..0d967445c5f2 100644 --- a/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js @@ -16,6 +16,12 @@ export const validated_query_no_args = query((arg) => (arg === undefined ? 'succ export const validated_query_with_arg = query(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); +export const validated_live_query_no_args = query.live(function* (arg) { + yield arg === undefined ? 'success' : 'failure'; +}); +export const validated_live_query_with_arg = query.live(schema, function* (...arg) { + yield typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure'; +}); export const validated_prerendered_query_no_args = prerender((arg) => arg === undefined ? 'success' : 'failure' diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 9429312078da..0955da2b5fa4 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -362,6 +362,35 @@ test.describe('remote function mutations', () => { await expect(page.locator('#phrase')).toHaveText('i am your father'); }); + test('query.live streams updates and reconnects after disconnect', async ({ page }) => { + await page.goto('/remote/live'); + await page.click('#reset'); + + await expect(page.locator('#first-value')).toHaveText('0'); + await expect(page.locator('#count')).toHaveText('0'); + await expect(page.locator('#connected')).toHaveText('true'); + + await page.click('#increment'); + await expect(page.locator('#count')).toHaveText('1'); + + await page.click('#drop'); + + await page.click('#increment'); + await expect(page.locator('#count')).toHaveText('2'); + + await page.click('#reconnect'); + await expect(page.locator('#connected')).toHaveText('true'); + }); + + test('query.live can be detached from the page', async ({ page }) => { + await page.goto('/remote/live'); + await page.click('#reset'); + await expect(page.locator('#count')).toHaveText('0'); + + await page.click('#toggle-live'); + await expect(page.locator('#detached')).toHaveText('detached'); + }); + test.describe('query runtime guardrails', () => { test('query created outside tracking context can run but cannot expose reactive state', async ({ page diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 5bd7916d6383..a70bdfcf4ef6 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -20,6 +20,11 @@ test.describe('remote functions', () => { await expect(page.locator('body')).not.toContainText('Loading todo'); }); + test('query.live renders the first yielded value during SSR', async ({ page }) => { + await page.goto('/remote/live'); + await expect(page.locator('#first-value')).toHaveText('0'); + }); + test('run is blocked during server render', async ({ page }) => { await page.goto('/remote/query-runtime-errors/run-in-render'); await expect(page.locator('#error')).toContainText( diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index 59439717f43d..5d108088e9cb 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -3,6 +3,7 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import { RemoteForm, RemoteFormInput, + RemoteLiveQueryFunction, RemotePrerenderFunction, RemoteQueryFunction, invalid @@ -83,6 +84,60 @@ function query_tests() { } query_tests(); +function live_query_tests() { + const no_args: RemoteLiveQueryFunction = query.live(async function* () { + yield 'hello'; + }); + void no_args(); + // @ts-expect-error + void no_args(''); + + const one_arg: RemoteLiveQueryFunction = query.live( + 'unchecked', + async function* (a: number) { + yield a.toString(); + } + ); + void one_arg(1); + // @ts-expect-error + void one_arg('1'); + // @ts-expect-error + void one_arg(); + + async function live_without_args() { + const q = query.live(async function* () { + yield 'Hello world'; + }); + + const result: string = await q(); + result; + + const iterator: AsyncIterator = await q().run(); + iterator; + + q().connected === true; + q().reconnect(); + // @ts-expect-error + q().refresh(); + // @ts-expect-error + q().set('x'); + } + void live_without_args(); + + async function live_with_schema() { + const q = query.live(schema, async function* (a) { + yield a; + }); + + const result: string = await q('x'); + result; + // @ts-expect-error + void q(123); + } + void live_with_schema(); +} +live_query_tests(); + function prerender_tests() { const no_args: RemotePrerenderFunction = prerender(() => 'Hello world'); void no_args(); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6b3ce5e4cad7..7819aca70c10 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -4,7 +4,11 @@ declare module '@sveltejs/kit' { import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; import type { StandardSchemaV1 } from '@standard-schema/spec'; - import type { RouteId as AppRouteId, LayoutParams as AppLayoutParams, ResolvedPathname } from '$app/types'; + import type { + RouteId as AppRouteId, + LayoutParams as AppLayoutParams, + ResolvedPathname + } from '$app/types'; // @ts-ignore this is an optional peer dependency so could be missing. Written like this so dts-buddy preserves the ts-ignore type Span = import('@opentelemetry/api').Span; @@ -271,7 +275,10 @@ declare module '@sveltejs/kit' { * @param name the name of the cookie * @param opts the options, passed directly to `cookie.serialize`. The `path` must match the path of the cookie you want to delete. See documentation [here](https://github.com/jshttp/cookie#cookieserializename-value-options) */ - delete: (name: string, opts: import('cookie').CookieSerializeOptions & { path: string }) => void; + delete: ( + name: string, + opts: import('cookie').CookieSerializeOptions & { path: string } + ) => void; /** * Serialize a cookie name-value pair into a `Set-Cookie` header string, but don't apply it to the response. @@ -1549,7 +1556,10 @@ declare module '@sveltejs/kit' { * but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. * @param input the html chunk and the info if this is the last chunk */ - transformPageChunk?: (input: { html: string; done: boolean }) => MaybePromise; + transformPageChunk?: (input: { + html: string; + done: boolean; + }) => MaybePromise; /** * Determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. * By default, none will be included. @@ -1797,7 +1807,11 @@ declare module '@sveltejs/kit' { } // If T is unknown or has an index signature, the types below will recurse indefinitely and create giant unions that TS can't handle - type WillRecurseIndefinitely = unknown extends T ? true : string extends keyof T ? true : false; + type WillRecurseIndefinitely = unknown extends T + ? true + : string extends keyof T + ? true + : false; // Input type mappings for form fields type InputTypeMap = { @@ -1896,19 +1910,20 @@ declare module '@sveltejs/kit' { /** * Form field accessor type that provides name(), value(), and issues() methods */ - export type RemoteFormField = RemoteFormFieldMethods & { - /** - * Returns an object that can be spread onto an input element with the correct type attribute, - * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. - * @example - * ```svelte - * - * - * - * ``` - */ - as>(...args: AsArgs): InputElementProps; - }; + export type RemoteFormField = + RemoteFormFieldMethods & { + /** + * Returns an object that can be spread onto an input element with the correct type attribute, + * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. + * @example + * ```svelte + * + * + * + * ``` + */ + as>(...args: AsArgs): InputElementProps; + }; type RemoteFormFieldContainer = RemoteFormFieldMethods & { /** Validation issues belonging to this or any of the fields that belong to it, if any */ @@ -2144,6 +2159,19 @@ declare module '@sveltejs/kit' { withOverride(update: (current: T) => T): RemoteQueryOverride; }; + export type RemoteLiveQuery = RemoteResource & { + /** + * Returns an async iterator with live updates. + * Unlike awaiting the resource directly, this can only be used _outside_ render + * (i.e. in load functions, event handlers and so on) + */ + run(): Promise>; + /** `true` if the live stream is currently connected. */ + readonly connected: boolean; + /** Reconnects the live stream immediately. */ + reconnect(): void; + }; + export interface RemoteQueryOverride { _key: string; release(): void; @@ -2162,6 +2190,13 @@ declare module '@sveltejs/kit' { export type RemoteQueryFunction = ( arg: undefined extends Input ? Input | void : Input ) => RemoteQuery; + + /** + * The return value of a remote `query.live` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. + */ + export type RemoteLiveQueryFunction = ( + arg: undefined extends Input ? Input | void : Input + ) => RemoteLiveQuery; interface AdapterEntry { /** * A string that uniquely identifies an HTTP service (e.g. serverless function) and is used for deduplication. @@ -2184,7 +2219,9 @@ declare module '@sveltejs/kit' { * A function that is invoked once the entry has been created. This is where you * should write the function to the filesystem and generate redirect manifests. */ - complete(entry: { generateManifest(opts: { relativePath: string }): string }): MaybePromise; + complete(entry: { + generateManifest(opts: { relativePath: string }): string; + }): MaybePromise; } // Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts @@ -2667,16 +2704,24 @@ declare module '@sveltejs/kit' { * @throws {HttpError} This error instructs SvelteKit to initiate HTTP error handling. * @throws {Error} If the provided status is invalid (not between 400 and 599). */ - export function error(status: number, body?: { - message: string; - } extends App.Error ? App.Error | string | undefined : never): never; + export function error( + status: number, + body?: { + message: string; + } extends App.Error + ? App.Error | string | undefined + : never + ): never; /** * Checks whether this is an error thrown by {@link error}. * @param status The status to filter for. * */ - export function isHttpError(e: unknown, status?: T): e is (HttpError_1 & { + export function isHttpError( + e: unknown, + status?: T + ): e is HttpError_1 & { status: T extends undefined ? never : T; - }); + }; /** * Redirect a request. When called during request handling, SvelteKit will return a redirect response. * Make sure you're not catching the thrown redirect, which would prevent SvelteKit from handling it. @@ -2693,7 +2738,10 @@ declare module '@sveltejs/kit' { * @throws {Redirect} This error instructs SvelteKit to redirect to the specified location. * @throws {Error} If the provided status is invalid. * */ - export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL): never; + export function redirect( + status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), + location: string | URL + ): never; /** * Checks whether this is a redirect thrown by {@link redirect}. * @param e The object to check. @@ -2777,23 +2825,42 @@ declare module '@sveltejs/kit' { wasNormalized: boolean; denormalize: (url?: string | URL) => URL; }; - export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; - export type NumericRange = Exclude, LessThan>; + export type LessThan< + TNumber extends number, + TArray extends any[] = [] + > = TNumber extends TArray['length'] + ? TArray[number] + : LessThan; + export type NumericRange = Exclude< + TEnd | LessThan, + LessThan + >; type ValidPageOption = (typeof valid_page_options_array)[number]; type PageOptions = Partial>; - const valid_page_options_array: readonly ["ssr", "prerender", "csr", "trailingSlash", "config", "entries", "load"]; + const valid_page_options_array: readonly [ + 'ssr', + 'prerender', + 'csr', + 'trailingSlash', + 'config', + 'entries', + 'load' + ]; export const VERSION: string; class HttpError_1 { - - constructor(status: number, body: { - message: string; - } extends App.Error ? (App.Error | string | undefined) : App.Error); + constructor( + status: number, + body: { + message: string; + } extends App.Error + ? App.Error | string | undefined + : App.Error + ); status: number; body: App.Error; toString(): string; } class Redirect_1 { - constructor(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308, location: string); status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308; location: string; @@ -2880,13 +2947,20 @@ declare module '@sveltejs/kit/hooks' { } declare module '@sveltejs/kit/node' { - export function getRequest({ request, base, bodySizeLimit }: { - request: import("http").IncomingMessage; + export function getRequest({ + request, + base, + bodySizeLimit + }: { + request: import('http').IncomingMessage; base: string; bodySizeLimit?: number; }): Promise; - export function setResponse(res: import("http").ServerResponse, response: Response): Promise; + export function setResponse( + res: import('http').ServerResponse, + response: Response + ): Promise; /** * Converts a file on disk to a readable stream * @since 2.4.0 @@ -2911,7 +2985,7 @@ declare module '@sveltejs/kit/vite' { /** * Returns the SvelteKit Vite plugins. * */ - export function sveltekit(): Promise; + export function sveltekit(): Promise; export {}; } @@ -2959,7 +3033,10 @@ declare module '$app/forms' { * } * ``` * */ - export function deserialize | undefined, Failure extends Record | undefined>(result: string): import("@sveltejs/kit").ActionResult; + export function deserialize< + Success extends Record | undefined, + Failure extends Record | undefined + >(result: string): import('@sveltejs/kit').ActionResult; /** * This action enhances a `
` element that otherwise would work without JavaScript. * @@ -2983,14 +3060,23 @@ declare module '$app/forms' { * @param form_element The form element * @param submit Submit callback */ - export function enhance | undefined, Failure extends Record | undefined>(form_element: HTMLFormElement, submit?: import("@sveltejs/kit").SubmitFunction): { + export function enhance< + Success extends Record | undefined, + Failure extends Record | undefined + >( + form_element: HTMLFormElement, + submit?: import('@sveltejs/kit').SubmitFunction + ): { destroy(): void; }; /** * This action updates the `form` property of the current page with the given data and updates `page.status`. * In case of an error, it redirects to the nearest error page. * */ - export function applyAction | undefined, Failure extends Record | undefined>(result: import("@sveltejs/kit").ActionResult): Promise; + export function applyAction< + Success extends Record | undefined, + Failure extends Record | undefined + >(result: import('@sveltejs/kit').ActionResult): Promise; export {}; } @@ -3001,7 +3087,9 @@ declare module '$app/navigation' { * * `afterNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function afterNavigate(callback: (navigation: import("@sveltejs/kit").AfterNavigate) => void): void; + export function afterNavigate( + callback: (navigation: import('@sveltejs/kit').AfterNavigate) => void + ): void; /** * A navigation interceptor that triggers before we navigate to a URL, whether by clicking a link, calling `goto(...)`, or using the browser back/forward controls. * @@ -3013,7 +3101,9 @@ declare module '$app/navigation' { * * `beforeNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function beforeNavigate(callback: (navigation: import("@sveltejs/kit").BeforeNavigate) => void): void; + export function beforeNavigate( + callback: (navigation: import('@sveltejs/kit').BeforeNavigate) => void + ): void; /** * A lifecycle function that runs the supplied `callback` immediately before we navigate to a new URL except during full-page navigations. * @@ -3023,7 +3113,9 @@ declare module '$app/navigation' { * * `onNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function onNavigate(callback: (navigation: import("@sveltejs/kit").OnNavigate) => MaybePromise<(() => void) | void>): void; + export function onNavigate( + callback: (navigation: import('@sveltejs/kit').OnNavigate) => MaybePromise<(() => void) | void> + ): void; /** * If called when the page is being updated following a navigation (in `onMount` or `afterNavigate` or an action, for example), this disables SvelteKit's built-in scroll handling. * This is generally discouraged, since it breaks user expectations. @@ -3038,14 +3130,17 @@ declare module '$app/navigation' { * @param url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param {Object} opts Options related to the navigation * */ - export function goto(url: string | URL, opts?: { - replaceState?: boolean | undefined; - noScroll?: boolean | undefined; - keepFocus?: boolean | undefined; - invalidateAll?: boolean | undefined; - invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; - state?: App.PageState | undefined; - }): Promise; + export function goto( + url: string | URL, + opts?: { + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; + state?: App.PageState | undefined; + } + ): Promise; /** * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated. * @@ -3072,7 +3167,9 @@ declare module '$app/navigation' { * Causes all currently active remote functions to refresh, and all `load` functions belonging to the currently active page to re-run (unless disabled via the option argument). * Returns a `Promise` that resolves when the page is subsequently updated. * */ - export function refreshAll({ includeLoadFunctions }?: { + export function refreshAll({ + includeLoadFunctions + }?: { includeLoadFunctions?: boolean; }): Promise; /** @@ -3086,14 +3183,17 @@ declare module '$app/navigation' { * * @param href Page to preload * */ - export function preloadData(href: string): Promise<{ - type: "loaded"; - status: number; - data: Record; - } | { - type: "redirect"; - location: string; - }>; + export function preloadData(href: string): Promise< + | { + type: 'loaded'; + status: number; + data: Record; + } + | { + type: 'redirect'; + location: string; + } + >; /** * Programmatically imports the code for routes that haven't yet been fetched. * Typically, you might call this to speed up subsequent navigation. @@ -3121,7 +3221,15 @@ declare module '$app/navigation' { } declare module '$app/paths' { - import type { RouteIdWithSearchOrHash, PathnameWithSearchOrHash, ResolvedPathname, RouteId, RouteParams, Asset, Pathname as Pathname_1 } from '$app/types'; + import type { + RouteIdWithSearchOrHash, + PathnameWithSearchOrHash, + ResolvedPathname, + RouteId, + RouteParams, + Asset, + Pathname as Pathname_1 + } from '$app/types'; /** * A string that matches [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths). * @@ -3152,16 +3260,15 @@ declare module '$app/paths' { ? Pathname : T; - type ResolveArgs = - T extends RouteId - ? RouteParams extends Record + type ResolveArgs = T extends RouteId + ? RouteParams extends Record + ? [route: T] + : [route: T, params: RouteParams] + : StripSearchOrHash extends infer U extends RouteId + ? RouteParams extends Record ? [route: T] - : [route: T, params: RouteParams] - : StripSearchOrHash extends infer U extends RouteId - ? RouteParams extends Record - ? [route: T] - : [route: T, params: RouteParams] - : [route: T]; + : [route: T, params: RouteParams] + : [route: T]; /** * Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path. * @@ -3199,7 +3306,9 @@ declare module '$app/paths' { * @since 2.26 * * */ - export function resolve(...args: ResolveArgs): ResolvedPathname; + export function resolve( + ...args: ResolveArgs + ): ResolvedPathname; /** * Match a path or URL to a route ID and extracts any parameters. * @@ -3227,7 +3336,16 @@ declare module '$app/paths' { } declare module '$app/server' { - import type { RequestEvent, RemoteCommand, RemoteForm, RemoteFormInput, InvalidField, RemotePrerenderFunction, RemoteQueryFunction } from '@sveltejs/kit'; + import type { + RequestEvent, + RemoteCommand, + RemoteForm, + RemoteFormInput, + InvalidField, + RemotePrerenderFunction, + RemoteQueryFunction, + RemoteLiveQueryFunction + } from '@sveltejs/kit'; import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * Read the contents of an imported asset from the filesystem @@ -3265,7 +3383,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function command(validate: "unchecked", fn: (arg: Input) => Output): RemoteCommand; + export function command( + validate: 'unchecked', + fn: (arg: Input) => Output + ): RemoteCommand; /** * Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3273,7 +3394,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function command(validate: Schema, fn: (arg: StandardSchemaV1.InferOutput) => Output): RemoteCommand, Output>; + export function command( + validate: Schema, + fn: (arg: StandardSchemaV1.InferOutput) => Output + ): RemoteCommand, Output>; /** * Creates a form object that can be spread onto a `` element. * @@ -3289,7 +3413,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function form(validate: "unchecked", fn: (data: Input, issue: InvalidField) => MaybePromise): RemoteForm; + export function form( + validate: 'unchecked', + fn: (data: Input, issue: InvalidField) => MaybePromise + ): RemoteForm; /** * Creates a form object that can be spread onto a `` element. * @@ -3297,7 +3424,16 @@ declare module '$app/server' { * * @since 2.27 */ - export function form>, Output>(validate: Schema, fn: (data: StandardSchemaV1.InferOutput, issue: InvalidField>) => MaybePromise): RemoteForm, Output>; + export function form< + Schema extends StandardSchemaV1>, + Output + >( + validate: Schema, + fn: ( + data: StandardSchemaV1.InferOutput, + issue: InvalidField> + ) => MaybePromise + ): RemoteForm, Output>; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3305,10 +3441,15 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender(fn: () => MaybePromise, options?: { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } | undefined): RemotePrerenderFunction; + export function prerender( + fn: () => MaybePromise, + options?: + | { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } + | undefined + ): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3316,10 +3457,16 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender(validate: "unchecked", fn: (arg: Input) => MaybePromise, options?: { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } | undefined): RemotePrerenderFunction; + export function prerender( + validate: 'unchecked', + fn: (arg: Input) => MaybePromise, + options?: + | { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } + | undefined + ): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3327,10 +3474,16 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, options?: { - inputs?: RemotePrerenderInputsGenerator>; - dynamic?: boolean; - } | undefined): RemotePrerenderFunction, Output>; + export function prerender( + schema: Schema, + fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, + options?: + | { + inputs?: RemotePrerenderInputsGenerator>; + dynamic?: boolean; + } + | undefined + ): RemotePrerenderFunction, Output>; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3346,7 +3499,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function query(validate: "unchecked", fn: (arg: Input) => MaybePromise): RemoteQueryFunction; + export function query( + validate: 'unchecked', + fn: (arg: Input) => MaybePromise + ): RemoteQueryFunction; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3354,7 +3510,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function query(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise): RemoteQueryFunction, Output>; + export function query( + schema: Schema, + fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise + ): RemoteQueryFunction, Output>; export namespace query { /** * Creates a batch query function that collects multiple calls and executes them in a single request @@ -3363,7 +3522,10 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>): RemoteQueryFunction; + function batch( + validate: 'unchecked', + fn: (args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output> + ): RemoteQueryFunction; /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -3371,7 +3533,40 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; + function batch( + schema: Schema, + fn: ( + args: StandardSchemaV1.InferOutput[] + ) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output> + ): RemoteQueryFunction, Output>; + /** + * Creates a live query function that streams updates whenever data changes. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. + */ + function live( + fn: () => MaybePromise | AsyncIterable> + ): RemoteLiveQueryFunction; + /** + * Creates a live query function that streams updates whenever data changes. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. + */ + function live( + validate: 'unchecked', + fn: (arg: Input) => MaybePromise | AsyncIterable> + ): RemoteLiveQueryFunction; + /** + * Creates a live query function that streams updates whenever data changes. + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. + */ + function live( + schema: Schema, + fn: ( + arg: StandardSchemaV1.InferOutput + ) => MaybePromise | AsyncIterable> + ): RemoteLiveQueryFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; @@ -3416,19 +3611,21 @@ declare module '$app/state' { * On the server, values can only be read during rendering (in other words _not_ in e.g. `load` functions). In the browser, the values can be read at any time. * * */ - export const page: import("@sveltejs/kit").Page; + export const page: import('@sveltejs/kit').Page; /** * A read-only object representing an in-progress navigation, with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. * Values are `null` when no navigation is occurring, or during server rendering. * */ - export const navigating: import("@sveltejs/kit").Navigation | { - from: null; - to: null; - type: null; - willUnload: null; - delta: null; - complete: null; - }; + export const navigating: + | import('@sveltejs/kit').Navigation + | { + from: null; + to: null; + type: null; + willUnload: null; + delta: null; + complete: null; + }; /** * A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * */ @@ -3442,11 +3639,10 @@ declare module '$app/state' { declare module '$app/stores' { export function getStores(): { - page: typeof page; - + navigating: typeof navigating; - + updated: typeof updated; }; /** @@ -3456,7 +3652,7 @@ declare module '$app/stores' { * * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const page: import("svelte/store").Readable; + export const page: import('svelte/store').Readable; /** * A readable store. * When navigating starts, its value is a `Navigation` object with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. @@ -3466,7 +3662,9 @@ declare module '$app/stores' { * * @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const navigating: import("svelte/store").Readable; + export const navigating: import('svelte/store').Readable< + import('@sveltejs/kit').Navigation | null + >; /** * A readable store whose initial value is `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * @@ -3474,12 +3672,12 @@ declare module '$app/stores' { * * @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const updated: import("svelte/store").Readable & { + export const updated: import('svelte/store').Readable & { check(): Promise; }; export {}; -}/** +} /** * It's possible to tell SvelteKit how to type objects inside your app by declaring the `App` namespace. By default, a new project will have a file called `src/app.d.ts` containing the following: * * ```ts @@ -3628,4 +3826,4 @@ declare module '$app/types' { export type Asset = ReturnType; } -//# sourceMappingURL=index.d.ts.map \ No newline at end of file +//# sourceMappingURL=index.d.ts.map diff --git a/query-live-plan.md b/query-live-plan.md new file mode 100644 index 000000000000..50314de65d49 --- /dev/null +++ b/query-live-plan.md @@ -0,0 +1,34 @@ +# query.live implementation plan + +- [ ] 1. Types + public API surface + - [ ] Add `query.live` to server/runtime exports and remote client factory mapping + - [ ] Extend internal remote type unions with `query_live` + - [ ] Add public live query types and `$app/server` declarations + +- [ ] 2. Server-side primitive + - [ ] Implement `query.live(...)` overloads with the same validation semantics as `query` and `query.batch` + - [ ] Enforce callback returns an `AsyncIterator`/`AsyncIterable` + - [ ] SSR await behavior: read first yielded value, then stop iteration via `return()` + +- [ ] 3. Live remote endpoint + - [ ] Handle `query_live` in server remote dispatcher + - [ ] Return a streaming `ReadableStream` response + - [ ] Ensure abort/disconnect calls iterator `return()` for cleanup + +- [ ] 4. Client live resource + - [ ] Add live query resource implementation in `query.svelte.js` + - [ ] Connect while active instances exist; disconnect when inactive + - [ ] Expose `connected` and `reconnect()` + - [ ] Implement automatic reconnect with exponential backoff + jitter + - [ ] Resume reconnect attempts on `online` event + +- [ ] 5. Tests + - [ ] Extend async app fixtures/routes for deterministic live updates and cleanup checks + - [ ] Add SSR + CSR tests for first value, streaming updates, disconnect cleanup + - [ ] Add reconnect tests (drop + manual reconnect + online resume) + - [ ] Add validation coverage for `query.live` + - [ ] Add TypeScript type tests for `query.live` signatures and resource shape + +- [ ] 6. Docs + verification + - [ ] Document `query.live` in remote functions docs + - [ ] Run focused tests and fix regressions From af10f8b5c7910c5afec2ecff335bfa3df819749b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Mar 2026 18:54:24 -0400 Subject: [PATCH 02/36] fix: make query.live await blocks update with stream values --- .../client/remote-functions/query.svelte.js | 40 +++++++++++++++++-- .../kit/test/apps/async/test/client.test.js | 1 + 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 842935a46bfd..6d2c0b54efdd 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -579,12 +579,30 @@ export class LiveQuery { #loading = $state(true); #ready = $state(false); #connected = $state(false); + #version = $state(0); /** @type {T | undefined} */ #raw = $state.raw(); /** @type {any} */ #error = $state.raw(undefined); /** @type {Promise} */ #promise; + + /** @type {Promise['then']} */ + // @ts-expect-error TS doesn't understand that the promise returns something + #then = $derived.by(() => { + this.#version; + const p = this.#promise; + + return (resolve, reject) => { + const result = p.then(tick).then(() => /** @type {T} */ (this.#raw)); + + if (resolve || reject) { + return result.then(resolve, reject); + } + + return result; + }; + }); /** @type {(value: T | PromiseLike) => void} */ #resolve_first; /** @type {(reason?: any) => void} */ @@ -632,6 +650,7 @@ export class LiveQuery { this.#loading = false; this.#error = undefined; this.#raw = value; + this.#version += 1; } #disconnect_current() { @@ -750,15 +769,30 @@ export class LiveQuery { } get then() { - return this.#promise.then.bind(this.#promise); + return this.#then; } get catch() { - return this.#promise.catch.bind(this.#promise); + this.#then; + return (/** @type {any} */ reject) => { + return this.#then(undefined, reject); + }; } get finally() { - return this.#promise.finally.bind(this.#promise); + this.#then; + return (/** @type {any} */ fn) => { + return this.#then( + (value) => { + fn(); + return value; + }, + (error) => { + fn(); + throw error; + } + ); + }; } get current() { diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 0955da2b5fa4..dffe3eee2c83 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -372,6 +372,7 @@ test.describe('remote function mutations', () => { await page.click('#increment'); await expect(page.locator('#count')).toHaveText('1'); + await expect(page.locator('#first-value')).toHaveText('1'); await page.click('#drop'); From 9fd9c25f7f66eda19e32e473485152a425892a3c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Mar 2026 19:20:30 -0400 Subject: [PATCH 03/36] fix: cancel live query streams on disconnect and reload --- .../20-core-concepts/60-remote-functions.md | 13 +++++ packages/kit/src/exports/public.d.ts | 8 +++ .../src/runtime/app/server/remote/query.js | 58 +++++++++++++++---- .../client/remote-functions/query.svelte.js | 14 +++++ packages/kit/src/runtime/server/remote.js | 29 ++-------- .../src/routes/remote/live/live.remote.js | 26 ++++++++- .../kit/test/apps/async/test/client.test.js | 22 +++++++ packages/kit/test/types/remote.test.ts | 10 +++- packages/kit/types/index.d.ts | 22 ++++++- 9 files changed, 158 insertions(+), 44 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index f7890b5037ba..7bc3f7de2967 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -242,6 +242,19 @@ export const getCount = query.live(async function* () { }); ``` +The callback receives a context with an `AbortSignal` so that long-running waits can stop immediately when the client disconnects: + +```js +export const getCount = query.live(async function* (_, { signal }) { + yield count; + + while (true) { + await wait_for_change({ signal }); + yield count; + } +}); +``` + On the server, `await getCount()` reads the first yielded value and then closes the iterator. This allows SSR to serialize the initial value and reuse it during hydration. On the client, the query stays connected while it's actively used in a component. When there are no active uses left, the stream disconnects and server-side iteration is stopped. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index ee4d7539d99d..e4498d48f9a6 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2183,6 +2183,14 @@ export type RemoteLiveQuery = RemoteResource & { reconnect(): void; }; +export interface RemoteLiveQueryContext { + /** + * Abort signal for the current live connection. + * Use this to stop pending work promptly when the client disconnects. + */ + readonly signal: AbortSignal; +} + export interface RemoteQueryOverride { _key: string; release(): void; diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 6fd2802394b9..77568dfda304 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -59,7 +59,7 @@ export function query(validate_or_fn, maybe_fn) { const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ - const validate = create_validator(validate_or_fn, maybe_fn); + const validate = create_validator(validate_or_fn, /** @type {any} */ (maybe_fn)); /** @type {RemoteInfo} */ const __ = { type: 'query', id: '', name: '' }; @@ -91,7 +91,7 @@ export function query(validate_or_fn, maybe_fn) { * * @template Output * @overload - * @param {() => MaybePromise | AsyncIterable>} fn + * @param {(arg: void, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction} */ /** @@ -99,7 +99,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {'unchecked'} validate - * @param {(arg: Input) => MaybePromise | AsyncIterable>} fn + * @param {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction} */ /** @@ -107,23 +107,23 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {Schema} schema - * @param {(arg: StandardSchemaV1.InferOutput) => MaybePromise | AsyncIterable>} fn + * @param {(arg: StandardSchemaV1.InferOutput, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction, Output>} */ /** * @template Input * @template Output * @param {any} validate_or_fn - * @param {(args?: Input) => MaybePromise | AsyncIterable>} [maybe_fn] + * @param {(args: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} [maybe_fn] * @returns {RemoteLiveQueryFunction} */ /*@__NO_SIDE_EFFECTS__*/ function live(validate_or_fn, maybe_fn) { - /** @type {(arg?: Input) => MaybePromise | AsyncIterable>} */ + /** @type {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} */ const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ - const validate = create_validator(validate_or_fn, maybe_fn); + const validate = create_validator(validate_or_fn, /** @type {any} */ (maybe_fn)); /** * @param {any} event @@ -136,7 +136,14 @@ function live(validate_or_fn, maybe_fn) { state, false, () => validate(arg), - async (input) => to_async_iterator(await fn(input), __.name) + async (input) => { + const controller = new AbortController(); + + return { + iterator: to_async_iterator(await fn(input, { signal: controller.signal }), __.name), + cancel: () => controller.abort() + }; + } ); /** @type {RemoteInfo & { type: 'query_live'; run: typeof run }} */ @@ -157,7 +164,8 @@ function live(validate_or_fn, maybe_fn) { arg, state, async () => { - const iterator = await run(event, state, arg); + const live = await run(event, state, arg); + const iterator = with_live_cancel(live); try { const { value, done } = await iterator.next(); @@ -168,11 +176,11 @@ function live(validate_or_fn, maybe_fn) { return value; } finally { + live.cancel(); await iterator.return?.(); } }, - () => - /** @type {Promise>} */ (/** @type {unknown} */ (run(event, state, arg))) + async () => with_live_cancel(await run(event, state, arg)) ); }; @@ -478,6 +486,34 @@ function to_async_iterator(source, name) { throw new Error(`query.live '${name}' must return an AsyncIterator or AsyncIterable`); } +/** + * @template T + * @param {{ iterator: AsyncIterator; cancel: () => void }} live + * @returns {AsyncIterableIterator} + */ +function with_live_cancel(live) { + const iterator = live.iterator; + + /** @type {AsyncIterableIterator} */ + const wrapped = { + next(value) { + return iterator.next(value); + }, + return(value) { + live.cancel(); + return iterator.return ? iterator.return(value) : Promise.resolve({ value, done: true }); + }, + throw(error) { + return iterator.throw ? iterator.throw(error) : Promise.reject(error); + }, + [Symbol.asyncIterator]() { + return wrapped; + } + }; + + return wrapped; +} + /** * @param {RemoteInfo} __ * @param {'set' | 'refresh'} action diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 6d2c0b54efdd..493ec4bba8b3 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -740,11 +740,22 @@ export class LiveQuery { void this.#connect_stream(); }; + #on_offline = () => { + this.#disconnect_current(); + }; + + #on_pagehide = () => { + this.#disconnect_current(); + }; + connect() { this.#active = true; if (typeof window !== 'undefined') { window.addEventListener('online', this.#on_online); + window.addEventListener('offline', this.#on_offline); + window.addEventListener('pagehide', this.#on_pagehide); + window.addEventListener('beforeunload', this.#on_pagehide); } this.#clear_retry(); @@ -760,6 +771,9 @@ export class LiveQuery { if (typeof window !== 'undefined') { window.removeEventListener('online', this.#on_online); + window.removeEventListener('offline', this.#on_offline); + window.removeEventListener('pagehide', this.#on_pagehide); + window.removeEventListener('beforeunload', this.#on_pagehide); } } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 1d76dfc6212c..3eb835ddffa4 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -145,7 +145,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) if (info.type === 'query_live') { const live_info = - /** @type {RemoteInfo & { type: 'query_live'; run: (event: RequestEvent, state: RequestState, arg: any) => Promise> }} */ ( + /** @type {RemoteInfo & { type: 'query_live'; run: (event: RequestEvent, state: RequestState, arg: any) => Promise<{ iterator: AsyncIterator; cancel: () => void }> }} */ ( info ); @@ -161,10 +161,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) new URL(event.request.url).searchParams.get('payload') ); - const iterator = to_async_iterator( - await live_info.run(event, state, parse_remote_arg(payload, transport)), - info.name - ); + const live = await live_info.run(event, state, parse_remote_arg(payload, transport)); + const iterator = live.iterator; const encoder = new TextEncoder(); let closed = false; @@ -172,6 +170,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const close = async () => { if (closed) return; closed = true; + live.cancel(); await iterator.return?.(); }; @@ -342,26 +341,6 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } } -/** - * @template T - * @param {AsyncIterator | AsyncIterable} source - * @param {string} name - * @returns {AsyncIterator} - */ -function to_async_iterator(source, name) { - const maybe = /** @type {any} */ (source); - - if (maybe && typeof maybe[Symbol.asyncIterator] === 'function') { - return maybe[Symbol.asyncIterator](); - } - - if (maybe && typeof maybe.next === 'function') { - return maybe; - } - - throw new Error(`query.live '${name}' must return an AsyncIterator or AsyncIterable`); -} - /** @type {typeof handle_remote_form_post_internal} */ export async function handle_remote_form_post(event, state, manifest, id) { return record_span({ diff --git a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js index 36a890b954c3..7d5d04e97cd7 100644 --- a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js @@ -16,14 +16,36 @@ function notify() { listeners.clear(); } -export const get_count = query.live(async function* () { +/** @param {AbortSignal} signal */ +function wait_for_change(signal) { + return new Promise((resolve) => { + const on_change = () => { + signal.removeEventListener('abort', on_abort); + resolve('changed'); + }; + + const on_abort = () => { + listeners.delete(on_change); + resolve('aborted'); + }; + + listeners.add(on_change); + signal.addEventListener('abort', on_abort, { once: true }); + }); +} + +export const get_count = query.live(async function* (_, { signal }) { active_connections += 1; try { yield count; while (true) { - await new Promise((resolve) => listeners.add(resolve)); + const status = await wait_for_change(signal); + + if (status === 'aborted') { + return; + } if (drop_next) { drop_next = false; diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index dffe3eee2c83..2071b8c8d503 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -392,6 +392,28 @@ test.describe('remote function mutations', () => { await expect(page.locator('#detached')).toHaveText('detached'); }); + test('query.live cleans up server iterator on reload', async ({ page }) => { + await page.goto('/remote/live'); + await page.click('#stats'); + await expect(page.locator('#stats-value')).not.toHaveText('pending'); + const before_cleanup = JSON.parse( + (await page.locator('#stats-value').textContent()) ?? '{}' + ).cleanup_count; + + await page.reload(); + await expect(page.locator('#count')).toBeVisible(); + + await expect + .poll(async () => { + await page.click('#stats'); + const value = (await page.locator('#stats-value').textContent()) ?? '{}'; + if (value === 'pending') return before_cleanup; + const stats = JSON.parse(value); + return stats.cleanup_count; + }) + .toBeGreaterThan(before_cleanup); + }); + test.describe('query runtime guardrails', () => { test('query created outside tracking context can run but cannot expose reactive state', async ({ page diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index 5d108088e9cb..709a9f1f465e 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -125,9 +125,13 @@ function live_query_tests() { void live_without_args(); async function live_with_schema() { - const q = query.live(schema, async function* (a) { - yield a; - }); + const q: RemoteLiveQueryFunction = query.live( + schema, + async function* (a, { signal }) { + signal.aborted; + yield a; + } + ); const result: string = await q('x'); result; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 7819aca70c10..afad7422bf39 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2172,6 +2172,14 @@ declare module '@sveltejs/kit' { reconnect(): void; }; + export interface RemoteLiveQueryContext { + /** + * Abort signal for the current live connection. + * Use this to stop pending work promptly when the client disconnects. + */ + readonly signal: AbortSignal; + } + export interface RemoteQueryOverride { _key: string; release(): void; @@ -3342,6 +3350,7 @@ declare module '$app/server' { RemoteForm, RemoteFormInput, InvalidField, + RemoteLiveQueryContext, RemotePrerenderFunction, RemoteQueryFunction, RemoteLiveQueryFunction @@ -3545,7 +3554,10 @@ declare module '$app/server' { * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. */ function live( - fn: () => MaybePromise | AsyncIterable> + fn: ( + arg: void, + context: RemoteLiveQueryContext + ) => MaybePromise | AsyncIterable> ): RemoteLiveQueryFunction; /** * Creates a live query function that streams updates whenever data changes. @@ -3554,7 +3566,10 @@ declare module '$app/server' { */ function live( validate: 'unchecked', - fn: (arg: Input) => MaybePromise | AsyncIterable> + fn: ( + arg: Input, + context: RemoteLiveQueryContext + ) => MaybePromise | AsyncIterable> ): RemoteLiveQueryFunction; /** * Creates a live query function that streams updates whenever data changes. @@ -3564,7 +3579,8 @@ declare module '$app/server' { function live( schema: Schema, fn: ( - arg: StandardSchemaV1.InferOutput + arg: StandardSchemaV1.InferOutput, + context: RemoteLiveQueryContext ) => MaybePromise | AsyncIterable> ): RemoteLiveQueryFunction, Output>; } From 92afb7de1980bb68b68041ccaeb35b0da1a4f267 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 19:16:28 -0400 Subject: [PATCH 04/36] regenerate types --- packages/kit/types/index.d.ts | 411 ++++++++++------------------------ 1 file changed, 121 insertions(+), 290 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index afad7422bf39..f94f9699cf89 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -4,11 +4,7 @@ declare module '@sveltejs/kit' { import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; import type { StandardSchemaV1 } from '@standard-schema/spec'; - import type { - RouteId as AppRouteId, - LayoutParams as AppLayoutParams, - ResolvedPathname - } from '$app/types'; + import type { RouteId as AppRouteId, LayoutParams as AppLayoutParams, ResolvedPathname } from '$app/types'; // @ts-ignore this is an optional peer dependency so could be missing. Written like this so dts-buddy preserves the ts-ignore type Span = import('@opentelemetry/api').Span; @@ -275,10 +271,7 @@ declare module '@sveltejs/kit' { * @param name the name of the cookie * @param opts the options, passed directly to `cookie.serialize`. The `path` must match the path of the cookie you want to delete. See documentation [here](https://github.com/jshttp/cookie#cookieserializename-value-options) */ - delete: ( - name: string, - opts: import('cookie').CookieSerializeOptions & { path: string } - ) => void; + delete: (name: string, opts: import('cookie').CookieSerializeOptions & { path: string }) => void; /** * Serialize a cookie name-value pair into a `Set-Cookie` header string, but don't apply it to the response. @@ -1556,10 +1549,7 @@ declare module '@sveltejs/kit' { * but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. * @param input the html chunk and the info if this is the last chunk */ - transformPageChunk?: (input: { - html: string; - done: boolean; - }) => MaybePromise; + transformPageChunk?: (input: { html: string; done: boolean }) => MaybePromise; /** * Determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. * By default, none will be included. @@ -1807,11 +1797,7 @@ declare module '@sveltejs/kit' { } // If T is unknown or has an index signature, the types below will recurse indefinitely and create giant unions that TS can't handle - type WillRecurseIndefinitely = unknown extends T - ? true - : string extends keyof T - ? true - : false; + type WillRecurseIndefinitely = unknown extends T ? true : string extends keyof T ? true : false; // Input type mappings for form fields type InputTypeMap = { @@ -1910,20 +1896,19 @@ declare module '@sveltejs/kit' { /** * Form field accessor type that provides name(), value(), and issues() methods */ - export type RemoteFormField = - RemoteFormFieldMethods & { - /** - * Returns an object that can be spread onto an input element with the correct type attribute, - * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. - * @example - * ```svelte - * - * - * - * ``` - */ - as>(...args: AsArgs): InputElementProps; - }; + export type RemoteFormField = RemoteFormFieldMethods & { + /** + * Returns an object that can be spread onto an input element with the correct type attribute, + * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. + * @example + * ```svelte + * + * + * + * ``` + */ + as>(...args: AsArgs): InputElementProps; + }; type RemoteFormFieldContainer = RemoteFormFieldMethods & { /** Validation issues belonging to this or any of the fields that belong to it, if any */ @@ -2227,9 +2212,7 @@ declare module '@sveltejs/kit' { * A function that is invoked once the entry has been created. This is where you * should write the function to the filesystem and generate redirect manifests. */ - complete(entry: { - generateManifest(opts: { relativePath: string }): string; - }): MaybePromise; + complete(entry: { generateManifest(opts: { relativePath: string }): string }): MaybePromise; } // Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts @@ -2712,24 +2695,16 @@ declare module '@sveltejs/kit' { * @throws {HttpError} This error instructs SvelteKit to initiate HTTP error handling. * @throws {Error} If the provided status is invalid (not between 400 and 599). */ - export function error( - status: number, - body?: { - message: string; - } extends App.Error - ? App.Error | string | undefined - : never - ): never; + export function error(status: number, body?: { + message: string; + } extends App.Error ? App.Error | string | undefined : never): never; /** * Checks whether this is an error thrown by {@link error}. * @param status The status to filter for. * */ - export function isHttpError( - e: unknown, - status?: T - ): e is HttpError_1 & { + export function isHttpError(e: unknown, status?: T): e is (HttpError_1 & { status: T extends undefined ? never : T; - }; + }); /** * Redirect a request. When called during request handling, SvelteKit will return a redirect response. * Make sure you're not catching the thrown redirect, which would prevent SvelteKit from handling it. @@ -2746,10 +2721,7 @@ declare module '@sveltejs/kit' { * @throws {Redirect} This error instructs SvelteKit to redirect to the specified location. * @throws {Error} If the provided status is invalid. * */ - export function redirect( - status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), - location: string | URL - ): never; + export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL): never; /** * Checks whether this is a redirect thrown by {@link redirect}. * @param e The object to check. @@ -2833,42 +2805,23 @@ declare module '@sveltejs/kit' { wasNormalized: boolean; denormalize: (url?: string | URL) => URL; }; - export type LessThan< - TNumber extends number, - TArray extends any[] = [] - > = TNumber extends TArray['length'] - ? TArray[number] - : LessThan; - export type NumericRange = Exclude< - TEnd | LessThan, - LessThan - >; + export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; + export type NumericRange = Exclude, LessThan>; type ValidPageOption = (typeof valid_page_options_array)[number]; type PageOptions = Partial>; - const valid_page_options_array: readonly [ - 'ssr', - 'prerender', - 'csr', - 'trailingSlash', - 'config', - 'entries', - 'load' - ]; + const valid_page_options_array: readonly ["ssr", "prerender", "csr", "trailingSlash", "config", "entries", "load"]; export const VERSION: string; class HttpError_1 { - constructor( - status: number, - body: { - message: string; - } extends App.Error - ? App.Error | string | undefined - : App.Error - ); + + constructor(status: number, body: { + message: string; + } extends App.Error ? (App.Error | string | undefined) : App.Error); status: number; body: App.Error; toString(): string; } class Redirect_1 { + constructor(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308, location: string); status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308; location: string; @@ -2955,20 +2908,13 @@ declare module '@sveltejs/kit/hooks' { } declare module '@sveltejs/kit/node' { - export function getRequest({ - request, - base, - bodySizeLimit - }: { - request: import('http').IncomingMessage; + export function getRequest({ request, base, bodySizeLimit }: { + request: import("http").IncomingMessage; base: string; bodySizeLimit?: number; }): Promise; - export function setResponse( - res: import('http').ServerResponse, - response: Response - ): Promise; + export function setResponse(res: import("http").ServerResponse, response: Response): Promise; /** * Converts a file on disk to a readable stream * @since 2.4.0 @@ -2993,7 +2939,7 @@ declare module '@sveltejs/kit/vite' { /** * Returns the SvelteKit Vite plugins. * */ - export function sveltekit(): Promise; + export function sveltekit(): Promise; export {}; } @@ -3041,10 +2987,7 @@ declare module '$app/forms' { * } * ``` * */ - export function deserialize< - Success extends Record | undefined, - Failure extends Record | undefined - >(result: string): import('@sveltejs/kit').ActionResult; + export function deserialize | undefined, Failure extends Record | undefined>(result: string): import("@sveltejs/kit").ActionResult; /** * This action enhances a `` element that otherwise would work without JavaScript. * @@ -3068,23 +3011,14 @@ declare module '$app/forms' { * @param form_element The form element * @param submit Submit callback */ - export function enhance< - Success extends Record | undefined, - Failure extends Record | undefined - >( - form_element: HTMLFormElement, - submit?: import('@sveltejs/kit').SubmitFunction - ): { + export function enhance | undefined, Failure extends Record | undefined>(form_element: HTMLFormElement, submit?: import("@sveltejs/kit").SubmitFunction): { destroy(): void; }; /** * This action updates the `form` property of the current page with the given data and updates `page.status`. * In case of an error, it redirects to the nearest error page. * */ - export function applyAction< - Success extends Record | undefined, - Failure extends Record | undefined - >(result: import('@sveltejs/kit').ActionResult): Promise; + export function applyAction | undefined, Failure extends Record | undefined>(result: import("@sveltejs/kit").ActionResult): Promise; export {}; } @@ -3095,9 +3029,7 @@ declare module '$app/navigation' { * * `afterNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function afterNavigate( - callback: (navigation: import('@sveltejs/kit').AfterNavigate) => void - ): void; + export function afterNavigate(callback: (navigation: import("@sveltejs/kit").AfterNavigate) => void): void; /** * A navigation interceptor that triggers before we navigate to a URL, whether by clicking a link, calling `goto(...)`, or using the browser back/forward controls. * @@ -3109,9 +3041,7 @@ declare module '$app/navigation' { * * `beforeNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function beforeNavigate( - callback: (navigation: import('@sveltejs/kit').BeforeNavigate) => void - ): void; + export function beforeNavigate(callback: (navigation: import("@sveltejs/kit").BeforeNavigate) => void): void; /** * A lifecycle function that runs the supplied `callback` immediately before we navigate to a new URL except during full-page navigations. * @@ -3121,9 +3051,7 @@ declare module '$app/navigation' { * * `onNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ - export function onNavigate( - callback: (navigation: import('@sveltejs/kit').OnNavigate) => MaybePromise<(() => void) | void> - ): void; + export function onNavigate(callback: (navigation: import("@sveltejs/kit").OnNavigate) => MaybePromise<(() => void) | void>): void; /** * If called when the page is being updated following a navigation (in `onMount` or `afterNavigate` or an action, for example), this disables SvelteKit's built-in scroll handling. * This is generally discouraged, since it breaks user expectations. @@ -3138,17 +3066,14 @@ declare module '$app/navigation' { * @param url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param {Object} opts Options related to the navigation * */ - export function goto( - url: string | URL, - opts?: { - replaceState?: boolean | undefined; - noScroll?: boolean | undefined; - keepFocus?: boolean | undefined; - invalidateAll?: boolean | undefined; - invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; - state?: App.PageState | undefined; - } - ): Promise; + export function goto(url: string | URL, opts?: { + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + invalidate?: (string | URL | ((url: URL) => boolean))[] | undefined; + state?: App.PageState | undefined; + }): Promise; /** * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated. * @@ -3175,9 +3100,7 @@ declare module '$app/navigation' { * Causes all currently active remote functions to refresh, and all `load` functions belonging to the currently active page to re-run (unless disabled via the option argument). * Returns a `Promise` that resolves when the page is subsequently updated. * */ - export function refreshAll({ - includeLoadFunctions - }?: { + export function refreshAll({ includeLoadFunctions }?: { includeLoadFunctions?: boolean; }): Promise; /** @@ -3191,17 +3114,14 @@ declare module '$app/navigation' { * * @param href Page to preload * */ - export function preloadData(href: string): Promise< - | { - type: 'loaded'; - status: number; - data: Record; - } - | { - type: 'redirect'; - location: string; - } - >; + export function preloadData(href: string): Promise<{ + type: "loaded"; + status: number; + data: Record; + } | { + type: "redirect"; + location: string; + }>; /** * Programmatically imports the code for routes that haven't yet been fetched. * Typically, you might call this to speed up subsequent navigation. @@ -3229,15 +3149,7 @@ declare module '$app/navigation' { } declare module '$app/paths' { - import type { - RouteIdWithSearchOrHash, - PathnameWithSearchOrHash, - ResolvedPathname, - RouteId, - RouteParams, - Asset, - Pathname as Pathname_1 - } from '$app/types'; + import type { RouteIdWithSearchOrHash, PathnameWithSearchOrHash, ResolvedPathname, RouteId, RouteParams, Asset, Pathname as Pathname_1 } from '$app/types'; /** * A string that matches [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths). * @@ -3268,15 +3180,16 @@ declare module '$app/paths' { ? Pathname : T; - type ResolveArgs = T extends RouteId - ? RouteParams extends Record - ? [route: T] - : [route: T, params: RouteParams] - : StripSearchOrHash extends infer U extends RouteId - ? RouteParams extends Record + type ResolveArgs = + T extends RouteId + ? RouteParams extends Record ? [route: T] - : [route: T, params: RouteParams] - : [route: T]; + : [route: T, params: RouteParams] + : StripSearchOrHash extends infer U extends RouteId + ? RouteParams extends Record + ? [route: T] + : [route: T, params: RouteParams] + : [route: T]; /** * Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path. * @@ -3314,9 +3227,7 @@ declare module '$app/paths' { * @since 2.26 * * */ - export function resolve( - ...args: ResolveArgs - ): ResolvedPathname; + export function resolve(...args: ResolveArgs): ResolvedPathname; /** * Match a path or URL to a route ID and extracts any parameters. * @@ -3344,17 +3255,7 @@ declare module '$app/paths' { } declare module '$app/server' { - import type { - RequestEvent, - RemoteCommand, - RemoteForm, - RemoteFormInput, - InvalidField, - RemoteLiveQueryContext, - RemotePrerenderFunction, - RemoteQueryFunction, - RemoteLiveQueryFunction - } from '@sveltejs/kit'; + import type { RequestEvent, RemoteCommand, RemoteForm, RemoteFormInput, InvalidField, RemotePrerenderFunction, RemoteQueryFunction, RemoteLiveQueryFunction } from '@sveltejs/kit'; import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * Read the contents of an imported asset from the filesystem @@ -3392,10 +3293,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function command( - validate: 'unchecked', - fn: (arg: Input) => Output - ): RemoteCommand; + export function command(validate: "unchecked", fn: (arg: Input) => Output): RemoteCommand; /** * Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3403,10 +3301,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function command( - validate: Schema, - fn: (arg: StandardSchemaV1.InferOutput) => Output - ): RemoteCommand, Output>; + export function command(validate: Schema, fn: (arg: StandardSchemaV1.InferOutput) => Output): RemoteCommand, Output>; /** * Creates a form object that can be spread onto a `` element. * @@ -3422,10 +3317,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function form( - validate: 'unchecked', - fn: (data: Input, issue: InvalidField) => MaybePromise - ): RemoteForm; + export function form(validate: "unchecked", fn: (data: Input, issue: InvalidField) => MaybePromise): RemoteForm; /** * Creates a form object that can be spread onto a `` element. * @@ -3433,16 +3325,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function form< - Schema extends StandardSchemaV1>, - Output - >( - validate: Schema, - fn: ( - data: StandardSchemaV1.InferOutput, - issue: InvalidField> - ) => MaybePromise - ): RemoteForm, Output>; + export function form>, Output>(validate: Schema, fn: (data: StandardSchemaV1.InferOutput, issue: InvalidField>) => MaybePromise): RemoteForm, Output>; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3450,15 +3333,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender( - fn: () => MaybePromise, - options?: - | { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } - | undefined - ): RemotePrerenderFunction; + export function prerender(fn: () => MaybePromise, options?: { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } | undefined): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3466,16 +3344,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender( - validate: 'unchecked', - fn: (arg: Input) => MaybePromise, - options?: - | { - inputs?: RemotePrerenderInputsGenerator; - dynamic?: boolean; - } - | undefined - ): RemotePrerenderFunction; + export function prerender(validate: "unchecked", fn: (arg: Input) => MaybePromise, options?: { + inputs?: RemotePrerenderInputsGenerator; + dynamic?: boolean; + } | undefined): RemotePrerenderFunction; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3483,16 +3355,10 @@ declare module '$app/server' { * * @since 2.27 */ - export function prerender( - schema: Schema, - fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, - options?: - | { - inputs?: RemotePrerenderInputsGenerator>; - dynamic?: boolean; - } - | undefined - ): RemotePrerenderFunction, Output>; + export function prerender(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise, options?: { + inputs?: RemotePrerenderInputsGenerator>; + dynamic?: boolean; + } | undefined): RemotePrerenderFunction, Output>; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3508,10 +3374,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function query( - validate: 'unchecked', - fn: (arg: Input) => MaybePromise - ): RemoteQueryFunction; + export function query(validate: "unchecked", fn: (arg: Input) => MaybePromise): RemoteQueryFunction; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. * @@ -3519,10 +3382,7 @@ declare module '$app/server' { * * @since 2.27 */ - export function query( - schema: Schema, - fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise - ): RemoteQueryFunction, Output>; + export function query(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise): RemoteQueryFunction, Output>; export namespace query { /** * Creates a batch query function that collects multiple calls and executes them in a single request @@ -3531,10 +3391,7 @@ declare module '$app/server' { * * @since 2.35 */ - function batch( - validate: 'unchecked', - fn: (args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output> - ): RemoteQueryFunction; + function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>): RemoteQueryFunction; /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -3542,47 +3399,24 @@ declare module '$app/server' { * * @since 2.35 */ - function batch( - schema: Schema, - fn: ( - args: StandardSchemaV1.InferOutput[] - ) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output> - ): RemoteQueryFunction, Output>; + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; /** - * Creates a live query function that streams updates whenever data changes. + * Creates a live remote query. When called from the browser, the function will be invoked on the server via a streaming `fetch` call. * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. - */ - function live( - fn: ( - arg: void, - context: RemoteLiveQueryContext - ) => MaybePromise | AsyncIterable> - ): RemoteLiveQueryFunction; - /** - * Creates a live query function that streams updates whenever data changes. - * - * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. - */ - function live( - validate: 'unchecked', - fn: ( - arg: Input, - context: RemoteLiveQueryContext - ) => MaybePromise | AsyncIterable> - ): RemoteLiveQueryFunction; - /** - * Creates a live query function that streams updates whenever data changes. * - * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. - */ - function live( - schema: Schema, - fn: ( - arg: StandardSchemaV1.InferOutput, - context: RemoteLiveQueryContext - ) => MaybePromise | AsyncIterable> - ): RemoteLiveQueryFunction, Output>; + * */ + function live(fn: (arg: void, context: { + signal: AbortSignal; + }) => MaybePromise | AsyncIterable>): RemoteLiveQueryFunction; + + function live(validate: "unchecked", fn: (arg: Input, context: { + signal: AbortSignal; + }) => MaybePromise | AsyncIterable>): RemoteLiveQueryFunction; + + function live(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput, context: { + signal: AbortSignal; + }) => MaybePromise | AsyncIterable>): RemoteLiveQueryFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; @@ -3627,21 +3461,19 @@ declare module '$app/state' { * On the server, values can only be read during rendering (in other words _not_ in e.g. `load` functions). In the browser, the values can be read at any time. * * */ - export const page: import('@sveltejs/kit').Page; + export const page: import("@sveltejs/kit").Page; /** * A read-only object representing an in-progress navigation, with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. * Values are `null` when no navigation is occurring, or during server rendering. * */ - export const navigating: - | import('@sveltejs/kit').Navigation - | { - from: null; - to: null; - type: null; - willUnload: null; - delta: null; - complete: null; - }; + export const navigating: import("@sveltejs/kit").Navigation | { + from: null; + to: null; + type: null; + willUnload: null; + delta: null; + complete: null; + }; /** * A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * */ @@ -3655,10 +3487,11 @@ declare module '$app/state' { declare module '$app/stores' { export function getStores(): { + page: typeof page; - + navigating: typeof navigating; - + updated: typeof updated; }; /** @@ -3668,7 +3501,7 @@ declare module '$app/stores' { * * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const page: import('svelte/store').Readable; + export const page: import("svelte/store").Readable; /** * A readable store. * When navigating starts, its value is a `Navigation` object with `from`, `to`, `type` and (if `type === 'popstate'`) `delta` properties. @@ -3678,9 +3511,7 @@ declare module '$app/stores' { * * @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const navigating: import('svelte/store').Readable< - import('@sveltejs/kit').Navigation | null - >; + export const navigating: import("svelte/store").Readable; /** * A readable store whose initial value is `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling. * @@ -3688,12 +3519,12 @@ declare module '$app/stores' { * * @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, [see docs for more info](https://svelte.dev/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated)) * */ - export const updated: import('svelte/store').Readable & { + export const updated: import("svelte/store").Readable & { check(): Promise; }; export {}; -} /** +}/** * It's possible to tell SvelteKit how to type objects inside your app by declaring the `App` namespace. By default, a new project will have a file called `src/app.d.ts` containing the following: * * ```ts @@ -3842,4 +3673,4 @@ declare module '$app/types' { export type Asset = ReturnType; } -//# sourceMappingURL=index.d.ts.map +//# sourceMappingURL=index.d.ts.map \ No newline at end of file From 29d67e9911eafb1b98c377b42997ef5c489bac89 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 21:52:48 -0400 Subject: [PATCH 05/36] tweak --- packages/kit/src/runtime/server/remote.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 3eb835ddffa4..58980486ef45 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -174,13 +174,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) await iterator.return?.(); }; - event.request.signal.addEventListener( - 'abort', - () => { - void close(); - }, - { once: true } - ); + event.request.signal.addEventListener('abort', close, { once: true }); return new Response( new ReadableStream({ From 704eec415496ececd913c7f39ca8c34ee5eab281 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 21:53:17 -0400 Subject: [PATCH 06/36] remove plan doc --- query-live-plan.md | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 query-live-plan.md diff --git a/query-live-plan.md b/query-live-plan.md deleted file mode 100644 index 50314de65d49..000000000000 --- a/query-live-plan.md +++ /dev/null @@ -1,34 +0,0 @@ -# query.live implementation plan - -- [ ] 1. Types + public API surface - - [ ] Add `query.live` to server/runtime exports and remote client factory mapping - - [ ] Extend internal remote type unions with `query_live` - - [ ] Add public live query types and `$app/server` declarations - -- [ ] 2. Server-side primitive - - [ ] Implement `query.live(...)` overloads with the same validation semantics as `query` and `query.batch` - - [ ] Enforce callback returns an `AsyncIterator`/`AsyncIterable` - - [ ] SSR await behavior: read first yielded value, then stop iteration via `return()` - -- [ ] 3. Live remote endpoint - - [ ] Handle `query_live` in server remote dispatcher - - [ ] Return a streaming `ReadableStream` response - - [ ] Ensure abort/disconnect calls iterator `return()` for cleanup - -- [ ] 4. Client live resource - - [ ] Add live query resource implementation in `query.svelte.js` - - [ ] Connect while active instances exist; disconnect when inactive - - [ ] Expose `connected` and `reconnect()` - - [ ] Implement automatic reconnect with exponential backoff + jitter - - [ ] Resume reconnect attempts on `online` event - -- [ ] 5. Tests - - [ ] Extend async app fixtures/routes for deterministic live updates and cleanup checks - - [ ] Add SSR + CSR tests for first value, streaming updates, disconnect cleanup - - [ ] Add reconnect tests (drop + manual reconnect + online resume) - - [ ] Add validation coverage for `query.live` - - [ ] Add TypeScript type tests for `query.live` signatures and resource shape - -- [ ] 6. Docs + verification - - [ ] Document `query.live` in remote functions docs - - [ ] Run focused tests and fix regressions From c69d5eed88e3b8c6fb2f34252153e5d6ad94888c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 22:00:21 -0400 Subject: [PATCH 07/36] tidy up --- packages/kit/src/runtime/client/client.js | 8 ++++- .../client/remote-functions/query.svelte.js | 29 +++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index b4b3c7a01a0d..1d430a2ce6f7 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -301,11 +301,17 @@ const preload_tokens = new Set(); export let pending_invalidate; /** - * @type {Map | RemoteLiveQueryCacheEntry>} + * @type {Map>} * A map of id -> query info with all queries that currently exist in the app. */ export const query_map = new Map(); +/** + * @type {Map>} + * A map of id -> live query info with all live queries that currently exist in the app. + */ +export const live_query_map = new Map(); + /** * @param {import('./types.js').SvelteKitApp} _app * @param {HTMLElement} _target diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 493ec4bba8b3..6bf80076dc65 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -1,7 +1,7 @@ /** @import { RemoteLiveQueryFunction, RemoteQueryFunction } from '@sveltejs/kit' */ /** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '$app/paths/internal/client'; -import { app, goto, query_map, query_responses } from '../client.js'; +import { app, goto, live_query_map, query_map, query_responses } from '../client.js'; import { get_remote_request_headers, remote_request } from './shared.svelte.js'; import * as devalue from 'devalue'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; @@ -49,8 +49,7 @@ export function query(id) { // If this reruns as part of HMR, refresh the query for (const [key, entry] of query_map) { if (key === id || key.startsWith(id + '/')) { - void (/** @type {any} */ (entry.resource).refresh?.()); - void (/** @type {any} */ (entry.resource).reconnect?.()); + void entry.resource.refresh(); } } } @@ -72,9 +71,9 @@ export function query(id) { */ export function query_live(id) { if (DEV) { - for (const [key, entry] of query_map) { + for (const [key, entry] of live_query_map) { if (key === id || key.startsWith(id + '/')) { - void (/** @type {any} */ (entry.resource).reconnect?.()); + void entry.resource.reconnect(); } } } @@ -951,11 +950,11 @@ class QueryProxy { ); } - return /** @type {Query} */ (cached.resource); + return cached.resource; } #safe_get_cached_query() { - return /** @type {Query | undefined} */ (query_map.get(this._key)?.resource); + return query_map.get(this._key)?.resource; } get current() { @@ -1069,7 +1068,7 @@ class LiveQueryProxy { /** @returns {RemoteLiveQueryCacheEntry} */ #get_or_create_cache_entry() { - let cached = query_map.get(this._key); + let cached = live_query_map.get(this._key); if (!cached) { const c = (cached = { @@ -1082,7 +1081,7 @@ class LiveQueryProxy { c.resource = new LiveQuery(this.#id, this._key, this.#payload); }); - query_map.set(this._key, cached); + live_query_map.set(this._key, cached); } cached.count += 1; @@ -1099,12 +1098,12 @@ class LiveQueryProxy { entry.count -= 1; return () => { - const cached = query_map.get(this._key); + const cached = live_query_map.get(this._key); if (cached?.count === 0) { - /** @type {any} */ (cached.resource).disconnect?.(); - /** @type {any} */ (cached.resource).destroy?.(); + cached.resource.disconnect(); + cached.resource.destroy(); cached.cleanup(); - query_map.delete(this._key); + live_query_map.delete(this._key); } }; } @@ -1123,7 +1122,7 @@ class LiveQueryProxy { ); } - const cached = query_map.get(this._key); + const cached = live_query_map.get(this._key); if (!cached) { throw new Error( @@ -1135,7 +1134,7 @@ class LiveQueryProxy { } #safe_get_cached_query() { - return /** @type {LiveQuery | undefined} */ (query_map.get(this._key)?.resource); + return live_query_map.get(this._key)?.resource; } get current() { From 1a6ba2f167b36dd5eddceb1b0826e84d6c940ddb Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 22:13:19 -0400 Subject: [PATCH 08/36] fix test --- .../async/src/routes/remote/validation/validation.remote.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js index 0d967445c5f2..6026833eac5a 100644 --- a/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js @@ -16,11 +16,11 @@ export const validated_query_no_args = query((arg) => (arg === undefined ? 'succ export const validated_query_with_arg = query(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); -export const validated_live_query_no_args = query.live(function* (arg) { +export const validated_live_query_no_args = query.live(function* (arg, _context) { yield arg === undefined ? 'success' : 'failure'; }); -export const validated_live_query_with_arg = query.live(schema, function* (...arg) { - yield typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure'; +export const validated_live_query_with_arg = query.live(schema, function* (arg, _context) { + yield typeof arg === 'string' ? 'success' : 'failure'; }); export const validated_prerendered_query_no_args = prerender((arg) => From 1b8991b2c105df30b3bea4d9e40012a33f10e619 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 22:16:13 -0400 Subject: [PATCH 09/36] simplify --- .../runtime/client/remote-functions/query.svelte.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 6bf80076dc65..e513c5be3805 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -78,7 +78,7 @@ export function query_live(id) { } } - return create_live_query_function(id); + return (arg) => new LiveQueryProxy(id, arg); } /** @@ -185,16 +185,6 @@ function create_query_function(id, fn) { return (arg) => /** @type {any} */ (new QueryProxy(id, arg, fn)); } -/** - * @template Input - * @template Output - * @param {string} id - * @returns {RemoteLiveQueryFunction} - */ -function create_live_query_function(id) { - return (arg) => /** @type {any} */ (new LiveQueryProxy(id, arg)); -} - /** * @template T * @implements {Promise} From 872710bf0fa7a1e0dfaa2625cf2d673c4cc81d6e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 22:26:00 -0400 Subject: [PATCH 10/36] changeset --- .changeset/wet-rings-repair.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wet-rings-repair.md diff --git a/.changeset/wet-rings-repair.md b/.changeset/wet-rings-repair.md new file mode 100644 index 000000000000..cc25b15aa5aa --- /dev/null +++ b/.changeset/wet-rings-repair.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: experimental `query.live` function From 1d0fff3aaed0860664f79e831387291a89485a78 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 18 Mar 2026 22:27:28 -0400 Subject: [PATCH 11/36] revert --- documentation/docs/20-core-concepts/60-remote-functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 7bc3f7de2967..cdd6f749bfa6 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -33,7 +33,7 @@ export default config; ## Overview -Remote functions are exported from a `.remote.js` or `.remote.ts` file, and come in five flavours: `query`, `query.live`, `form`, `command` and `prerender`. On the client, the exported functions are transformed to `fetch` wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote files can be placed anywhere in your `src` directory (except inside the `src/lib/server` directory), and third party libraries can provide them, too. +Remote functions are exported from a `.remote.js` or `.remote.ts` file, and come in four flavours: `query`, `form`, `command` and `prerender`. On the client, the exported functions are transformed to `fetch` wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote files can be placed anywhere in your `src` directory (except inside the `src/lib/server` directory), and third party libraries can provide them, too. ## query From 1545da997fb9266593bee241dc2a47df1314b6f9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 10:35:56 -0400 Subject: [PATCH 12/36] fix types --- .../kit/src/runtime/app/server/remote/query.js | 16 ++++++++-------- packages/kit/types/index.d.ts | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 77568dfda304..9e113baf6bfe 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -91,7 +91,7 @@ export function query(validate_or_fn, maybe_fn) { * * @template Output * @overload - * @param {(arg: void, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} fn + * @param {(arg: void, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction} */ /** @@ -99,7 +99,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {'unchecked'} validate - * @param {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} fn + * @param {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction} */ /** @@ -107,19 +107,19 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {Schema} schema - * @param {(arg: StandardSchemaV1.InferOutput, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} fn + * @param {(arg: StandardSchemaV1.InferOutput, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction, Output>} */ /** * @template Input * @template Output * @param {any} validate_or_fn - * @param {(args: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} [maybe_fn] + * @param {(args: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} [maybe_fn] * @returns {RemoteLiveQueryFunction} */ /*@__NO_SIDE_EFFECTS__*/ function live(validate_or_fn, maybe_fn) { - /** @type {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterable>} */ + /** @type {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} */ const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ @@ -415,7 +415,7 @@ function create_query_resource(__, arg, state, fn) { * @param {any} arg * @param {RequestState} state * @param {() => Promise} get_first_value - * @param {() => Promise>} get_iterator + * @param {() => MaybePromise>} get_iterator * @returns {RemoteLiveQuery} */ function create_live_query_resource(__, arg, state, get_first_value, get_iterator) { @@ -443,7 +443,7 @@ function create_live_query_resource(__, arg, state, get_first_value, get_iterato reconnect() { throw new Error(`Cannot call '${__.name}.reconnect()' on the server`); }, - run() { + async run() { if (!state.is_in_universal_load) { throw new Error( 'On the server, .run() can only be called in universal `load` functions. Anywhere else, just await the query directly' @@ -468,7 +468,7 @@ Object.defineProperty(query, 'live', { value: live, enumerable: true }); /** * @template T - * @param {AsyncIterator | AsyncIterable} source + * @param {Generator | AsyncIterator | AsyncIterable} source * @param {string} name * @returns {AsyncIterator} */ diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f94f9699cf89..f1152de122e8 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3408,15 +3408,15 @@ declare module '$app/server' { * */ function live(fn: (arg: void, context: { signal: AbortSignal; - }) => MaybePromise | AsyncIterable>): RemoteLiveQueryFunction; + }) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction; function live(validate: "unchecked", fn: (arg: Input, context: { signal: AbortSignal; - }) => MaybePromise | AsyncIterable>): RemoteLiveQueryFunction; + }) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction; function live(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput, context: { signal: AbortSignal; - }) => MaybePromise | AsyncIterable>): RemoteLiveQueryFunction, Output>; + }) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; From 54ad84e9b23c95b340c6ae414f81cde45c1fe30a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 11:00:52 -0400 Subject: [PATCH 13/36] tighten up types a bit --- packages/kit/src/core/postbuild/prerender.js | 2 +- .../src/runtime/app/server/remote/command.js | 6 +- .../kit/src/runtime/app/server/remote/form.js | 4 +- .../runtime/app/server/remote/prerender.js | 6 +- .../src/runtime/app/server/remote/query.js | 16 ++-- packages/kit/src/runtime/server/remote.js | 11 +-- packages/kit/src/types/internal.d.ts | 78 +++++++++++-------- 7 files changed, 65 insertions(+), 58 deletions(-) diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 8caa68fdfe85..103f4092d265 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -496,7 +496,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { internal.set_manifest(manifest); internal.set_read_implementation((file) => createReadableStream(`${out}/server/${file}`)); - /** @type {Array} */ + /** @type {Array} */ const prerender_functions = []; for (const loader of Object.values(manifest._.remotes)) { diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index 353dc0af7738..d1b199da806f 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -1,5 +1,5 @@ /** @import { RemoteCommand } from '@sveltejs/kit' */ -/** @import { RemoteInfo, MaybePromise } from 'types' */ +/** @import { MaybePromise, RemoteCommandInfo } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { create_validator, run_remote_function } from './shared.js'; @@ -58,10 +58,10 @@ export function command(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteInfo} */ + /** @type {RemoteCommandInfo} */ const __ = { type: 'command', id: '', name: '' }; - /** @type {RemoteCommand & { __: RemoteInfo }} */ + /** @type {RemoteCommand & { __: RemoteCommandInfo }} */ const wrapper = (arg) => { const { event, state } = get_request_store(); diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 71d4de251946..53e3bc067c5d 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -1,5 +1,5 @@ /** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */ -/** @import { InternalRemoteFormIssue, MaybePromise, RemoteInfo } from 'types' */ +/** @import { InternalRemoteFormIssue, MaybePromise, RemoteFormInfo } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { DEV } from 'esm-env'; @@ -84,7 +84,7 @@ export function form(validate_or_fn, maybe_fn) { } }); - /** @type {RemoteInfo} */ + /** @type {RemoteFormInfo} */ const __ = { type: 'form', name: '', diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index de71b97b17b8..50e3e820269d 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -1,5 +1,5 @@ /** @import { RemoteResource, RemotePrerenderFunction } from '@sveltejs/kit' */ -/** @import { RemotePrerenderInputsGenerator, RemoteInfo, MaybePromise } from 'types' */ +/** @import { RemotePrerenderInputsGenerator, MaybePromise, RemotePrerenderInfo } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { error, json } from '@sveltejs/kit'; import { DEV } from 'esm-env'; @@ -76,7 +76,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteInfo} */ + /** @type {RemotePrerenderInfo} */ const __ = { type: 'prerender', id: '', @@ -86,7 +86,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { dynamic: options?.dynamic }; - /** @type {RemotePrerenderFunction & { __: RemoteInfo }} */ + /** @type {RemotePrerenderFunction & { __: RemotePrerenderInfo }} */ const wrapper = (arg) => { /** @type {Promise & Partial>} */ const promise = (async () => { diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 9e113baf6bfe..475133996bc8 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -1,5 +1,5 @@ /** @import { RemoteLiveQuery, RemoteLiveQueryFunction, RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */ -/** @import { RemoteInfo, MaybePromise, RequestState } from 'types' */ +/** @import { RemoteInfo, MaybePromise, RequestState, RemoteQueryLiveInfo, RemoteQueryBatchInfo, RemoteQueryInfo } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { create_remote_key, stringify, stringify_remote_arg } from '../../../shared.js'; @@ -61,10 +61,10 @@ export function query(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, /** @type {any} */ (maybe_fn)); - /** @type {RemoteInfo} */ + /** @type {RemoteQueryInfo} */ const __ = { type: 'query', id: '', name: '' }; - /** @type {RemoteQueryFunction & { __: RemoteInfo }} */ + /** @type {RemoteQueryFunction & { __: RemoteQueryInfo }} */ const wrapper = (arg) => { if (prerendering) { throw new Error( @@ -146,10 +146,10 @@ function live(validate_or_fn, maybe_fn) { } ); - /** @type {RemoteInfo & { type: 'query_live'; run: typeof run }} */ + /** @type {RemoteQueryLiveInfo} */ const __ = { type: 'query_live', id: '', name: '', run }; - /** @type {RemoteLiveQueryFunction & { __: RemoteInfo }} */ + /** @type {RemoteLiveQueryFunction & { __: RemoteQueryLiveInfo }} */ const wrapper = (arg) => { if (prerendering) { throw new Error( @@ -231,7 +231,7 @@ function batch(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteInfo & { type: 'query_batch' }} */ + /** @type {RemoteQueryBatchInfo} */ const __ = { type: 'query_batch', id: '', @@ -272,7 +272,7 @@ function batch(validate_or_fn, maybe_fn) { /** @type {Map void, reject: (error: any) => void}> }>} */ let batching = new Map(); - /** @type {RemoteQueryFunction & { __: RemoteInfo }} */ + /** @type {RemoteQueryFunction & { __: RemoteQueryBatchInfo }} */ const wrapper = (arg) => { if (prerendering) { throw new Error( @@ -411,7 +411,7 @@ function create_query_resource(__, arg, state, fn) { } /** - * @param {RemoteInfo} __ + * @param {RemoteQueryLiveInfo} __ * @param {any} arg * @param {RequestState} state * @param {() => Promise} get_first_value diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 58980486ef45..eab319848bf9 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -1,5 +1,5 @@ /** @import { ActionResult, RemoteForm, RequestEvent, SSRManifest } from '@sveltejs/kit' */ -/** @import { RemoteFunctionResponse, RemoteInfo, RequestState, SSROptions } from 'types' */ +/** @import { RemoteFormInfo, RemoteFunctionResponse, RemoteInfo, RequestState, SSROptions } from 'types' */ import { json, error } from '@sveltejs/kit'; import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; @@ -144,11 +144,6 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } if (info.type === 'query_live') { - const live_info = - /** @type {RemoteInfo & { type: 'query_live'; run: (event: RequestEvent, state: RequestState, arg: any) => Promise<{ iterator: AsyncIterator; cancel: () => void }> }} */ ( - info - ); - if (event.request.method !== 'GET') { throw new SvelteKitError( 405, @@ -161,7 +156,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) new URL(event.request.url).searchParams.get('payload') ); - const live = await live_info.run(event, state, parse_remote_arg(payload, transport)); + const live = await info.run(event, state, parse_remote_arg(payload, transport)); const iterator = live.iterator; const encoder = new TextEncoder(); @@ -387,7 +382,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { } try { - const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (form).__).fn; + const fn = /** @type {RemoteFormInfo} */ (/** @type {any} */ (form).__).fn; const { data, meta, form_data } = await deserialize_binary_form(event.request); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 9c784040b4fa..5c1c55826db4 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -569,40 +569,52 @@ export type BinaryFormMeta = { validate_only?: boolean; }; +interface BaseRemoteInfo { + type: string; + id: string; + name: string; +} + +export interface RemoteQueryInfo extends BaseRemoteInfo { + type: 'query'; +} +export interface RemoteQueryLiveInfo extends BaseRemoteInfo { + type: 'query_live'; + run( + event: RequestEvent, + state: RequestState, + arg: any + ): Promise<{ iterator: AsyncIterator; cancel: () => void }>; +} + +export interface RemoteQueryBatchInfo extends BaseRemoteInfo { + type: 'query_batch'; + run: (args: any[], options: SSROptions) => Promise; +} + +export interface RemoteCommandInfo extends BaseRemoteInfo { + type: 'command'; +} + +export interface RemoteFormInfo extends BaseRemoteInfo { + type: 'form'; + fn(body: Record, meta: BinaryFormMeta, form_data: FormData | null): Promise; +} + +export interface RemotePrerenderInfo extends BaseRemoteInfo { + type: 'prerender'; + has_arg: boolean; + dynamic?: boolean; + inputs?: RemotePrerenderInputsGenerator; +} + export type RemoteInfo = - | { - type: 'query' | 'query_live' | 'command'; - id: string; - name: string; - } - | { - /** - * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) - */ - type: 'query_batch'; - id: string; - name: string; - /** Direct access to the function, for remote functions called from the client */ - run: (args: any[], options: SSROptions) => Promise; - } - | { - type: 'form'; - id: string; - name: string; - fn: ( - body: Record, - meta: BinaryFormMeta, - form_data: FormData | null - ) => Promise; - } - | { - type: 'prerender'; - id: string; - name: string; - has_arg: boolean; - dynamic?: boolean; - inputs?: RemotePrerenderInputsGenerator; - }; + | RemoteQueryInfo + | RemoteQueryLiveInfo + | RemoteQueryBatchInfo + | RemoteCommandInfo + | RemoteFormInfo + | RemotePrerenderInfo; export interface InternalRemoteFormIssue extends RemoteFormIssue { name: string; From da1e41c4e7c6e25965ccbb16172ffb4f952bd19e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 12:48:50 -0400 Subject: [PATCH 14/36] fix typechecking error --- packages/kit/src/runtime/app/server/remote/query.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 475133996bc8..50d027dc8f02 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -130,8 +130,8 @@ function live(validate_or_fn, maybe_fn) { * @param {any} state * @param {any} arg */ - const run = (event, state, arg) => - run_remote_function( + const run = async (event, state, arg) => { + return await run_remote_function( event, state, false, @@ -145,6 +145,7 @@ function live(validate_or_fn, maybe_fn) { }; } ); + }; /** @type {RemoteQueryLiveInfo} */ const __ = { type: 'query_live', id: '', name: '', run }; From f0dadadf6ba1fa4e40baf2f2b7ddca9991108d88 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 12:52:08 -0400 Subject: [PATCH 15/36] better naming --- packages/kit/src/core/postbuild/analyse.js | 6 ++-- packages/kit/src/core/postbuild/prerender.js | 15 +++++---- .../src/exports/internal/remote-functions.js | 4 +-- packages/kit/src/exports/vite/index.js | 2 +- .../src/runtime/app/server/remote/command.js | 6 ++-- .../kit/src/runtime/app/server/remote/form.js | 4 +-- .../runtime/app/server/remote/prerender.js | 6 ++-- .../src/runtime/app/server/remote/query.js | 24 +++++++------- .../src/runtime/app/server/remote/shared.js | 20 +++++------ .../kit/src/runtime/server/page/render.js | 8 ++--- packages/kit/src/runtime/server/remote.js | 32 +++++++++--------- packages/kit/src/types/internal.d.ts | 33 ++++++++++--------- 12 files changed, 84 insertions(+), 76 deletions(-) diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index aa99609e6a04..2b967e555f2b 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -163,12 +163,12 @@ async function analyse({ const exports = new Map(); for (const name in functions) { - const info = /** @type {import('types').RemoteInfo} */ (functions[name].__); - const type = info.type; + const internals = /** @type {import('types').RemoteInternals} */ (functions[name].__); + const type = internals.type; exports.set(name, { type, - dynamic: type !== 'prerender' || info.dynamic + dynamic: type !== 'prerender' || internals.dynamic }); } diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 103f4092d265..806952e39268 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -496,7 +496,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { internal.set_manifest(manifest); internal.set_read_implementation((file) => createReadableStream(`${out}/server/${file}`)); - /** @type {Array} */ + /** @type {Array} */ const prerender_functions = []; for (const loader of Object.values(manifest._.remotes)) { @@ -549,13 +549,16 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } const transport = (await internal.get_hooks()).transport ?? {}; - for (const info of prerender_functions) { - if (info.has_arg) { - for (const arg of (await info.inputs?.()) ?? []) { - void enqueue(null, remote_prefix + info.id + '/' + stringify_remote_arg(arg, transport)); + for (const internals of prerender_functions) { + if (internals.has_arg) { + for (const arg of (await internals.inputs?.()) ?? []) { + void enqueue( + null, + remote_prefix + internals.id + '/' + stringify_remote_arg(arg, transport) + ); } } else { - void enqueue(null, remote_prefix + info.id); + void enqueue(null, remote_prefix + internals.id); } } diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index f6dd1c4d794c..f7da7b41742d 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -1,6 +1,6 @@ -/** @import { RemoteInfo } from 'types' */ +/** @import { RemoteInternals } from 'types' */ -/** @type {RemoteInfo['type'][]} */ +/** @type {RemoteInternals['type'][]} */ const types = ['command', 'form', 'prerender', 'query', 'query_batch', 'query_live']; /** diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 7e22d5e85f1c..0254d638da8e 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -759,7 +759,7 @@ async function kit({ svelte_config }) { // For the client, read the exports and create a new module that only contains fetch functions with the correct metadata - /** @type {Map} */ + /** @type {Map} */ const map = new Map(); // in dev, load the server module here (which will result in this hook diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index d1b199da806f..2265ee3e7e7a 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -1,5 +1,5 @@ /** @import { RemoteCommand } from '@sveltejs/kit' */ -/** @import { MaybePromise, RemoteCommandInfo } from 'types' */ +/** @import { MaybePromise, RemoteCommandInternals } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { create_validator, run_remote_function } from './shared.js'; @@ -58,10 +58,10 @@ export function command(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteCommandInfo} */ + /** @type {RemoteCommandInternals} */ const __ = { type: 'command', id: '', name: '' }; - /** @type {RemoteCommand & { __: RemoteCommandInfo }} */ + /** @type {RemoteCommand & { __: RemoteCommandInternals }} */ const wrapper = (arg) => { const { event, state } = get_request_store(); diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 53e3bc067c5d..1e9c8f012872 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -1,5 +1,5 @@ /** @import { RemoteFormInput, RemoteForm, InvalidField } from '@sveltejs/kit' */ -/** @import { InternalRemoteFormIssue, MaybePromise, RemoteFormInfo } from 'types' */ +/** @import { InternalRemoteFormIssue, MaybePromise, RemoteFormInternals } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { DEV } from 'esm-env'; @@ -84,7 +84,7 @@ export function form(validate_or_fn, maybe_fn) { } }); - /** @type {RemoteFormInfo} */ + /** @type {RemoteFormInternals} */ const __ = { type: 'form', name: '', diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index 50e3e820269d..d31761e2decc 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -1,5 +1,5 @@ /** @import { RemoteResource, RemotePrerenderFunction } from '@sveltejs/kit' */ -/** @import { RemotePrerenderInputsGenerator, MaybePromise, RemotePrerenderInfo } from 'types' */ +/** @import { RemotePrerenderInputsGenerator, MaybePromise, RemotePrerenderInternals } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { error, json } from '@sveltejs/kit'; import { DEV } from 'esm-env'; @@ -76,7 +76,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemotePrerenderInfo} */ + /** @type {RemotePrerenderInternals} */ const __ = { type: 'prerender', id: '', @@ -86,7 +86,7 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { dynamic: options?.dynamic }; - /** @type {RemotePrerenderFunction & { __: RemotePrerenderInfo }} */ + /** @type {RemotePrerenderFunction & { __: RemotePrerenderInternals }} */ const wrapper = (arg) => { /** @type {Promise & Partial>} */ const promise = (async () => { diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 50d027dc8f02..6eefddcebfc2 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -1,5 +1,5 @@ /** @import { RemoteLiveQuery, RemoteLiveQueryFunction, RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */ -/** @import { RemoteInfo, MaybePromise, RequestState, RemoteQueryLiveInfo, RemoteQueryBatchInfo, RemoteQueryInfo } from 'types' */ +/** @import { RemoteInternals, MaybePromise, RequestState, RemoteQueryLiveInternals, RemoteQueryBatchInternals, RemoteQueryInternals } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { get_request_store } from '@sveltejs/kit/internal/server'; import { create_remote_key, stringify, stringify_remote_arg } from '../../../shared.js'; @@ -61,10 +61,10 @@ export function query(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, /** @type {any} */ (maybe_fn)); - /** @type {RemoteQueryInfo} */ + /** @type {RemoteQueryInternals} */ const __ = { type: 'query', id: '', name: '' }; - /** @type {RemoteQueryFunction & { __: RemoteQueryInfo }} */ + /** @type {RemoteQueryFunction & { __: RemoteQueryInternals }} */ const wrapper = (arg) => { if (prerendering) { throw new Error( @@ -147,10 +147,10 @@ function live(validate_or_fn, maybe_fn) { ); }; - /** @type {RemoteQueryLiveInfo} */ + /** @type {RemoteQueryLiveInternals} */ const __ = { type: 'query_live', id: '', name: '', run }; - /** @type {RemoteLiveQueryFunction & { __: RemoteQueryLiveInfo }} */ + /** @type {RemoteLiveQueryFunction & { __: RemoteQueryLiveInternals }} */ const wrapper = (arg) => { if (prerendering) { throw new Error( @@ -232,7 +232,7 @@ function batch(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteQueryBatchInfo} */ + /** @type {RemoteQueryBatchInternals} */ const __ = { type: 'query_batch', id: '', @@ -273,7 +273,7 @@ function batch(validate_or_fn, maybe_fn) { /** @type {Map void, reject: (error: any) => void}> }>} */ let batching = new Map(); - /** @type {RemoteQueryFunction & { __: RemoteQueryBatchInfo }} */ + /** @type {RemoteQueryFunction & { __: RemoteQueryBatchInternals }} */ const wrapper = (arg) => { if (prerendering) { throw new Error( @@ -350,7 +350,7 @@ function batch(validate_or_fn, maybe_fn) { } /** - * @param {RemoteInfo} __ + * @param {RemoteInternals} __ * @param {any} arg * @param {RequestState} state * @param {() => Promise} fn @@ -412,7 +412,7 @@ function create_query_resource(__, arg, state, fn) { } /** - * @param {RemoteQueryLiveInfo} __ + * @param {RemoteQueryLiveInternals} __ * @param {any} arg * @param {RequestState} state * @param {() => Promise} get_first_value @@ -516,10 +516,10 @@ function with_live_cancel(live) { } /** - * @param {RemoteInfo} __ + * @param {RemoteInternals} __ * @param {'set' | 'refresh'} action * @param {any} [arg] - * @returns {{ __: RemoteInfo; state: any; refreshes: Record>; cache: Record; refreshes_key: string; cache_key: string }} + * @returns {{ __: RemoteInternals; state: any; refreshes: Record>; cache: Record; refreshes_key: string; cache_key: string }} */ function get_refresh_context(__, action, arg) { const { state } = get_request_store(); @@ -540,7 +540,7 @@ function get_refresh_context(__, action, arg) { } /** - * @param {{ __: RemoteInfo; refreshes: Record>; cache: Record; refreshes_key: string; cache_key: string }} context + * @param {{ __: RemoteInternals; refreshes: Record>; cache: Record; refreshes_key: string; cache_key: string }} context * @param {any} value * @param {boolean} [is_immediate_refresh=false] * @returns {Promise} diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index 5c9674eb3ec2..e42e69d5bb6d 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -1,5 +1,5 @@ /** @import { RequestEvent } from '@sveltejs/kit' */ -/** @import { ServerHooks, MaybePromise, RequestState, RemoteInfo, RequestStore } from 'types' */ +/** @import { ServerHooks, MaybePromise, RequestState, RemoteInternals, RequestStore } from 'types' */ import { parse } from 'devalue'; import { error } from '@sveltejs/kit'; import { with_request_store, get_request_store } from '@sveltejs/kit/internal/server'; @@ -66,18 +66,18 @@ export function create_validator(validate_or_fn, maybe_fn) { * Also saves an uneval'ed version of the result for later HTML inlining for hydration. * * @template {MaybePromise} T - * @param {RemoteInfo} info + * @param {RemoteInternals} internals * @param {any} arg * @param {RequestState} state * @param {() => Promise} get_result * @returns {Promise} */ -export async function get_response(info, arg, state, get_result) { +export async function get_response(internals, arg, state, get_result) { // wait a beat, in case `myQuery().set(...)` or `myQuery().refresh()` is immediately called // eslint-disable-next-line @typescript-eslint/await-thenable await 0; - const cache = get_cache(info, state); + const cache = get_cache(internals, state); const key = stringify_remote_arg(arg, state.transport); const entry = (cache[key] ??= { serialize: false, @@ -86,8 +86,8 @@ export async function get_response(info, arg, state, get_result) { entry.serialize ||= !!state.is_in_universal_load; - if (state.is_in_render && info.id) { - const remote_key = create_remote_key(info.id, key); + if (state.is_in_render && internals.id) { + const remote_key = create_remote_key(internals.id, key); Promise.resolve(entry.data) .then((value) => { @@ -168,15 +168,15 @@ export async function run_remote_function(event, state, allow_cookies, get_input } /** - * @param {RemoteInfo} info + * @param {RemoteInternals} internals * @param {RequestState} state */ -export function get_cache(info, state = get_request_store().state) { - let cache = state.remote.data?.get(info); +export function get_cache(internals, state = get_request_store().state) { + let cache = state.remote.data?.get(internals); if (cache === undefined) { cache = {}; - (state.remote.data ??= new Map()).set(info, cache); + (state.remote.data ??= new Map()).set(internals, cache); } return cache; diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 26b8942dd0ea..7357bf483d88 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -515,19 +515,19 @@ export async function render_response({ /** @type {Record} */ const prerender = {}; - for (const [info, cache] of remote.data) { + for (const [internals, cache] of remote.data) { // remote functions without an `id` aren't exported, and thus // cannot be called from the client - if (!info.id) continue; + if (!internals.id) continue; for (const key in cache) { const entry = cache[key]; if (!entry.serialize) continue; - const remote_key = create_remote_key(info.id, key); + const remote_key = create_remote_key(internals.id, key); - const store = info.type === 'prerender' ? prerender : query; + const store = internals.type === 'prerender' ? prerender : query; if (event_state.remote.refreshes?.[remote_key] !== undefined) { // This entry was refreshed/set by a command or form action. diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index eab319848bf9..8b8588db1091 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -1,5 +1,5 @@ /** @import { ActionResult, RemoteForm, RequestEvent, SSRManifest } from '@sveltejs/kit' */ -/** @import { RemoteFormInfo, RemoteFunctionResponse, RemoteInfo, RequestState, SSROptions } from 'types' */ +/** @import { RemoteFormInternals, RemoteFunctionResponse, RemoteInternals, RequestState, SSROptions } from 'types' */ import { json, error } from '@sveltejs/kit'; import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; @@ -48,20 +48,20 @@ async function handle_remote_call_internal(event, state, options, manifest, id) if (!fn) error(404); - /** @type {RemoteInfo} */ - const info = fn.__; + /** @type {RemoteInternals} */ + const internals = fn.__; const transport = options.hooks.transport; event.tracing.current.setAttributes({ - 'sveltekit.remote.call.type': info.type, - 'sveltekit.remote.call.name': info.name + 'sveltekit.remote.call.type': internals.type, + 'sveltekit.remote.call.name': internals.name }); /** @type {string[] | undefined} */ let form_client_refreshes; try { - if (info.type === 'query_batch') { + if (internals.type === 'query_batch') { if (event.request.method !== 'POST') { throw new SvelteKitError( 405, @@ -77,7 +77,9 @@ async function handle_remote_call_internal(event, state, options, manifest, id) payloads.map((payload) => parse_remote_arg(payload, transport)) ); - const results = await with_request_store({ event, state }, () => info.run(args, options)); + const results = await with_request_store({ event, state }, () => + internals.run(args, options) + ); return json( /** @type {RemoteFunctionResponse} */ ({ @@ -87,7 +89,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - if (info.type === 'form') { + if (internals.type === 'form') { if (event.request.method !== 'POST') { throw new SvelteKitError( 405, @@ -116,7 +118,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) data.id = JSON.parse(decodeURIComponent(additional_args)); } - const fn = info.fn; + const fn = internals.fn; const result = await with_request_store({ event, state }, () => fn(data, meta, form_data)); return json( @@ -128,7 +130,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - if (info.type === 'command') { + if (internals.type === 'command') { /** @type {{ payload: string, refreshes: string[] }} */ const { payload, refreshes } = await event.request.json(); const arg = parse_remote_arg(payload, transport); @@ -143,7 +145,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - if (info.type === 'query_live') { + if (internals.type === 'query_live') { if (event.request.method !== 'GET') { throw new SvelteKitError( 405, @@ -156,7 +158,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) new URL(event.request.url).searchParams.get('payload') ); - const live = await info.run(event, state, parse_remote_arg(payload, transport)); + const live = await internals.run(event, state, parse_remote_arg(payload, transport)); const iterator = live.iterator; const encoder = new TextEncoder(); @@ -244,7 +246,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } const payload = - info.type === 'prerender' + internals.type === 'prerender' ? additional_args : /** @type {string} */ ( // new URL(...) necessary because we're hiding the URL from the user in the event object @@ -284,7 +286,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) { // By setting a non-200 during prerendering we fail the prerender process (unless handleHttpError handles it). // Errors at runtime will be passed to the client and are handled there - status: state.prerendering || info.type === 'query_live' ? status : undefined, + status: state.prerendering || internals.type === 'query_live' ? status : undefined, headers: { 'cache-control': 'private, no-store' } @@ -382,7 +384,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) { } try { - const fn = /** @type {RemoteFormInfo} */ (/** @type {any} */ (form).__).fn; + const fn = /** @type {RemoteFormInternals} */ (/** @type {any} */ (form).__).fn; const { data, meta, form_data } = await deserialize_binary_form(event.request); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 5c1c55826db4..2107e241f0f5 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -379,7 +379,7 @@ export interface ServerMetadata { }>; routes: Map; /** For each hashed remote file, a map of export name -> { type, dynamic }, where `dynamic` is `false` for non-dynamic prerender functions */ - remotes: Map>; + remotes: Map>; } export interface SSRComponent { @@ -575,10 +575,10 @@ interface BaseRemoteInfo { name: string; } -export interface RemoteQueryInfo extends BaseRemoteInfo { +export interface RemoteQueryInternals extends BaseRemoteInfo { type: 'query'; } -export interface RemoteQueryLiveInfo extends BaseRemoteInfo { +export interface RemoteQueryLiveInternals extends BaseRemoteInfo { type: 'query_live'; run( event: RequestEvent, @@ -587,34 +587,34 @@ export interface RemoteQueryLiveInfo extends BaseRemoteInfo { ): Promise<{ iterator: AsyncIterator; cancel: () => void }>; } -export interface RemoteQueryBatchInfo extends BaseRemoteInfo { +export interface RemoteQueryBatchInternals extends BaseRemoteInfo { type: 'query_batch'; run: (args: any[], options: SSROptions) => Promise; } -export interface RemoteCommandInfo extends BaseRemoteInfo { +export interface RemoteCommandInternals extends BaseRemoteInfo { type: 'command'; } -export interface RemoteFormInfo extends BaseRemoteInfo { +export interface RemoteFormInternals extends BaseRemoteInfo { type: 'form'; fn(body: Record, meta: BinaryFormMeta, form_data: FormData | null): Promise; } -export interface RemotePrerenderInfo extends BaseRemoteInfo { +export interface RemotePrerenderInternals extends BaseRemoteInfo { type: 'prerender'; has_arg: boolean; dynamic?: boolean; inputs?: RemotePrerenderInputsGenerator; } -export type RemoteInfo = - | RemoteQueryInfo - | RemoteQueryLiveInfo - | RemoteQueryBatchInfo - | RemoteCommandInfo - | RemoteFormInfo - | RemotePrerenderInfo; +export type RemoteInternals = + | RemoteQueryInternals + | RemoteQueryLiveInternals + | RemoteQueryBatchInternals + | RemoteCommandInternals + | RemoteFormInternals + | RemotePrerenderInternals; export interface InternalRemoteFormIssue extends RemoteFormIssue { name: string; @@ -640,7 +640,10 @@ export interface RequestState { record_span: RecordSpan; }; readonly remote: { - data: null | Map }>>; + data: null | Map< + RemoteInternals, + Record }> + >; forms: null | Map; refreshes: null | Record>; }; From 19f5e96fdaacf5c7d5f3ff3de5972ac9ffa6abaf Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 13:25:01 -0400 Subject: [PATCH 16/36] tweak docs --- .../20-core-concepts/60-remote-functions.md | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index cdd6f749bfa6..99334d8cc5e0 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -227,53 +227,38 @@ export const getWeather = query.batch(v.string(), async (cityIds) => { ## query.live -`query.live` is for streaming updates from the server. It works like `query`, including argument validation, but the callback returns an `AsyncIterator` (typically an async generator): +`query.live` is for accessing real-time data from the server. It behaves similarly to `query`, but the callback — typically an async [generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function*) — returns an `AsyncIterable`: ```js import { query } from '$app/server'; -export const getCount = query.live(async function* () { - yield 0; - - while (true) { - await wait_for_count_change(); - yield get_current_count(); - } -}); -``` - -The callback receives a context with an `AbortSignal` so that long-running waits can stop immediately when the client disconnects: - -```js -export const getCount = query.live(async function* (_, { signal }) { - yield count; - +export const getTime = query.live(async function* () { while (true) { - await wait_for_change({ signal }); - yield count; + yield new Date(); + await new Promise((f) => setTimeout(f, 1000)); } }); ``` -On the server, `await getCount()` reads the first yielded value and then closes the iterator. This allows SSR to serialize the initial value and reuse it during hydration. +During server-side rendering, `await getTime()` returns the first yielded value then closes the iterator. This initial value is serialized and reused during hydration. -On the client, the query stays connected while it's actively used in a component. When there are no active uses left, the stream disconnects and server-side iteration is stopped. +On the client, the query stays connected while it's actively used in a component. Multiple instances share a connection. When there are no active uses left, the stream disconnects and server-side iteration is stopped. Live queries expose a `connected` property and `reconnect()` method: ```svelte -

{count.current}

-

connected: {String(count.connected)}

- +

{await time}

+

connected: {time.connected}

+ ``` -Unlike `query`, live queries do not have a `refresh()` method. +Unlike `query`, live queries do not have a `refresh()` method, as they are self-updating. As with `query` and `query.batch`, call `.run()` outside render when you need imperative access. For live queries, `run()` returns a `Promise>`. From 84d4d78438a6d84ff36411c3e416d5a3d0ac764f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 13:49:27 -0400 Subject: [PATCH 17/36] tweak docs --- documentation/docs/20-core-concepts/60-remote-functions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 99334d8cc5e0..dbfdbaf7fbc2 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -258,6 +258,8 @@ Live queries expose a `connected` property and `reconnect()` method: ``` +If the connection drops, `connected` becomes `false`. SvelteKit will attempt to reconnect passively, with exponential backoff, and actively if `navigator.onLine` goes from `false` to `true`. + Unlike `query`, live queries do not have a `refresh()` method, as they are self-updating. As with `query` and `query.batch`, call `.run()` outside render when you need imperative access. For live queries, `run()` returns a `Promise>`. From 6cc8478b1feef19c62f7e2c256c7766bee5d2fc9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 15:33:22 -0400 Subject: [PATCH 18/36] get rid of second argument, we dont need it --- packages/kit/src/exports/public.d.ts | 8 --- .../src/runtime/app/server/remote/query.js | 53 +++---------------- packages/kit/src/runtime/server/remote.js | 4 +- packages/kit/src/types/internal.d.ts | 6 +-- .../src/routes/remote/live/live.remote.js | 6 ++- .../remote/validation/validation.remote.js | 4 +- packages/kit/test/types/remote.test.ts | 10 ++-- packages/kit/types/index.d.ts | 20 ++----- 8 files changed, 22 insertions(+), 89 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index e4498d48f9a6..ee4d7539d99d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2183,14 +2183,6 @@ export type RemoteLiveQuery = RemoteResource & { reconnect(): void; }; -export interface RemoteLiveQueryContext { - /** - * Abort signal for the current live connection. - * Use this to stop pending work promptly when the client disconnects. - */ - readonly signal: AbortSignal; -} - export interface RemoteQueryOverride { _key: string; release(): void; diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 6eefddcebfc2..a7061996a206 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -91,7 +91,7 @@ export function query(validate_or_fn, maybe_fn) { * * @template Output * @overload - * @param {(arg: void, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} fn + * @param {(arg: void) => MaybePromise | AsyncIterator | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction} */ /** @@ -99,7 +99,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {'unchecked'} validate - * @param {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} fn + * @param {(arg: Input) => MaybePromise | AsyncIterator | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction} */ /** @@ -107,19 +107,19 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {Schema} schema - * @param {(arg: StandardSchemaV1.InferOutput, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} fn + * @param {(arg: StandardSchemaV1.InferOutput) => MaybePromise | AsyncIterator | AsyncIterable>} fn * @returns {RemoteLiveQueryFunction, Output>} */ /** * @template Input * @template Output * @param {any} validate_or_fn - * @param {(args: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} [maybe_fn] + * @param {(args: Input) => MaybePromise | AsyncIterator | AsyncIterable>} [maybe_fn] * @returns {RemoteLiveQueryFunction} */ /*@__NO_SIDE_EFFECTS__*/ function live(validate_or_fn, maybe_fn) { - /** @type {(arg: Input, context: { signal: AbortSignal }) => MaybePromise | AsyncIterator | AsyncIterable>} */ + /** @type {(arg: Input) => MaybePromise | AsyncIterator | AsyncIterable>} */ const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ @@ -136,14 +136,7 @@ function live(validate_or_fn, maybe_fn) { state, false, () => validate(arg), - async (input) => { - const controller = new AbortController(); - - return { - iterator: to_async_iterator(await fn(input, { signal: controller.signal }), __.name), - cancel: () => controller.abort() - }; - } + async (input) => to_async_iterator(await fn(input), __.name) ); }; @@ -165,8 +158,7 @@ function live(validate_or_fn, maybe_fn) { arg, state, async () => { - const live = await run(event, state, arg); - const iterator = with_live_cancel(live); + const iterator = await run(event, state, arg); try { const { value, done } = await iterator.next(); @@ -177,11 +169,10 @@ function live(validate_or_fn, maybe_fn) { return value; } finally { - live.cancel(); await iterator.return?.(); } }, - async () => with_live_cancel(await run(event, state, arg)) + async () => run(event, state, arg) ); }; @@ -487,34 +478,6 @@ function to_async_iterator(source, name) { throw new Error(`query.live '${name}' must return an AsyncIterator or AsyncIterable`); } -/** - * @template T - * @param {{ iterator: AsyncIterator; cancel: () => void }} live - * @returns {AsyncIterableIterator} - */ -function with_live_cancel(live) { - const iterator = live.iterator; - - /** @type {AsyncIterableIterator} */ - const wrapped = { - next(value) { - return iterator.next(value); - }, - return(value) { - live.cancel(); - return iterator.return ? iterator.return(value) : Promise.resolve({ value, done: true }); - }, - throw(error) { - return iterator.throw ? iterator.throw(error) : Promise.reject(error); - }, - [Symbol.asyncIterator]() { - return wrapped; - } - }; - - return wrapped; -} - /** * @param {RemoteInternals} __ * @param {'set' | 'refresh'} action diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 8b8588db1091..460afe392f52 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -158,8 +158,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) new URL(event.request.url).searchParams.get('payload') ); - const live = await internals.run(event, state, parse_remote_arg(payload, transport)); - const iterator = live.iterator; + const iterator = await internals.run(event, state, parse_remote_arg(payload, transport)); const encoder = new TextEncoder(); let closed = false; @@ -167,7 +166,6 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const close = async () => { if (closed) return; closed = true; - live.cancel(); await iterator.return?.(); }; diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index e10b4b15862f..02f8553578d2 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -580,11 +580,7 @@ export interface RemoteQueryInternals extends BaseRemoteInternals { } export interface RemoteQueryLiveInternals extends BaseRemoteInternals { type: 'query_live'; - run( - event: RequestEvent, - state: RequestState, - arg: any - ): Promise<{ iterator: AsyncIterator; cancel: () => void }>; + run(event: RequestEvent, state: RequestState, arg: any): Promise>; } export interface RemoteQueryBatchInternals extends BaseRemoteInternals { diff --git a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js index 7d5d04e97cd7..2a6522a89e00 100644 --- a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js @@ -1,4 +1,4 @@ -import { command, query } from '$app/server'; +import { command, getRequestEvent, query } from '$app/server'; let count = 0; let drop_next = false; @@ -34,7 +34,9 @@ function wait_for_change(signal) { }); } -export const get_count = query.live(async function* (_, { signal }) { +export const get_count = query.live(async function* () { + const signal = getRequestEvent().request.signal; + active_connections += 1; try { diff --git a/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js index 6026833eac5a..03461be9f4bb 100644 --- a/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/validation/validation.remote.js @@ -16,10 +16,10 @@ export const validated_query_no_args = query((arg) => (arg === undefined ? 'succ export const validated_query_with_arg = query(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); -export const validated_live_query_no_args = query.live(function* (arg, _context) { +export const validated_live_query_no_args = query.live(function* (arg) { yield arg === undefined ? 'success' : 'failure'; }); -export const validated_live_query_with_arg = query.live(schema, function* (arg, _context) { +export const validated_live_query_with_arg = query.live(schema, function* (arg) { yield typeof arg === 'string' ? 'success' : 'failure'; }); diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index 709a9f1f465e..d38a44807d19 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -125,13 +125,9 @@ function live_query_tests() { void live_without_args(); async function live_with_schema() { - const q: RemoteLiveQueryFunction = query.live( - schema, - async function* (a, { signal }) { - signal.aborted; - yield a; - } - ); + const q: RemoteLiveQueryFunction = query.live(schema, async function* (a) { + yield a; + }); const result: string = await q('x'); result; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f1152de122e8..85c8748abe93 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2157,14 +2157,6 @@ declare module '@sveltejs/kit' { reconnect(): void; }; - export interface RemoteLiveQueryContext { - /** - * Abort signal for the current live connection. - * Use this to stop pending work promptly when the client disconnects. - */ - readonly signal: AbortSignal; - } - export interface RemoteQueryOverride { _key: string; release(): void; @@ -3406,17 +3398,11 @@ declare module '$app/server' { * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query-live) for full documentation. * * */ - function live(fn: (arg: void, context: { - signal: AbortSignal; - }) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction; + function live(fn: (arg: void) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction; - function live(validate: "unchecked", fn: (arg: Input, context: { - signal: AbortSignal; - }) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction; + function live(validate: "unchecked", fn: (arg: Input) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction; - function live(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput, context: { - signal: AbortSignal; - }) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction, Output>; + function live(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise | AsyncIterator | AsyncIterable>): RemoteLiveQueryFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; From 2fb9c67d0bddcb8bd496595bee3e293b5b004d38 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 15:38:20 -0400 Subject: [PATCH 19/36] unnecessary --- packages/kit/src/runtime/app/server/remote/query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index a7061996a206..7a1e0bfc8b64 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -59,7 +59,7 @@ export function query(validate_or_fn, maybe_fn) { const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ - const validate = create_validator(validate_or_fn, /** @type {any} */ (maybe_fn)); + const validate = create_validator(validate_or_fn, maybe_fn); /** @type {RemoteQueryInternals} */ const __ = { type: 'query', id: '', name: '' }; From af19c4b0e1adfaa349859b9628e61fe39c2b4788 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 15:38:44 -0400 Subject: [PATCH 20/36] unnecessary --- packages/kit/src/runtime/app/server/remote/query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 7a1e0bfc8b64..42bdba67ede6 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -123,7 +123,7 @@ function live(validate_or_fn, maybe_fn) { const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ - const validate = create_validator(validate_or_fn, /** @type {any} */ (maybe_fn)); + const validate = create_validator(validate_or_fn, maybe_fn); /** * @param {any} event From 165e514ee8577e94cdd6384bae0e926b91215d14 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 15:44:08 -0400 Subject: [PATCH 21/36] revert --- .../kit/src/runtime/client/remote-functions/query.svelte.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 329bd5fd7f71..3e3711eb9625 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -49,7 +49,8 @@ export function query(id) { // If this reruns as part of HMR, refresh the query for (const [key, entry] of query_map) { if (key === id || key.startsWith(id + '/')) { - void entry.resource.refresh(); + // use optional chaining in case a prerender function was turned into a query + void entry.resource.refresh?.(); } } } From cf5c566fea6b91207dae94523c383886d32d0e21 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 15:53:10 -0400 Subject: [PATCH 22/36] revert --- .../kit/src/runtime/client/remote-functions/shared.svelte.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index 91127828c03e..eff2cfe00400 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -81,6 +81,6 @@ export function refresh_queries(stringified_refreshes, updates = []) { } // Update the query with the new value const entry = query_map.get(key); - /** @type {any} */ (entry?.resource)?.set?.(value); + entry?.resource.set(value); } } From 31e80f2f52f6ff9fcd7c4a8421d28c04284b3474 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 15:54:21 -0400 Subject: [PATCH 23/36] tidy --- packages/kit/src/runtime/client/client.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 3cbbe4383701..0ecfde557f97 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -418,7 +418,7 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru // Rerun queries if (force_invalidation) { query_map.forEach(({ resource }) => { - void (/** @type {any} */ (resource).refresh?.()); + void resource.refresh(); }); } @@ -526,7 +526,7 @@ export async function _goto(url, options, redirect_count, nav_token) { query_map.forEach(({ resource }, key) => { // Only refresh those that already existed on the old page if (query_keys?.includes(key)) { - void (/** @type {any} */ (resource).refresh?.()); + void resource.refresh(); } }); }); From a06f3cfa9dc1faef4bfde4c3cb1a08cd420d2024 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 16:02:57 -0400 Subject: [PATCH 24/36] tweak --- packages/kit/src/runtime/server/remote.js | 63 +++++++++++------------ 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 460afe392f52..3a187a9f6af0 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -161,21 +161,30 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const iterator = await internals.run(event, state, parse_remote_arg(payload, transport)); const encoder = new TextEncoder(); + + /** + * @param {ReadableStreamDefaultController} controller + * @param {any} payload + */ + function send(controller, payload) { + controller.enqueue(encoder.encode(JSON.stringify(payload) + '\n')); + } + let closed = false; - const close = async () => { + async function cancel() { if (closed) return; closed = true; await iterator.return?.(); - }; + } - event.request.signal.addEventListener('abort', close, { once: true }); + event.request.signal.addEventListener('abort', cancel, { once: true }); return new Response( new ReadableStream({ async pull(controller) { if (event.request.signal.aborted) { - await close(); + await cancel(); controller.close(); return; } @@ -184,55 +193,41 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const { value, done } = await iterator.next(); if (done) { - await close(); + await cancel(); controller.close(); return; } - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'result', - result: stringify(value, transport) - }) + '\n' - ) - ); + send(controller, { + type: 'result', + result: stringify(value, transport) + }); } catch (error) { if (!event.request.signal.aborted) { if (error instanceof Redirect) { - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'redirect', - location: error.location - }) + '\n' - ) - ); + send(controller, { + type: 'redirect', + location: error.location + }); } else { const status = error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500; - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'error', - error: await handle_error_and_jsonify(event, state, options, error), - status - }) + '\n' - ) - ); + send(controller, { + type: 'error', + error: await handle_error_and_jsonify(event, state, options, error), + status + }); } } - await close(); + await cancel(); controller.close(); } }, - async cancel() { - await close(); - } + cancel }), { headers: { From e2716580367df09c94e777f766d881457b2b2798 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Mar 2026 16:08:30 -0400 Subject: [PATCH 25/36] not sure what this is for --- packages/kit/src/runtime/server/remote.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 3a187a9f6af0..0524dfe68eb2 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -279,7 +279,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) { // By setting a non-200 during prerendering we fail the prerender process (unless handleHttpError handles it). // Errors at runtime will be passed to the client and are handled there - status: state.prerendering || internals.type === 'query_live' ? status : undefined, + status: state.prerendering ? status : undefined, headers: { 'cache-control': 'private, no-store' } From 7fb2d8e4ed4d5a363560dd3dfdd6563b694164a0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 20 Mar 2026 09:45:53 -0400 Subject: [PATCH 26/36] tidy up --- .../client/remote-functions/query.svelte.js | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 3e3711eb9625..3a796b3fad52 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -390,6 +390,7 @@ async function get_stream_reader(response) { const content_type = response.headers.get('content-type') ?? ''; if (response.ok && content_type.includes('application/json')) { + // we can end up here if we e.g. redirect in `handle` const result = await response.json(); if (result.type === 'redirect') { @@ -397,32 +398,19 @@ async function get_stream_reader(response) { throw new Redirect(307, result.location); } - if (result.type === 'error') { - throw new HttpError(result.status ?? 500, result.error); - } - throw new Error('Invalid query.live response'); } if (!response.ok) { - let result; - try { - result = await response.json(); + const result = await response.json(); + + if (result.type === 'error') { + throw new HttpError(result.status ?? response.status ?? 500, result.error); + } } catch { throw new HttpError(response.status, response.statusText); } - - if (result.type === 'redirect') { - await goto(result.location); - throw new Redirect(307, result.location); - } - - if (result.type === 'error') { - throw new HttpError(result.status ?? response.status ?? 500, result.error); - } - - throw new HttpError(response.status, response.statusText); } if (!response.body) { From b579163c20d58f5004709d676fc7c398fa85764e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 20 Mar 2026 17:03:37 -0400 Subject: [PATCH 27/36] goddammit codex was right --- .../kit/src/runtime/client/remote-functions/query.svelte.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 3a796b3fad52..2f5bc808e3c8 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -398,6 +398,10 @@ async function get_stream_reader(response) { throw new Redirect(307, result.location); } + if (result.type === 'error') { + throw new HttpError(result.status ?? 500, result.error); + } + throw new Error('Invalid query.live response'); } From c228920e9be2701a5e7e31b400a926764be691a9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 20 Mar 2026 17:19:34 -0400 Subject: [PATCH 28/36] don't attempt to reconnect to a finished live query --- packages/kit/src/exports/public.d.ts | 2 ++ .../client/remote-functions/query.svelte.js | 31 ++++++++++++++----- .../src/routes/remote/live/LiveView.svelte | 8 ++++- .../src/routes/remote/live/live.remote.js | 9 +++++- .../kit/test/apps/async/test/client.test.js | 27 ++++++++++++++++ packages/kit/test/types/remote.test.ts | 1 + packages/kit/types/index.d.ts | 2 ++ 7 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index ee4d7539d99d..4839d381d720 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -2179,6 +2179,8 @@ export type RemoteLiveQuery = RemoteResource & { run(): Promise>; /** `true` if the live stream is currently connected. */ readonly connected: boolean; + /** `true` once the live stream iterator has completed. */ + readonly finished: boolean; /** Reconnects the live stream immediately. */ reconnect(): void; }; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 2f5bc808e3c8..52707eeb9570 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -552,6 +552,7 @@ export class LiveQuery { #loading = $state(true); #ready = $state(false); #connected = $state(false); + #finished = $state(false); #version = $state(0); /** @type {T | undefined} */ #raw = $state.raw(); @@ -633,7 +634,7 @@ export class LiveQuery { } #schedule_reconnect() { - if (!this.#active || this.#destroyed || this.#retry_timer) return; + if (!this.#active || this.#destroyed || this.#finished || this.#retry_timer) return; if (typeof navigator !== 'undefined' && navigator.onLine === false) { return; @@ -651,7 +652,7 @@ export class LiveQuery { } async #connect_stream() { - if (!this.#active || this.#destroyed) return; + if (!this.#active || this.#destroyed || this.#finished) return; const connection = ++this.#connection; const controller = new AbortController(); @@ -671,12 +672,16 @@ export class LiveQuery { const reader = await get_stream_reader(response); const next_value = create_stream_reader(reader); + let finished = false; this.#connected = true; this.#attempt = 0; while (this.#active && !this.#destroyed && connection === this.#connection) { const value = await next_value(); - if (value === undefined) break; + if (value === undefined) { + finished = true; + break; + } if (!this.#ready) { this.#resolve_first(value); @@ -684,6 +689,10 @@ export class LiveQuery { this.#set_value(value); } + + if (finished && this.#active && !this.#destroyed && connection === this.#connection) { + this.#finished = true; + } } catch (error) { if (controller.signal.aborted || connection !== this.#connection) { return; @@ -700,7 +709,7 @@ export class LiveQuery { this.#connected = false; this.#controller = null; - if (this.#active && !this.#destroyed) { + if (this.#active && !this.#destroyed && !this.#finished) { this.#schedule_reconnect(); } } @@ -708,7 +717,7 @@ export class LiveQuery { } #on_online = () => { - if (!this.#active || this.#destroyed) return; + if (!this.#active || this.#destroyed || this.#finished) return; this.#clear_retry(); void this.#connect_stream(); }; @@ -732,7 +741,7 @@ export class LiveQuery { } this.#clear_retry(); - if (!this.#controller) { + if (!this.#controller && !this.#finished) { void this.#connect_stream(); } } @@ -802,8 +811,12 @@ export class LiveQuery { return this.#connected; } + get finished() { + return this.#finished; + } + reconnect() { - if (!this.#active || this.#destroyed) return; + if (!this.#active || this.#destroyed || this.#finished) return; this.#attempt = 0; this.#clear_retry(); this.#disconnect_current(); @@ -1131,6 +1144,10 @@ class LiveQueryProxy { return this.#get_cached_query().connected; } + get finished() { + return this.#get_cached_query().finished; + } + run() { if (is_in_effect()) { throw new Error( diff --git a/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte b/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte index ad1cbe97c2fc..e4f13f6a6882 100644 --- a/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/live/LiveView.svelte @@ -1,7 +1,8 @@

{String(live.ready)}

@@ -9,3 +10,8 @@

{live.current}

{await live}

+ +

{finite.current}

+

{String(finite.connected)}

+

{String(finite.finished)}

+ diff --git a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js index 2a6522a89e00..e45c357d8769 100644 --- a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js @@ -4,6 +4,7 @@ let count = 0; let drop_next = false; let active_connections = 0; let cleanup_count = 0; +let finite_connection_count = 0; /** @type {Set<() => void>} */ const listeners = new Set(); @@ -51,7 +52,7 @@ export const get_count = query.live(async function* () { if (drop_next) { drop_next = false; - return; + throw new Error('stream dropped'); } yield count; @@ -62,6 +63,11 @@ export const get_count = query.live(async function* () { } }); +export const get_finite_count = query.live(async function* () { + finite_connection_count += 1; + yield count; +}); + export const increment = command(() => { count += 1; notify(); @@ -81,6 +87,7 @@ export const get_stats = query(() => { return { active_connections, cleanup_count, + finite_connection_count, count }; }); diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 2071b8c8d503..dffd59476ec5 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -383,6 +383,33 @@ test.describe('remote function mutations', () => { await expect(page.locator('#connected')).toHaveText('true'); }); + test('query.live marks finite iterators as finished and does not reconnect', async ({ page }) => { + await page.goto('/remote/live'); + await page.click('#reset'); + + await expect(page.locator('#finite-finished')).toHaveText('true'); + await expect(page.locator('#finite-connected')).toHaveText('false'); + + await page.waitForTimeout(200); + await page.click('#stats'); + await expect(page.locator('#stats-value')).not.toHaveText('pending'); + const stats = JSON.parse((await page.locator('#stats-value').textContent()) ?? '{}'); + const before = stats.finite_connection_count; + + await page.waitForTimeout(200); + await page.click('#stats'); + await expect(page.locator('#stats-value')).not.toHaveText('pending'); + const after_wait = JSON.parse((await page.locator('#stats-value').textContent()) ?? '{}'); + expect(after_wait.finite_connection_count).toBe(before); + + await page.click('#finite-reconnect'); + await page.waitForTimeout(100); + await page.click('#stats'); + await expect(page.locator('#stats-value')).not.toHaveText('pending'); + const after_reconnect = JSON.parse((await page.locator('#stats-value').textContent()) ?? '{}'); + expect(after_reconnect.finite_connection_count).toBe(before); + }); + test('query.live can be detached from the page', async ({ page }) => { await page.goto('/remote/live'); await page.click('#reset'); diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index d38a44807d19..3191f1faff71 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -116,6 +116,7 @@ function live_query_tests() { iterator; q().connected === true; + q().finished === false; q().reconnect(); // @ts-expect-error q().refresh(); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 85c8748abe93..f534fb198fb3 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2153,6 +2153,8 @@ declare module '@sveltejs/kit' { run(): Promise>; /** `true` if the live stream is currently connected. */ readonly connected: boolean; + /** `true` once the live stream iterator has completed. */ + readonly finished: boolean; /** Reconnects the live stream immediately. */ reconnect(): void; }; From 6fe92bf989ba689c4f92ba9d96252e0fc8e2abde Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 20 Mar 2026 17:57:31 -0400 Subject: [PATCH 29/36] fix --- packages/kit/src/runtime/app/server/remote/query.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 42bdba67ede6..f60db1ef95e0 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -429,6 +429,7 @@ function create_live_query_resource(__, arg, state, get_first_value, get_iterato finally(onfinally) { return get_promise().finally(onfinally); }, + finished: false, loading: true, ready: false, connected: false, From c036ff79ba8b16d61f84717fd37a315b60d955d3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 22 Mar 2026 15:47:11 -0400 Subject: [PATCH 30/36] lint --- .../test/apps/async/src/routes/remote/validation/+page.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte index 13583c271553..49fd04d6b553 100644 --- a/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/validation/+page.svelte @@ -74,6 +74,7 @@ status = 'error'; } catch { try { + // @ts-expect-error await validated_live_query_no_args('invalid').run(); status = 'error'; } catch { @@ -111,6 +112,7 @@ } try { + // @ts-expect-error await validated_live_query_with_arg(1).run(); status = 'error'; } catch (e) { @@ -166,6 +168,7 @@ try { // @ts-expect-error validate_result(await validated_query_with_arg('valid', 'ignored').run()); + // @ts-expect-error validate_result(await read_live(validated_live_query_with_arg('valid', 'ignored'))); // @ts-expect-error validate_result(await validated_prerendered_query_with_arg('valid', 'ignored')); From dd63c1ce2c8a5d47fd2064e0bc5cb0f08ac52c0c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 22 Mar 2026 20:50:54 -0400 Subject: [PATCH 31/36] allow server to trigger a reconnect --- .../src/runtime/app/server/remote/command.js | 1 + .../kit/src/runtime/app/server/remote/form.js | 1 + .../src/runtime/app/server/remote/query.js | 10 ++++++++- .../client/remote-functions/command.svelte.js | 11 +++++++++- .../client/remote-functions/form.svelte.js | 11 +++++++++- .../client/remote-functions/shared.svelte.js | 9 +++++++- packages/kit/src/runtime/server/remote.js | 18 ++++++++++++--- packages/kit/src/runtime/server/respond.js | 3 ++- packages/kit/src/types/internal.d.ts | 3 +++ .../async/src/routes/remote/live/+page.svelte | 3 ++- .../src/routes/remote/live/live.remote.js | 4 ++++ .../kit/test/apps/async/test/client.test.js | 22 +++++++++++++++++++ 12 files changed, 87 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index 2265ee3e7e7a..5df57cd5b355 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -78,6 +78,7 @@ export function command(validate_or_fn, maybe_fn) { } state.remote.refreshes ??= {}; + state.remote.reconnects ??= new Set(); const promise = Promise.resolve( run_remote_function(event, state, true, () => validate(arg), fn) diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 1e9c8f012872..707fc5935fdc 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -137,6 +137,7 @@ export function form(validate_or_fn, maybe_fn) { } state.remote.refreshes ??= {}; + state.remote.reconnects ??= new Set(); const issue = create_issues(); diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index f60db1ef95e0..4b8baeeed506 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -434,7 +434,15 @@ function create_live_query_resource(__, arg, state, get_first_value, get_iterato ready: false, connected: false, reconnect() { - throw new Error(`Cannot call '${__.name}.reconnect()' on the server`); + const reconnects = state.remote.reconnects; + + if (!reconnects) { + throw new Error( + `Cannot call reconnect on query.live '${__.name}' because it is not executed in the context of a command/form remote function` + ); + } + + reconnects.add(create_remote_key(__.id, stringify_remote_arg(arg, state.transport))); }, async run() { if (!state.is_in_universal_load) { diff --git a/packages/kit/src/runtime/client/remote-functions/command.svelte.js b/packages/kit/src/runtime/client/remote-functions/command.svelte.js index 5838f406e88f..ec6f4440d5df 100644 --- a/packages/kit/src/runtime/client/remote-functions/command.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/command.svelte.js @@ -6,7 +6,12 @@ import * as devalue from 'devalue'; import { HttpError } from '@sveltejs/kit/internal'; import { app } from '../client.js'; import { stringify_remote_arg } from '../../shared.js'; -import { get_remote_request_headers, refresh_queries, release_overrides } from './shared.svelte.js'; +import { + get_remote_request_headers, + refresh_queries, + reconnect_live_queries, + release_overrides +} from './shared.svelte.js'; /** * Client-version of the `command` function from `$app/server`. @@ -70,6 +75,10 @@ export function command(id) { refresh_queries(result.refreshes, updates); } + if (result.reconnects) { + reconnect_live_queries(result.reconnects); + } + return devalue.parse(result.result, app.decoders); } } finally { diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index b504eae5e3eb..aa2ac3160d18 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -8,7 +8,7 @@ import { DEV } from 'esm-env'; import { HttpError } from '@sveltejs/kit/internal'; import { app, query_responses, _goto, set_nearest_error_page, invalidateAll } from '../client.js'; import { tick } from 'svelte'; -import { refresh_queries, release_overrides } from './shared.svelte.js'; +import { refresh_queries, reconnect_live_queries, release_overrides } from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, @@ -230,6 +230,10 @@ export function form(id) { } else { void invalidateAll(); } + + if (form_result.reconnects) { + reconnect_live_queries(form_result.reconnects); + } } } else if (form_result.type === 'redirect') { const refreshes = form_result.refreshes ?? ''; @@ -237,6 +241,11 @@ export function form(id) { if (!invalidateAll) { refresh_queries(refreshes, updates); } + + if (form_result.reconnects) { + reconnect_live_queries(form_result.reconnects); + } + // Use internal version to allow redirects to external URLs void _goto(form_result.location, { invalidateAll }, 0); } else { diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index eff2cfe00400..79889f8b2fdc 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -2,7 +2,7 @@ /** @import { RemoteFunctionResponse } from 'types' */ /** @import { Query } from './query.svelte.js' */ import * as devalue from 'devalue'; -import { app, goto, query_map } from '../client.js'; +import { app, goto, live_query_map, query_map } from '../client.js'; import { HttpError, Redirect } from '@sveltejs/kit/internal'; import { untrack } from 'svelte'; import { navigating, page } from '../state.svelte.js'; @@ -84,3 +84,10 @@ export function refresh_queries(stringified_refreshes, updates = []) { entry?.resource.set(value); } } + +/** @param {string[]} reconnects */ +export function reconnect_live_queries(reconnects) { + for (const key of reconnects) { + live_query_map.get(key)?.resource.reconnect(); + } +} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 0524dfe68eb2..046d850d5ca9 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -125,7 +125,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) /** @type {RemoteFunctionResponse} */ ({ type: 'result', result: stringify(result, transport), - refreshes: result.issues ? undefined : await serialize_refreshes(meta.remote_refreshes) + refreshes: result.issues ? undefined : await serialize_refreshes(meta.remote_refreshes), + reconnects: serialize_reconnects() }) ); } @@ -140,7 +141,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) /** @type {RemoteFunctionResponse} */ ({ type: 'result', result: stringify(data, transport), - refreshes: await serialize_refreshes(refreshes) + refreshes: await serialize_refreshes(refreshes), + reconnects: serialize_reconnects() }) ); } @@ -262,7 +264,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) /** @type {RemoteFunctionResponse} */ ({ type: 'redirect', location: error.location, - refreshes: await serialize_refreshes(form_client_refreshes) + refreshes: await serialize_refreshes(form_client_refreshes), + reconnects: serialize_reconnects() }) ); } @@ -323,6 +326,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) transport ); } + + function serialize_reconnects() { + const reconnects = state.remote.reconnects; + if (!reconnects || reconnects.size === 0) { + return undefined; + } + + return Array.from(reconnects); + } } /** @type {typeof handle_remote_form_post_internal} */ diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 6ff2b13d8d2d..900080ea96ef 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -152,7 +152,8 @@ export async function internal_respond(request, options, manifest, state) { remote: { data: null, forms: null, - refreshes: null + refreshes: null, + reconnects: null }, is_in_remote_function: false, is_in_render: false, diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 02f8553578d2..ebda086434bb 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -303,6 +303,7 @@ export type RemoteFunctionResponse = | (ServerRedirectNode & { /** devalue'd Record */ refreshes?: string; + reconnects?: string[]; }) | ServerErrorNode | { @@ -310,6 +311,7 @@ export type RemoteFunctionResponse = result: string; /** devalue'd Record */ refreshes: string | undefined; + reconnects?: string[]; }; /** @@ -642,6 +644,7 @@ export interface RequestState { >; forms: null | Map; refreshes: null | Record>; + reconnects: null | Set; }; readonly is_in_remote_function: boolean; readonly is_in_render: boolean; diff --git a/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte index 450ec3addce1..7dd98711fa28 100644 --- a/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/live/+page.svelte @@ -1,6 +1,6 @@

{String(live.ready)}

@@ -15,3 +26,6 @@

{String(finite.connected)}

{String(finite.finished)}

+ +

{duplicate_payload.current?.count}

+

{duplicate_updates}

diff --git a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js index 47fde7a96670..919b63e7ccaf 100644 --- a/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js +++ b/packages/kit/test/apps/async/src/routes/remote/live/live.remote.js @@ -68,6 +68,22 @@ export const get_finite_count = query.live(async function* () { yield count; }); +export const get_duplicate_payload = query.live(async function* () { + const signal = getRequestEvent().request.signal; + + yield { count }; + + while (true) { + const status = await wait_for_change(signal); + + if (status === 'aborted') { + return; + } + + yield { count }; + } +}); + export const increment = command(() => { count += 1; notify(); @@ -78,6 +94,10 @@ export const reset = command(() => { notify(); }); +export const notify_only = command(() => { + notify(); +}); + export const drop = command(() => { drop_next = true; notify(); diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 1a5205f0cf16..3de16ee28b4f 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -447,6 +447,19 @@ test.describe('remote function mutations', () => { await expect(page.locator('#detached')).toHaveText('detached'); }); + test('query.live does not resend unchanged devalue payloads', async ({ page }) => { + await page.goto('/remote/live'); + await page.click('#reset'); + + await expect(page.locator('#duplicate-payload-count')).toHaveText('0'); + const before = Number(await page.locator('#duplicate-updates').textContent()); + + await page.click('#notify-only'); + await page.waitForTimeout(100); + await expect(page.locator('#duplicate-payload-count')).toHaveText('0'); + await expect(page.locator('#duplicate-updates')).toHaveText(String(before)); + }); + test('query.live cleans up server iterator on reload', async ({ page }) => { await page.goto('/remote/live'); await page.click('#stats'); From 3a63d78f19a3b2a069ef25ae25ec0e14278c959e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 23 Mar 2026 14:04:45 -0400 Subject: [PATCH 35/36] tweak --- packages/kit/src/runtime/server/remote.js | 25 +++++++++-------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index b8073558b1e0..0eaa7d63e8ba 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -173,8 +173,9 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } let closed = false; - let has_previous_result = false; - let previous_result = ''; + + /** @type {string | undefined} */ + let result = undefined; async function cancel() { if (closed) return; @@ -203,21 +204,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) return; } - const result = stringify(value, transport); + // only send changed data + if (result !== (result = stringify(value, transport))) { + send(controller, { + type: 'result', + result + }); - if (has_previous_result && result === previous_result) { - continue; + return; } - - has_previous_result = true; - previous_result = result; - - send(controller, { - type: 'result', - result - }); - - return; } } catch (error) { if (!event.request.signal.aborted) { From ff17ca9548f2ddba2b66c8a50f2bec1d18c0ae61 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 23 Mar 2026 15:03:24 -0400 Subject: [PATCH 36/36] slightly better error handling --- .../client/remote-functions/query.svelte.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index a2d50ae1bd24..328939a27403 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -402,19 +402,17 @@ async function get_stream_reader(response) { throw new HttpError(result.status ?? 500, result.error); } - throw new Error('Invalid query.live response'); + throw new HttpError(500, 'Invalid query.live response'); } if (!response.ok) { - try { - const result = await response.json(); + const result = await response.json().catch(() => ({ + type: 'error', + status: response.status, + error: response.statusText + })); - if (result.type === 'error') { - throw new HttpError(result.status ?? response.status ?? 500, result.error); - } - } catch { - throw new HttpError(response.status, response.statusText); - } + throw new HttpError(result.status ?? response.status ?? 500, result.error); } if (!response.body) {