From 8da518d09cd2c7f9e2a15c67eaf7fb20a2c28e4f Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 9 Oct 2026 13:14:24 -0600 Subject: [PATCH] feat: add `experimental.streaming`, to start the work inside pending boundaries during SSR and stream `Warp` values to the client --- .changeset/warp-streaming.md | 5 + documentation/docs/06-runtime/05-warp.md | 41 ++- .../98-reference/.generated/server-errors.md | 6 + .../svelte/messages/server-errors/errors.md | 4 + .../server/visitors/SvelteBoundary.js | 9 + packages/svelte/src/compiler/types/index.d.ts | 11 + .../svelte/src/compiler/validate-options.js | 3 +- packages/svelte/src/internal/server/errors.js | 12 + .../src/internal/server/render-context.js | 26 +- .../svelte/src/internal/server/renderer.js | 345 +++++++++++++++--- .../svelte/src/internal/server/types.d.ts | 9 +- packages/svelte/src/internal/server/warp.js | 6 +- .../svelte/src/internal/server/warp.test.ts | 172 +++++++++ packages/svelte/src/server/index.d.ts | 8 +- packages/svelte/src/server/public.d.ts | 11 +- .../svelte/tests/runtime-legacy/shared.ts | 12 + .../samples/streaming-warp-nested/_config.js | 25 ++ .../samples/streaming-warp-nested/main.svelte | 22 ++ .../streaming-warp-rejected/_config.js | 23 ++ .../streaming-warp-rejected/main.svelte | 24 ++ .../samples/streaming-warp/Child.svelte | 13 + .../samples/streaming-warp/_config.js | 23 ++ .../samples/streaming-warp/main.svelte | 13 + .../async-boundary-streaming/_config.js | 3 + .../_expected/client/index.svelte.js | 37 ++ .../_expected/server/index.svelte.js | 23 ++ .../async-boundary-streaming/index.svelte | 13 + packages/svelte/types/index.d.ts | 33 +- 28 files changed, 874 insertions(+), 58 deletions(-) create mode 100644 .changeset/warp-streaming.md create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp-nested/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/streaming-warp/main.svelte create mode 100644 packages/svelte/tests/snapshot/samples/async-boundary-streaming/_config.js create mode 100644 packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/client/index.svelte.js create mode 100644 packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/server/index.svelte.js create mode 100644 packages/svelte/tests/snapshot/samples/async-boundary-streaming/index.svelte diff --git a/.changeset/warp-streaming.md b/.changeset/warp-streaming.md new file mode 100644 index 0000000000..466eb441f6 --- /dev/null +++ b/.changeset/warp-streaming.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `experimental.streaming` option, to start the work inside pending boundaries during server rendering and stream `Warp` values to the client diff --git a/documentation/docs/06-runtime/05-warp.md b/documentation/docs/06-runtime/05-warp.md index 7b919ff24c..14c4000b24 100644 --- a/documentation/docs/06-runtime/05-warp.md +++ b/documentation/docs/06-runtime/05-warp.md @@ -120,6 +120,45 @@ const { head, body } = await withWarp(async () => { `withWarp` also accepts a `replacer`, which is used for any values the `replacer` passed to `render` doesn't handle. Each `withWarp` can only contain one `render`. +## Streaming + +By default, when the server encounters a [``](svelte-boundary) with a `pending` snippet, it renders the `pending` snippet and nothing else. The client then renders the boundary's contents itself, which means any data they need only starts loading once the page has hydrated. + +With the `experimental.streaming` compiler option, the server also starts rendering the boundary's contents in the background (discarding the output). Any values they add to a `Warp` are sent to the client as soon as they're available, so the client can use them instead of loading the data again: + +```js +/// file: svelte.config.js +export default { + compilerOptions: { + experimental: { + async: true, + streaming: true + } + } +}; +``` + +Values that have already resolved by the time the HTML is rendered are included in the `head`, as usual. The rest are streamed via the `tail` of the render result, which contains ``; + return { + head: `\n\t\t${body}`, + tail: + late === null || !ready.next + ? empty() + : stream_scripts(tail, ready.next, csp_attr, late.close) + }; } /** @@ -1016,11 +1124,139 @@ export class Renderer { const MACROTASK = Symbol('macrotask'); +/** Values that were added to `Warp` instances after the `head` was generated */ +class LateValues { + /** + * @param {Array<[string, WarpKey, unknown]>} entries + * @param {Promise | null} next + */ + constructor(entries, next) { + this.entries = entries; + this.next = next; + } +} + +/** @returns {AsyncIterable} */ +async function* empty() {} + +/** + * Streams the values that are added to `Warp` instances after the `head` was generated, + * as a chain of promises that each resolve to a batch of values and the next promise. + * The chain ends once the background work is done. + * @param {WarpStore} store + * @param {SSRState} background + */ +function stream_late_values(store, background) { + /** @type {Array<[string, WarpKey, unknown]>} */ + let entries = []; + let link = /** @type {ReturnType>} */ (deferred()); + let scheduled = false; + let closed = false; + + const values = link.promise; + + function flush() { + if (!scheduled || closed) return; + scheduled = false; + + const current = link; + link = deferred(); + current.resolve(new LateValues(entries, link.promise)); + entries = []; + } + + function close() { + if (closed) return; + closed = true; + + store.late = null; + link.resolve(entries.length > 0 ? new LateValues(entries, null) : null); + entries = []; + + Renderer.finish_background(background); + } + + store.late = (id, key, value) => { + entries.push([id, key, value]); + + if (!scheduled) { + scheduled = true; + queueMicrotask(flush); + } + }; + + background.settle().then(close, close); + + return { values, close }; +} + +/** + * @param {AsyncIterator} tail + * @param {Promise>} next The pending result of `tail.next()` + * @param {string} csp_attr + * @param {() => void} close + * @returns {AsyncIterable} + */ +function stream_scripts(tail, next, csp_attr, close) { + const generator = generate_scripts(tail, next, csp_attr); + + /** @type {AsyncIterableIterator} */ + const iterator = { + next: () => generator.next(), + // if the consumer stops early, stop the background work. this is + // done here because `return` doesn't run a generator that hasn't started + return: async () => { + close(); + await tail.return?.(); + return generator.return(undefined); + }, + [Symbol.asyncIterator]: () => iterator + }; + + return iterator; +} + +/** + * Turns the blocks from `unevalStream` into ``; + } +} + /** * Takes the blocks from `tail` that are ready now — i.e. the ones for promises that have already * settled, which `unevalStream` emits within a few microtasks — without waiting for the rest * @param {AsyncIterator} tail - * @returns {Promise<{ blocks: string, done: boolean, next?: Promise> }>} + * @returns {Promise<{ blocks: string, next: Promise> | null }>} */ async function take_ready(tail) { let blocks = ''; @@ -1034,11 +1270,11 @@ async function take_ready(tail) { if (result === MACROTASK) { // the block this resolves to is the next one, so it must not be dropped - return { blocks, done: false, next }; + return { blocks, next }; } const { done, value } = /** @type {IteratorResult} */ (result); - if (done) return { blocks, done: true }; + if (done) return { blocks, next: null }; blocks += `\n\t\t\t\t${value}`; } @@ -1076,6 +1312,18 @@ export class SSRState { /** @readonly @type {string} */ id_prefix; + /** + * The state for work started in the background by pending boundaries, if there is any + * @type {SSRState | null} + */ + background = null; + + /** + * The renderers for work started in the background, if this is the state for that work + * @type {Renderer[]} + */ + background_renderers = []; + /** @readonly @type {Set<{ hash: string; code: string }>} */ css = new Set(); @@ -1151,6 +1399,17 @@ export class SSRState { return controller.signal; } + /** @returns {SSRState} */ + get_background() { + if (this.background === null) { + // background work gets its own state, so that it can't affect the rendered output + this.background = new SSRState(this.mode, this.id_prefix, this.csp, this.transformError); + this.background.background = this.background; + } + + return this.background; + } + get_title() { return this.#title.value; } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 082c43e27c..62d885abd8 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -25,8 +25,13 @@ export interface WarpStore { stacks: Map>; /** dev-only: `hydratable` clobbering checks, which reject on mismatch */ comparisons: Promise[]; - /** Whether the values have been serialized, after which no more can be added */ + /** Whether the values have been serialized into the `head` */ emitted: boolean; + /** + * When streaming, receives values that are set after the `head` has been generated, so they can be + * sent to the client in the `tail`. If `null` once the values have been serialized, no more can be added + */ + late: ((id: string, key: WarpKey, value: unknown) => void) | null; } export interface RenderContext { @@ -35,4 +40,6 @@ export interface RenderContext { rendered: boolean; /** The `replacer` passed to `withWarp` */ replacer: UnevalReplacer | undefined; + /** Settles when the background work of the render (inside pending boundaries) is done, if there is any */ + background: Promise | null; } diff --git a/packages/svelte/src/internal/server/warp.js b/packages/svelte/src/internal/server/warp.js index 47c00ac1cb..3737031008 100644 --- a/packages/svelte/src/internal/server/warp.js +++ b/packages/svelte/src/internal/server/warp.js @@ -199,7 +199,11 @@ function set(store, id, key, value) { */ function record(store, id, key, value) { if (store.emitted) { - e.warp_set_after_render(id, String(key)); + if (store.late === null) { + e.warp_set_after_render(id, String(key)); + } + + store.late(id, key, value); } if (DEV) { diff --git a/packages/svelte/src/internal/server/warp.test.ts b/packages/svelte/src/internal/server/warp.test.ts index a2f67ef2c4..f8577dbf38 100644 --- a/packages/svelte/src/internal/server/warp.test.ts +++ b/packages/svelte/src/internal/server/warp.test.ts @@ -5,6 +5,7 @@ import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index. import { withWarp } from './render-context.js'; import { Warp } from './warp.js'; import { hydratable } from './hydratable.js'; +import { getAbortSignal } from './abort-signal.js'; beforeAll(() => { enable_async_mode_flag(); @@ -309,3 +310,174 @@ describe('hydratable', () => { expect(values?.get('test')?.get('a')).toBe(2); }); }); + +describe('streaming', () => { + function delay(value: T, ms = 0) { + return new Promise((fulfil) => setTimeout(() => fulfil(value), ms)); + } + + /** Collects the tail, and returns the revived values once everything has been evaluated */ + async function revive_streamed(head: string, tail: AsyncIterable) { + const window: { __svelte?: { w?: Map> } } = {}; + const run = (html: string) => { + for (const [, script] of html.matchAll(/]*)?>([\s\S]*?)<\/script>/g)) { + new Function('window', script)(window); + } + }; + + run(head); + + const chunks: string[] = []; + for await (const chunk of tail) { + chunks.push(chunk); + run(chunk); + } + + return { values: window.__svelte?.w, chunks }; + } + + test('has an empty tail without background work', async () => { + const { tail } = await render(() => { + warp.set('a', Promise.resolve(1)); + }); + + const chunks = []; + for await (const chunk of tail) chunks.push(chunk); + expect(chunks).toEqual([]); + }); + + test('discards the output of background work', async () => { + const { body, head } = await render((renderer) => { + renderer.push('

loading

'); + renderer.background(async (renderer) => { + await delay(null); + renderer.push('

loaded

'); + renderer.title((renderer) => renderer.push('nope')); + }); + }); + + expect(body).toBe('

loading

'); + expect(head).not.toContain('nope'); + }); + + test('streams values that are pending when the head is generated', async () => { + const { head, tail } = await render((renderer) => { + warp.set('settled', Promise.resolve('settled')); + renderer.background(() => { + warp.set('pending', delay('later', 10)); + }); + }); + + // the settled promise is resolved in the head script, while the pending one is not + expect(head).toMatch(/s\.r\(\d+,0,"settled"\)/); + expect(head).not.toContain('"later"'); + + const { values, chunks } = await revive_streamed(head, tail); + expect(chunks.length).toBeGreaterThan(0); + expect(await values?.get('test')?.get('settled')).toBe('settled'); + expect(await values?.get('test')?.get('pending')).toBe('later'); + }); + + test('streams values that are added after the head is generated', async () => { + const { head, tail } = await render((renderer) => { + renderer.background(async () => { + const user = await warp.getOrInsertComputed('user', () => delay({ id: 1 }, 10)); + warp.getOrInsertComputed(`posts:${user.id}`, () => delay(['a', 'b'], 10)); + }); + }); + + const { values } = await revive_streamed(head, tail); + expect(await values?.get('test')?.get('user')).toEqual({ id: 1 }); + expect(await values?.get('test')?.get('posts:1')).toEqual(['a', 'b']); + }); + + test('adds values that depend on a promise in the same chunk that resolves it', async () => { + const { head, tail } = await render((renderer) => { + renderer.background(async () => { + const user = await warp.getOrInsertComputed('user', () => delay({ id: 1 }, 10)); + warp.set(`name:${user.id}`, 'Rich'); + }); + }); + + const window: { __svelte?: { w?: Map> } } = {}; + new Function('window', head.match(/ + + + {@const user = await warp.getOrInsertComputed('user', () => delay({ id: 1, environment }))} + {@const posts = await warp.getOrInsertComputed(`posts:${user.id}`, () => delay(`posts from ${environment}`))} + +

{user.environment}: {posts}

+ + {#snippet pending()} +

loading

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/_config.js b/packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/_config.js new file mode 100644 index 0000000000..8d48cd2b15 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/_config.js @@ -0,0 +1,23 @@ +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + mode: ['async-server', 'hydrate'], + compileOptions: { + experimental: { async: true, streaming: true } + }, + + server_props: { environment: 'server' }, + props: { environment: 'browser' }, + ssrHtml: '

loading

', + + transformError: (error) => + error instanceof Error ? { message: error.message.toUpperCase() } : error, + + async test({ assert, target }) { + await new Promise((fulfil) => setTimeout(fulfil, 50)); + + // the rejection happened in the background on the server, and was streamed through `transformError` + assert.htmlEqual(target.innerHTML, '

failed: FROM SERVER

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/main.svelte b/packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/main.svelte new file mode 100644 index 0000000000..eff8e5c9bd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/streaming-warp-rejected/main.svelte @@ -0,0 +1,24 @@ + + + +

+ {await warp.getOrInsertComputed( + 'data', + () => new Promise((_, reject) => setTimeout(() => reject(new Error(`from ${environment}`)), 10)) + )} +

+ + {#snippet pending()} +

loading

+ {/snippet} + + {#snippet failed(error)} +

failed: {(error as { message: string }).message}

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/streaming-warp/Child.svelte b/packages/svelte/tests/runtime-runes/samples/streaming-warp/Child.svelte new file mode 100644 index 0000000000..799d91d079 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/streaming-warp/Child.svelte @@ -0,0 +1,13 @@ + + +

{data}

diff --git a/packages/svelte/tests/runtime-runes/samples/streaming-warp/_config.js b/packages/svelte/tests/runtime-runes/samples/streaming-warp/_config.js new file mode 100644 index 0000000000..b6328ee5c0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/streaming-warp/_config.js @@ -0,0 +1,23 @@ +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + mode: ['async-server', 'hydrate', 'client'], + compileOptions: { + experimental: { async: true, streaming: true } + }, + + server_props: { environment: 'server' }, + props: { environment: 'browser' }, + ssrHtml: '

loading

', + + async test({ assert, target, variant }) { + await new Promise((fulfil) => setTimeout(fulfil, 50)); + + // when hydrating, the data loaded on the server (inside the pending boundary) is streamed to the client + assert.htmlEqual( + target.innerHTML, + variant === 'hydrate' ? '

from server

' : '

from browser

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/streaming-warp/main.svelte b/packages/svelte/tests/runtime-runes/samples/streaming-warp/main.svelte new file mode 100644 index 0000000000..84fcd3a8e0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/streaming-warp/main.svelte @@ -0,0 +1,13 @@ + + + + + + {#snippet pending()} +

loading

+ {/snippet} +
diff --git a/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_config.js b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_config.js new file mode 100644 index 0000000000..58a801f76d --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({ compileOptions: { experimental: { async: true, streaming: true } } }); diff --git a/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/client/index.svelte.js new file mode 100644 index 0000000000..b2f87fd2a8 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/client/index.svelte.js @@ -0,0 +1,37 @@ +import 'svelte/internal/disclose-version'; +import 'svelte/internal/flags/async'; +import * as $ from 'svelte/internal/client'; +import { Warp } from 'svelte'; + +var root = $.from_html(`

loading

`); +var root_1 = $.from_html(`

`); + +export default function Async_boundary_streaming($$anchor, $$props) { + $.push($$props, true); + + const warp = new Warp('app'); + var fragment = $.comment(); + var node = $.first_child(fragment); + + { + const pending = ($$anchor) => { + var p = root(); + + $.append($$anchor, p); + }; + + $.boundary(node, { pending }, ($$anchor) => { + var p_1 = root_1(); + var text = $.only_child(p_1, true); + + $.template_effect(($0) => $.set_text(text, $0), void 0, [ + () => warp.getOrInsertComputed('data', () => Promise.resolve('data')) + ]); + + $.append($$anchor, p_1); + }); + } + + $.append($$anchor, fragment); + $.pop(); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/server/index.svelte.js new file mode 100644 index 0000000000..6c5726de83 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/_expected/server/index.svelte.js @@ -0,0 +1,23 @@ +import 'svelte/internal/flags/async'; +import * as $ from 'svelte/internal/server'; +import { Warp } from 'svelte'; + +export default function Async_boundary_streaming($$renderer, $$props) { + $$renderer.component(($$renderer) => { + const warp = new Warp('app'); + + $$renderer.push(``); + + { + $$renderer.push(`

loading

`); + } + + $$renderer.push(``); + + $$renderer.background(($$renderer) => { + $$renderer.push(`

`); + $$renderer.push(async () => $.escape((await $.save(warp.getOrInsertComputed('data', () => Promise.resolve('data'))))())); + $$renderer.push(`

`); + }); + }); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/async-boundary-streaming/index.svelte b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/index.svelte new file mode 100644 index 0000000000..8fed0f3a41 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/async-boundary-streaming/index.svelte @@ -0,0 +1,13 @@ + + + +

{await warp.getOrInsertComputed('data', () => Promise.resolve('data'))}

+ + {#snippet pending()} +

loading

+ {/snippet} +
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5556f2046b..fee37e2ec2 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1246,6 +1246,17 @@ declare module 'svelte/compiler' { * @since 5.36 */ async?: boolean; + /** + * During server rendering, start the work inside `` elements with a `pending` snippet, + * instead of only rendering the `pending` snippet. Values added to `Warp` instances by that work are + * streamed to the client via the `tail` of the render result, so that the client doesn't need to redo it. + * Requires `experimental.async`. + * + * If you use this, you **must** write every chunk of `tail` into the response after the rendered HTML. + * Otherwise, promises on the client will never settle. + * @since 5.58 + */ + streaming?: boolean; }; } /** @@ -2805,7 +2816,16 @@ declare module 'svelte/server' { }; } - export type RenderOutput = SyncRenderOutput & PromiseLike; + export interface AsyncRenderOutput extends SyncRenderOutput { + /** + * `