refactor: serialize `Warp` values with a single `unevalStream` call

elliott/warp-core
Elliott Johnson 11 hours ago
parent 59cdcbcb8f
commit f4a45dcb17
No known key found for this signature in database

@ -105,7 +105,7 @@ Values added to a `Warp` on the server may already be in use (or on their way to
### warp_serialization_failed
```
Failed to serialize the value with key `%key%` in `Warp` `%id%`.
Failed to serialize data added to a `Warp`.
`Warp` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a `replacer` to `render` or `withWarp`.

@ -3,14 +3,9 @@
### unresolved_warp
```
The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during the render.
The value was set in:
%stack%
Part of the data added to `Warp` instances was not used during the render, so the response was delayed until it resolved.
```
The server has to wait for this value to resolve before it can send the rendered HTML, which delays the response even though the value was not needed for the render.
The most likely cause of this is creating a value in the `script` block of your component and then `await`ing
the result inside a `svelte:boundary` with a `pending` snippet:

@ -78,7 +78,7 @@ Values added to a `Warp` on the server may already be in use (or on their way to
## warp_serialization_failed
> Failed to serialize the value with key `%key%` in `Warp` `%id%`.
> Failed to serialize data added to a `Warp`.
>
> `Warp` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a `replacer` to `render` or `withWarp`.
>

@ -1,11 +1,6 @@
## unresolved_warp
> The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during the render.
>
> The value was set in:
> %stack%
The server has to wait for this value to resolve before it can send the rendered HTML, which delays the response even though the value was not needed for the render.
> Part of the data added to `Warp` instances was not used during the render, so the response was delayed until it resolved.
The most likely cause of this is creating a value in the `script` block of your component and then `await`ing
the result inside a `svelte:boundary` with a `pending` snippet:

@ -156,19 +156,17 @@ export function warp_method_unsupported(method) {
}
/**
* Failed to serialize the value with key `%key%` in `Warp` `%id%`.
* Failed to serialize data added to a `Warp`.
*
* `Warp` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a `replacer` to `render` or `withWarp`.
*
* Cause:
* %stack%
* @param {string} key
* @param {string} id
* @param {string} stack
* @returns {never}
*/
export function warp_serialization_failed(key, id, stack) {
const error = new Error(`warp_serialization_failed\nFailed to serialize the value with key \`${key}\` in \`Warp\` \`${id}\`.
export function warp_serialization_failed(stack) {
const error = new Error(`warp_serialization_failed\nFailed to serialize data added to a \`Warp\`.
\`Warp\` can serialize anything [\`uneval\` from \`devalue\`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a \`replacer\` to \`render\` or \`withWarp\`.

@ -13,11 +13,9 @@ import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { HYDRATION_START_FAILED } from '../../constants.js';
import { attributes } from './index.js';
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 { DEV } from 'esm-env';
import { escape_html } from '../../escaping.js';
/** @typedef {'head' | 'body'} RendererType */
@ -869,8 +867,8 @@ 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, waiting for any promises inside them to settle first.
* @param {WarpStore} store
* @param {UnevalReplacer | undefined} replacer
* @returns {Promise<string | null>}
@ -881,18 +879,6 @@ export class Renderer {
await store.comparisons[i];
}
/** @type {Map<Promise<unknown>, unknown>} */
const resolved = new Map();
/** @type {Set<Promise<unknown>>} */
const visited = new Set();
// 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);
}
}
store.emitted = true;
/** @type {Map<string, Map<WarpKey, unknown>>} */
@ -906,27 +892,50 @@ export class Renderer {
return null;
}
const { head, tail } = devalue.unevalStream(
payload,
(thing, js) => {
if (is_promise(thing) && resolved.has(thing)) {
return js`Promise.resolve(${resolved.get(thing)})`;
}
/** @type {unknown} */
let serialization_error = null;
return replacer?.(thing, js);
},
{
/** @type {ReturnType<typeof devalue.unevalStream>} */
let stream;
try {
stream = devalue.unevalStream(payload, replacer, {
id: `${this.global.id_prefix}w`,
scope: 'window.__svelte.d',
// rejected promises reject on the client too, with whatever `transformError` returns
transformError: (error) => this.global.transformError(error)
transformError: (error) => {
// a promise resolved to something that can't be serialized
if (error instanceof devalue.DevalueError) {
serialization_error ??= error;
return;
}
// rejected promises reject on the client too, with whatever `transformError` returns
return this.global.transformError(error);
}
});
} catch (error) {
serialization_failed(error);
}
const { head, tail } = stream;
const ready = await take_ready(tail);
let blocks = ready.blocks;
if (!ready.done) {
// 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.
w.unresolved_warp();
let result = await /** @type {Promise<IteratorResult<string>>} */ (ready.next);
while (!result.done) {
blocks += `\n\t\t\t\t${result.value}`;
result = await tail.next();
}
);
}
// every promise has settled, so the tail is only rejections and finishes right away
let blocks = '';
for await (const block of tail) {
blocks += `\n\t\t\t${block}`;
if (serialization_error !== null) {
serialization_failed(serialization_error);
}
const body = `
@ -1005,104 +1014,43 @@ export class Renderer {
}
}
const PENDING = Symbol('pending');
const MACROTASK = Symbol('macrotask');
/**
* Returns the outcome of `promise` if it has already settled, or `PENDING` otherwise
* @param {Promise<unknown>} promise
* @returns {Promise<{ ok: boolean, value: unknown } | typeof PENDING>}
* 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<string>} tail
* @returns {Promise<{ blocks: string, done: boolean, next?: Promise<IteratorResult<string>> }>}
*/
function peek(promise) {
// if `promise` has settled, its reaction is queued before the one for the already-resolved `PENDING`
return Promise.race([promise, Promise.resolve(PENDING)]).then(
(value) => (value === PENDING ? PENDING : { ok: true, value }),
(value) => ({ ok: false, value })
);
}
/**
* Finds the promises inside a `Warp` value (including inside the values those promises resolve to),
* waits for them to settle, and records the values of the ones that resolved
* @param {WarpStore} store
* @param {string} id
* @param {WarpKey} key
* @param {unknown} value
* @param {UnevalReplacer | undefined} replacer
* @param {Map<Promise<unknown>, unknown>} resolved
* @param {Set<Promise<unknown>>} visited
*/
async function resolve_warp_value(store, id, key, value, replacer, resolved, visited) {
let warned = false;
const queue = [value];
while (queue.length > 0) {
/** @type {Promise<unknown>[]} */
const promises = [];
try {
// we only care about the promises this finds, and whether it throws
devalue.uneval(queue.splice(0), (thing, js) => {
if (is_promise(thing)) {
if (!visited.has(thing)) {
visited.add(thing);
promises.push(thing);
}
return js`0`;
}
return replacer?.(thing, js);
});
} catch (error) {
e.warp_serialization_failed(
String(key),
id,
serialization_stack(
DEV ? get_stack(store, id, key) : undefined,
/** @type {any} */ (error)?.stack
)
);
async function take_ready(tail) {
let blocks = '';
while (true) {
const next = tail.next();
const result = await Promise.race([
next,
new Promise((fulfil) => setTimeout(() => fulfil(MACROTASK), 0))
]);
if (result === MACROTASK) {
// the block this resolves to is the next one, so it must not be dropped
return { blocks, done: false, next };
}
for (const promise of promises) {
let outcome = await peek(promise);
if (outcome === PENDING) {
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.
warned = true;
w.unresolved_warp(String(key), id, get_stack(store, id, key));
}
const { done, value } = /** @type {IteratorResult<string>} */ (result);
if (done) return { blocks, done: true };
outcome = await promise.then(
(value) => ({ ok: true, value }),
(value) => ({ ok: false, value })
);
}
// rejected promises are left for `unevalStream`, which rejects them on the client
if (outcome.ok) {
resolved.set(promise, outcome.value);
queue.push(outcome.value);
}
}
blocks += `\n\t\t\t\t${value}`;
}
}
/**
* @param {string | undefined} root_stack
* @param {string | undefined} uneval_stack
* @param {unknown} error
* @returns {never}
*/
function serialization_stack(root_stack, uneval_stack) {
let out = '';
if (root_stack) {
out += root_stack + '\n';
}
if (uneval_stack) {
out += 'Caused by:\n' + uneval_stack + '\n';
}
return out || '<missing stack trace>';
function serialization_failed(error) {
const { stack, path } = /** @type {any} */ (error) ?? {};
e.warp_serialization_failed((stack ?? String(error)) + (path ? `\n(at \`${path}\`)` : ''));
}
/**

@ -6,24 +6,11 @@ var bold = 'font-weight: bold';
var normal = 'font-weight: normal';
/**
* The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during the render.
*
* The value was set in:
* %stack%
* @param {string} key
* @param {string} id
* @param {string} stack
* Part of the data added to `Warp` instances was not used during the render, so the response was delayed until it resolved.
*/
export function unresolved_warp(key, id, stack) {
export function unresolved_warp() {
if (DEV) {
console.warn(
`%c[svelte] unresolved_warp\n%cThe value with key \`${key}\` in \`Warp\` \`${id}\` was created, but at least part of it was not used during the render.
The value was set in:
${stack}\nhttps://svelte.dev/e/unresolved_warp`,
bold,
normal
);
console.warn(`%c[svelte] unresolved_warp\n%cPart of the data added to \`Warp\` instances was not used during the render, so the response was delayed until it resolved.\nhttps://svelte.dev/e/unresolved_warp`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/unresolved_warp`);
}

@ -83,19 +83,18 @@ describe('Warp', () => {
expect(values?.get('other')?.get('a')).toBe(2);
});
test('waits for promises, including nested ones, and inlines them as resolved', async () => {
test('waits for promises, including nested ones', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { head } = await render(() => {
warp.set(
'a',
Promise.resolve({ nested: new Promise((fulfil) => setTimeout(() => fulfil(42))) })
Promise.resolve({ nested: new Promise((fulfil) => setTimeout(() => fulfil(42), 20)) })
);
});
const a = await revive(head)?.get('test')?.get('a');
expect(head).toContain('Promise.resolve(');
expect(await (a as any).nested).toBe(42);
// the nested promise was still pending when the render finished

@ -14,7 +14,7 @@ export default test({
// for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
assert.include(
warnings[0],
'The value with key `partially_used` in `Warp` `svelte:hydratable`'
'Part of the data added to `Warp` instances was not used during the render'
);
},

@ -8,10 +8,10 @@
() => {
return {
used: new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 10) : rej('should not run')
),
unused: new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('no, sith daddy, please tell me'), 0) : rej('should not run')
(res, rej) => environment === 'server' ? setTimeout(() => res('no, sith daddy, please tell me'), 50) : rej('should not run')
),
}
}

@ -11,7 +11,10 @@ export default test({
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
// for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
assert.include(warnings[0], 'The value with key `unused_key` in `Warp` `svelte:hydratable`');
assert.include(
warnings[0],
'Part of the data added to `Warp` instances was not used during the render'
);
},
async test({ assert, target }) {

@ -6,7 +6,7 @@
const unresolved_hydratable = hydratable(
"unused_key",
() => new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 10) : rej('should not run')
)
);
</script>

@ -13,7 +13,10 @@ export default test({
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
assert.include(warnings[0], 'The value with key `data` in `Warp` `app`');
assert.include(
warnings[0],
'Part of the data added to `Warp` instances was not used during the render'
);
},
async test({ assert, target }) {

@ -6,7 +6,7 @@
const warp = new Warp<string, Promise<string>>('app');
const data = warp.getOrInsertComputed(
'data',
() => new Promise((_, reject) => setTimeout(() => reject(new Error(`from ${environment}`))))
() => new Promise((_, reject) => setTimeout(() => reject(new Error(`from ${environment}`)), 10))
);
</script>

@ -11,7 +11,10 @@ export default test({
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
assert.include(warnings[0], 'The value with key `partially_used` in `Warp` `app`');
assert.include(
warnings[0],
'Part of the data added to `Warp` instances was not used during the render'
);
},
async test({ assert, target }) {

@ -6,10 +6,10 @@
const warp = new Warp('app');
const partially_used = warp.getOrInsertComputed('partially_used', () => ({
used: new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 10) : rej('should not run')
),
unused: new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('no, sith daddy, please tell me'), 0) : rej('should not run')
(res, rej) => environment === 'server' ? setTimeout(() => res('no, sith daddy, please tell me'), 50) : rej('should not run')
),
}));
</script>

@ -10,7 +10,10 @@ export default test({
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
assert.include(warnings[0], 'The value with key `unused_key` in `Warp` `app`');
assert.include(
warnings[0],
'Part of the data added to `Warp` instances was not used during the render'
);
},
async test({ assert, target }) {

@ -5,7 +5,7 @@
const warp = new Warp('app');
const unresolved = warp.getOrInsertComputed('unused_key', () => new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 10) : rej('should not run')
));
</script>

@ -3,5 +3,5 @@ import { test } from '../../test';
export default test({
mode: ['async'],
csp: { hash: true },
script_hashes: ['sha256-kcOu9IHb0N7eeK7eTBoi3kn39HD0+3o4IiIQcydJV3U=']
script_hashes: ['sha256-/O9j+G/R3k/zGliNPs7Ausxs9KBZyprD7zlO4ivy2ME=']
});

@ -3,7 +3,7 @@
{
const w = (window.__svelte ??= {}).w ??= new Map();
for (const [id, values] of new Map([["svelte:hydratable",new Map([["key",Promise.resolve("bar")]])]])) {
for (const [id, values] of (()=>{let n=window.__svelte.d||(window.__svelte.d={__proto__:null}),s=(n["w"]={o:[],p:[]});s.d=(i)=>{let p=new Promise((a,b)=>{s.p[i]=[a,b]});p.catch(()=>{});return p};s.r=(i,k,v)=>{s.p[i][k](v);delete s.p[i]};return (s.o[0]=new Map([["svelte:hydratable",(s.o[1]=new Map([["key",(s.o[2]=s.d(0))]]))]]))})()) {
const existing = w.get(id);
if (existing) {
@ -12,5 +12,6 @@
w.set(id, values);
}
}
((s)=>{s.r(0,0,"bar");delete window.__svelte.d["w"]})(window.__svelte.d["w"]);
}
</script>

@ -3,7 +3,7 @@
{
const w = (window.__svelte ??= {}).w ??= new Map();
for (const [id, values] of new Map([["svelte:hydratable",new Map([["key",Promise.resolve("bar")]])]])) {
for (const [id, values] of (()=>{let n=window.__svelte.d||(window.__svelte.d={__proto__:null}),s=(n["w"]={o:[],p:[]});s.d=(i)=>{let p=new Promise((a,b)=>{s.p[i]=[a,b]});p.catch(()=>{});return p};s.r=(i,k,v)=>{s.p[i][k](v);delete s.p[i]};return (s.o[0]=new Map([["svelte:hydratable",(s.o[1]=new Map([["key",(s.o[2]=s.d(0))]]))]]))})()) {
const existing = w.get(id);
if (existing) {
@ -12,5 +12,6 @@
w.set(id, values);
}
}
((s)=>{s.r(0,0,"bar");delete window.__svelte.d["w"]})(window.__svelte.d["w"]);
}
</script>
Loading…
Cancel
Save