From 9a424cd784d52891201fb8649c40e4afbad2dc63 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Nov 2025 15:23:36 -0700 Subject: [PATCH] add errors --- .../98-reference/.generated/client-errors.md | 6 -- .../.generated/client-warnings.md | 19 ++++++ .../98-reference/.generated/server-errors.md | 23 +++++++ .../98-reference/.generated/shared-errors.md | 6 ++ .../svelte/messages/client-errors/errors.md | 4 -- .../messages/client-warnings/warnings.md | 17 +++++ .../svelte/messages/server-errors/errors.md | 21 ++++++ .../svelte/messages/shared-errors/errors.md | 4 ++ packages/svelte/src/internal/client/errors.js | 16 ----- .../svelte/src/internal/client/hydratable.js | 65 ++++++++++++------- .../src/internal/client/reactivity/batch.js | 2 +- .../src/internal/client/reactivity/cache.js | 9 +++ .../src/internal/client/reactivity/fetcher.js | 6 ++ .../internal/client/reactivity/resource.js | 5 ++ .../svelte/src/internal/client/types.d.ts | 2 +- .../svelte/src/internal/client/warnings.js | 12 ++++ packages/svelte/src/internal/server/errors.js | 20 ++++++ .../svelte/src/internal/server/hydratable.js | 54 ++++++++++----- .../src/internal/server/reactivity/cache.js | 12 +++- .../src/internal/server/reactivity/fetcher.js | 5 ++ .../internal/server/reactivity/resource.js | 5 ++ .../svelte/src/internal/server/types.d.ts | 14 ++-- packages/svelte/src/internal/shared/errors.js | 17 +++++ 23 files changed, 266 insertions(+), 78 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 3f1cb8f76b..789e012386 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -130,12 +130,6 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. -### experimental_async_fork - -``` -Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` -``` - ### flush_sync_in_effect ``` diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index c95ace2229..4deb338521 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -140,6 +140,25 @@ 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 + +``` +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 + +``` + ### hydration_attribute_changed ``` diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index 6263032212..c60d43f430 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -14,6 +14,29 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) The `html` property of server render results has been deprecated. Use `body` instead. ``` +### hydratable_clobbering + +``` +Attempted to set hydratable with key `%key%` twice. This behavior is undefined. + +First set occurred at: +%stack% +``` + +This error occurs when using `hydratable` or `setHydratableValue` 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 `hasHydratableValue`. + +```svelte + +``` + ### lifecycle_function_unavailable ``` diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 07e13dea45..136b3f4957 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -1,5 +1,11 @@ +### experimental_async_required + +``` +Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` +``` + ### invalid_default_snippet ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index ae7d811b2e..20442bd2bc 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -100,10 +100,6 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. -## experimental_async_fork - -> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` - ## flush_sync_in_effect > Cannot use `flushSync` inside an effect diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 9763c8df1a..b51fc6b53c 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -124,6 +124,23 @@ 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 + +> 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 + +``` + ## hydration_attribute_changed > The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index 49d2a310f6..4f2491a25a 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -8,6 +8,27 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) > The `html` property of server render results has been deprecated. Use `body` instead. +## hydratable_clobbering + +> Attempted to set hydratable with key `%key%` twice. This behavior is undefined. +> +> First set occurred at: +> %stack% + +This error occurs when using `hydratable` or `setHydratableValue` 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 `hasHydratableValue`. + +```svelte + +``` + ## lifecycle_function_unavailable > `%name%(...)` is not available on the server diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index e3959034a3..bf053283e4 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -1,3 +1,7 @@ +## experimental_async_required + +> Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` + ## invalid_default_snippet > Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8a5fde4f3b..8d0e47a94d 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -229,22 +229,6 @@ export function effect_update_depth_exceeded() { } } -/** - * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` - * @returns {never} - */ -export function experimental_async_fork() { - if (DEV) { - const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`); - - error.name = 'Svelte error'; - - throw error; - } else { - throw new Error(`https://svelte.dev/e/experimental_async_fork`); - } -} - /** * Cannot use `flushSync` inside an effect * @returns {never} diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index f513989091..4045eb94ce 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -1,5 +1,8 @@ /** @import { Decode, Transport } from '#shared' */ +import { async_mode_flag } from '../flags/index.js'; import { hydrating } from './dom/hydration.js'; +import * as w from './warnings.js'; +import * as e from './errors.js'; /** * @template T @@ -9,17 +12,20 @@ import { hydrating } from './dom/hydration.js'; * @returns {T} */ export function hydratable(key, fn, options) { - if (!hydrating) { - return fn(); - } - var store = window.__svelte?.h; - const val = store?.get(key); - if (val === undefined) { - // TODO this should really be an error or at least a warning because it would be disastrous to expect - // something to be synchronously hydratable and then have it not be - return fn(); + if (!async_mode_flag) { + e.experimental_async_required('hydratable'); } - return decode(val, options?.decode); + + return access_hydratable_store( + key, + (val, has) => { + if (!has) { + w.hydratable_missing_but_expected(key); + } + return decode(val, options?.decode); + }, + fn + ); } /** @@ -29,18 +35,15 @@ export function hydratable(key, fn, options) { * @returns {T | undefined} */ export function get_hydratable_value(key, options = {}) { - // TODO probably can DRY this out with the above - if (!hydrating) { - return undefined; - } - - var store = window.__svelte?.h; - const val = store?.get(key); - if (val === undefined) { - return undefined; + if (!async_mode_flag) { + e.experimental_async_required('getHydratableValue'); } - return decode(val, options.decode); + return access_hydratable_store( + key, + (val) => decode(val, options.decode), + () => undefined + ); } /** @@ -48,11 +51,29 @@ export function get_hydratable_value(key, options = {}) { * @returns {boolean} */ export function has_hydratable_value(key) { + if (!async_mode_flag) { + e.experimental_async_required('hasHydratableValue'); + } + return access_hydratable_store( + key, + (_, has) => has, + () => false + ); +} + +/** + * @template T + * @param {string} key + * @param {(val: unknown, has: boolean) => T} on_hydrating + * @param {() => T} on_not_hydrating + * @returns {T} + */ +function access_hydratable_store(key, on_hydrating, on_not_hydrating) { if (!hydrating) { - return false; + return on_not_hydrating(); } var store = window.__svelte?.h; - return store?.has(key) ?? false; + return on_hydrating(store?.get(key), store?.has(key) ?? false); } /** diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 57aa185a31..68d620c546 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -895,7 +895,7 @@ export function eager(fn) { */ export function fork(fn) { if (!async_mode_flag) { - e.experimental_async_fork(); + e.experimental_async_required('fork'); } if (current_batch !== null) { diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index ef4844fb6c..ee2283dea3 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,7 +1,9 @@ /** @import { CacheEntry } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; import { BaseCacheObserver } from '../../shared/cache-observer.js'; import { tick } from '../runtime.js'; import { get_effect_validation_error_code, render_effect } from './effects.js'; +import * as e from '../errors.js'; /** @typedef {{ count: number, item: any }} Entry */ /** @type {Map} */ @@ -14,6 +16,10 @@ const client_cache = new Map(); * @returns {ReturnType} */ export function cache(key, fn) { + if (!async_mode_flag) { + e.experimental_async_required('cache'); + } + const cached = client_cache.has(key); const entry = client_cache.get(key); const maybe_remove = create_remover(key); @@ -70,6 +76,9 @@ function create_remover(key) { */ export class CacheObserver extends BaseCacheObserver { constructor(prefix = '') { + if (!async_mode_flag) { + e.experimental_async_required('CacheObserver'); + } super(() => client_cache, prefix); } } diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js index 49cecaf6d6..6d447ef19d 100644 --- a/packages/svelte/src/internal/client/reactivity/fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -3,6 +3,8 @@ import { cache } from './cache'; import { fetch_json } from '../../shared/utils.js'; import { hydratable } from '../hydratable'; import { resource } from './resource'; +import { async_mode_flag } from '../../flags'; +import * as e from '../errors.js'; /** * @template TReturn @@ -11,6 +13,10 @@ import { resource } from './resource'; * @returns {Resource} */ export function fetcher(url, init) { + if (!async_mode_flag) { + e.experimental_async_required('fetcher'); + } + const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); } diff --git a/packages/svelte/src/internal/client/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js index 8aad0b23d8..7f3780e680 100644 --- a/packages/svelte/src/internal/client/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -2,6 +2,8 @@ /** @import { Resource as ResourceType } from '#shared' */ import { state, derived, set, get, tick } from '../index.js'; import { deferred } from '../../shared/utils.js'; +import { async_mode_flag } from '../../flags/index.js'; +import * as e from '../errors.js'; /** * @template T @@ -9,6 +11,9 @@ import { deferred } from '../../shared/utils.js'; * @returns {ResourceType} */ export function resource(fn) { + if (!async_mode_flag) { + e.experimental_async_required('resource'); + } return /** @type {ResourceType} */ (new Resource(fn)); } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index e07a5ee798..409a2ba317 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; + h?: Map; }; } } diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index 1081ef5861..a9a50c57d6 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -87,6 +87,18 @@ export function event_handler_invalid(handler, suggestion) { } } +/** + * Expected to find a hydratable with key `%key%` during hydration, but did not. + * @param {string} key + */ +export function hydratable_missing_but_expected(key) { + if (DEV) { + 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`); + } +} + /** * The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value * @param {string} attribute diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index bde49fe935..d229b44ec0 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -26,6 +26,26 @@ export function html_deprecated() { throw error; } +/** + * Attempted to set hydratable with key `%key%` twice. This behavior is undefined. + * + * First set occurred at: + * %stack% + * @param {string} key + * @param {string} stack + * @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. + +First set occurred at: +${stack}\nhttps://svelte.dev/e/hydratable_clobbering`); + + error.name = 'Svelte error'; + + throw error; +} + /** * `%name%(...)` is not available on the server * @param {string} name diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 1f3cbee3a8..1b70353f40 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,14 +1,10 @@ /** @import { Encode, Transport } from '#shared' */ +/** @import { HydratableEntry } from '#server' */ +import { async_mode_flag } from '../flags/index.js'; import { get_render_context } from './render-context.js'; - -/** @type {string | null} */ -export let hydratable_key = null; - -/** @param {string | null} key */ -export function set_hydratable_key(key) { - hydratable_key = key; -} +import * as e from './errors.js'; +import { DEV } from 'esm-env'; /** * @template T @@ -18,16 +14,19 @@ export function set_hydratable_key(key) { * @returns {T} */ export function hydratable(key, fn, options) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable'); + } + const store = get_render_context(); if (store.hydratables.has(key)) { - // TODO error - throw new Error("can't have two hydratables with the same key"); + e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown'); } - const result = fn(); - store.hydratables.set(key, { value: result, encode: options?.encode }); - return result; + const entry = create_entry(fn(), options?.encode); + store.hydratables.set(key, entry); + return entry.value; } /** * @template T @@ -36,15 +35,34 @@ export function hydratable(key, fn, options) { * @param {{ encode?: Encode }} [options] */ export function set_hydratable_value(key, value, options = {}) { + if (!async_mode_flag) { + e.experimental_async_required('setHydratableValue'); + } + const store = get_render_context(); if (store.hydratables.has(key)) { - // TODO error - throw new Error("can't have two hydratables with the same key"); + e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown'); } - store.hydratables.set(key, { + store.hydratables.set(key, create_entry(value, options?.encode)); +} + +/** + * @template T + * @param {T} value + * @param {Encode | undefined} encode + */ +function create_entry(value, encode) { + /** @type {Omit & { value: T }} */ + const entry = { value, - encode: options.encode - }); + encode + }; + + if (DEV) { + entry.stack = new Error().stack; + } + + return entry; } diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index d38be324ec..019a10f655 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,6 +1,7 @@ +import { async_mode_flag } from '../../flags/index.js'; import { BaseCacheObserver } from '../../shared/cache-observer.js'; -import { set_hydratable_key } from '../hydratable.js'; import { get_render_context } from '../render-context.js'; +import * as e from '../errors.js'; /** * @template {(...args: any[]) => any} TFn @@ -9,14 +10,16 @@ import { get_render_context } from '../render-context.js'; * @returns {ReturnType} */ export function cache(key, fn) { + if (!async_mode_flag) { + e.experimental_async_required('cache'); + } + const cache = get_render_context().cache; const entry = cache.get(key); if (entry) { return /** @type {ReturnType} */ (entry); } - set_hydratable_key(key); const new_entry = fn(); - set_hydratable_key(null); cache.set(key, new_entry); return new_entry; } @@ -27,6 +30,9 @@ export function cache(key, fn) { */ export class CacheObserver extends BaseCacheObserver { constructor(prefix = '') { + if (!async_mode_flag) { + e.experimental_async_required('CacheObserver'); + } super(() => get_render_context().cache, prefix); } } diff --git a/packages/svelte/src/internal/server/reactivity/fetcher.js b/packages/svelte/src/internal/server/reactivity/fetcher.js index 5db1db00e7..ee674a8faa 100644 --- a/packages/svelte/src/internal/server/reactivity/fetcher.js +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -1,8 +1,10 @@ /** @import { GetRequestInit, Resource } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; import { fetch_json } from '../../shared/utils.js'; import { hydratable } from '../hydratable.js'; import { cache } from './cache'; import { resource } from './resource.js'; +import * as e from '../errors.js'; /** * @template TReturn @@ -11,6 +13,9 @@ import { resource } from './resource.js'; * @returns {Resource} */ export function fetcher(url, init) { + if (!async_mode_flag) { + e.experimental_async_required('fetcher'); + } const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); } diff --git a/packages/svelte/src/internal/server/reactivity/resource.js b/packages/svelte/src/internal/server/reactivity/resource.js index 57c651152f..d354c76359 100644 --- a/packages/svelte/src/internal/server/reactivity/resource.js +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -1,4 +1,6 @@ /** @import { Resource as ResourceType } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; +import * as e from '../errors.js'; /** * @template T @@ -6,6 +8,9 @@ * @returns {ResourceType} */ export function resource(fn) { + if (!async_mode_flag) { + e.experimental_async_required('resource'); + } return /** @type {ResourceType} */ (new Resource(fn)); } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 2cdf2df277..6a27af22a6 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -15,14 +15,14 @@ export interface SSRContext { element?: Element; } +export interface HydratableEntry { + value: unknown; + encode: Encode | undefined; + stack?: string; +} + export interface RenderContext { - hydratables: Map< - string, - { - value: unknown; - encode: Encode | undefined; - } - >; + hydratables: Map; cache: Map; } diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index 669cdd96a7..b13a65b598 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -2,6 +2,23 @@ import { DEV } from 'esm-env'; +/** + * Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` + * @param {string} name + * @returns {never} + */ +export function experimental_async_required(name) { + if (DEV) { + const error = new Error(`experimental_async_required\nCannot use \`${name}(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_required`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/experimental_async_required`); + } +} + /** * Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead * @returns {never}