diff --git a/documentation/docs/98-reference/.generated/server-warnings.md b/documentation/docs/98-reference/.generated/server-warnings.md index 70896e1694..c01f1bfd6e 100644 --- a/documentation/docs/98-reference/.generated/server-warnings.md +++ b/documentation/docs/98-reference/.generated/server-warnings.md @@ -1,12 +1,15 @@ -### unused_hydratable +### unresolved_hydratable ``` -A `hydratable` value with key `%key%` was created, but not used during the render. +A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render. -Stack: +The `hydratable` was initialized in: %stack% + +The unresolved data is: +%unresolved_data% ``` The most likely cause of this is creating a `hydratable` in the `script` block of your component and then `await`ing @@ -30,3 +33,5 @@ the result inside a `svelte:boundary` with a `pending` snippet: ``` Consider inlining the `hydratable` call inside the boundary so that it's not called on the server. + +Note that this can also happen when a `hydratable` contains multiple promises and some but not all of them have been used. diff --git a/packages/svelte/messages/server-warnings/warnings.md b/packages/svelte/messages/server-warnings/warnings.md index 461595ebe9..ec4e61fe46 100644 --- a/packages/svelte/messages/server-warnings/warnings.md +++ b/packages/svelte/messages/server-warnings/warnings.md @@ -1,9 +1,12 @@ -## unused_hydratable +## unresolved_hydratable -> A `hydratable` value with key `%key%` was created, but not used during the render. +> A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render. > -> Stack: +> The `hydratable` was initialized in: > %stack% +> +> The unresolved data is: +> %unresolved_data% The most likely cause of this is creating a `hydratable` in the `script` block of your component and then `await`ing the result inside a `svelte:boundary` with a `pending` snippet: @@ -26,3 +29,5 @@ the result inside a `svelte:boundary` with a `pending` snippet: ``` Consider inlining the `hydratable` call inside the boundary so that it's not called on the server. + +Note that this can also happen when a `hydratable` contains multiple promises and some but not all of them have been used. diff --git a/packages/svelte/src/internal/client/dev/tracing.js b/packages/svelte/src/internal/client/dev/tracing.js index 4688637f5d..bd11138f66 100644 --- a/packages/svelte/src/internal/client/dev/tracing.js +++ b/packages/svelte/src/internal/client/dev/tracing.js @@ -5,6 +5,7 @@ import { define_property } from '../../shared/utils.js'; import { DERIVED, ASYNC, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { effect_tracking } from '../reactivity/effects.js'; import { active_reaction, untrack } from '../runtime.js'; +import { get_infinite_stack } from '../../shared/dev.js'; /** * @typedef {{ @@ -134,55 +135,37 @@ export function trace(label, fn) { * @returns {Error & { stack: string } | null} */ export function get_stack(label) { - // @ts-ignore stackTraceLimit doesn't exist everywhere - const limit = Error.stackTraceLimit; + return get_infinite_stack(label, (stack) => { + if (!stack) return; - // @ts-ignore - Error.stackTraceLimit = Infinity; - let error = Error(); + const lines = stack.split('\n'); + const new_lines = ['\n']; - // @ts-ignore - Error.stackTraceLimit = limit; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const posixified = line.replaceAll('\\', '/'); - const stack = error.stack; + if (line === 'Error') { + continue; + } - if (!stack) return null; + if (line.includes('validate_each_keys')) { + return undefined; + } - const lines = stack.split('\n'); - const new_lines = ['\n']; + if (posixified.includes('svelte/src/internal') || posixified.includes('node_modules/.vite')) { + continue; + } - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const posixified = line.replaceAll('\\', '/'); - - if (line === 'Error') { - continue; - } - - if (line.includes('validate_each_keys')) { - return null; + new_lines.push(line); } - if (posixified.includes('svelte/src/internal') || posixified.includes('node_modules/.vite')) { - continue; + if (new_lines.length === 1) { + return undefined; } - new_lines.push(line); - } - - if (new_lines.length === 1) { - return null; - } - - define_property(error, 'stack', { - value: new_lines.join('\n') + return new_lines.join('\n'); }); - - define_property(error, 'name', { - value: label - }); - - return /** @type {Error & { stack: string }} */ (error); } /** diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index f5e1ed689b..2c88f453e2 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -29,8 +29,7 @@ export function hydratable(key, fn) { return fn(); } - const val = /** @type {() => T} */ (store.get(key)); - return val; + return /** @type {T} */ (store.get(key)); } /** @param {string} key */ diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 42db18fd1a..33b45cf98a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -6,7 +6,7 @@ declare global { interface Window { __svelte?: { /** hydratables */ - h?: Map unknown>; + h?: Map; /** unused hydratable keys */ uh?: Set; }; diff --git a/packages/svelte/src/internal/server/dev.js b/packages/svelte/src/internal/server/dev.js index 1211670f94..60c7bd9f46 100644 --- a/packages/svelte/src/internal/server/dev.js +++ b/packages/svelte/src/internal/server/dev.js @@ -4,6 +4,7 @@ import { is_tag_valid_with_ancestor, is_tag_valid_with_parent } from '../../html-tree-validation.js'; +import { get_infinite_stack } from '../shared/dev.js'; import { set_ssr_context, ssr_context } from './context.js'; import * as e from './errors.js'; import { Renderer } from './renderer.js'; @@ -98,3 +99,36 @@ export function validate_snippet_args(renderer) { e.invalid_snippet_arguments(); } } + +/** + * @param {string} label + * @returns {Error & { stack: string } | null} + */ +export function get_stack(label) { + return get_infinite_stack(label, (stack) => { + if (!stack) return; + + const lines = stack.split('\n'); + const new_lines = []; + + for (const line of lines) { + const posixified = line.replaceAll('\\', '/'); + + if (line.startsWith('Error:')) { + continue; + } + + if (posixified.includes('svelte/src/internal') || posixified.includes('node_modules/.vite')) { + continue; + } + + new_lines.push(line); + } + + if (new_lines.length === 1) { + return undefined; + } + + return new_lines.join('\n'); + }); +} diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index a40f551619..1d5aa73deb 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,8 +1,10 @@ -/** @import { HydratableContext } from '#server' */ +/** @import { HydratableContext, HydratableLookupEntry } 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 { uneval } from 'devalue'; +import { get_stack } from './dev.js'; +import { DEV } from 'esm-env'; /** * @template T @@ -17,16 +19,23 @@ export function hydratable(key, fn) { const store = get_render_context(); - const entry = store.hydratable.lookup.get(key); - if (entry !== undefined) { - return /** @type {T} */ (entry.value); + const existing_entry = store.hydratable.lookup.get(key); + if (existing_entry !== undefined) { + return /** @type {T} */ (existing_entry.value); } const result = fn(); - store.hydratable.lookup.set(key, { + /** @type {HydratableLookupEntry} */ + const entry = { value: result, root_index: encode(result, key, store.hydratable) - }); + }; + + if (DEV) { + entry.stack = get_stack(`hydratable"`)?.stack; + } + + store.hydratable.lookup.set(key, entry); return result; } @@ -50,15 +59,17 @@ function encode(value, key, hydratable_context) { function create_replacer(key, hydratable_context) { /** * @param {unknown} value - * @param {(value: any) => string} inner_uneval */ - const replacer = (value, inner_uneval) => { + const replacer = (value) => { 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; + /** @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})`; } }; diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index e701c069e8..1b5c5da9cc 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -8,9 +8,9 @@ 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 { DEV } from 'esm-env'; +import { get_stack } from './dev.js'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ @@ -578,32 +578,16 @@ export class Renderer { async #collect_hydratables() { const ctx = get_render_context().hydratable; - // for (const [k, v] of ctx.lookup) { - // // TODO - root-level - // // if (ctx.unresolved_promises.has(/** @type {Promise} */ (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' - // // ); - // // } - // // } - // // } - // } + for (const [promise, key] of ctx.unresolved_promises) { + // this is a problem -- it means we've finished the render but we're still waiting on a promise to resolve so we can + // serialize it, so we're blocking the response on useless content. + w.unresolved_hydratable( + key, + DEV ? ctx.lookup.get(key)?.stack ?? 'unavailable' : 'unavailable in production builds', + await promise + ); + } + return await Renderer.#hydratable_block(ctx, []); } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 73b2c45182..975de28beb 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -18,12 +18,13 @@ export interface SSRContext { export interface HydratableLookupEntry { value: unknown; root_index: number; + stack?: string; } export interface HydratableContext { lookup: Map; values: MaybePromise[]; - unresolved_promises: Map, string>; + unresolved_promises: Map, string>; } export interface RenderContext { diff --git a/packages/svelte/src/internal/server/warnings.js b/packages/svelte/src/internal/server/warnings.js index 5dd1bbcf14..2f9ec3da49 100644 --- a/packages/svelte/src/internal/server/warnings.js +++ b/packages/svelte/src/internal/server/warnings.js @@ -6,24 +6,31 @@ var bold = 'font-weight: bold'; var normal = 'font-weight: normal'; /** - * A `hydratable` value with key `%key%` was created, but not used during the render. + * A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render. * - * Stack: + * The `hydratable` was initialized in: * %stack% + * + * The unresolved data is: + * %unresolved_data% * @param {string} key * @param {string} stack + * @param {string} unresolved_data */ -export function unused_hydratable(key, stack) { +export function unresolved_hydratable(key, stack, unresolved_data) { if (DEV) { console.warn( - `%c[svelte] unused_hydratable\n%cA \`hydratable\` value with key \`${key}\` was created, but not used during the render. + `%c[svelte] unresolved_hydratable\n%cA \`hydratable\` value with key \`${key}\` was created, but at least part of it was not used during the render. + +The \`hydratable\` was initialized in: +${stack} -Stack: -${stack}\nhttps://svelte.dev/e/unused_hydratable`, +The unresolved data is: +${unresolved_data}\nhttps://svelte.dev/e/unresolved_hydratable`, bold, normal ); } else { - console.warn(`https://svelte.dev/e/unused_hydratable`); + console.warn(`https://svelte.dev/e/unresolved_hydratable`); } } \ No newline at end of file diff --git a/packages/svelte/src/internal/shared/dev.js b/packages/svelte/src/internal/shared/dev.js new file mode 100644 index 0000000000..3c7a3165b2 --- /dev/null +++ b/packages/svelte/src/internal/shared/dev.js @@ -0,0 +1,26 @@ +import { define_property } from './utils'; + +/** + * @param {string} label + * @param {(stack: string | undefined) => string | undefined} fn + * @returns {Error & { stack: string } | null} + */ +export function get_infinite_stack(label, fn) { + const limit = Error.stackTraceLimit; + Error.stackTraceLimit = Infinity; + let error = Error(); + Error.stackTraceLimit = limit; + const stack = fn(error.stack); + + if (!stack) return null; + + define_property(error, 'stack', { + value: stack + }); + + define_property(error, 'name', { + value: label + }); + + return /** @type {Error & { stack: string }} */ (error); +} diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 19483f2e1d..c5529747e4 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -86,6 +86,7 @@ export interface RuntimeTest = Record void | Promise; test_ssr?: (args: { logs: any[]; + warnings: any[]; assert: Assert; variant: 'ssr' | 'async-ssr'; }) => void | Promise; @@ -105,7 +106,7 @@ export interface RuntimeTest = Record unknown>; + h?: Map; uh?: Set; } | undefined; @@ -266,7 +267,16 @@ async function run_test_variant( i++; } - if (str.slice(0, i).includes('logs')) { + let ssr_str = config.test_ssr?.toString() ?? ''; + let sn = 0; + let si = 0; + while (si < ssr_str.length) { + if (ssr_str[si] === '(') sn++; + if (ssr_str[si] === ')' && --sn === 0) break; + si++; + } + + if (str.slice(0, i).includes('logs') || ssr_str.slice(0, si).includes('logs')) { // eslint-disable-next-line no-console console.log = (...args) => { logs.push(...args); @@ -277,7 +287,11 @@ async function run_test_variant( manual_hydrate = true; } - if (str.slice(0, i).includes('warnings') || config.warnings) { + if ( + str.slice(0, i).includes('warnings') || + config.warnings || + ssr_str.slice(0, si).includes('warnings') + ) { // eslint-disable-next-line no-console console.warn = (...args) => { if (typeof args[0] === 'string' && args[0].startsWith('%c[svelte]')) { @@ -397,6 +411,7 @@ async function run_test_variant( if (config.test_ssr) { await config.test_ssr({ logs, + warnings, // @ts-expect-error assert: { ...assert, diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js deleted file mode 100644 index 57904ef576..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { ok, test } from '../../test'; - -export default test({ - skip_no_async: true, - skip_mode: ['server'], - - server_props: { environment: 'server' }, - ssrHtml: '

