From 37f2e0ef485ddd5e163378cc554854133c02b8f0 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 17 Nov 2025 17:21:48 -0700 Subject: [PATCH] feat: capture clobbering better, capture unused keys, don't block on unused keys --- .../98-reference/.generated/server-errors.md | 11 ++- .../.generated/server-warnings.md | 32 +++++++++ .../svelte/messages/server-errors/errors.md | 11 ++- .../messages/server-warnings/warnings.md | 28 ++++++++ .../svelte/src/internal/client/hydratable.js | 7 +- .../svelte/src/internal/client/types.d.ts | 2 + packages/svelte/src/internal/server/errors.js | 19 +++-- .../svelte/src/internal/server/hydratable.js | 22 ++++-- .../svelte/src/internal/server/renderer.js | 71 +++++++++++++++---- .../svelte/src/internal/server/types.d.ts | 1 + .../svelte/src/internal/server/warnings.js | 25 ++++++- .../svelte/tests/runtime-legacy/shared.ts | 4 +- .../_config.js | 13 ---- .../main.svelte | 14 ---- .../samples/hydratable-unused-keys/_config.js | 20 ++++++ .../hydratable-unused-keys/main.svelte | 19 +++++ .../_config.js | 3 +- .../_expected.html | 0 .../hydratable-clobbering-but-ok/main.svelte | 6 ++ .../main.svelte | 6 -- 20 files changed, 247 insertions(+), 67 deletions(-) create mode 100644 documentation/docs/98-reference/.generated/server-warnings.md create mode 100644 packages/svelte/messages/server-warnings/warnings.md delete mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js delete mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/main.svelte rename packages/svelte/tests/server-side-rendering/samples/{hydratable-clobbering-imperative => hydratable-clobbering-but-ok}/_config.js (55%) create mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/main.svelte delete mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index 3162844ec9..95bb5fa7ee 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -17,13 +17,18 @@ The `html` property of server render results has been deprecated. Use `body` ins ### hydratable_clobbering ``` -Attempted to set hydratable with key `%key%` twice. This behavior is undefined. +Attempted to set hydratable with key `%key%` twice with different values. -First set occurred at: +First instance occurred at: %stack% + +Second instance occurred at: +%stack2% ``` -This error occurs when using `hydratable` or `hydratable.set` multiple times with the same key. To avoid this, you can combine `hydratable` with `cache`, or check whether the value has already been set with `hydratable.has`. +This error occurs when using `hydratable` multiple times with the same key. To avoid this, you can: +- Ensure all invocations with the same key result in the same value +- Update the keys to make both instances unique ```svelte + + +

{(await user).name}

