Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
91882ee
feat:
elliott-with-the-longest-name-on-github Nov 13, 2025
352ebbe
doc comments
elliott-with-the-longest-name-on-github Nov 13, 2025
2d475ac
types
elliott-with-the-longest-name-on-github Nov 13, 2025
2218b1e
types
elliott-with-the-longest-name-on-github Nov 13, 2025
d541688
changeset
elliott-with-the-longest-name-on-github Nov 13, 2025
fd92394
tests
elliott-with-the-longest-name-on-github Nov 14, 2025
4b146b6
docs
elliott-with-the-longest-name-on-github Nov 14, 2025
1ad5de0
hopefully
elliott-with-the-longest-name-on-github Nov 14, 2025
a6b7bc2
lint
elliott-with-the-longest-name-on-github Nov 14, 2025
f76c1aa
finally figured out test issues
elliott-with-the-longest-name-on-github Nov 14, 2025
bc9df88
get docs building
Rich-Harris Nov 16, 2025
a025b9a
the easy stuff
elliott-with-the-longest-name-on-github Nov 17, 2025
53ccd2e
prune errors
elliott-with-the-longest-name-on-github Nov 17, 2025
37f2e0e
feat: capture clobbering better, capture unused keys, don't block on …
elliott-with-the-longest-name-on-github Nov 18, 2025
05e60b1
Merge branch 'elliott/hydratable' of github.com:sveltejs/svelte into …
elliott-with-the-longest-name-on-github Nov 18, 2025
caad89b
progress on serializing nested promises
elliott-with-the-longest-name-on-github Nov 19, 2025
5af28fe
fix
elliott-with-the-longest-name-on-github Nov 19, 2025
923b086
idk man but the tests are passing so i'ma checkpoint this
elliott-with-the-longest-name-on-github Nov 20, 2025
9fed6f0
fix tests
elliott-with-the-longest-name-on-github Nov 20, 2025
9343114
compare resolved serialized values
Rich-Harris Nov 20, 2025
ce926c3
robustify
Rich-Harris Nov 20, 2025
2ec34a2
Merge branch 'elliott/hydratable' of github.com:sveltejs/svelte into …
elliott-with-the-longest-name-on-github Nov 20, 2025
90dd32b
thunkify
elliott-with-the-longest-name-on-github Nov 20, 2025
3f3ad1a
fixes
elliott-with-the-longest-name-on-github Nov 20, 2025
640fabd
ajsldkfjalsdfkjasd
elliott-with-the-longest-name-on-github Nov 20, 2025
9af6013
tests
elliott-with-the-longest-name-on-github Nov 20, 2025
9c88438
docs
elliott-with-the-longest-name-on-github Nov 21, 2025
0c34711
ugh
elliott-with-the-longest-name-on-github Nov 21, 2025
74b878f
ugh ugh ugh
elliott-with-the-longest-name-on-github Nov 21, 2025
614fdf2
Update documentation/docs/06-runtime/05-hydratable.md
elliott-with-the-longest-name-on-github Nov 21, 2025
9884945
make errors better
elliott-with-the-longest-name-on-github Nov 21, 2025
b5f2143
Merge branch 'elliott/hydratable' of github.com:sveltejs/svelte into …
elliott-with-the-longest-name-on-github Nov 21, 2025
3c36c7e
tweak
elliott-with-the-longest-name-on-github Nov 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-masks-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: `hydratable` API
107 changes: 107 additions & 0 deletions documentation/docs/06-runtime/05-hydratable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
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:

```svelte
<script>
import { getUser } from 'my-database-library';

// This will get the user on the server, render the user's name into the h1,
// and then, during hydration on the client, it will get the user _again_,
// blocking hydration until it's done.
const user = await getUser();
</script>

<h1>{user.name}</h1>
```

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
<script>
import { hydratable } from 'svelte';
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
// 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());
</script>

<h1>{user.name}</h1>
```

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:

```js
import * as devalue from 'devalue';