The current environment is: server

', - - props: { environment: 'browser' }, - - async test({ assert, target, variant }) { - const p = target.querySelector('p'); - ok(p); - if (variant === 'hydrate') { - assert.htmlEqual(p.outerHTML, '

The current environment is: server

'); - } else { - assert.htmlEqual(p.outerHTML, '

The current environment is: browser

'); - } - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte deleted file mode 100644 index 18b7f83467..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -

The current environment is: {value}

diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js index b04d81d639..3349cbcb66 100644 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js @@ -9,5 +9,5 @@ export default test({ props: { environment: 'browser' }, - runtime_error: 'hydratable_missing_but_expected_e' + runtime_error: 'hydratable_missing_but_required' }); 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 index 2b0239893c..0f4acc600b 100644 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/_config.js @@ -1,5 +1,5 @@ import { tick } from 'svelte'; -import { ok, test } from '../../test'; +import { test } from '../../test'; export default test({ skip_no_async: true, @@ -8,6 +8,12 @@ export default test({ server_props: { environment: 'server' }, ssrHtml: '
Loading...
', + test_ssr({ assert, warnings }) { + assert.strictEqual(warnings.length, 1); + // for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it + assert.include(warnings[0], 'A `hydratable` value with key `unused_key`'); + }, + async test({ assert, target }) { // let it hydrate and resolve the promise on the client await tick(); 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 index 4ab4801ddf..67848e7f6f 100644 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-unused-keys/main.svelte @@ -6,7 +6,7 @@ const unresolved_hydratable = hydratable( "unused_key", () => new Promise( - (res) => environment === 'server' ? undefined : res('did you ever hear the tragedy of darth plagueis the wise?') + (res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run') ) );