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

elliott/warp-core
Elliott Johnson 2 days 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 ### 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`. `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 ### unresolved_warp
``` ```
The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during 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 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.
The most likely cause of this is creating a value in the `script` block of your component and then `await`ing 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: 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 ## 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`. > `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 ## unresolved_warp
> The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during 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 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.
The most likely cause of this is creating a value in the `script` block of your component and then `await`ing 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: 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`. * `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: * Cause:
* %stack% * %stack%
* @param {string} key
* @param {string} id
* @param {string} stack * @param {string} stack
* @returns {never} * @returns {never}
*/ */
export function warp_serialization_failed(key, id, stack) { export function warp_serialization_failed(stack) {
const error = new Error(`warp_serialization_failed\nFailed to serialize the value with key \`${key}\` in \`Warp\` \`${id}\`. 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\`. \`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 { HYDRATION_START_FAILED } from '../../constants.js';
import { attributes } from './index.js'; import { attributes } from './index.js';
import { with_render_context } from './render-context.js'; import { with_render_context } from './render-context.js';
import { get_stack, is_promise } from './warp.js';
import { sha256 } from './crypto.js'; import { sha256 } from './crypto.js';
import * as devalue from 'devalue'; import * as devalue from 'devalue';
import { has_own_property, is_array, noop } from '../shared/utils.js'; import { has_own_property, is_array, noop } from '../shared/utils.js';
import { DEV } from 'esm-env';
import { escape_html } from '../../escaping.js'; import { escape_html } from '../../escaping.js';
/** @typedef {'head' | 'body'} RendererType */ /** @typedef {'head' | 'body'} RendererType */
@ -869,8 +867,8 @@ export class Renderer {
} }
/** /**
* Waits for the values added to `Warp` instances to resolve, then serializes them * Serializes the values added to `Warp` instances into a `<script>` that recreates them
* into a `<script>` that recreates them on the client. * on the client, waiting for any promises inside them to settle first.
* @param {WarpStore} store * @param {WarpStore} store
* @param {UnevalReplacer | undefined} replacer * @param {UnevalReplacer | undefined} replacer
* @returns {Promise<string | null>} * @returns {Promise<string | null>}
@ -881,18 +879,6 @@ export class Renderer {
await store.comparisons[i]; 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; store.emitted = true;
/** @type {Map<string, Map<WarpKey, unknown>>} */ /** @type {Map<string, Map<WarpKey, unknown>>} */
@ -906,27 +892,50 @@ export class Renderer {
return null; return null;
} }
const { head, tail } = devalue.unevalStream( /** @type {unknown} */
payload, let serialization_error = null;
(thing, js) => {
if (is_promise(thing) && resolved.has(thing)) {
return js`Promise.resolve(${resolved.get(thing)})`;
}
return replacer?.(thing, js); /** @type {ReturnType<typeof devalue.unevalStream>} */
}, let stream;
{
try {
stream = devalue.unevalStream(payload, replacer, {
id: `${this.global.id_prefix}w`, id: `${this.global.id_prefix}w`,
scope: 'window.__svelte.d', scope: 'window.__svelte.d',
// rejected promises reject on the client too, with whatever `transformError` returns transformError: (error) => {
transformError: (error) => this.global.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 if (serialization_error !== null) {
let blocks = ''; serialization_failed(serialization_error);
for await (const block of tail) {
blocks += `\n\t\t\t${block}`;
} }
const body = ` 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 * Takes the blocks from `tail` that are ready now — i.e. the ones for promises that have already
* @param {Promise<unknown>} promise * settled, which `unevalStream` emits within a few microtasks — without waiting for the rest
* @returns {Promise<{ ok: boolean, value: unknown } | typeof PENDING>} * @param {AsyncIterator<string>} tail
* @returns {Promise<{ blocks: string, done: boolean, next?: Promise<IteratorResult<string>> }>}
*/ */
function peek(promise) { async function take_ready(tail) {
// if `promise` has settled, its reaction is queued before the one for the already-resolved `PENDING` let blocks = '';
return Promise.race([promise, Promise.resolve(PENDING)]).then(
(value) => (value === PENDING ? PENDING : { ok: true, value }), while (true) {
(value) => ({ ok: false, value }) const next = tail.next();
); const result = await Promise.race([
} next,
new Promise((fulfil) => setTimeout(() => fulfil(MACROTASK), 0))
/** ]);
* 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 if (result === MACROTASK) {
* @param {WarpStore} store // the block this resolves to is the next one, so it must not be dropped
* @param {string} id return { blocks, done: false, next };
* @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
)
);
} }
for (const promise of promises) { const { done, value } = /** @type {IteratorResult<string>} */ (result);
let outcome = await peek(promise); if (done) return { blocks, done: true };
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));
}
outcome = await promise.then( blocks += `\n\t\t\t\t${value}`;
(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);
}
}
} }
} }
/** /**
* @param {string | undefined} root_stack * @param {unknown} error
* @param {string | undefined} uneval_stack * @returns {never}
*/ */
function serialization_stack(root_stack, uneval_stack) { function serialization_failed(error) {
let out = ''; const { stack, path } = /** @type {any} */ (error) ?? {};
if (root_stack) { e.warp_serialization_failed((stack ?? String(error)) + (path ? `\n(at \`${path}\`)` : ''));
out += root_stack + '\n';
}
if (uneval_stack) {
out += 'Caused by:\n' + uneval_stack + '\n';
}
return out || '<missing stack trace>';
} }
/** /**

@ -6,24 +6,11 @@ var bold = 'font-weight: bold';
var normal = 'font-weight: normal'; 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. * Part of the data added to `Warp` instances was not used during the render, so the response was delayed until it resolved.
*
* The value was set in:
* %stack%
* @param {string} key
* @param {string} id
* @param {string} stack
*/ */
export function unresolved_warp(key, id, stack) { export function unresolved_warp() {
if (DEV) { if (DEV) {
console.warn( 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);
`%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
);
} else { } else {
console.warn(`https://svelte.dev/e/unresolved_warp`); console.warn(`https://svelte.dev/e/unresolved_warp`);
} }

@ -83,19 +83,18 @@ describe('Warp', () => {
expect(values?.get('other')?.get('a')).toBe(2); 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(() => {}); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { try {
const { head } = await render(() => { const { head } = await render(() => {
warp.set( warp.set(
'a', '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'); const a = await revive(head)?.get('test')?.get('a');
expect(head).toContain('Promise.resolve(');
expect(await (a as any).nested).toBe(42); expect(await (a as any).nested).toBe(42);
// the nested promise was still pending when the render finished // 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 // for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
assert.include( assert.include(
warnings[0], 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 { return {
used: new Promise( 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( 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 }) { test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1); 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 // 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 }) { async test({ assert, target }) {

@ -6,7 +6,7 @@
const unresolved_hydratable = hydratable( const unresolved_hydratable = hydratable(
"unused_key", "unused_key",
() => new Promise( () => 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> </script>

@ -13,7 +13,10 @@ export default test({
test_ssr({ assert, warnings }) { test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1); 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 }) { async test({ assert, target }) {

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

@ -11,7 +11,10 @@ export default test({
test_ssr({ assert, warnings }) { test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1); 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 }) { async test({ assert, target }) {

@ -6,10 +6,10 @@
const warp = new Warp('app'); const warp = new Warp('app');
const partially_used = warp.getOrInsertComputed('partially_used', () => ({ const partially_used = warp.getOrInsertComputed('partially_used', () => ({
used: new Promise( 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( 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> </script>

@ -10,7 +10,10 @@ export default test({
test_ssr({ assert, warnings }) { test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1); 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 }) { async test({ assert, target }) {

@ -5,7 +5,7 @@
const warp = new Warp('app'); const warp = new Warp('app');
const unresolved = warp.getOrInsertComputed('unused_key', () => new Promise( 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> </script>

@ -3,5 +3,5 @@ import { test } from '../../test';
export default test({ export default test({
mode: ['async'], mode: ['async'],
csp: { hash: true }, 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(); 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); const existing = w.get(id);
if (existing) { if (existing) {
@ -12,5 +12,6 @@
w.set(id, values); w.set(id, values);
} }
} }
((s)=>{s.r(0,0,"bar");delete window.__svelte.d["w"]})(window.__svelte.d["w"]);
} }
</script> </script>

@ -3,7 +3,7 @@
{ {
const w = (window.__svelte ??= {}).w ??= new Map(); 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); const existing = w.get(id);
if (existing) { if (existing) {
@ -12,5 +12,6 @@
w.set(id, values); w.set(id, values);
} }
} }
((s)=>{s.r(0,0,"bar");delete window.__svelte.d["w"]})(window.__svelte.d["w"]);
} }
</script> </script>
Loading…
Cancel
Save