/**
* @param {any} value
*/
function encode (value) {
return 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
<script>
import { hydratable } from 'svelte';
import { BROWSER } from 'esm-env';
import { encode, decode } from '$lib/encoders';

const random = hydratable('random', () => Math.random(), { transport: BROWSER ? { decode } : { encode }});
</script>
```

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 });
```
23 changes: 17 additions & 6 deletions documentation/docs/98-reference/.generated/client-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -164,6 +158,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
<script>
import { hydratable } from 'svelte';
```

if (BROWSER) {
// bad! nothing can become interactive until this asynchronous work is done
await hydratable('foo', get_slow_random_number);
}
</script>
```

### hydration_failed

```
Expand Down
17 changes: 17 additions & 0 deletions documentation/docs/98-reference/.generated/client-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script>
import { hydratable } from 'svelte';
```

if (BROWSER) {
// bad! nothing can become interactive until this asynchronous work is done
await hydratable('foo', get_slow_random_number);
}
</script>
```

### hydration_attribute_changed

```
Expand Down
34 changes: 34 additions & 0 deletions documentation/docs/98-reference/.generated/server-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,44 @@ 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 with different values.
First instance occurred at:
%stack%
Second instance occurred at:
%stack2%
```

This error occurs when using `hydratable` multiple times with the same key. To avoid this, you can:
- Ensure all invocations with the same key result in the same value
- Update the keys to make both instances unique

```svelte
<script>
import { hydratable } from 'svelte';
await Promise.all([
// which one should "win" and be serialized in the rendered response?
hydratable('hello', () => 'world'),
hydratable('hello', () => 'dad')
])
</script>
```

### 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we know, at the time we call e.render_context_unavailable, whether AsyncLocalStorage is available or not — can we incorporate that information into the addendum?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is incorporated into the addendum -- I just couldn't dream up a way to be both helpful and put this much context into the addendum

```
32 changes: 32 additions & 0 deletions documentation/docs/98-reference/.generated/server-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->

### unused_hydratable

```
A `hydratable` value with key `%key%` was created, but not used during the render.

Stack:
%stack%
```

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:

```svelte
<script>
import { hydratable } from 'svelte';
import { getUser } from '$lib/get-user.js';

const user = hydratable('user', getUser);
</script>

<svelte:boundary>
<h1>{(await user).name}</h1>

{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>
```

Consider inlining the `hydratable` call inside the boundary so that it's not called on the server.
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/shared-errors.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->

### experimental_async_required

```
Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true`
```

### invalid_default_snippet

```
Expand Down
19 changes: 15 additions & 4 deletions packages/svelte/messages/client-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -124,6 +120,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
<script>
import { hydratable } from 'svelte';

if (BROWSER) {
// bad! nothing can become interactive until this asynchronous work is done
await hydratable('foo', get_slow_random_number);
}
</script>
```

## hydration_failed

> Failed to hydrate the application
Expand Down
15 changes: 15 additions & 0 deletions packages/svelte/messages/client-warnings/warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script>
import { hydratable } from 'svelte';

if (BROWSER) {
// bad! nothing can become interactive until this asynchronous work is done
await hydratable('foo', get_slow_random_number);
}
</script>
```

## 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
Expand Down
30 changes: 30 additions & 0 deletions packages/svelte/messages/server-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,38 @@ 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 with different values.
>
> First instance occurred at:
> %stack%
>
> Second instance occurred at:
> %stack2%

This error occurs when using `hydratable` multiple times with the same key. To avoid this, you can:
- Ensure all invocations with the same key result in the same value
- Update the keys to make both instances unique

```svelte
<script>
import { hydratable } from 'svelte';
await Promise.all([
// which one should "win" and be serialized in the rendered response?
hydratable('hello', () => 'world'),
hydratable('hello', () => 'dad')
])
</script>
```

## 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.
28 changes: 28 additions & 0 deletions packages/svelte/messages/server-warnings/warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## unused_hydratable

> A `hydratable` value with key `%key%` was created, but not used during the render.
>
> Stack:
> %stack%
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:

```svelte
<script>
import { hydratable } from 'svelte';
import { getUser } from '$lib/get-user.js';
const user = hydratable('user', getUser);
</script>
<svelte:boundary>
<h1>{(await user).name}</h1>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>
```

Consider inlining the `hydratable` call inside the boundary so that it's not called on the server.
4 changes: 4 additions & 0 deletions packages/svelte/messages/shared-errors/errors.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading