feat: add `experimental.streaming`, to start the work inside pending boundaries during SSR and stream `Warp` values to the client

elliott/warp-streaming
Elliott Johnson 5 hours ago
parent 59cdcbcb8f
commit fe03447c65
No known key found for this signature in database

@ -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

@ -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>`](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 `<script>` tags that you **must** write into the response after the HTML — otherwise, promises on the client that are waiting for data from the server will never settle:
```js
/// file: server.js
// @noErrors
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const { head, body, tail } = await render(App);
response.write(`<html><head>${head}</head><body>${body}`);
for await (const script of tail) {
response.write(script);
}
response.end('</body></html>');
```
If you stop iterating over `tail` early, the background work is stopped. Since the streamed `<script>` tags can't be known in advance, streaming can't be used with `csp: { hash: true }` — use a `nonce` instead.
## CSP
`Warp` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:
@ -176,7 +215,7 @@ response.headers.set(
);
```
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.
We recommend using `nonce` over hash if you can, as `hash` cannot be used with [streaming](#Streaming).
## `hydratable`

@ -58,6 +58,12 @@ This error occurs when using `hydratable` multiple times with the same key. To a
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
```
### invalid_csp_streaming
```
`csp.hash` cannot be used with `experimental.streaming`, since the streamed `<script>` tags cannot be known in advance. Use `csp.nonce` instead.
```
### invalid_id_prefix
```

@ -44,6 +44,10 @@ This error occurs when using `hydratable` multiple times with the same key. To a
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
## invalid_csp_streaming
> `csp.hash` cannot be used with `experimental.streaming`, since the streamed `<script>` tags cannot be known in advance. Use `csp.nonce` instead.
## invalid_id_prefix
> The `idPrefix` option cannot include `--`.

@ -57,8 +57,15 @@ export function SvelteBoundary(node, context) {
let children_body;
if (pending_attribute || pending_snippet) {
// with `experimental.streaming`, the children are rendered in the background (and their output
// discarded), so that any data they need starts loading on the server and can be streamed to the client
const background = context.state.options.experimental.streaming
? [b.stmt(b.call('$$renderer.background', b.arrow([b.id('$$renderer')], children_block)))]
: [];
if (pending_attribute && is_pending_attr_nullish && !pending_snippet) {
const { callee, pending_block } = build_pending_attribute_block(pending_attribute, context);
pending_block.body.push(...background);
children_body = b.block([
b.if(
@ -71,6 +78,8 @@ export function SvelteBoundary(node, context) {
children_body = pending_attribute
? build_pending_attribute_block(pending_attribute, context).pending_block
: build_pending_snippet_block(/** @type {AST.SnippetBlock} */ (pending_snippet), context);
children_body.body.push(...background);
}
} else {
children_body = b.block(build_template([block_open, children_block, block_close]));

@ -238,6 +238,17 @@ export interface ModuleCompileOptions {
* @since 5.36
*/
async?: boolean;
/**
* During server rendering, start the work inside `<svelte:boundary>` 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;
};
}

@ -44,7 +44,8 @@ const common_options = {
warningFilter: fun(() => true),
experimental: object({
async: boolean(false)
async: boolean(false),
streaming: boolean(false)
})
};

@ -81,6 +81,18 @@ export function invalid_csp() {
throw error;
}
/**
* `csp.hash` cannot be used with `experimental.streaming`, since the streamed `<script>` tags cannot be known in advance. Use `csp.nonce` instead.
* @returns {never}
*/
export function invalid_csp_streaming() {
const error = new Error(`invalid_csp_streaming\n\`csp.hash\` cannot be used with \`experimental.streaming\`, since the streamed \`<script>\` tags cannot be known in advance. Use \`csp.nonce\` instead.\nhttps://svelte.dev/e/invalid_csp_streaming`);
error.name = 'Svelte error';
throw error;
}
/**
* The `idPrefix` option cannot include `--`.
* @returns {never}

@ -39,11 +39,13 @@ function create_render_context(owner, replacer) {
values: new Map(),
stacks: new Map(),
comparisons: [],
emitted: false
emitted: false,
late: null
},
owner,
rendered: owner === 'render',
replacer
replacer,
background: null
};
}
@ -98,15 +100,23 @@ export async function withWarp(fn, options = {}) {
*/
async function run(ctx, fn) {
if (in_webcontainer()) {
const { promise, resolve } = deferred();
const { promise: lock, resolve } = deferred();
const previous_render = current_render;
current_render = promise;
current_render = lock;
await previous_render;
context = ctx;
return fn(ctx).finally(() => {
context = null;
resolve();
});
const promise = fn(ctx);
// background work after the render still needs the context, so hold on to it until that's done
promise
.then(() => ctx.background)
.catch(noop)
.finally(() => {
context = null;
resolve();
});
return promise;
}
try {

@ -2,7 +2,7 @@
/** @import { UnevalReplacer } from 'devalue' */
/** @import { RenderContext, SSRContext, WarpStore } from './types.js' */
/** @import { WarpKey } from '#shared' */
/** @import { Csp, RenderOutput, SyncRenderOutput, Sha256Source } from '../../server/public.js' */
/** @import { AsyncRenderOutput, Csp, RenderOutput, Sha256Source } from '../../server/public.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { STALE_REACTION } from '../client/constants.js';
@ -16,7 +16,7 @@ import { with_render_context } from './render-context.js';
import { get_stack, is_promise } from './warp.js';
import { sha256 } from './crypto.js';
import * as devalue from 'devalue';
import { has_own_property, is_array, noop } from '../shared/utils.js';
import { deferred, has_own_property, is_array, noop } from '../shared/utils.js';
import { DEV } from 'esm-env';
import { escape_html } from '../../escaping.js';
@ -31,7 +31,7 @@ class RenderResult {
/** @type {() => AccumulatedContent} */
#render;
/** @type {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>} */
/** @type {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }>} */
#render_async;
/** @type {AccumulatedContent | undefined} */
@ -40,12 +40,12 @@ class RenderResult {
/** @type {{ script: '' }} */
#hashes = { script: '' };
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }> | undefined} */
#promise;
/**
* @param {() => AccumulatedContent} render
* @param {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>} render_async
* @param {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }>} render_async
*/
constructor(render, render_async) {
this.#render = render;
@ -77,7 +77,7 @@ class RenderResult {
*
* @template TResult1
* @template [TResult2=never]
* @param {(value: SyncRenderOutput) => TResult1} onfulfilled
* @param {(value: AsyncRenderOutput) => TResult1} onfulfilled
* @param {(reason: unknown) => TResult2} onrejected
*/
then(onfulfilled, onrejected) {
@ -87,7 +87,8 @@ class RenderResult {
head: result.head,
body: result.body,
html: result.body,
hashes: { script: [] }
hashes: { script: [] },
tail: empty()
});
return Promise.resolve(user_result);
}
@ -102,7 +103,7 @@ class RenderResult {
return result;
});
return this.#promise.then(
(result) => onfulfilled(/** @type {SyncRenderOutput} */ (result)),
(result) => onfulfilled(/** @type {AsyncRenderOutput} */ (result)),
onrejected
);
}
@ -402,6 +403,57 @@ export class Renderer {
}
}
/**
* Runs the children of a pending boundary in the background, discarding their output, so that the
* data they need starts loading on the server. Only used with `experimental.streaming`.
* @param {(renderer: Renderer) => MaybePromise<void>} fn
*/
background(fn) {
if (this.global.mode === 'sync') return;
const state = this.global.get_background();
const renderer = new Renderer(state, this);
state.background_renderers.push(renderer);
const parent = ssr_context;
set_ssr_context({
...ssr_context,
p: parent,
c: null,
r: renderer,
i: ssr_context?.i ?? false
});
try {
const result = fn(renderer);
if (result instanceof Promise) {
result.catch(noop);
result.finally(() => set_ssr_context(null)).catch(noop);
renderer.promise = state.track(result);
}
} catch {
// the client will render the children itself, and handle the error then
} finally {
set_ssr_context(parent);
}
}
/**
* Called once the background work has settled, or is no longer needed
* @param {SSRState} state
*/
static finish_background(state) {
state.abort();
for (const renderer of state.background_renderers) {
renderer.#run_on_destroy(true);
}
state.background_renderers.length = 0;
}
/**
* Create a component renderer. The component renderer inherits the state from the parent,
* but has its own content. It is treated as an ordering boundary for ondestroy callbacks.
@ -760,12 +812,12 @@ export class Renderer {
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; replacer?: UnevalReplacer }} options
* @param {RenderContext} context
* @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>}
* @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }>}
*/
static async #render_async(component, options, context) {
const previous_context = ssr_context;
const renderer = Renderer.#create('async', options);
/** @type {(AccumulatedContent & { hashes: { script: Sha256Source[] } }) | undefined} */
/** @type {(AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }) | undefined} */
let result;
let render_error;
let failed = false;
@ -774,25 +826,39 @@ export class Renderer {
try {
Renderer.#open_render(renderer, component, options);
const content = await renderer.#collect_content_async();
const background = renderer.global.background;
if (background !== null) {
context.background = background.settle();
}
const warp = await renderer.#collect_warp(
context.warp,
compose_replacers(options.replacer, context.replacer)
compose_replacers(options.replacer, context.replacer),
background
);
if (warp !== null) {
content.head = warp + content.head;
if (warp.head !== null) {
content.head = warp.head + content.head;
}
result = Renderer.#close_render(content, renderer);
result = { ...Renderer.#close_render(content, renderer), tail: warp.tail };
} catch (error) {
render_error = error;
failed = true;
renderer.global.abort();
await renderer.global.settle();
if (renderer.global.background !== null) {
context.warp.late = null;
Renderer.finish_background(renderer.global.background);
}
}
renderer.#run_on_destroy(failed);
if (failed) throw render_error;
return /** @type {AccumulatedContent & { hashes: { script: Sha256Source[] } }} */ (result);
return /** @type {AccumulatedContent & { hashes: { script: Sha256Source[] }, tail: AsyncIterable<string> }} */ (
result
);
} finally {
set_ssr_context(previous_context);
renderer.global.abort();
@ -869,18 +935,27 @@ export class Renderer {
}
/**
* Waits for the values added to `Warp` instances to resolve, then serializes them
* into a `<script>` that recreates them on the client.
* Serializes the values added to `Warp` instances into a `<script>` that recreates them on the client.
*
* If there's no background work, this waits for every promise to settle first. Otherwise, the work inside
* pending boundaries may still be going on, and the client is waiting for it — so only the promises that
* have already settled are included in the `<script>`, and the rest (along with any values added later)
* are streamed to the client via the `tail`.
* @param {WarpStore} store
* @param {UnevalReplacer | undefined} replacer
* @returns {Promise<string | null>}
* @param {SSRState | null} background
* @returns {Promise<{ head: string | null, tail: AsyncIterable<string> }>}
*/
async #collect_warp(store, replacer) {
async #collect_warp(store, replacer, background) {
// these reject if there's a mismatch. a loop, as more can be added while we're awaiting
for (let i = 0; i < store.comparisons.length; i += 1) {
await store.comparisons[i];
}
if (background !== null && this.global.csp.hash) {
e.invalid_csp_streaming();
}
/** @type {Map<Promise<unknown>, unknown>} */
const resolved = new Map();
/** @type {Set<Promise<unknown>>} */
@ -889,30 +964,56 @@ export class Renderer {
// values can be added while we're awaiting, and Map iteration includes them
for (const [id, values] of store.values) {
for (const [key, value] of values) {
await resolve_warp_value(store, id, key, value, replacer, resolved, visited);
await resolve_warp_value(
store,
id,
key,
value,
replacer,
resolved,
visited,
background === null
);
}
}
store.emitted = true;
/** @type {Map<string, Map<WarpKey, unknown>>} */
const payload = new Map();
const values = new Map();
for (const [id, values] of store.values) {
if (values.size > 0) payload.set(id, values);
for (const [id, v] of store.values) {
if (v.size > 0) values.set(id, v);
}
if (payload.size === 0) {
return null;
if (values.size === 0 && background === null) {
return { head: null, tail: empty() };
}
const late = background === null ? null : stream_late_values(store, background);
const { head, tail } = devalue.unevalStream(
payload,
late === null ? values : [values, late.values],
(thing, js) => {
if (is_promise(thing) && resolved.has(thing)) {
return js`Promise.resolve(${resolved.get(thing)})`;
}
if (thing instanceof LateValues) {
// add the values to the client's `Warp`s as soon as this is evaluated, rather than
// when the promise resolves, so that they're there before anything awaiting the
// promises resolved in the same block runs
return js`((e, n) => {
const w = window.__svelte.w;
for (const [id, k, v] of e) {
let m = w.get(id);
if (!m) w.set(id, (m = new Map()));
m.set(k, v);
}
return n;
})(${thing.entries}, ${thing.next})`;
}
return replacer?.(thing, js);
},
{
@ -923,17 +1024,25 @@ export class Renderer {
}
);
// every promise has settled, so the tail is only rejections and finishes right away
let csp_attr = '';
if (this.global.csp.nonce) {
csp_attr = ` nonce="${this.global.csp.nonce}"`;
}
let blocks = '';
for await (const block of tail) {
blocks += `\n\t\t\t${block}`;
if (late === null) {
// every promise has settled, so the tail is only rejections and finishes right away
for await (const block of tail) {
blocks += `\n\t\t\t\t${block}`;
}
}
const body = `
{
const w = (window.__svelte ??= {}).w ??= new Map();
for (const [id, values] of ${head}) {
for (const [id, values] of ${late === null ? head : `(${head})[0]`}) {
const existing = w.get(id);
if (existing) {
@ -945,17 +1054,17 @@ export class Renderer {
}
`;
let csp_attr = '';
if (this.global.csp.nonce) {
csp_attr = ` nonce="${this.global.csp.nonce}"`;
} else if (this.global.csp.hash) {
if (this.global.csp.hash) {
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// because the it's impossible for identical data to occur multiple times in a single render
const hash = await sha256(body);
this.global.csp.script_hashes.push(`sha256-${hash}`);
}
return `\n\t\t<script${csp_attr}>${body}</script>`;
return {
head: `\n\t\t<script${csp_attr}>${body}</script>`,
tail: late === null ? empty() : stream_scripts(tail, csp_attr, late.close)
};
}
/**
@ -1007,6 +1116,136 @@ export class Renderer {
const PENDING = Symbol('pending');
/** Values that were added to `Warp` instances after the `head` was generated */
class LateValues {
/**
* @param {Array<[string, WarpKey, unknown]>} entries
* @param {Promise<LateValues | null> | null} next
*/
constructor(entries, next) {
this.entries = entries;
this.next = next;
}
}
/** @returns {AsyncIterable<string>} */
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<typeof deferred<LateValues | null>>} */ (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<string>} tail
* @param {string} csp_attr
* @param {() => void} close
* @returns {AsyncIterable<string>}
*/
function stream_scripts(tail, csp_attr, close) {
const generator = generate_scripts(tail, csp_attr);
/** @type {AsyncIterableIterator<string>} */
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 `<script>` tags. Blocks that arrive at around the same time are
* combined, so that server-side work which was waiting on a promise that just resolved has a chance to add
* the values that depend on it — otherwise, the client might not receive them before it needs them.
* @param {AsyncIterator<string>} tail
* @param {string} csp_attr
* @returns {AsyncGenerator<string>}
*/
async function* generate_scripts(tail, csp_attr) {
const MACROTASK = Symbol('macrotask');
let next = tail.next();
while (true) {
const result = await next;
if (result.done) return;
let code = result.value;
next = tail.next();
while (true) {
const more = await Promise.race([
next,
new Promise((fulfil) => setTimeout(() => fulfil(MACROTASK), 0))
]);
if (more === MACROTASK) break;
const { done, value } = /** @type {IteratorResult<string>} */ (more);
if (done) break;
code += value;
next = tail.next();
}
yield `<script${csp_attr}>${code}</script>`;
}
}
/**
* Returns the outcome of `promise` if it has already settled, or `PENDING` otherwise
* @param {Promise<unknown>} promise
@ -1030,8 +1269,9 @@ function peek(promise) {
* @param {UnevalReplacer | undefined} replacer
* @param {Map<Promise<unknown>, unknown>} resolved
* @param {Set<Promise<unknown>>} visited
* @param {boolean} wait Whether to wait for promises that haven't settled yet
*/
async function resolve_warp_value(store, id, key, value, replacer, resolved, visited) {
async function resolve_warp_value(store, id, key, value, replacer, resolved, visited, wait) {
let warned = false;
const queue = [value];
@ -1068,6 +1308,9 @@ async function resolve_warp_value(store, id, key, value, replacer, resolved, vis
let outcome = await peek(promise);
if (outcome === PENDING) {
// this will be streamed to the client once it settles
if (!wait) continue;
if (!warned) {
// this is a problem -- it means we've finished the render but we're still waiting on a promise
// to resolve so we can serialize it, so we're blocking the response on useless content.
@ -1128,6 +1371,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();
@ -1203,6 +1458,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;
}

@ -25,8 +25,13 @@ export interface WarpStore {
stacks: Map<string, Map<WarpKey, string>>;
/** dev-only: `hydratable` clobbering checks, which reject on mismatch */
comparisons: Promise<void>[];
/** 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 {
@ -37,4 +42,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<void> | null;
}

@ -175,7 +175,11 @@ function get_values(store, id) {
*/
function set(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);
}
get_values(store, id).set(key, value);

@ -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();
@ -291,3 +292,174 @@ describe('hydratable', () => {
expect(values?.get('test')?.get('a')).toBe(2);
});
});
describe('streaming', () => {
function delay<T>(value: T, ms = 0) {
return new Promise<T>((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<string>) {
const window: { __svelte?: { w?: Map<string, Map<unknown, unknown>> } } = {};
const run = (html: string) => {
for (const [, script] of html.matchAll(/<script(?:\s[^>]*)?>([\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('<p>loading</p>');
renderer.background(async (renderer) => {
await delay(null);
renderer.push('<p>loaded</p>');
renderer.title((renderer) => renderer.push('<title>nope</title>'));
});
});
expect(body).toBe('<!--[--><p>loading</p><!--]-->');
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 inlined as resolved, while the pending one is not
expect(head).toContain('Promise.resolve("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<string, Map<unknown, unknown>> } } = {};
new Function('window', head.match(/<script>([\s\S]*?)<\/script>/)![1])(window);
const user = window.__svelte?.w?.get('test')?.get('user') as Promise<unknown>;
let found: unknown;
user.then(() => (found = window.__svelte?.w?.get('test')?.get('name:1')));
for await (const chunk of tail) {
new Function('window', chunk.replace(/^<script>/, '').replace(/<\/script>$/, ''))(window);
}
await user;
expect(found).toBe('Rich');
});
test('stops background work if the tail is abandoned', async () => {
let signal: AbortSignal | undefined;
let destroyed = false;
const { tail } = await render((renderer) => {
renderer.background((renderer) => {
renderer.component((renderer) => {
signal = getAbortSignal();
renderer.on_destroy(() => (destroyed = true));
warp.set('never', new Promise(() => {}));
return new Promise(() => {});
});
});
});
const iterator = tail[Symbol.asyncIterator]();
expect(signal?.aborted).toBe(false);
await iterator.return?.();
expect(signal?.aborted).toBe(true);
expect(destroyed).toBe(true);
});
test('cannot add values after the background work is done', async () => {
let promise: Promise<void> | undefined;
const { tail } = await render((renderer) => {
renderer.background(async () => {
await delay(null);
});
promise = delay(null, 20).then(() => {
warp.set('a', 1);
});
});
for await (const _ of tail);
await expect(promise).rejects.toThrow('warp_set_after_render');
});
test('cannot be used with csp.hash', async () => {
await expect(
render(
(renderer) => {
renderer.background(() => {});
},
{ csp: { hash: true } }
)
).rejects.toThrow('invalid_csp_streaming');
});
test('adds the nonce to streamed scripts', async () => {
const { tail } = await render(
(renderer) => {
renderer.background(() => {
warp.set('a', delay(1));
});
},
{ csp: { nonce: 'xyz' } }
);
for await (const chunk of tail) {
expect(chunk.startsWith('<script nonce="xyz">')).toBe(true);
}
});
});

@ -2,7 +2,13 @@ import type { UnevalReplacer } from 'devalue';
import type { Csp, RenderOutput } from './public.js';
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
export type { Csp, RenderOutput, SyncRenderOutput, Sha256Source } from './public.js';
export type {
AsyncRenderOutput,
Csp,
RenderOutput,
SyncRenderOutput,
Sha256Source
} from './public.js';
/**
* Only available on the server and when compiling with the `server` option.

@ -14,4 +14,13 @@ export interface SyncRenderOutput {
};
}
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;
export interface AsyncRenderOutput extends SyncRenderOutput {
/**
* `<script>` tags that must be written into the response after the rendered HTML, in order.
* When using `experimental.streaming`, these send the data loaded inside `<svelte:boundary>` elements
* with a `pending` snippet to the client as it becomes available. Otherwise, this is empty.
*/
tail: AsyncIterable<string>;
}
export type RenderOutput = SyncRenderOutput & PromiseLike<AsyncRenderOutput>;

@ -405,6 +405,7 @@ async function run_test_variant(
const target = window.document.querySelector('main') as HTMLElement;
let snapshot = undefined;
let tail: AsyncIterable<string> | undefined;
if (variant === 'hydrate' || variant === 'ssr' || variant === 'async-ssr') {
if (ssr_context !== null) {
@ -424,6 +425,7 @@ async function run_test_variant(
? await render_result
: render_result;
const { body, head } = rendered;
if ('tail' in rendered) tail = rendered.tail;
const prefix = variant === 'async-ssr' ? 'async_' : '';
fs.writeFileSync(`${cwd}/_output/${prefix}rendered.html`, body);
@ -494,6 +496,16 @@ async function run_test_variant(
)?.textContent;
if (!script) return;
(0, eval)(script);
// simulate streaming the tail after the rendered HTML
if (tail) {
const chunks = tail;
(async () => {
for await (const chunk of chunks) {
(0, eval)(chunk.replace(/^<script[^>]*>/, '').replace(/<\/script>$/, ''));
}
})();
}
};
if (runes) {

@ -0,0 +1,25 @@
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: '<p>loading</p>',
async test({ assert, target, variant }) {
await new Promise((fulfil) => setTimeout(fulfil, 100));
// the second value is added on the server after the first resolves, and still reaches the client
assert.htmlEqual(
target.innerHTML,
variant === 'hydrate'
? '<p>server: posts from server</p>'
: '<p>browser: posts from browser</p>'
);
}
});

@ -0,0 +1,22 @@
<script lang="ts">
import { Warp } from 'svelte';
const { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp<string, Promise<any>>('app');
function delay<T>(value: T) {
return new Promise<T>((fulfil) => setTimeout(() => fulfil(value), 10));
}
</script>
<svelte:boundary>
{@const user = await warp.getOrInsertComputed('user', () => delay({ id: 1, environment }))}
{@const posts = await warp.getOrInsertComputed(`posts:${user.id}`, () => delay(`posts from ${environment}`))}
<p>{user.environment}: {posts}</p>
{#snippet pending()}
<p>loading</p>
{/snippet}
</svelte:boundary>

@ -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: '<p>loading</p>',
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, '<p>failed: FROM SERVER</p>');
}
});

@ -0,0 +1,24 @@
<script lang="ts">
import { Warp } from 'svelte';
const { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp<string, Promise<string>>('app');
</script>
<svelte:boundary>
<p>
{await warp.getOrInsertComputed(
'data',
() => new Promise((_, reject) => setTimeout(() => reject(new Error(`from ${environment}`)), 10))
)}
</p>
{#snippet pending()}
<p>loading</p>
{/snippet}
{#snippet failed(error)}
<p>failed: {(error as { message: string }).message}</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,13 @@
<script lang="ts">
import { Warp } from 'svelte';
const { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp<string, Promise<string>>('app');
const data = await warp.getOrInsertComputed(
'data',
() => new Promise((fulfil) => setTimeout(() => fulfil(`from ${environment}`), 10))
);
</script>
<p>{data}</p>

@ -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: '<p>loading</p>',
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' ? '<p>from server</p>' : '<p>from browser</p>'
);
}
});

@ -0,0 +1,13 @@
<script lang="ts">
import Child from './Child.svelte';
const { environment }: { environment: 'server' | 'browser' } = $props();
</script>
<svelte:boundary>
<Child {environment} />
{#snippet pending()}
<p>loading</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,3 @@
import { test } from '../../test';
export default test({ compileOptions: { experimental: { async: true, streaming: true } } });

@ -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(`<p>loading</p>`);
var root_1 = $.from_html(`<p> </p>`);
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();
}

@ -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(`<p>loading</p>`);
}
$$renderer.push(`<!--]-->`);
$$renderer.background(($$renderer) => {
$$renderer.push(`<p>`);
$$renderer.push(async () => $.escape((await $.save(warp.getOrInsertComputed('data', () => Promise.resolve('data'))))()));
$$renderer.push(`</p>`);
});
});
}

@ -0,0 +1,13 @@
<script>
import { Warp } from 'svelte';
const warp = new Warp('app');
</script>
<svelte:boundary>
<p>{await warp.getOrInsertComputed('data', () => Promise.resolve('data'))}</p>
{#snippet pending()}
<p>loading</p>
{/snippet}
</svelte:boundary>

@ -1246,6 +1246,17 @@ declare module 'svelte/compiler' {
* @since 5.36
*/
async?: boolean;
/**
* During server rendering, start the work inside `<svelte:boundary>` 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<SyncRenderOutput>;
export interface AsyncRenderOutput extends SyncRenderOutput {
/**
* `<script>` tags that must be written into the response after the rendered HTML, in order.
* When using `experimental.streaming`, these send the data loaded inside `<svelte:boundary>` elements
* with a `pending` snippet to the client as it becomes available. Otherwise, this is empty.
*/
tail: AsyncIterable<string>;
}
export type RenderOutput = SyncRenderOutput & PromiseLike<AsyncRenderOutput>;
export {};
}
@ -3371,6 +3391,17 @@ declare module 'svelte/types/compiler/interfaces' {
* @since 5.36
*/
async?: boolean;
/**
* During server rendering, start the work inside `<svelte:boundary>` 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;
};
}
/**

Loading…
Cancel
Save