From 91882ee9dbf84f3b8d9d71982f43a7d62d9aa261 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 13 Nov 2025 15:59:55 -0700 Subject: [PATCH 01/11] feat: --- .../98-reference/.generated/client-errors.md | 29 ++++- .../.generated/client-warnings.md | 17 +++ .../98-reference/.generated/server-errors.md | 35 ++++++ .../98-reference/.generated/shared-errors.md | 6 + .../svelte/messages/client-errors/errors.md | 23 +++- .../messages/client-warnings/warnings.md | 15 +++ .../svelte/messages/server-errors/errors.md | 29 +++++ .../svelte/messages/shared-errors/errors.md | 4 + packages/svelte/package.json | 1 + packages/svelte/src/index-client.js | 1 + packages/svelte/src/index-server.js | 2 + packages/svelte/src/internal/client/errors.js | 42 +++++-- .../svelte/src/internal/client/hydratable.js | 112 ++++++++++++++++++ .../src/internal/client/reactivity/batch.js | 2 +- .../svelte/src/internal/client/types.d.ts | 9 ++ .../svelte/src/internal/client/warnings.js | 24 ++++ .../svelte/src/internal/server/context.js | 5 +- packages/svelte/src/internal/server/errors.js | 48 ++++++++ .../svelte/src/internal/server/hydratable.js | 89 ++++++++++++++ .../src/internal/server/render-context.js | 93 +++++++++++++++ .../svelte/src/internal/server/renderer.js | 103 +++++++++++++--- .../src/internal/server/renderer.test.ts | 39 +++++- .../svelte/src/internal/server/types.d.ts | 12 ++ packages/svelte/src/internal/shared/errors.js | 17 +++ .../svelte/src/internal/shared/types.d.ts | 42 +++++++ packages/svelte/src/internal/shared/utils.js | 2 +- .../samples/hydratables/_config.js | 19 +++ .../samples/hydratables/main.svelte | 9 ++ packages/svelte/types/index.d.ts | 22 ++++ pnpm-lock.yaml | 8 ++ 30 files changed, 821 insertions(+), 38 deletions(-) create mode 100644 packages/svelte/src/internal/client/hydratable.js create mode 100644 packages/svelte/src/internal/server/hydratable.js create mode 100644 packages/svelte/src/internal/server/render-context.js create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratables/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratables/main.svelte diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 3f1cb8f76bab..4de99988e91a 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 ``` @@ -146,6 +140,12 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +### fn_unavailable_on_client + +``` +`%name%`(...) is unavailable in the browser. +``` + ### fork_discarded ``` @@ -164,6 +164,23 @@ Cannot create a fork inside an effect or when state changes are pending `getAbortSignal()` can only be called inside an effect or derived ``` +### hydratable_missing_but_expected_e + +``` +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_failed ``` diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index c95ace22293d..d7de5998facf 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -140,6 +140,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_w + +``` +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 626303221248..0630e378f113 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -8,12 +8,40 @@ Encountered asynchronous work while rendering synchronously. You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +### fn_unavailable_on_server + +``` +`%name%`(...) is unavailable on the server. +``` + ### html_deprecated ``` 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 `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`. + +```svelte + +``` + ### lifecycle_function_unavailable ``` @@ -21,3 +49,10 @@ The `html` property of server render results has been deprecated. Use `body` ins ``` Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render. + +### render_context_unavailable + +``` +Failed to retrieve `render` context. %addendum% +If `AsyncLocalStorage` is available, you're likely calling a function that needs access to the `render` context (`hydratable`, `cache`, or something that depends on these) from outside of `render`. If `AsyncLocalStorage` is not available, these functions must also be called synchronously from within `render` -- i.e. not after any `await`s. +``` diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 07e13dea459b..136b3f4957d6 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 ae7d811b2e08..36d9caf4d41d 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 @@ -112,6 +108,10 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +## fn_unavailable_on_client + +> `%name%`(...) is unavailable in the browser. + ## fork_discarded > Cannot commit a fork that was already discarded @@ -124,6 +124,21 @@ This restriction only applies when using the `experimental.async` option, which > `getAbortSignal()` can only be called inside an effect or derived +## hydratable_missing_but_expected_e + +> 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_failed > Failed to hydrate the application diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 9763c8df1ab8..f5e8f18005e1 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -124,6 +124,21 @@ 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_w + +> 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 49d2a310f601..1b699f3390e1 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -4,12 +4,41 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +## fn_unavailable_on_server + +> `%name%`(...) is unavailable on the server. + ## html_deprecated > 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 `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`. + +```svelte + +``` + ## lifecycle_function_unavailable > `%name%(...)` is not available on the server Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render. + +## render_context_unavailable + +> Failed to retrieve `render` context. %addendum% +If `AsyncLocalStorage` is available, you're likely calling a function that needs access to the `render` context (`hydratable`, `cache`, or something that depends on these) from outside of `render`. If `AsyncLocalStorage` is not available, these functions must also be called synchronously from within `render` -- i.e. not after any `await`s. diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index e3959034a3c3..bf053283e434 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/package.json b/packages/svelte/package.json index 731ae6f00459..a874bedcf3d4 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -174,6 +174,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 4fcfff980dd8..0eb1b8031502 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -249,6 +249,7 @@ export { hasContext, setContext } from './internal/client/context.js'; +export { hydratable } from './internal/client/hydratable.js'; export { hydrate, mount, unmount } from './internal/client/render.js'; export { tick, untrack, settled } from './internal/client/runtime.js'; export { createRawSnippet } from './internal/client/dom/blocks/snippet.js'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 61b0d98c0650..9fb810fd9ebd 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -51,4 +51,6 @@ export { setContext } from './internal/server/context.js'; +export { hydratable } from './internal/server/hydratable.js'; + export { createRawSnippet } from './internal/server/blocks/snippet.js'; diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8a5fde4f3b9a..1b3b4e97725c 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -230,34 +230,35 @@ export function effect_update_depth_exceeded() { } /** - * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + * Cannot use `flushSync` inside an effect * @returns {never} */ -export function experimental_async_fork() { +export function flush_sync_in_effect() { 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`); + const error = new Error(`flush_sync_in_effect\nCannot use \`flushSync\` inside an effect\nhttps://svelte.dev/e/flush_sync_in_effect`); error.name = 'Svelte error'; throw error; } else { - throw new Error(`https://svelte.dev/e/experimental_async_fork`); + throw new Error(`https://svelte.dev/e/flush_sync_in_effect`); } } /** - * Cannot use `flushSync` inside an effect + * `%name%`(...) is unavailable in the browser. + * @param {string} name * @returns {never} */ -export function flush_sync_in_effect() { +export function fn_unavailable_on_client(name) { if (DEV) { - const error = new Error(`flush_sync_in_effect\nCannot use \`flushSync\` inside an effect\nhttps://svelte.dev/e/flush_sync_in_effect`); + const error = new Error(`fn_unavailable_on_client\n\`${name}\`(...) is unavailable in the browser.\nhttps://svelte.dev/e/fn_unavailable_on_client`); error.name = 'Svelte error'; throw error; } else { - throw new Error(`https://svelte.dev/e/flush_sync_in_effect`); + throw new Error(`https://svelte.dev/e/fn_unavailable_on_client`); } } @@ -309,6 +310,31 @@ export function get_abort_signal_outside_reaction() { } } +/** + * 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 + * `; + } } export class SSRState { @@ -673,3 +716,33 @@ export class SSRState { } } } + +export class MemoizedUneval { + /** @type {Map} */ + #cache = new Map(); + + /** + * @param {unknown} value + * @returns {string} + */ + uneval = (value) => { + return uneval(value, (value, uneval) => { + const cached = this.#cache.get(value); + if (cached) { + // this breaks my brain a bit, but: + // - when the entry is defined but its value is `undefined`, calling `uneval` below will cause the custom replacer to be called again + // - because the custom replacer returns this, which is `undefined`, it will fall back to the default serialization + // - ...which causes it to return a string + // - ...which is then added to this cache before being returned + return cached.value; + } + + const stub = {}; + this.#cache.set(value, stub); + + const result = uneval(value); + stub.value = result; + return result; + }); + }; +} diff --git a/packages/svelte/src/internal/server/renderer.test.ts b/packages/svelte/src/internal/server/renderer.test.ts index 8e9a377a5b15..a2a979aeb4d4 100644 --- a/packages/svelte/src/internal/server/renderer.test.ts +++ b/packages/svelte/src/internal/server/renderer.test.ts @@ -1,7 +1,8 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { Renderer, SSRState } from './renderer.js'; +import { MemoizedUneval, Renderer, SSRState } from './renderer.js'; import type { Component } from 'svelte'; import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; +import { uneval } from 'devalue'; test('collects synchronous body content by default', () => { const component = (renderer: Renderer) => { @@ -355,3 +356,39 @@ describe('async', () => { expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']); }); }); + +describe('MemoizedDevalue', () => { + test.each([ + 1, + 'general kenobi', + { foo: 'bar' }, + [1, 2], + null, + undefined, + new Map([[1, '2']]) + ] as const)('has same behavior as unmemoized devalue for %s', (input) => { + expect(new MemoizedUneval().uneval(input)).toBe(uneval(input)); + }); + + test('caches results', () => { + const memoized = new MemoizedUneval(); + let calls = 0; + + const input = { + get only_once() { + calls++; + return 42; + } + }; + + const first = memoized.uneval(input); + const max_calls = calls; + const second = memoized.uneval(input); + memoized.uneval(input); + + expect(first).toBe(second); + // for reasons I don't quite comprehend, it does get called twice, but both calls happen in the first + // serialization, and don't increase afterwards + expect(calls).toBe(max_calls); + }); +}); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 53cefabc69c4..6b1cf6e1f2e0 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,3 +1,4 @@ +import type { Encode } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -14,6 +15,17 @@ export interface SSRContext { element?: Element; } +export interface HydratableEntry { + value: unknown; + encode: Encode | undefined; + stack?: string; +} + +export interface RenderContext { + hydratables: Map; + cache: Map>; +} + export interface SyncRenderOutput { /** HTML that goes into the `` */ head: string; diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index 669cdd96a7f3..b13a65b59865 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} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 4deeb76b2f4b..1bcdd36f979b 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -8,3 +8,45 @@ export type Getters = { }; export type Snapshot = ReturnType>; + +export type MaybePromise = T | Promise; + +export type Decode = (value: any) => T; + +export type Encode = (value: T) => string; + +export type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + +export type Hydratable = { + (key: string, fn: () => T, options?: { transport?: Transport }): T; + get: (key: string, options?: { decode?: Decode }) => T | undefined; + has: (key: string) => boolean; + set: (key: string, value: T, options?: { encode?: Encode }) => void; +}; + +export type Resource = { + then: Promise>['then']; + catch: Promise>['catch']; + finally: Promise>['finally']; + refresh: () => Promise; + set: (value: Awaited) => void; + loading: boolean; + error: any; +} & ( + | { + ready: false; + current: undefined; + } + | { + ready: true; + current: Awaited; + } +); diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 10f8597520d9..659cd10040ac 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -48,7 +48,7 @@ export function run_all(arr) { /** * TODO replace with Promise.withResolvers once supported widely enough - * @template T + * @template [T=void] */ export function deferred() { /** @type {(value: T) => void} */ diff --git a/packages/svelte/tests/runtime-runes/samples/hydratables/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratables/_config.js new file mode 100644 index 000000000000..b7622279e3f0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratables/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + skip_mode: ['server'], + + server_props: { environment: 'server' }, + ssrHtml: '

