progress on serializing nested promises

elliott/hydratable
Elliott Johnson 17 hours 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
```
### hydratable_missing_but_expected_e
### hydratable_missing_but_required
```
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';
```
if (BROWSER) {
// 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%?
```
### hydratable_missing_but_expected_w
### hydratable_missing_but_expected
```
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';
```
if (BROWSER) {
// 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
## hydratable_missing_but_expected_e
## hydratable_missing_but_required
> 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';

@ -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%?
## hydratable_missing_but_expected_w
## hydratable_missing_but_expected
> 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';

@ -369,4 +369,3 @@ export interface Fork {
}
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.
* 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
* @returns {never}
*/
export function hydratable_missing_but_expected_e(key) {
export function hydratable_missing_but_required(key) {
if (DEV) {
const error = new Error(`hydratable_missing_but_expected_e\nExpected 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_e`);
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`);
error.name = 'Svelte error';
throw error;
} 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
* @param {string} key
* @param {() => T} fn
* @param {Transport<T>} [transport]
* @returns {T}
*/
export function hydratable(key, fn, transport) {
export function hydratable(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable');
}
@ -30,24 +29,15 @@ export function hydratable(key, fn, transport) {
return fn();
}
return decode(store?.get(key), transport?.decode);
}
/**
* @template T
* @param {unknown} val
* @param {Decode<T> | undefined} decode
* @returns {T}
*/
function decode(val, decode) {
return (decode ?? ((val) => /** @type {T} */ (val)))(val);
const val = /** @type {() => T} */ (store.get(key));
return val();
}
/** @param {string} key */
function hydratable_missing_but_expected(key) {
if (DEV) {
e.hydratable_missing_but_expected_e(key);
e.hydratable_missing_but_required(key);
} else {
w.hydratable_missing_but_expected_w(key);
w.hydratable_missing_but_expected(key);
}
}

@ -6,7 +6,7 @@ declare global {
interface Window {
__svelte?: {
/** hydratables */
h?: Map<string, unknown>;
h?: Map<string, () => unknown>;
/** unused hydratable keys */
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.
* 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
*/
export function hydratable_missing_but_expected_w(key) {
export function hydratable_missing_but_expected(key) {
if (DEV) {
console.warn(
`%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
);
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);
} 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 { HydratableEntry } from '#server' */
/** @import { HydratableContext } from '#server' */
import { async_mode_flag } from '../flags/index.js';
import { get_render_context } from './render-context.js';
import * as e from './errors.js';
import { DEV } from 'esm-env';
/** @type {WeakSet<HydratableEntry>} */
export const unresolved_hydratables = new WeakSet();
import { uneval } from 'devalue';
/**
* @template T
* @param {string} key
* @param {() => T} fn
* @param {Transport<T>} [transport]
* @returns {T}
*/
export function hydratable(key, fn, transport) {
export function hydratable(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable');
}
const store = get_render_context();
const entry = create_entry(fn(), transport?.encode);
const existing_entry = store.hydratables.get(key);
if (DEV && existing_entry !== undefined) {
(existing_entry.dev_competing_entries ??= []).push(entry);
return entry.value;
const entry = store.hydratable.lookup.get(key);
if (entry !== undefined) {
return /** @type {T} */ (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 {T} value
* @param {Encode<T> | undefined} encode
* @param {unknown} value
* @param {string} key
* @param {HydratableContext} hydratable_context
* @returns {number}
*/
function create_entry(value, encode) {
/** @type {Omit<HydratableEntry, 'value'> & { value: T }} */
const entry = {
value,
encode
};
function encode(value, key, hydratable_context) {
const replacer = create_replacer(key, hydratable_context);
return hydratable_context.values.push(uneval(value, replacer)) - 1;
}
if (DEV) {
entry.stack = new Error().stack;
if (
typeof value === 'object' &&
value !== null &&
'then' in value &&
typeof value.then === 'function'
) {
unresolved_hydratables.add(entry);
value.then(() => unresolved_hydratables.delete(entry));
/**
* @param {string} key
* @param {HydratableContext} hydratable_context
* @returns {(value: unknown, uneval: (value: any) => string) => string | undefined}
*/
function create_replacer(key, hydratable_context) {
/**
* @param {unknown} value
* @param {(value: any) => string} inner_uneval
*/
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) {
try {
sync_context = {
hydratables: new Map(),
cache: new Map()
hydratable: {
lookup: new Map(),
values: [],
unresolved_promises: new Map()
}
};
if (in_webcontainer()) {
const { promise, resolve } = deferred();

@ -1,5 +1,5 @@
/** @import { Component } from 'svelte' */
/** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.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 { uneval } from 'devalue';
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';
/** @typedef {'head' | 'body'} RendererType */
@ -577,39 +576,35 @@ export class Renderer {
}
async #collect_hydratables() {
const map = get_render_context().hydratables;
/** @type {[string, string][]} */
let entries = [];
/** @type {string[]} */
let unused_keys = [];
for (const [k, v] of map) {
const encode = v.encode ?? uneval;
if (unresolved_hydratables.has(v)) {
// this is a problem -- it means we've finished the render but somehow not consumed a hydratable, which means we've done
// extra work that won't get used on the client
w.unused_hydratable(k, v.stack ?? 'unavailable');
unused_keys.push(k);
continue;
}
const encoded = encode(await v.value);
if (DEV && v.dev_competing_entries?.length) {
for (const competing_entry of v.dev_competing_entries) {
const competing_encoded = (competing_entry.encode ?? uneval)(await competing_entry.value);
if (encoded !== competing_encoded) {
e.hydratable_clobbering(
k,
v.stack ?? 'unavailable',
competing_entry.stack ?? 'unavailable'
);
}
}
}
entries.push([k, encoded]);
}
if (entries.length === 0 && unused_keys.length === 0) return null;
return Renderer.#hydratable_block(entries, unused_keys);
const ctx = get_render_context().hydratable;
// for (const [k, v] of ctx.lookup) {
// // TODO - root-level
// // if (ctx.unresolved_promises.has(/** @type {Promise<unknown>} */ (v.value))) {
// // // this is a problem -- it means we've finished the render but somehow not consumed a hydratable, which means we've done
// // // extra work that will get serialized and sent but then not used on the client
// // w.unused_hydratable(k, v.stack ?? 'unavailable');
// // unused_keys.push(k);
// // continue;
// // }
// // TODO - nested
// // const encoded = encode(await v.value);
// // if (DEV && v.dev_competing_entries?.length) {
// // for (const competing_entry of v.dev_competing_entries) {
// // const competing_encoded = (competing_entry.encode ?? uneval)(await competing_entry.value);
// // if (encoded !== competing_encoded) {
// // e.hydratable_clobbering(
// // k,
// // v.stack ?? 'unavailable',
// // competing_entry.stack ?? 'unavailable'
// // );
// // }
// // }
// // }
// }
return await Renderer.#hydratable_block(ctx, []);
}
/**
@ -667,33 +662,39 @@ export class Renderer {
}
/**
* @param {[string, string][]} serialized_entries
* @param {HydratableContext} ctx
* @param {string[]} unused_keys
*/
static #hydratable_block(serialized_entries, unused_keys) {
let entries = [];
for (const [k, v] of serialized_entries) {
entries.push(`[${JSON.stringify(k)},${v}]`);
static async #hydratable_block(ctx, unused_keys) {
if (ctx.lookup.size === 0 && unused_keys.length === 0) {
return null;
}
const values = await Promise.all(ctx.values);
// TODO csp -- have discussed but not implemented
return `
<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>`;
}
/** @param {[string, string][]} serialized_entries */
static #used_hydratables(serialized_entries) {
/** @param {HydratableContext['lookup']} lookup */
static #used_hydratables(lookup) {
let entries = [];
for (const [k, v] of serialized_entries) {
entries.push(`[${JSON.stringify(k)},${v}]`);
for (const [k, v] of lookup) {
entries.push(`[${JSON.stringify(k)},${v.root_index}]`);
}
return `
const store = sv.h ??= new Map();
for (const [k,v] of [${entries.join(',')}]) {
store.set(k, v);
for (const [k,i] of [${entries.join(',')}]) {
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 { Renderer } from './renderer';
@ -15,16 +15,19 @@ export interface SSRContext {
element?: Element;
}
export interface HydratableEntry {
export interface HydratableLookupEntry {
value: unknown;
encode: Encode<any> | undefined;
stack?: string;
dev_competing_entries?: Omit<HydratableEntry, 'dev_competing_entries'>[];
root_index: number;
}
export interface HydratableContext {
lookup: Map<string, HydratableLookupEntry>;
values: MaybePromise<string>[];
unresolved_promises: Map<Promise<unknown>, string>;
}
export interface RenderContext {
hydratables: Map<string, HydratableEntry>;
cache: Map<symbol, Map<any, any>>;
hydratable: HydratableContext;
}
export interface SyncRenderOutput {

@ -10,26 +10,3 @@ export type Getters<T> = {
export type Snapshot<T> = ReturnType<typeof $state.snapshot<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 {
var __svelte:
| {
h?: Map<string, unknown>;
h?: Map<string, () => unknown>;
uh?: Set<string>;
}
| undefined;

@ -450,33 +450,7 @@ declare module 'svelte' {
* @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead
* */
export function afterUpdate(fn: () => void): void;
type Getters<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;
export function hydratable<T>(key: string, fn: () => T): T;
/**
* Create a snippet programmatically
* */
@ -618,6 +592,9 @@ declare module 'svelte' {
* ```
* */
export function untrack<T>(fn: () => T): T;
type Getters<T> = {
[K in keyof T]: () => T[K];
};
export {};
}

Loading…
Cancel
Save