progress on serializing nested promises

elliott/hydratable
Elliott Johnson 2 days ago
parent 05e60b1320
commit caad89b888

@ -158,15 +158,17 @@ Cannot create a fork inside an effect or when state changes are pending
`getAbortSignal()` can only be called inside an effect or derived `getAbortSignal()` can only be called inside an effect or derived
``` ```
### hydratable_missing_but_expected_e ### hydratable_missing_but_required
``` ```
Expected to find a hydratable with key `%key%` during hydration, but did not. Expected to find a hydratable with key `%key%` during hydration, but did not.
```
This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
```svelte ```svelte
<script> <script>
import { hydratable } from 'svelte'; import { hydratable } from 'svelte';
```
if (BROWSER) { if (BROWSER) {
// bad! nothing can become interactive until this asynchronous work is done // bad! nothing can become interactive until this asynchronous work is done

@ -140,15 +140,17 @@ The easiest way to log a value as it changes over time is to use the [`$inspect`
%handler% should be a function. Did you mean to %suggestion%? %handler% should be a function. Did you mean to %suggestion%?
``` ```
### hydratable_missing_but_expected_w ### hydratable_missing_but_expected
``` ```
Expected to find a hydratable with key `%key%` during hydration, but did not. Expected to find a hydratable with key `%key%` during hydration, but did not.
```
This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
```svelte ```svelte
<script> <script>
import { hydratable } from 'svelte'; import { hydratable } from 'svelte';
```
if (BROWSER) { if (BROWSER) {
// bad! nothing can become interactive until this asynchronous work is done // bad! nothing can become interactive until this asynchronous work is done

@ -120,10 +120,12 @@ This restriction only applies when using the `experimental.async` option, which
> `getAbortSignal()` can only be called inside an effect or derived > `getAbortSignal()` can only be called inside an effect or derived
## hydratable_missing_but_expected_e ## hydratable_missing_but_required
> Expected to find a hydratable with key `%key%` during hydration, but did not. > Expected to find a hydratable with key `%key%` during hydration, but did not.
This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
```svelte ```svelte
<script> <script>
import { hydratable } from 'svelte'; import { hydratable } from 'svelte';

@ -124,10 +124,12 @@ The easiest way to log a value as it changes over time is to use the [`$inspect`
> %handler% should be a function. Did you mean to %suggestion%? > %handler% should be a function. Did you mean to %suggestion%?
## hydratable_missing_but_expected_w ## hydratable_missing_but_expected
> Expected to find a hydratable with key `%key%` during hydration, but did not. > Expected to find a hydratable with key `%key%` during hydration, but did not.
This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
```svelte ```svelte
<script> <script>
import { hydratable } from 'svelte'; import { hydratable } from 'svelte';

@ -369,4 +369,3 @@ export interface Fork {
} }
export * from './index-client.js'; export * from './index-client.js';
export { Transport, Encode, Decode } from '#shared';

@ -295,26 +295,18 @@ export function get_abort_signal_outside_reaction() {
/** /**
* Expected to find a hydratable with key `%key%` during hydration, but did not. * Expected to find a hydratable with key `%key%` during hydration, but did not.
* This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
* ```svelte
* <script>
* import { hydratable } from 'svelte';
* @param {string} key * @param {string} key
* @returns {never} * @returns {never}
*/ */
export function hydratable_missing_but_expected_e(key) { export function hydratable_missing_but_required(key) {
if (DEV) { if (DEV) {
const error = new Error(`hydratable_missing_but_expected_e\nExpected to find a hydratable with key \`${key}\` during hydration, but did not. const error = new Error(`hydratable_missing_but_required\nExpected to find a hydratable with key \`${key}\` during hydration, but did not.\nhttps://svelte.dev/e/hydratable_missing_but_required`);
This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
\`\`\`svelte
<script>
import { hydratable } from 'svelte';\nhttps://svelte.dev/e/hydratable_missing_but_expected_e`);
error.name = 'Svelte error'; error.name = 'Svelte error';
throw error; throw error;
} else { } else {
throw new Error(`https://svelte.dev/e/hydratable_missing_but_expected_e`); throw new Error(`https://svelte.dev/e/hydratable_missing_but_required`);
} }
} }

@ -9,10 +9,9 @@ import { DEV } from 'esm-env';
* @template T * @template T
* @param {string} key * @param {string} key
* @param {() => T} fn * @param {() => T} fn
* @param {Transport<T>} [transport]
* @returns {T} * @returns {T}
*/ */
export function hydratable(key, fn, transport) { export function hydratable(key, fn) {
if (!async_mode_flag) { if (!async_mode_flag) {
e.experimental_async_required('hydratable'); e.experimental_async_required('hydratable');
} }
@ -30,24 +29,15 @@ export function hydratable(key, fn, transport) {
return fn(); return fn();
} }
return decode(store?.get(key), transport?.decode); const val = /** @type {() => T} */ (store.get(key));
} return val();
/**
* @template T
* @param {unknown} val
* @param {Decode<T> | undefined} decode
* @returns {T}
*/
function decode(val, decode) {
return (decode ?? ((val) => /** @type {T} */ (val)))(val);
} }
/** @param {string} key */ /** @param {string} key */
function hydratable_missing_but_expected(key) { function hydratable_missing_but_expected(key) {
if (DEV) { if (DEV) {
e.hydratable_missing_but_expected_e(key); e.hydratable_missing_but_required(key);
} else { } else {
w.hydratable_missing_but_expected_w(key); w.hydratable_missing_but_expected(key);
} }
} }

@ -6,7 +6,7 @@ declare global {
interface Window { interface Window {
__svelte?: { __svelte?: {
/** hydratables */ /** hydratables */
h?: Map<string, unknown>; h?: Map<string, () => unknown>;
/** unused hydratable keys */ /** unused hydratable keys */
uh?: Set<string>; uh?: Set<string>;
}; };

@ -89,25 +89,13 @@ export function event_handler_invalid(handler, suggestion) {
/** /**
* Expected to find a hydratable with key `%key%` during hydration, but did not. * Expected to find a hydratable with key `%key%` during hydration, but did not.
* This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
* ```svelte
* <script>
* import { hydratable } from 'svelte';
* @param {string} key * @param {string} key
*/ */
export function hydratable_missing_but_expected_w(key) { export function hydratable_missing_but_expected(key) {
if (DEV) { if (DEV) {
console.warn( console.warn(`%c[svelte] hydratable_missing_but_expected\n%cExpected to find a hydratable with key \`${key}\` during hydration, but did not.\nhttps://svelte.dev/e/hydratable_missing_but_expected`, bold, normal);
`%c[svelte] hydratable_missing_but_expected_w\n%cExpected to find a hydratable with key \`${key}\` during hydration, but did not.
This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes.
\`\`\`svelte
<script>
import { hydratable } from 'svelte';\nhttps://svelte.dev/e/hydratable_missing_but_expected_w`,
bold,
normal
);
} else { } else {
console.warn(`https://svelte.dev/e/hydratable_missing_but_expected_w`); console.warn(`https://svelte.dev/e/hydratable_missing_but_expected`);
} }
} }

@ -1,62 +1,67 @@
/** @import { Encode, Transport } from '#shared' */ /** @import { HydratableContext } from '#server' */
/** @import { HydratableEntry } from '#server' */
import { async_mode_flag } from '../flags/index.js'; import { async_mode_flag } from '../flags/index.js';
import { get_render_context } from './render-context.js'; import { get_render_context } from './render-context.js';
import * as e from './errors.js'; import * as e from './errors.js';
import { DEV } from 'esm-env'; import { uneval } from 'devalue';
/** @type {WeakSet<HydratableEntry>} */
export const unresolved_hydratables = new WeakSet();
/** /**
* @template T * @template T
* @param {string} key * @param {string} key
* @param {() => T} fn * @param {() => T} fn
* @param {Transport<T>} [transport]
* @returns {T} * @returns {T}
*/ */
export function hydratable(key, fn, transport) { export function hydratable(key, fn) {
if (!async_mode_flag) { if (!async_mode_flag) {
e.experimental_async_required('hydratable'); e.experimental_async_required('hydratable');
} }
const store = get_render_context(); const store = get_render_context();
const entry = create_entry(fn(), transport?.encode); const entry = store.hydratable.lookup.get(key);
const existing_entry = store.hydratables.get(key); if (entry !== undefined) {
if (DEV && existing_entry !== undefined) { return /** @type {T} */ (entry.value);
(existing_entry.dev_competing_entries ??= []).push(entry);
return entry.value;
} }
store.hydratables.set(key, entry);
return entry.value; const result = fn();
store.hydratable.lookup.set(key, {
value: result,
root_index: encode(result, key, store.hydratable)
});
return result;
} }
/** /**
* @template T * @param {unknown} value
* @param {T} value * @param {string} key
* @param {Encode<T> | undefined} encode * @param {HydratableContext} hydratable_context
* @returns {number}
*/ */
function create_entry(value, encode) { function encode(value, key, hydratable_context) {
/** @type {Omit<HydratableEntry, 'value'> & { value: T }} */ const replacer = create_replacer(key, hydratable_context);
const entry = { return hydratable_context.values.push(uneval(value, replacer)) - 1;
value, }
encode
};
if (DEV) { /**
entry.stack = new Error().stack; * @param {string} key
* @param {HydratableContext} hydratable_context
if ( * @returns {(value: unknown, uneval: (value: any) => string) => string | undefined}
typeof value === 'object' && */
value !== null && function create_replacer(key, hydratable_context) {
'then' in value && /**
typeof value.then === 'function' * @param {unknown} value
) { * @param {(value: any) => string} inner_uneval
unresolved_hydratables.add(entry); */
value.then(() => unresolved_hydratables.delete(entry)); const replacer = (value, inner_uneval) => {
if (value instanceof Promise) {
hydratable_context.unresolved_promises.set(value, key);
value.finally(() => hydratable_context.unresolved_promises.delete(value));
// use the root-level uneval because we need a separate, top-level entry for each promise
const index =
hydratable_context.values.push(value.then((v) => `r(${uneval(v, replacer)})`)) - 1;
return `d(${index})`;
} }
} };
return entry; return replacer;
} }

@ -55,8 +55,11 @@ export function get_render_context() {
export async function with_render_context(fn) { export async function with_render_context(fn) {
try { try {
sync_context = { sync_context = {
hydratables: new Map(), hydratable: {
cache: new Map() lookup: new Map(),
values: [],
unresolved_promises: new Map()
}
}; };
if (in_webcontainer()) { if (in_webcontainer()) {
const { promise, resolve } = deferred(); const { promise, resolve } = deferred();

@ -1,5 +1,5 @@
/** @import { Component } from 'svelte' */ /** @import { Component } from 'svelte' */
/** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */ /** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { MaybePromise } from '#shared' */ /** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js'; import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js'; import { abort } from './abort-signal.js';
@ -10,7 +10,6 @@ import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js'; import { attributes } from './index.js';
import { uneval } from 'devalue'; import { uneval } from 'devalue';
import { get_render_context, with_render_context, init_render_context } from './render-context.js'; import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { unresolved_hydratables } from './hydratable.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
/** @typedef {'head' | 'body'} RendererType */ /** @typedef {'head' | 'body'} RendererType */
@ -577,39 +576,35 @@ export class Renderer {
} }
async #collect_hydratables() { async #collect_hydratables() {
const map = get_render_context().hydratables; const ctx = get_render_context().hydratable;
/** @type {[string, string][]} */ // for (const [k, v] of ctx.lookup) {
let entries = []; // // TODO - root-level
/** @type {string[]} */ // // if (ctx.unresolved_promises.has(/** @type {Promise<unknown>} */ (v.value))) {
let unused_keys = []; // // // this is a problem -- it means we've finished the render but somehow not consumed a hydratable, which means we've done
for (const [k, v] of map) { // // // extra work that will get serialized and sent but then not used on the client
const encode = v.encode ?? uneval; // // w.unused_hydratable(k, v.stack ?? 'unavailable');
if (unresolved_hydratables.has(v)) { // // unused_keys.push(k);
// this is a problem -- it means we've finished the render but somehow not consumed a hydratable, which means we've done // // continue;
// extra work that won't get used on the client // // }
w.unused_hydratable(k, v.stack ?? 'unavailable');
unused_keys.push(k); // // TODO - nested
continue;
} // // const encoded = encode(await v.value);
// // if (DEV && v.dev_competing_entries?.length) {
const encoded = encode(await v.value); // // for (const competing_entry of v.dev_competing_entries) {
if (DEV && v.dev_competing_entries?.length) { // // const competing_encoded = (competing_entry.encode ?? uneval)(await competing_entry.value);
for (const competing_entry of v.dev_competing_entries) { // // if (encoded !== competing_encoded) {
const competing_encoded = (competing_entry.encode ?? uneval)(await competing_entry.value); // // e.hydratable_clobbering(
if (encoded !== competing_encoded) { // // k,
e.hydratable_clobbering( // // v.stack ?? 'unavailable',
k, // // competing_entry.stack ?? 'unavailable'
v.stack ?? 'unavailable', // // );
competing_entry.stack ?? 'unavailable' // // }
); // // }
} // // }
} // }
} return await Renderer.#hydratable_block(ctx, []);
entries.push([k, encoded]);
}
if (entries.length === 0 && unused_keys.length === 0) return null;
return Renderer.#hydratable_block(entries, unused_keys);
} }
/** /**
@ -667,33 +662,39 @@ export class Renderer {
} }
/** /**
* @param {[string, string][]} serialized_entries * @param {HydratableContext} ctx
* @param {string[]} unused_keys * @param {string[]} unused_keys
*/ */
static #hydratable_block(serialized_entries, unused_keys) { static async #hydratable_block(ctx, unused_keys) {
let entries = []; if (ctx.lookup.size === 0 && unused_keys.length === 0) {
for (const [k, v] of serialized_entries) { return null;
entries.push(`[${JSON.stringify(k)},${v}]`);
} }
const values = await Promise.all(ctx.values);
// TODO csp -- have discussed but not implemented // TODO csp -- have discussed but not implemented
return ` return `
<script> <script>
{ {
const sv = window.__svelte ??= {};${Renderer.#used_hydratables(serialized_entries)}${Renderer.#unused_hydratables(unused_keys)} const r = (v) => Promise.resolve(v);
const w = (v) => () => v;
const v = [${values.map((v) => `w(${v})`).join(',')}];
function d(i) { return v[i] };
const sv = window.__svelte ??= {};${Renderer.#used_hydratables(ctx.lookup)}${Renderer.#unused_hydratables(unused_keys)}
} }
</script>`; </script>`;
} }
/** @param {[string, string][]} serialized_entries */ /** @param {HydratableContext['lookup']} lookup */
static #used_hydratables(serialized_entries) { static #used_hydratables(lookup) {
let entries = []; let entries = [];
for (const [k, v] of serialized_entries) { for (const [k, v] of lookup) {
entries.push(`[${JSON.stringify(k)},${v}]`); entries.push(`[${JSON.stringify(k)},${v.root_index}]`);
} }
return ` return `
const store = sv.h ??= new Map(); const store = sv.h ??= new Map();
for (const [k,v] of [${entries.join(',')}]) { for (const [k,i] of [${entries.join(',')}]) {
store.set(k, v); store.set(k, d(i));
}`; }`;
} }

@ -1,4 +1,4 @@
import type { Encode } from '#shared'; import type { MaybePromise } from '#shared';
import type { Element } from './dev'; import type { Element } from './dev';
import type { Renderer } from './renderer'; import type { Renderer } from './renderer';
@ -15,16 +15,19 @@ export interface SSRContext {
element?: Element; element?: Element;
} }
export interface HydratableEntry { export interface HydratableLookupEntry {
value: unknown; value: unknown;
encode: Encode<any> | undefined; root_index: number;
stack?: string; }
dev_competing_entries?: Omit<HydratableEntry, 'dev_competing_entries'>[];
export interface HydratableContext {
lookup: Map<string, HydratableLookupEntry>;
values: MaybePromise<string>[];
unresolved_promises: Map<Promise<unknown>, string>;
} }
export interface RenderContext { export interface RenderContext {
hydratables: Map<string, HydratableEntry>; hydratable: HydratableContext;
cache: Map<symbol, Map<any, any>>;
} }
export interface SyncRenderOutput { export interface SyncRenderOutput {

@ -10,26 +10,3 @@ export type Getters<T> = {
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>; export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;
export type MaybePromise<T> = T | Promise<T>; export type MaybePromise<T> = T | Promise<T>;
/** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */
export type Decode<T> = (value: any) => T;
/** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */
export type Encode<T> = (value: T) => string;
/**
* Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg:
* ```ts
* import { BROWSER } from 'esm-env';
* const transport: Transport<MyType> = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction };
* ```
*/
export type Transport<T> =
| {
encode: Encode<T>;
decode?: undefined;
}
| {
encode?: undefined;
decode: Decode<T>;
};

@ -105,7 +105,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
declare global { declare global {
var __svelte: var __svelte:
| { | {
h?: Map<string, unknown>; h?: Map<string, () => unknown>;
uh?: Set<string>; uh?: Set<string>;
} }
| undefined; | undefined;

@ -450,33 +450,7 @@ declare module 'svelte' {
* @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead
* */ * */
export function afterUpdate(fn: () => void): void; export function afterUpdate(fn: () => void): void;
type Getters<T> = { export function hydratable<T>(key: string, fn: () => T): T;
[K in keyof T]: () => T[K];
};
/** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */
export type Decode<T> = (value: any) => T;
/** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */
export type Encode<T> = (value: T) => string;
/**
* Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg:
* ```ts
* import { BROWSER } from 'esm-env';
* const transport: Transport<MyType> = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction };
* ```
*/
export type Transport<T> =
| {
encode: Encode<T>;
decode?: undefined;
}
| {
encode?: undefined;
decode: Decode<T>;
};
export function hydratable<T>(key: string, fn: () => T, transport?: Transport<T> | undefined): T;
/** /**
* Create a snippet programmatically * Create a snippet programmatically
* */ * */
@ -618,6 +592,9 @@ declare module 'svelte' {
* ``` * ```
* */ * */
export function untrack<T>(fn: () => T): T; export function untrack<T>(fn: () => T): T;
type Getters<T> = {
[K in keyof T]: () => T[K];
};
export {}; export {};
} }

Loading…
Cancel
Save