From 9343114bae29de1f7defd57bc06362f0350b1abd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 20 Nov 2025 13:11:08 -0500 Subject: [PATCH 1/2] compare resolved serialized values --- .../svelte/src/internal/server/hydratable.js | 113 +++++++++++------- .../svelte/src/internal/server/renderer.js | 2 +- .../svelte/src/internal/server/types.d.ts | 7 +- 3 files changed, 77 insertions(+), 45 deletions(-) diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 1d5aa73deb..ba081972e7 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -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,88 @@ 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[]} values + * @param {Map, 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; + const result = `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} */ - 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 ?? '', + b.stack ?? '' + ); + } } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 1b5c5da9cc..761b1415b0 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -672,7 +672,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(); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 975de28beb..b3cf80d026 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -17,7 +17,12 @@ export interface SSRContext { export interface HydratableLookupEntry { value: unknown; - root_index: number; + index: number; + /** dev-only */ + promises?: Array>; + /** dev-only */ + serialized?: string; + /** dev-only */ stack?: string; } From ce926c37f907225c393fd75aafcb4de7108a0dbf Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 20 Nov 2025 13:20:05 -0500 Subject: [PATCH 2/2] robustify --- packages/svelte/src/internal/server/hydratable.js | 7 ++++++- packages/svelte/src/internal/server/renderer.js | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index ba081972e7..8bdb6ca15d 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -59,7 +59,12 @@ function encode(key, value, values, unresolved) { serialize_promise.finally(() => unresolved?.delete(serialize_promise)); const index = values.push(serialize_promise) - 1; - const result = `d(${index})`; + + // 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( diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 761b1415b0..601494aa47 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -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 `