Merge branch 'elliott/hydratable' of github.com:sveltejs/svelte into elliott/hydratable

elliott/hydratable
Elliott Johnson 16 hours ago
commit 2ec34a23b2

@ -1,8 +1,9 @@
/** @import { HydratableContext, HydratableLookupEntry } from '#server' */
/** @import { HydratableLookupEntry } from '#server' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { get_render_context } from './render-context.js';
import * as e from './errors.js';
import { uneval } from 'devalue';
import * as devalue from 'devalue';
import { get_stack } from './dev.js';
import { DEV } from 'esm-env';
@ -17,62 +18,93 @@ export function hydratable(key, fn) {
e.experimental_async_required('hydratable');
}
const store = get_render_context();
const { hydratable } = get_render_context();
const existing_entry = store.hydratable.lookup.get(key);
if (existing_entry !== undefined) {
return /** @type {T} */ (existing_entry.value);
}
let entry = hydratable.lookup.get(key);
const result = fn();
/** @type {HydratableLookupEntry} */
const entry = {
value: result,
root_index: encode(result, key, store.hydratable)
};
if (entry !== undefined) {
if (DEV) {
compare(key, entry, encode(key, fn(), []));
}
if (DEV) {
entry.stack = get_stack(`hydratable"`)?.stack;
return /** @type {T} */ (entry.value);
}
store.hydratable.lookup.set(key, entry);
const value = fn();
entry = encode(key, value, hydratable.values, hydratable.unresolved_promises);
hydratable.lookup.set(key, entry);
return result;
return value;
}
/**
* @param {unknown} value
* @param {string} key
* @param {HydratableContext} hydratable_context
* @returns {number}
* @param {any} value
* @param {MaybePromise<string>[]} values
* @param {Map<Promise<any>, string>} [unresolved]
*/
function encode(value, key, hydratable_context) {
const replacer = create_replacer(key, hydratable_context);
return hydratable_context.values.push(uneval(value, replacer)) - 1;
function encode(key, value, values, unresolved) {
/** @type {HydratableLookupEntry} */
const entry = { value, index: -1 };
if (DEV) {
entry.stack = get_stack(`hydratable"`)?.stack;
}
let serialized = devalue.uneval(entry.value, (value, uneval) => {
if (value instanceof Promise) {
const serialize_promise = value.then((v) => `r(${uneval(v)})`);
unresolved?.set(serialize_promise, key);
serialize_promise.finally(() => unresolved?.delete(serialize_promise));
const index = values.push(serialize_promise) - 1;
// in dev, we serialize promises as `d("1")` instead of `d(1)`, because it's
// impossible for that string to occur 'naturally' (since the quote marks
// would have to be escaped). this allows us to check that repeat occurrences
// of a given hydratable are identical with a simple string comparison
const result = DEV ? `d("${index}")` : `d(${index})`;
if (DEV) {
(entry.promises ??= []).push(
serialize_promise.then((s) => {
serialized = serialized.replace(result, s);
entry.serialized = serialized;
})
);
}
return result;
}
});
entry.index = values.push(serialized) - 1;
return entry;
}
/**
* @param {string} key
* @param {HydratableContext} hydratable_context
* @returns {(value: unknown, uneval: (value: any) => string) => string | undefined}
* @param {HydratableLookupEntry} a
* @param {HydratableLookupEntry} b
*/
function create_replacer(key, hydratable_context) {
/**
* @param {unknown} value
*/
const replacer = (value) => {
if (value instanceof Promise) {
// use the root-level uneval because we need a separate, top-level entry for each promise
/** @type {Promise<string>} */
const serialize_promise = value.then((v) => `r(${uneval(v, replacer)})`);
hydratable_context.unresolved_promises.set(serialize_promise, key);
serialize_promise.finally(() =>
hydratable_context.unresolved_promises.delete(serialize_promise)
);
const index = hydratable_context.values.push(serialize_promise) - 1;
return `d(${index})`;
}
};
async function compare(key, a, b) {
for (const p of a.promises ?? []) {
await p;
}
return replacer;
for (const p of b.promises ?? []) {
await p;
}
if (a.serialized !== b.serialized) {
// TODO right now this causes an unhandled rejection — it
// needs to happen somewhere else
e.hydratable_clobbering(
key,
a.stack ?? '<missing stack trace>',
b.stack ?? '<missing stack trace>'
);
}
}

@ -654,7 +654,12 @@ export class Renderer {
return null;
}
const values = await Promise.all(ctx.values);
let values = await Promise.all(ctx.values);
if (DEV) {
// turn `d("1")` into `d(1)` — see `hydratable.js` for an explanation
values = values.map((v) => v.replace(/d\("(\d+)"\)/g, (_, i) => `d(${i})`));
}
// TODO csp -- have discussed but not implemented
return `
@ -672,7 +677,7 @@ export class Renderer {
static #used_hydratables(lookup) {
let entries = [];
for (const [k, v] of lookup) {
entries.push(`[${JSON.stringify(k)},${v.root_index}]`);
entries.push(`[${JSON.stringify(k)},${v.index}]`);
}
return `
const store = sv.h ??= new Map();

@ -17,7 +17,12 @@ export interface SSRContext {
export interface HydratableLookupEntry {
value: unknown;
root_index: number;
index: number;
/** dev-only */
promises?: Array<Promise<void>>;
/** dev-only */
serialized?: string;
/** dev-only */
stack?: string;
}

Loading…
Cancel
Save