+ + {#snippet pending()} +
Loading...
+ {/snippet} +
+``` + +Consider inlining the `hydratable` call inside the boundary so that it's not called on the server. diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index 6463271523..be726b261a 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -10,12 +10,17 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) ## hydratable_clobbering -> Attempted to set hydratable with key `%key%` twice. This behavior is undefined. +> Attempted to set hydratable with key `%key%` twice with different values. > -> First set occurred at: +> First instance occurred at: > %stack% +> +> Second instance occurred at: +> %stack2% -This error occurs when using `hydratable` or `hydratable.set` multiple times with the same key. To avoid this, you can combine `hydratable` with `cache`, or check whether the value has already been set with `hydratable.has`. +This error occurs when using `hydratable` multiple times with the same key. To avoid this, you can: +- Ensure all invocations with the same key result in the same value +- Update the keys to make both instances unique ```svelte + + +

{(await user).name}

+ + {#snippet pending()} +
Loading...
+ {/snippet} +
+``` + +Consider inlining the `hydratable` call inside the boundary so that it's not called on the server. diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 65377eb315..f627ed6b25 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -22,12 +22,15 @@ export function hydratable(key, fn, transport) { } const store = window.__svelte?.h; + const unused_keys = window.__svelte?.uh; if (!store?.has(key)) { - hydratable_missing_but_expected(key); + if (!unused_keys?.has(key)) { + hydratable_missing_but_expected(key); + } return fn(); } - return decode(store.get(key), transport?.decode); + return decode(store?.get(key), transport?.decode); } /** diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 409a2ba317..33b45cf98a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -7,6 +7,8 @@ declare global { __svelte?: { /** hydratables */ h?: Map; + /** unused hydratable keys */ + uh?: Set; }; } } diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index 7bde48f5e4..480862b3c5 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -27,19 +27,26 @@ export function html_deprecated() { } /** - * Attempted to set hydratable with key `%key%` twice. This behavior is undefined. + * Attempted to set hydratable with key `%key%` twice with different values. * - * First set occurred at: + * First instance occurred at: * %stack% + * + * Second instance occurred at: + * %stack2% * @param {string} key * @param {string} stack + * @param {string} stack2 * @returns {never} */ -export function hydratable_clobbering(key, stack) { - const error = new Error(`hydratable_clobbering\nAttempted to set hydratable with key \`${key}\` twice. This behavior is undefined. +export function hydratable_clobbering(key, stack, stack2) { + const error = new Error(`hydratable_clobbering\nAttempted to set hydratable with key \`${key}\` twice with different values. + +First instance occurred at: +${stack} -First set occurred at: -${stack}\nhttps://svelte.dev/e/hydratable_clobbering`); +Second instance occurred at: +${stack2}\nhttps://svelte.dev/e/hydratable_clobbering`); error.name = 'Svelte error'; diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index b896b66570..3b20ba4886 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -5,6 +5,9 @@ import { get_render_context } from './render-context.js'; import * as e from './errors.js'; import { DEV } from 'esm-env'; +/** @type {WeakSet} */ +export const unresolved_hydratables = new WeakSet(); + /** * @template T * @param {string} key @@ -19,11 +22,12 @@ export function hydratable(key, fn, transport) { const store = get_render_context(); - if (store.hydratables.has(key)) { - e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown'); - } - 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; + } store.hydratables.set(key, entry); return entry.value; } @@ -42,6 +46,16 @@ function create_entry(value, encode) { 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)); + } } return entry; diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 8df221522f..797c0a6715 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -5,10 +5,13 @@ import { async_mode_flag } from '../flags/index.js'; import { abort } from './abort-signal.js'; import { pop, push, set_ssr_context, ssr_context, save } from './context.js'; import * as e from './errors.js'; +import * as w from './warnings.js'; 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 */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ @@ -575,17 +578,38 @@ export class Renderer { async #collect_hydratables() { const map = get_render_context().hydratables; - /** @type {(value: unknown) => string} */ /** @type {[string, string][]} */ let entries = []; + /** @type {string[]} */ + let unused_keys = []; for (const [k, v] of map) { const encode = v.encode ?? uneval; - // sequential await is okay here -- all the work is already kicked off - entries.push([k, encode(await v.value)]); + 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) return null; - return Renderer.#hydratable_block(entries); + if (entries.length === 0 && unused_keys.length === 0) return null; + return Renderer.#hydratable_block(entries, unused_keys); } /** @@ -642,23 +666,46 @@ export class Renderer { }; } - /** @param {[string, string][]} serialized */ - static #hydratable_block(serialized) { + /** + * @param {[string, string][]} serialized_entries + * @param {string[]} unused_keys + */ + static #hydratable_block(serialized_entries, unused_keys) { let entries = []; - for (const [k, v] of serialized) { + for (const [k, v] of serialized_entries) { entries.push(`[${JSON.stringify(k)},${v}]`); } // TODO csp -- have discussed but not implemented return ` `; } + + /** @param {[string, string][]} serialized_entries */ + static #used_hydratables(serialized_entries) { + let entries = []; + for (const [k, v] of serialized_entries) { + entries.push(`[${JSON.stringify(k)},${v}]`); + } + return ` + const store = sv.h ??= new Map(); + for (const [k,v] of [${entries.join(',')}]) { + store.set(k, v); + }`; + } + + /** @param {string[]} unused_keys */ + static #unused_hydratables(unused_keys) { + if (unused_keys.length === 0) return ''; + return ` + const unused = sv.uh ??= new Set(); + for (const k of ${JSON.stringify(unused_keys)}) { + unused.add(k); + }`; + } } export class SSRState { diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 6b1cf6e1f2..bd0b41268a 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -19,6 +19,7 @@ export interface HydratableEntry { value: unknown; encode: Encode | undefined; stack?: string; + dev_competing_entries?: Omit[]; } export interface RenderContext { diff --git a/packages/svelte/src/internal/server/warnings.js b/packages/svelte/src/internal/server/warnings.js index d4ee7a86c2..5dd1bbcf14 100644 --- a/packages/svelte/src/internal/server/warnings.js +++ b/packages/svelte/src/internal/server/warnings.js @@ -3,4 +3,27 @@ import { DEV } from 'esm-env'; var bold = 'font-weight: bold'; -var normal = 'font-weight: normal'; \ No newline at end of file +var normal = 'font-weight: normal'; + +/** + * A `hydratable` value with key `%key%` was created, but not used during the render. + * + * Stack: + * %stack% + * @param {string} key + * @param {string} stack + */ +export function unused_hydratable(key, stack) { + if (DEV) { + console.warn( + `%c[svelte] unused_hydratable\n%cA \`hydratable\` value with key \`${key}\` was created, but not used during the render. + +Stack: +${stack}\nhttps://svelte.dev/e/unused_hydratable`, + bold, + normal + ); + } else { + console.warn(`https://svelte.dev/e/unused_hydratable`); + } +} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 11b3e7bfa4..b2d33cf922 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -106,6 +106,7 @@ declare global { var __svelte: | { h?: Map; + uh?: Set; } | undefined; } @@ -125,6 +126,7 @@ beforeAll(() => { beforeEach(() => { delete globalThis?.__svelte?.h; + delete globalThis?.__svelte?.uh; }); afterAll(() => { @@ -418,7 +420,7 @@ async function run_test_variant( const run_hydratables_init = () => { if (variant !== 'hydrate') return; const script = [...document.head.querySelectorAll('script').values()].find((script) => - script.textContent?.includes('(window.__svelte ??= {}).h') + script.textContent?.includes('const sv = window.__svelte ??= {}') )?.textContent; if (!script) return; (0, eval)(script); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js deleted file mode 100644 index 3990b65087..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js +++ /dev/null @@ -1,13 +0,0 @@ -import { test } from '../../test'; - -export default test({ - skip_no_async: true, - mode: ['async-server', 'hydrate'], - - server_props: { environment: 'server' }, - ssrHtml: '

The current environment is: server

', - - props: { environment: 'browser' }, - - runtime_error: 'hydratable_missing_but_expected_e' -}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte deleted file mode 100644 index 4784dd13b2..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -

The current environment is: {value}

diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/_config.js new file mode 100644 index 0000000000..2b0239893c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + skip_no_async: true, + mode: ['async-server', 'hydrate'], + + server_props: { environment: 'server' }, + ssrHtml: '
Loading...
', + + async test({ assert, target }) { + // let it hydrate and resolve the promise on the client + await tick(); + + assert.htmlEqual( + target.innerHTML, + '
did you ever hear the tragedy of darth plagueis the wise?
' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/main.svelte new file mode 100644 index 0000000000..4ab4801ddf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/main.svelte @@ -0,0 +1,19 @@ + + + +
{await unresolved_hydratable}
+ {#snippet pending()} +
Loading...
+ {/snippet} +
diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/_config.js similarity index 55% rename from packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js rename to packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/_config.js index 404260cc66..05de37a8bd 100644 --- a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/_config.js @@ -1,6 +1,5 @@ import { test } from '../../test'; export default test({ - mode: ['async'], - error: 'hydratable_clobbering' + mode: ['async'] }); diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/_expected.html b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/_expected.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/main.svelte b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/main.svelte new file mode 100644 index 0000000000..87a31a8359 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-but-ok/main.svelte @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte deleted file mode 100644 index 25a1166f83..0000000000 --- a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte +++ /dev/null @@ -1,6 +0,0 @@ - \ No newline at end of file