The current environment is: server

', + + props: { environment: 'browser' }, + html: '

The current environment is: server

', + + async test({ assert, target }) { + await tick(); + const p = target.querySelector('p'); + ok(p); + assert.htmlEqual(p.outerHTML, '

The current environment is: server

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratables/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratables/main.svelte new file mode 100644 index 000000000000..53b9c24f91c1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratables/main.svelte @@ -0,0 +1,9 @@ + + +

The current environment is: {value}

diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5e3ca77eb5cd..fcefe8e309f3 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -450,6 +450,7 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; + export const hydratable: Hydratable; /** * Create a snippet programmatically * */ @@ -595,6 +596,27 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; + type Decode = (value: any) => T; + + type Encode = (value: T) => string; + + type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + + type Hydratable = { + (key: string, fn: () => T, options?: { transport?: Transport }): T; + get: (key: string, options?: { decode?: Decode }) => T | undefined; + has: (key: string) => boolean; + set: (key: string, value: T, options?: { encode?: Encode }) => void; + }; + export {}; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0afaef0ceb2f..0b1f57213d31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + devalue: + specifier: ^5.5.0 + version: 5.5.0 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1515,6 +1518,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + devalue@5.5.0: + resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3990,6 +3996,8 @@ snapshots: detect-libc@1.0.3: optional: true + devalue@5.5.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 From 352ebbe0fad17bdf767e0b25ee0906032fcaec1a Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 13 Nov 2025 16:14:38 -0700 Subject: [PATCH 02/11] doc comments --- .../svelte/src/internal/shared/types.d.ts | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 1bcdd36f979b..8365b529f9e1 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,10 +11,19 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; +/** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */ export type Decode = (value: any) => T; +/** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */ export type Encode = (value: T) => string; +/** + * Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg: + * ```ts + * import { BROWSER } from 'esm-env'; + * const transport: Transport = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction }; + * ``` + */ export type Transport = | { encode: Encode; @@ -25,28 +34,25 @@ export type Transport = decode: Decode; }; +/** Make the result of a function hydratable. This means it will be serialized on the server and available synchronously during hydration on the client. */ export type Hydratable = { - (key: string, fn: () => T, options?: { transport?: Transport }): T; + ( + /** + * A key to identify this hydratable value. Each hydratable value must have a unique key. + * If writing a library that utilizes `hydratable`, prefix your keys with your library name to prevent naming collisions. + */ + key: string, + /** + * A function that returns the value to be hydrated. On the server, this value will be stashed and serialized. + * On the client during hydration, the value will be used synchronously instead of invoking the function. + */ + fn: () => T, + options?: { transport?: Transport } + ): T; + /** Get a hydratable value from the server-rendered store. If used after hydration, will always return `undefined`. Only works on the client. */ get: (key: string, options?: { decode?: Decode }) => T | undefined; + /** Check if a hydratable value exists in the server-rendered store. */ has: (key: string) => boolean; + /** Set a hydratable value. Only works on the server during `render`. */ set: (key: string, value: T, options?: { encode?: Encode }) => void; }; - -export type Resource = { - then: Promise>['then']; - catch: Promise>['catch']; - finally: Promise>['finally']; - refresh: () => Promise; - set: (value: Awaited) => void; - loading: boolean; - error: any; -} & ( - | { - ready: false; - current: undefined; - } - | { - ready: true; - current: Awaited; - } -); From 2d475ac9617b4154d080492cc0504e2243577530 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 13 Nov 2025 16:18:15 -0700 Subject: [PATCH 03/11] types --- packages/svelte/types/index.d.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index fcefe8e309f3..616d209be242 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -596,10 +596,19 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; + /** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */ type Decode = (value: any) => T; + /** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */ type Encode = (value: T) => string; + /** + * Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg: + * ```ts + * import { BROWSER } from 'esm-env'; + * const transport: Transport = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction }; + * ``` + */ type Transport = | { encode: Encode; @@ -610,10 +619,26 @@ declare module 'svelte' { decode: Decode; }; + /** Make the result of a function hydratable. This means it will be serialized on the server and available synchronously during hydration on the client. */ type Hydratable = { - (key: string, fn: () => T, options?: { transport?: Transport }): T; + ( + /** + * A key to identify this hydratable value. Each hydratable value must have a unique key. + * If writing a library that utilizes `hydratable`, prefix your keys with your library name to prevent naming collisions. + */ + key: string, + /** + * A function that returns the value to be hydrated. On the server, this value will be stashed and serialized. + * On the client during hydration, the value will be used synchronously instead of invoking the function. + */ + fn: () => T, + options?: { transport?: Transport } + ): T; + /** Get a hydratable value from the server-rendered store. If used after hydration, will always return `undefined`. Only works on the client. */ get: (key: string, options?: { decode?: Decode }) => T | undefined; + /** Check if a hydratable value exists in the server-rendered store. */ has: (key: string) => boolean; + /** Set a hydratable value. Only works on the server during `render`. */ set: (key: string, value: T, options?: { encode?: Encode }) => void; }; From 2218b1e97fe82d63f754f7383e85656f416cc4cd Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 13 Nov 2025 16:21:09 -0700 Subject: [PATCH 04/11] types --- packages/svelte/src/index.d.ts | 1 + packages/svelte/types/index.d.ts | 98 ++++++++++++++++---------------- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index a1782f5b61a5..42507385d6fb 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -369,3 +369,4 @@ export interface Fork { } export * from './index-client.js'; +export { Hydratable, Transport, Encode, Decode } from '#shared'; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 616d209be242..81700ae95b74 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -450,6 +450,55 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; + type Getters = { + [K in keyof T]: () => T[K]; + }; + + /** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */ + export type Decode = (value: any) => T; + + /** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */ + export type Encode = (value: T) => string; + + /** + * Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg: + * ```ts + * import { BROWSER } from 'esm-env'; + * const transport: Transport = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction }; + * ``` + */ + export type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + + /** Make the result of a function hydratable. This means it will be serialized on the server and available synchronously during hydration on the client. */ + export type Hydratable = { + ( + /** + * A key to identify this hydratable value. Each hydratable value must have a unique key. + * If writing a library that utilizes `hydratable`, prefix your keys with your library name to prevent naming collisions. + */ + key: string, + /** + * A function that returns the value to be hydrated. On the server, this value will be stashed and serialized. + * On the client during hydration, the value will be used synchronously instead of invoking the function. + */ + fn: () => T, + options?: { transport?: Transport } + ): T; + /** Get a hydratable value from the server-rendered store. If used after hydration, will always return `undefined`. Only works on the client. */ + get: (key: string, options?: { decode?: Decode }) => T | undefined; + /** Check if a hydratable value exists in the server-rendered store. */ + has: (key: string) => boolean; + /** Set a hydratable value. Only works on the server during `render`. */ + set: (key: string, value: T, options?: { encode?: Encode }) => void; + }; export const hydratable: Hydratable; /** * Create a snippet programmatically @@ -592,55 +641,6 @@ declare module 'svelte' { * ``` * */ export function untrack(fn: () => T): T; - type Getters = { - [K in keyof T]: () => T[K]; - }; - - /** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */ - type Decode = (value: any) => T; - - /** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */ - type Encode = (value: T) => string; - - /** - * Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg: - * ```ts - * import { BROWSER } from 'esm-env'; - * const transport: Transport = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction }; - * ``` - */ - type Transport = - | { - encode: Encode; - decode?: undefined; - } - | { - encode?: undefined; - decode: Decode; - }; - - /** Make the result of a function hydratable. This means it will be serialized on the server and available synchronously during hydration on the client. */ - type Hydratable = { - ( - /** - * A key to identify this hydratable value. Each hydratable value must have a unique key. - * If writing a library that utilizes `hydratable`, prefix your keys with your library name to prevent naming collisions. - */ - key: string, - /** - * A function that returns the value to be hydrated. On the server, this value will be stashed and serialized. - * On the client during hydration, the value will be used synchronously instead of invoking the function. - */ - fn: () => T, - options?: { transport?: Transport } - ): T; - /** Get a hydratable value from the server-rendered store. If used after hydration, will always return `undefined`. Only works on the client. */ - get: (key: string, options?: { decode?: Decode }) => T | undefined; - /** Check if a hydratable value exists in the server-rendered store. */ - has: (key: string) => boolean; - /** Set a hydratable value. Only works on the server during `render`. */ - set: (key: string, value: T, options?: { encode?: Encode }) => void; - }; export {}; } From d541688374a0b26986503b530768d0ced174a602 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 13 Nov 2025 16:24:45 -0700 Subject: [PATCH 05/11] changeset --- .changeset/big-masks-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/big-masks-shave.md diff --git a/.changeset/big-masks-shave.md b/.changeset/big-masks-shave.md new file mode 100644 index 000000000000..96c18fdb6c73 --- /dev/null +++ b/.changeset/big-masks-shave.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: `hydratable` API From fd92394f5761597e306c6e3c356c8ff5ff240645 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 14 Nov 2025 14:03:57 -0700 Subject: [PATCH 06/11] tests --- .../svelte/tests/runtime-legacy/shared.ts | 13 ++++++++++++ .../_config.js | 11 +++++----- .../hydratable-custom-transport/main.svelte | 15 ++++++++++++++ .../_config.js | 12 +++++++++++ .../main.svelte | 14 +++++++++++++ .../hydratable-error-on-missing/_config.js | 12 +++++++++++ .../hydratable-error-on-missing/main.svelte | 14 +++++++++++++ .../samples/hydratable/_config.js | 20 +++++++++++++++++++ .../{hydratables => hydratable}/main.svelte | 0 .../_config.js | 6 ++++++ .../main.svelte | 6 ++++++ .../samples/hydratable-clobbering/_config.js | 6 ++++++ .../samples/hydratable-clobbering/main.svelte | 6 ++++++ .../tests/server-side-rendering/test.ts | 2 +- 14 files changed, 131 insertions(+), 6 deletions(-) rename packages/svelte/tests/runtime-runes/samples/{hydratables => hydratable-custom-transport}/_config.js (52%) create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js create 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-error-on-missing/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/hydratable/_config.js rename packages/svelte/tests/runtime-runes/samples/{hydratables => hydratable}/main.svelte (100%) create mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index e0f10ca2ddf0..634f99e52c79 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -403,6 +403,16 @@ async function run_test_variant( throw new Error('Ensure dom mode is skipped'); }; + const run_hydratables_init = () => { + if (variant !== 'hydrate') return; + const script = document.head + .querySelectorAll('script') + .values() + .find((script) => script.textContent.includes('(window.__svelte ??= {}).h'))?.textContent; + if (!script) return; + (0, eval)(script); + }; + if (runes) { props = proxy({ ...(config.props || {}) }); @@ -411,6 +421,7 @@ async function run_test_variant( if (manual_hydrate && variant === 'hydrate') { hydrate_fn = () => { + run_hydratables_init(); instance = hydrate(mod.default, { target, props, @@ -419,6 +430,7 @@ async function run_test_variant( }); }; } else { + run_hydratables_init(); const render = variant === 'hydrate' ? hydrate : mount; instance = render(mod.default, { target, @@ -428,6 +440,7 @@ async function run_test_variant( }); } } else { + run_hydratables_init(); instance = createClassComponent({ component: mod.default, props: config.props, diff --git a/packages/svelte/tests/runtime-runes/samples/hydratables/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js similarity index 52% rename from packages/svelte/tests/runtime-runes/samples/hydratables/_config.js rename to packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js index b7622279e3f0..af004b900a1d 100644 --- a/packages/svelte/tests/runtime-runes/samples/hydratables/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js @@ -1,4 +1,3 @@ -import { tick } from 'svelte'; import { ok, test } from '../../test'; export default test({ @@ -8,12 +7,14 @@ export default test({ ssrHtml: '

The current environment is: server

', props: { environment: 'browser' }, - html: '

The current environment is: server

', - async test({ assert, target }) { - await tick(); + async test({ assert, target, variant }) { const p = target.querySelector('p'); ok(p); - assert.htmlEqual(p.outerHTML, '

The current environment is: server

'); + 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 new file mode 100644 index 000000000000..18b7f834676b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte @@ -0,0 +1,15 @@ + + +

The current environment is: {value}

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 new file mode 100644 index 000000000000..a8d1e1ddc467 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js @@ -0,0 +1,12 @@ +import { test } from '../../test'; + +export default test({ + 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 new file mode 100644 index 000000000000..4784dd13b2a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte @@ -0,0 +1,14 @@ + + +

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 new file mode 100644 index 000000000000..f6564753ce2a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js @@ -0,0 +1,12 @@ +import { ok, test } from '../../test'; + +export default test({ + 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/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/main.svelte new file mode 100644 index 000000000000..b7dfc0e7e252 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/main.svelte @@ -0,0 +1,14 @@ + + +

The current environment is: {value}

diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js new file mode 100644 index 000000000000..af004b900a1d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js @@ -0,0 +1,20 @@ +import { ok, test } from '../../test'; + +export default test({ + 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/hydratables/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable/main.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/hydratables/main.svelte rename to packages/svelte/tests/runtime-runes/samples/hydratable/main.svelte 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-imperative/_config.js new file mode 100644 index 000000000000..404260cc66d8 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + error: 'hydratable_clobbering' +}); 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 new file mode 100644 index 000000000000..25a1166f8324 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js new file mode 100644 index 000000000000..404260cc66d8 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + error: 'hydratable_clobbering' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte new file mode 100644 index 000000000000..764c2c241557 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index 7eede332a741..20997cdf6260 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -81,7 +81,7 @@ const { test, run } = suite_with_variants Date: Fri, 14 Nov 2025 14:31:43 -0700 Subject: [PATCH 07/11] docs --- .../docs/06-runtime/05-hydratable.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 documentation/docs/06-runtime/05-hydratable.md diff --git a/documentation/docs/06-runtime/05-hydratable.md b/documentation/docs/06-runtime/05-hydratable.md new file mode 100644 index 000000000000..671d2bf93bd5 --- /dev/null +++ b/documentation/docs/06-runtime/05-hydratable.md @@ -0,0 +1,99 @@ +--- +title: "`hydratable`" +--- + +In Svelte, when you want to render asynchonous content data on the server, you can simply `await` it. This is great! However, it comes with a major pitall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes: + +```svelte + + +

{user.name}

+``` + +That's silly, though. If we've already done the hard work of getting the data on the server, we don't want to get it again during hydration on the client. `hydratable` is a low-level API build to solve this problem. You probably won't need this very often -- it will probably be used behind the scenes by whatever datafetching library you use. For example, it powers [remote functions in SvelteKit](/docs/kit/remote-functions). + +To fix the example above: + +```svelte + + +

{user.name}

+``` + +This API can also be used to provide access to random or time-based values that are stable between server rendering and hydration. For example, to get a random number that doesn't update on hydration: + +```ts +import { hydratable } from 'svelte'; +const rand = hydratable('random', () => Math.random()); +``` + +If you're a library author, be sure to prefix the keys of your `hydratable` values with the name of your library so that your keys don't conflict with other libraries. + +## Imperative API + +If you're writing a library with separate server and client exports, it may be more convenient to use the imperative API: + +```ts +import { hydratable } from 'svelte'; + +const value = hydratable.get('foo'); // only works on the client +const hasValue = hydratable.has('foo'); +hydratable.set('foo', 'whatever value you want'); // only works on the server +``` + +## Custom serialization + +By default, Svelte uses [`devalue`](https://npmjs.com/package/devalue) to serialize your data on the server so that decoding it on the client requires no dependencies. If you need to serialize additional things not covered by `devalue`, you can provide your own transport mechanisms by writing custom `encode` and `decode` methods. + +### `encode` + +Encode receives a value and outputs _the JavaScript code necessary to create that value on the client_. For example, Svelte's built-in encoder looks like this: + +```ts +const encode = (value) => devalue.uneval(value); +encode(['hello', 'world']); // outputs `['hello', 'world']` +``` + +### `decode` + +`decode` accepts whatever the JavaScript that `encode` outputs resolves to, and returns whatever the final value from `hydratable` should be. + +### Usage + +When using the isomorphic API, you must provide either `encode` or `decode`, depending on the environment. This enables your bundler to treeshake the unneeded code during your build: + +```svelte + +``` + +For the imperative API, you just provide `encode` or `decode` depending on which method you're using: + +```ts +import { hydratable } from 'svelte'; +import { encode, decode } from '$lib/encoders'; + +const random = hydratable.get('random', { decode }); +hydratable.set('random', Math.random(), { encode }); +``` From 1ad5de0708c9162ad9b70b7ab3971ec4595db6c6 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 14 Nov 2025 14:39:09 -0700 Subject: [PATCH 08/11] hopefully --- packages/svelte/tests/runtime-legacy/shared.ts | 7 +++---- .../samples/hydratable-custom-transport/_config.js | 1 + .../hydratable-error-on-missing-imperative/_config.js | 1 + .../samples/hydratable-error-on-missing/_config.js | 1 + .../tests/runtime-runes/samples/hydratable/_config.js | 1 + 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 634f99e52c79..3a9ab6773165 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -405,10 +405,9 @@ 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'))?.textContent; + const script = [...document.head.querySelectorAll('script').values()].find((script) => + script.textContent.includes('(window.__svelte ??= {}).h') + )?.textContent; if (!script) return; (0, eval)(script); }; 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 index af004b900a1d..57904ef57608 100644 --- a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js @@ -1,6 +1,7 @@ import { ok, test } from '../../test'; export default test({ + skip_no_async: true, skip_mode: ['server'], server_props: { environment: 'server' }, 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 index a8d1e1ddc467..3990b65087e3 100644 --- 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 @@ -1,6 +1,7 @@ import { test } from '../../test'; export default test({ + skip_no_async: true, mode: ['async-server', 'hydrate'], server_props: { environment: 'server' }, 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 f6564753ce2a..b04d81d63914 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 @@ -1,6 +1,7 @@ import { ok, test } from '../../test'; export default test({ + skip_no_async: true, mode: ['async-server', 'hydrate'], server_props: { environment: 'server' }, diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js index af004b900a1d..57904ef57608 100644 --- a/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js @@ -1,6 +1,7 @@ import { ok, test } from '../../test'; export default test({ + skip_no_async: true, skip_mode: ['server'], server_props: { environment: 'server' }, From a6b7bc29cf0d12fc9c5f6b99f6cf577522f6ad8e Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 14 Nov 2025 14:54:51 -0700 Subject: [PATCH 09/11] lint --- packages/svelte/tests/runtime-legacy/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 3a9ab6773165..e69cb390bc00 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -406,7 +406,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('(window.__svelte ??= {}).h') )?.textContent; if (!script) return; (0, eval)(script); From f76c1aad7b4ca0b6eef1323fe2cd3bb6d7d5a206 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 14 Nov 2025 16:52:38 -0700 Subject: [PATCH 10/11] finally figured out test issues --- packages/svelte/tests/runtime-legacy/shared.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index e69cb390bc00..4ff5b03de0b4 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -5,7 +5,7 @@ import { createClassComponent } from 'svelte/legacy'; import { proxy } from 'svelte/internal/client'; import { flushSync, hydrate, mount, unmount } from 'svelte'; import { render } from 'svelte/server'; -import { afterAll, assert, beforeAll } from 'vitest'; +import { afterAll, assert, beforeAll, beforeEach } from 'vitest'; import { async_mode, compile_directory, fragments } from '../helpers.js'; import { assert_html_equal, assert_html_equal_with_options } from '../html_equal.js'; import { raf } from '../animation-helpers.js'; @@ -102,6 +102,14 @@ export interface RuntimeTest = Record { process.prependListener('unhandledRejection', unhandled_rejection_handler); }); +beforeEach(() => { + delete globalThis?.__svelte?.h; +}); + afterAll(() => { process.removeListener('unhandledRejection', unhandled_rejection_handler); }); @@ -539,6 +551,7 @@ async function run_test_variant( } } catch (err) { if (config.runtime_error) { + console.log(err); assert.include((err as Error).message, config.runtime_error); } else if (config.error && !unintended_error) { assert.include((err as Error).message, config.error); From bc9df88f160f2603ae88370256c448c691fd3de6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 15 Nov 2025 21:36:56 -0500 Subject: [PATCH 11/11] get docs building --- documentation/docs/06-runtime/05-hydratable.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/documentation/docs/06-runtime/05-hydratable.md b/documentation/docs/06-runtime/05-hydratable.md index 671d2bf93bd5..d6f5d9a1eb76 100644 --- a/documentation/docs/06-runtime/05-hydratable.md +++ b/documentation/docs/06-runtime/05-hydratable.md @@ -1,5 +1,5 @@ --- -title: "`hydratable`" +title: Hydratable data --- In Svelte, when you want to render asynchonous content data on the server, you can simply `await` it. This is great! However, it comes with a major pitall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes: @@ -27,7 +27,7 @@ To fix the example above: import { getUser } from 'my-database-library'; // During server rendering, this will serialize and stash the result of `getUser`, associating - // it with the provided key and baking it into the `head` content. During hydration, it will + // it with the provided key and baking it into the `head` content. During hydration, it will // look for the serialized version, returning it instead of running `getUser`. After hydration // is done, if it's called again, it'll simply invoke `getUser`. const user = await hydratable('user', getUser()); @@ -65,8 +65,16 @@ By default, Svelte uses [`devalue`](https://npmjs.com/package/devalue) to serial Encode receives a value and outputs _the JavaScript code necessary to create that value on the client_. For example, Svelte's built-in encoder looks like this: -```ts -const encode = (value) => devalue.uneval(value); +```js +import * as devalue from 'devalue'; + +/** + * @param {any} value + */ +function encode (value) { + return devalue.uneval(value); +} + encode(['hello', 'world']); // outputs `['hello', 'world']` ```