feat: `hydratable` (#17154)

* feat:

* doc comments

* types

* types

* changeset

* tests

* docs

* hopefully

* lint

* finally figured out test issues

* get docs building

* the easy stuff

* prune errors

* feat: capture clobbering better, capture unused keys, don't block on unused keys

* progress on serializing nested promises

* fix

* idk man but the tests are passing so i'ma checkpoint this

* fix tests

* compare resolved serialized values

* robustify

* thunkify

* fixes

* ajsldkfjalsdfkjasd

* tests

* docs

* ugh

* ugh ugh ugh

* Update documentation/docs/06-runtime/05-hydratable.md

Co-authored-by: kaysef <25851116+kaysef@users.noreply.github.com>

* make errors better

* tweak

* remove context restoration

* fix

* trim

* simplify hydratable (#17230)

* simplify hydratable

* tidy up

* unused

---------

Co-authored-by: Elliott Johnson <sejohnson@torchcloudconsulting.com>

* improve errors

* include all non-internal frames, handle identical stacks

* tweak example

* tweak message — setTimeout is covered by ALS

* cut out the middleman

* unused

* tidy

* Apply suggestions from code review

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: kaysef <25851116+kaysef@users.noreply.github.com>
pull/17234/head
Elliott Johnson 2 weeks ago committed by GitHub
parent 129c4086f7
commit c2a110cd81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: `hydratable` API

@ -0,0 +1,65 @@
---
title: Hydratable data
---
In Svelte, when you want to render asynchronous content data on the server, you can simply `await` it. This is great! However, it comes with a pitfall: 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 built to solve this problem. You probably won't need this very often -- it will 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.
## Serialization
All data returned from a `hydratable` function must be serializable. But this doesn't mean you're limited to JSON — Svelte uses [`devalue`](https://npmjs.com/package/devalue), which can serialize all sorts of things including `Map`, `Set`, `URL`, and `BigInt`. Check the documentation page for a full list. In addition to these, thanks to some Svelte magic, you can also fearlessly use promises:
```svelte
<script>
import { hydratable } from 'svelte';
const promises = hydratable('random', () => {
return {
one: Promise.resolve(1),
two: Promise.resolve(2)
}
});
</script>
{await promises.one}
{await promises.two}
```

@ -130,12 +130,16 @@ $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
### flush_sync_in_effect
```
Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
Cannot use `flushSync` inside an effect
```
The `flushSync()` function can be used to flush any pending effects synchronously. It cannot be used if effects are currently being flushed — in other words, you can call it after a state change but _not_ inside an effect.
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
### fork_discarded
```
@ -154,6 +158,25 @@ 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_required
```
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
```

@ -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
<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
```

@ -1,5 +1,13 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### async_local_storage_unavailable
```
The node API `AsyncLocalStorage` is not available, but is required to use async server rendering.
```
Some platforms require configuration flags to enable this API. Consult your platform's documentation.
### await_invalid
```
@ -14,6 +22,39 @@ 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.
%stack%
```
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';
// which one should "win" and be serialized in the rendered response?
const one = hydratable('not-unique', () => 1);
const two = hydratable('not-unique', () => 2);
</script>
```
### hydratable_serialization_failed
```
Failed to serialize `hydratable` data for key `%key%`.
`hydratable` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/uneval) can, plus Promises.
Cause:
%stack%
```
### lifecycle_function_unavailable
```
@ -21,3 +62,11 @@ 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.
### server_context_required
```
Could not resolve `render` context.
```
Certain functions such as `hydratable` cannot be invoked outside of a `render(...)` call, such as at the top level of a module.

@ -0,0 +1,34 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### unresolved_hydratable
```
A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
The `hydratable` was initialized in:
%stack%
```
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.
Note that this can also happen when a `hydratable` contains multiple promises and some but not all of them have been used.

@ -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
```

@ -100,9 +100,13 @@ $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
## flush_sync_in_effect
> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true`
> Cannot use `flushSync` inside an effect
The `flushSync()` function can be used to flush any pending effects synchronously. It cannot be used if effects are currently being flushed — in other words, you can call it after a state change but _not_ inside an effect.
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
## fork_discarded
@ -116,6 +120,23 @@ Often when encountering this issue, the value in question shouldn't be state (fo
> `getAbortSignal()` can only be called inside an effect or derived
## hydratable_missing_but_required
> 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

@ -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
<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

@ -1,3 +1,9 @@
## async_local_storage_unavailable
> The node API `AsyncLocalStorage` is not available, but is required to use async server rendering.
Some platforms require configuration flags to enable this API. Consult your platform's documentation.
## await_invalid
> Encountered asynchronous work while rendering synchronously.
@ -8,8 +14,43 @@ 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.
>
> %stack%
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';
// which one should "win" and be serialized in the rendered response?
const one = hydratable('not-unique', () => 1);
const two = hydratable('not-unique', () => 2);
</script>
```
## hydratable_serialization_failed
> Failed to serialize `hydratable` data for key `%key%`.
>
> `hydratable` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/uneval) can, plus Promises.
>
> Cause:
> %stack%
## 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.
## server_context_required
> Could not resolve `render` context.
Certain functions such as `hydratable` cannot be invoked outside of a `render(...)` call, such as at the top level of a module.

@ -0,0 +1,30 @@
## unresolved_hydratable
> A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
>
> The `hydratable` was initialized in:
> %stack%
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.
Note that this can also happen when a `hydratable` contains multiple promises and some but not all of them have been used.

@ -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

@ -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",

@ -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';

@ -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';

@ -2,7 +2,7 @@ import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js';
import { eager_effect, render_effect, validate_effect } from '../reactivity/effects.js';
import { untrack } from '../runtime.js';
import { get_stack } from './tracing.js';
import { get_error } from '../../shared/dev.js';
/**
* @param {() => any[]} get_value
@ -33,7 +33,7 @@ export function inspect(get_value, inspector, show_stack = false) {
inspector(...snap);
if (!initial) {
const stack = get_stack('$inspect(...)');
const stack = get_error('$inspect(...)');
// eslint-disable-next-line no-console
if (stack) {

@ -1,7 +1,6 @@
/** @import { Derived, Reaction, Value } from '#client' */
import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js';
import { define_property } from '../../shared/utils.js';
import { DERIVED, ASYNC, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';
import { effect_tracking } from '../reactivity/effects.js';
import { active_reaction, untrack } from '../runtime.js';
@ -131,62 +130,6 @@ export function trace(label, fn) {
}
}
/**
* @param {string} label
* @returns {Error & { stack: string } | null}
*/
export function get_stack(label) {
// @ts-ignore stackTraceLimit doesn't exist everywhere
const limit = Error.stackTraceLimit;
// @ts-ignore
Error.stackTraceLimit = Infinity;
let error = Error();
// @ts-ignore
Error.stackTraceLimit = limit;
const stack = error.stack;
if (!stack) return null;
const lines = stack.split('\n');
const new_lines = ['\n'];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const posixified = line.replaceAll('\\', '/');
if (line === 'Error') {
continue;
}
if (line.includes('validate_each_keys')) {
return null;
}
if (posixified.includes('svelte/src/internal') || posixified.includes('node_modules/.vite')) {
continue;
}
new_lines.push(line);
}
if (new_lines.length === 1) {
return null;
}
define_property(error, 'stack', {
value: new_lines.join('\n')
});
define_property(error, 'name', {
value: label
});
return /** @type {Error & { stack: string }} */ (error);
}
/**
* @param {Value} source
* @param {string} label

@ -230,18 +230,18 @@ 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`);
}
}
@ -293,6 +293,23 @@ export function get_abort_signal_outside_reaction() {
}
}
/**
* Expected to find a hydratable with key `%key%` during hydration, but did not.
* @param {string} key
* @returns {never}
*/
export function hydratable_missing_but_required(key) {
if (DEV) {
const error = new Error(`hydratable_missing_but_required\nExpected to find a hydratable with key \`${key}\` during hydration, but did not.\nhttps://svelte.dev/e/hydratable_missing_but_required`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/hydratable_missing_but_required`);
}
}
/**
* Failed to hydrate the application
* @returns {never}

@ -0,0 +1,33 @@
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';
import { DEV } from 'esm-env';
/**
* @template T
* @param {string} key
* @param {() => T} fn
* @returns {T}
*/
export function hydratable(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable');
}
if (hydrating) {
const store = window.__svelte?.h;
if (store?.has(key)) {
return /** @type {T} */ (store.get(key));
}
if (DEV) {
e.hydratable_missing_but_required(key);
} else {
w.hydratable_missing_but_expected(key);
}
}
return fn();
}

@ -25,7 +25,8 @@ import {
import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';
import { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js';
import { get_stack, tag } from './dev/tracing.js';
import { tag } from './dev/tracing.js';
import { get_error } from '../shared/dev.js';
import { tracing_mode_flag } from '../flags/index.js';
// TODO move all regexes into shared module?
@ -53,7 +54,7 @@ export function proxy(value) {
var is_proxied_array = is_array(value);
var version = source(0);
var stack = DEV && tracing_mode_flag ? get_stack('created at') : null;
var stack = DEV && tracing_mode_flag ? get_error('created at') : null;
var parent_version = update_version;
/**

@ -945,7 +945,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) {

@ -29,7 +29,7 @@ import * as e from '../errors.js';
import * as w from '../warnings.js';
import { async_effect, destroy_effect, effect_tracking, teardown } from './effects.js';
import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_stack } from '../dev/tracing.js';
import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { component_context } from '../context.js';
@ -84,7 +84,7 @@ export function derived(fn) {
};
if (DEV && tracing_mode_flag) {
signal.created = get_stack('created at');
signal.created = get_error('created at');
}
return signal;

@ -34,7 +34,8 @@ import {
} from '#client/constants';
import * as e from '../errors.js';
import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { get_stack, tag_proxy } from '../dev/tracing.js';
import { tag_proxy } from '../dev/tracing.js';
import { get_error } from '../../shared/dev.js';
import { component_context, is_runes } from '../context.js';
import { Batch, batch_values, eager_block_effects, schedule_effect } from './batch.js';
import { proxy } from '../proxy.js';
@ -78,7 +79,7 @@ export function source(v, stack) {
};
if (DEV && tracing_mode_flag) {
signal.created = stack ?? get_stack('created at');
signal.created = stack ?? get_error('created at');
signal.updated = null;
signal.set_during_effect = false;
signal.trace = null;
@ -196,7 +197,7 @@ export function internal_set(source, value) {
source.updated.set('', { error: /** @type {any} */ (null), count });
if (tracing_mode_flag || count > 5) {
const error = get_stack('updated at');
const error = get_error('updated at');
if (error !== null) {
let entry = source.updated.get(error.stack);

@ -33,7 +33,8 @@ import {
update_derived
} from './reactivity/deriveds.js';
import { async_mode_flag, tracing_mode_flag } from '../flags/index.js';
import { tracing_expressions, get_stack } from './dev/tracing.js';
import { tracing_expressions } from './dev/tracing.js';
import { get_error } from '../shared/dev.js';
import {
component_context,
dev_current_component_function,
@ -554,7 +555,7 @@ export function get(signal) {
// if (!tracking && !untracking && !was_read) {
// w.await_reactivity_loss(/** @type {string} */ (signal.label));
// var trace = get_stack('traced at');
// var trace = get_error('traced at');
// // eslint-disable-next-line no-console
// if (trace) console.warn(trace);
// }
@ -573,7 +574,7 @@ export function get(signal) {
if (signal.trace) {
signal.trace();
} else {
var trace = get_stack('traced at');
var trace = get_error('traced at');
if (trace) {
var entry = tracing_expressions.entries.get(signal);

@ -2,6 +2,15 @@ import type { Store } from '#shared';
import { STATE_SYMBOL } from './constants.js';
import type { Effect, Source, Value } from './reactivity/types.js';
declare global {
interface Window {
__svelte?: {
/** hydratables */
h?: Map<string, unknown>;
};
}
}
type EventCallback = (event: Event) => boolean;
export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>;

@ -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

@ -4,6 +4,7 @@ import {
is_tag_valid_with_ancestor,
is_tag_valid_with_parent
} from '../../html-tree-validation.js';
import { get_stack } from '../shared/dev.js';
import { set_ssr_context, ssr_context } from './context.js';
import * as e from './errors.js';
import { Renderer } from './renderer.js';
@ -98,3 +99,12 @@ export function validate_snippet_args(renderer) {
e.invalid_snippet_arguments();
}
}
export function get_user_code_location() {
const stack = get_stack();
return stack
.filter((line) => line.trim().startsWith('at '))
.map((line) => line.replace(/\((.*):\d+:\d+\)$/, (_, file) => `(${file})`))
.join('\n');
}

@ -2,6 +2,18 @@
export * from '../shared/errors.js';
/**
* The node API `AsyncLocalStorage` is not available, but is required to use async server rendering.
* @returns {never}
*/
export function async_local_storage_unavailable() {
const error = new Error(`async_local_storage_unavailable\nThe node API \`AsyncLocalStorage\` is not available, but is required to use async server rendering.\nhttps://svelte.dev/e/async_local_storage_unavailable`);
error.name = 'Svelte error';
throw error;
}
/**
* Encountered asynchronous work while rendering synchronously.
* @returns {never}
@ -26,6 +38,48 @@ export function html_deprecated() {
throw error;
}
/**
* Attempted to set `hydratable` with key `%key%` twice with different values.
*
* %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 with different values.
${stack}\nhttps://svelte.dev/e/hydratable_clobbering`);
error.name = 'Svelte error';
throw error;
}
/**
* Failed to serialize `hydratable` data for key `%key%`.
*
* `hydratable` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/uneval) can, plus Promises.
*
* Cause:
* %stack%
* @param {string} key
* @param {string} stack
* @returns {never}
*/
export function hydratable_serialization_failed(key, stack) {
const error = new Error(`hydratable_serialization_failed\nFailed to serialize \`hydratable\` data for key \`${key}\`.
\`hydratable\` can serialize anything [\`uneval\` from \`devalue\`](https://npmjs.com/package/uneval) can, plus Promises.
Cause:
${stack}\nhttps://svelte.dev/e/hydratable_serialization_failed`);
error.name = 'Svelte error';
throw error;
}
/**
* `%name%(...)` is not available on the server
* @param {string} name
@ -36,5 +90,17 @@ export function lifecycle_function_unavailable(name) {
error.name = 'Svelte error';
throw error;
}
/**
* Could not resolve `render` context.
* @returns {never}
*/
export function server_context_required() {
const error = new Error(`server_context_required\nCould not resolve \`render\` context.\nhttps://svelte.dev/e/server_context_required`);
error.name = 'Svelte error';
throw error;
}

@ -0,0 +1,136 @@
/** @import { HydratableLookupEntry } from '#server' */
import { async_mode_flag } from '../flags/index.js';
import { get_render_context } from './render-context.js';
import * as e from './errors.js';
import * as devalue from 'devalue';
import { get_stack } from '../shared/dev.js';
import { DEV } from 'esm-env';
import { get_user_code_location } from './dev.js';
/**
* @template T
* @param {string} key
* @param {() => T} fn
* @returns {T}
*/
export function hydratable(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable');
}
const { hydratable } = get_render_context();
let entry = hydratable.lookup.get(key);
if (entry !== undefined) {
if (DEV) {
const comparison = compare(key, entry, encode(key, fn()));
comparison.catch(() => {});
hydratable.comparisons.push(comparison);
}
return /** @type {T} */ (entry.value);
}
const value = fn();
entry = encode(key, value, hydratable.unresolved_promises);
hydratable.lookup.set(key, entry);
return value;
}
/**
* @param {string} key
* @param {any} value
* @param {Map<Promise<any>, string>} [unresolved]
*/
function encode(key, value, unresolved) {
/** @type {HydratableLookupEntry} */
const entry = { value, serialized: '' };
if (DEV) {
entry.stack = get_user_code_location();
}
let uid = 1;
entry.serialized = devalue.uneval(entry.value, (value, uneval) => {
if (value instanceof Promise) {
const p = value
.then((v) => `r(${uneval(v)})`)
.catch((devalue_error) =>
e.hydratable_serialization_failed(
key,
serialization_stack(entry.stack, devalue_error?.stack)
)
);
// prevent unhandled rejections from crashing the server
p.catch(() => {});
// track which promises are still resolving when render is complete
unresolved?.set(p, key);
p.finally(() => unresolved?.delete(p));
// we serialize promises as `"${i}"`, because it's impossible for that string
// to occur 'naturally' (since the quote marks would have to be escaped)
const placeholder = `"${uid++}"`;
(entry.promises ??= []).push(
p.then((s) => {
entry.serialized = entry.serialized.replace(placeholder, s);
})
);
return placeholder;
}
});
return entry;
}
/**
* @param {string} key
* @param {HydratableLookupEntry} a
* @param {HydratableLookupEntry} b
*/
async function compare(key, a, b) {
// note: these need to be loops (as opposed to Promise.all) because
// additional promises can get pushed to them while we're awaiting
// an earlier one
for (const p of a?.promises ?? []) {
await p;
}
for (const p of b?.promises ?? []) {
await p;
}
if (a.serialized !== b.serialized) {
const a_stack = /** @type {string} */ (a.stack);
const b_stack = /** @type {string} */ (b.stack);
const stack =
a_stack === b_stack
? `Occurred at:\n${a_stack}`
: `First occurrence at:\n${a_stack}\n\nSecond occurrence at:\n${b_stack}`;
e.hydratable_clobbering(key, stack);
}
}
/**
* @param {string | undefined} root_stack
* @param {string | undefined} uneval_stack
*/
function serialization_stack(root_stack, uneval_stack) {
let out = '';
if (root_stack) {
out += root_stack + '\n';
}
if (uneval_stack) {
out += 'Caused by:\n' + uneval_stack + '\n';
}
return out || '<missing stack trace>';
}

@ -0,0 +1,74 @@
// @ts-ignore -- we don't include node types in the production build
/** @import { AsyncLocalStorage } from 'node:async_hooks' */
/** @import { RenderContext } from '#server' */
import { deferred } from '../shared/utils.js';
import * as e from './errors.js';
/** @type {Promise<void> | null} */
let current_render = null;
/** @type {RenderContext | null} */
let context = null;
/** @returns {RenderContext} */
export function get_render_context() {
const store = context ?? als?.getStore();
if (!store) {
e.server_context_required();
}
return store;
}
/**
* @template T
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
export async function with_render_context(fn) {
context = {
hydratable: {
lookup: new Map(),
comparisons: [],
unresolved_promises: new Map()
}
};
if (in_webcontainer()) {
const { promise, resolve } = deferred();
const previous_render = current_render;
current_render = promise;
await previous_render;
return fn().finally(resolve);
}
try {
if (als === null) {
e.async_local_storage_unavailable();
}
return als.run(context, fn);
} finally {
context = null;
}
}
/** @type {AsyncLocalStorage<RenderContext | null> | null} */
let als = null;
export async function init_render_context() {
if (als !== null) return;
try {
// @ts-ignore -- we don't include node types in the production build
const { AsyncLocalStorage } = await import('node:async_hooks');
als = new AsyncLocalStorage();
} catch {}
}
// this has to be a function because rollup won't treeshake it if it's a constant
function in_webcontainer() {
// @ts-ignore -- this will fail when we run typecheck because we exclude node types
// eslint-disable-next-line n/prefer-global/process
return !!globalThis.process?.versions?.webcontainer;
}

@ -1,18 +1,19 @@
/** @import { Component } from 'svelte' */
/** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js';
import { pop, push, set_ssr_context, ssr_context } from './context.js';
import { pop, push, set_ssr_context, ssr_context, save } from './context.js';
import * as e from './errors.js';
import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { DEV } from 'esm-env';
/** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
/**
* @template T
* @typedef {T | Promise<T>} MaybePromise<T>
*/
/**
* @typedef {string | Renderer} RendererItem
*/
@ -423,7 +424,9 @@ export class Renderer {
});
return Promise.resolve(user_result);
}
async ??= Renderer.#render_async(component, options);
async ??= init_render_context().then(() =>
with_render_context(() => Renderer.#render_async(component, options))
);
return async.then((result) => {
Object.defineProperty(result, 'html', {
// eslint-disable-next-line getter-return
@ -515,15 +518,19 @@ export class Renderer {
* @returns {Promise<AccumulatedContent>}
*/
static async #render_async(component, options) {
var previous_context = ssr_context;
const previous_context = ssr_context;
try {
const renderer = Renderer.#open_render('async', component, options);
const content = await renderer.#collect_content_async();
const hydratables = await renderer.#collect_hydratables();
if (hydratables !== null) {
content.head = hydratables + content.head;
}
return Renderer.#close_render(content, renderer);
} finally {
abort();
set_ssr_context(previous_context);
abort();
}
}
@ -564,6 +571,23 @@ export class Renderer {
return content;
}
async #collect_hydratables() {
const ctx = get_render_context().hydratable;
for (const [_, key] of ctx.unresolved_promises) {
// this is a problem -- it means we've finished the render but we're still waiting on a promise to resolve so we can
// serialize it, so we're blocking the response on useless content.
w.unresolved_hydratable(key, ctx.lookup.get(key)?.stack ?? '<missing stack trace>');
}
for (const comparison of ctx.comparisons) {
// these reject if there's a mismatch
await comparison;
}
return await Renderer.#hydratable_block(ctx);
}
/**
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
@ -617,6 +641,48 @@ export class Renderer {
body
};
}
/**
* @param {HydratableContext} ctx
*/
static async #hydratable_block(ctx) {
if (ctx.lookup.size === 0) {
return null;
}
let entries = [];
let has_promises = false;
for (const [k, v] of ctx.lookup) {
if (v.promises) {
has_promises = true;
for (const p of v.promises) await p;
}
entries.push(`[${JSON.stringify(k)},${v.serialized}]`);
}
let prelude = `const h = (window.__svelte ??= {}).h ??= new Map();`;
if (has_promises) {
prelude = `const r = (v) => Promise.resolve(v);
${prelude}`;
}
// TODO csp -- have discussed but not implemented
return `
<script>
{
${prelude}
for (const [k, v] of [
${entries.join(',\n\t\t\t\t\t')}
]) {
h.set(k, v);
}
}
</script>`;
}
}
export class SSRState {

@ -1,3 +1,4 @@
import type { MaybePromise } from '#shared';
import type { Element } from './dev';
import type { Renderer } from './renderer';
@ -14,6 +15,24 @@ export interface SSRContext {
element?: Element;
}
export interface HydratableLookupEntry {
value: unknown;
serialized: string;
promises?: Array<Promise<void>>;
/** dev-only */
stack?: string;
}
export interface HydratableContext {
lookup: Map<string, HydratableLookupEntry>;
comparisons: Promise<void>[];
unresolved_promises: Map<Promise<string>, string>;
}
export interface RenderContext {
hydratable: HydratableContext;
}
export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;

@ -3,4 +3,27 @@
import { DEV } from 'esm-env';
var bold = 'font-weight: bold';
var normal = 'font-weight: normal';
var normal = 'font-weight: normal';
/**
* A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
*
* The `hydratable` was initialized in:
* %stack%
* @param {string} key
* @param {string} stack
*/
export function unresolved_hydratable(key, stack) {
if (DEV) {
console.warn(
`%c[svelte] unresolved_hydratable\n%cA \`hydratable\` value with key \`${key}\` was created, but at least part of it was not used during the render.
The \`hydratable\` was initialized in:
${stack}\nhttps://svelte.dev/e/unresolved_hydratable`,
bold,
normal
);
} else {
console.warn(`https://svelte.dev/e/unresolved_hydratable`);
}
}

@ -0,0 +1,65 @@
import { define_property } from './utils.js';
/**
* @param {string} label
* @returns {Error & { stack: string } | null}
*/
export function get_error(label) {
const error = new Error();
const stack = get_stack();
if (stack.length === 0) {
return null;
}
stack.unshift('\n');
define_property(error, 'stack', {
value: stack.join('\n')
});
define_property(error, 'name', {
value: label
});
return /** @type {Error & { stack: string }} */ (error);
}
/**
* @returns {string[]}
*/
export function get_stack() {
// @ts-ignore - doesn't exist everywhere
const limit = Error.stackTraceLimit;
// @ts-ignore - doesn't exist everywhere
Error.stackTraceLimit = Infinity;
const stack = new Error().stack;
// @ts-ignore - doesn't exist everywhere
Error.stackTraceLimit = limit;
if (!stack) return [];
const lines = stack.split('\n');
const new_lines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const posixified = line.replaceAll('\\', '/');
if (line.trim() === 'Error') {
continue;
}
if (line.includes('validate_each_keys')) {
return [];
}
if (posixified.includes('svelte/src/internal') || posixified.includes('node_modules/.vite')) {
continue;
}
new_lines.push(line);
}
return new_lines;
}

@ -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}

@ -8,3 +8,5 @@ export type Getters<T> = {
};
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;
export type MaybePromise<T> = T | Promise<T>;

@ -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} */

@ -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';
@ -86,6 +86,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
}) => void | Promise<void>;
test_ssr?: (args: {
logs: any[];
warnings: any[];
assert: Assert;
variant: 'ssr' | 'async-ssr';
}) => void | Promise<void>;
@ -102,6 +103,14 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
recover?: boolean;
}
declare global {
var __svelte:
| {
h?: Map<string, unknown>;
}
| undefined;
}
let unhandled_rejection: Error | null = null;
function unhandled_rejection_handler(err: Error) {
@ -115,6 +124,10 @@ beforeAll(() => {
process.prependListener('unhandledRejection', unhandled_rejection_handler);
});
beforeEach(() => {
delete globalThis?.__svelte?.h;
});
afterAll(() => {
process.removeListener('unhandledRejection', unhandled_rejection_handler);
});
@ -252,7 +265,16 @@ async function run_test_variant(
i++;
}
if (str.slice(0, i).includes('logs')) {
let ssr_str = config.test_ssr?.toString() ?? '';
let sn = 0;
let si = 0;
while (si < ssr_str.length) {
if (ssr_str[si] === '(') sn++;
if (ssr_str[si] === ')' && --sn === 0) break;
si++;
}
if (str.slice(0, i).includes('logs') || ssr_str.slice(0, si).includes('logs')) {
// eslint-disable-next-line no-console
console.log = (...args) => {
logs.push(...args);
@ -263,7 +285,11 @@ async function run_test_variant(
manual_hydrate = true;
}
if (str.slice(0, i).includes('warnings') || config.warnings) {
if (
str.slice(0, i).includes('warnings') ||
config.warnings ||
ssr_str.slice(0, si).includes('warnings')
) {
// eslint-disable-next-line no-console
console.warn = (...args) => {
if (typeof args[0] === 'string' && args[0].startsWith('%c[svelte]')) {
@ -383,6 +409,7 @@ async function run_test_variant(
if (config.test_ssr) {
await config.test_ssr({
logs,
warnings,
// @ts-expect-error
assert: {
...assert,
@ -403,6 +430,15 @@ 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 ??= {}')
)?.textContent;
if (!script) return;
(0, eval)(script);
};
if (runes) {
props = proxy({ ...(config.props || {}) });
@ -411,6 +447,7 @@ async function run_test_variant(
if (manual_hydrate && variant === 'hydrate') {
hydrate_fn = () => {
run_hydratables_init();
instance = hydrate(mod.default, {
target,
props,
@ -419,6 +456,7 @@ async function run_test_variant(
});
};
} else {
run_hydratables_init();
const render = variant === 'hydrate' ? hydrate : mount;
instance = render(mod.default, {
target,
@ -428,6 +466,7 @@ async function run_test_variant(
});
}
} else {
run_hydratables_init();
instance = createClassComponent({
component: mod.default,
props: config.props,

@ -0,0 +1,24 @@
import { tick } from 'svelte';
import { ok, test } from '../../test';
export default test({
skip_no_async: true,
skip_mode: ['server'],
server_props: { environment: 'server' },
ssrHtml: '<p>The current environment is: server</p>',
props: { environment: 'browser' },
async test({ assert, target, variant }) {
// make sure hydration has a chance to finish
await tick();
const p = target.querySelector('p');
ok(p);
if (variant === 'hydrate') {
assert.htmlEqual(p.outerHTML, '<p>The current environment is: server</p>');
} else {
assert.htmlEqual(p.outerHTML, '<p>The current environment is: browser</p>');
}
}
});

@ -0,0 +1,13 @@
<script lang="ts">
import { hydratable } from "svelte";
let { environment } = $props();
const value = hydratable("environment", () => Promise.resolve({
nested: Promise.resolve({
environment
})
}))
</script>
<p>The current environment is: {await value.then(res => res.nested).then(res => res.environment)}</p>

@ -0,0 +1,13 @@
import { ok, test } from '../../test';
export default test({
skip_no_async: true,
mode: ['async-server', 'hydrate'],
server_props: { environment: 'server' },
ssrHtml: '<p>The current environment is: server</p>',
props: { environment: 'browser' },
runtime_error: 'hydratable_missing_but_required'
});

@ -0,0 +1,14 @@
<script lang="ts">
import { hydratable } from "svelte";
let { environment }: { environment: 'server' | 'browser' } = $props();
let value;
if (environment === 'server') {
value = 'server';
} else {
value = hydratable('environment', () => environment);
}
</script>
<p>The current environment is: {value}</p>

@ -0,0 +1,27 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip_no_async: true,
mode: ['async-server', 'hydrate'],
server_props: { environment: 'server' },
ssrHtml:
'<div>did you ever hear the tragedy of darth plagueis the wise?</div><div>Loading...</div>',
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
// for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
assert.include(warnings[0], 'A `hydratable` value with key `partially_used`');
},
async test({ assert, target }) {
// make sure the hydratable promise on the client has a chance to run and reject (it shouldn't, because the server data should be used)
await tick();
assert.htmlEqual(
target.innerHTML,
'<div>did you ever hear the tragedy of darth plagueis the wise?</div><div>no, sith daddy, please tell me</div>'
);
}
});

@ -0,0 +1,27 @@
<script lang="ts">
import { hydratable } from "svelte";
const { environment } = $props();
const partially_used_hydratable = hydratable(
"partially_used",
() => {
return {
used: new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
),
unused: new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('no, sith daddy, please tell me'), 0) : rej('should not run')
),
}
}
);
</script>
<div>{await partially_used_hydratable.used}</div>
<svelte:boundary>
<div>{await partially_used_hydratable.unused}</div>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>

@ -0,0 +1,26 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip_no_async: true,
mode: ['async-server', 'hydrate'],
server_props: { environment: 'server' },
ssrHtml: '<div>Loading...</div>',
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
// for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
assert.include(warnings[0], 'A `hydratable` value with key `unused_key`');
},
async test({ assert, target }) {
// make sure the hydratable promise on the client has a chance to run and reject (it shouldn't, because the server data should be used)
await tick();
assert.htmlEqual(
target.innerHTML,
'<div>did you ever hear the tragedy of darth plagueis the wise?</div>'
);
}
});

@ -0,0 +1,19 @@
<script lang="ts">
import { hydratable } from "svelte";
const { environment } = $props();
const unresolved_hydratable = hydratable(
"unused_key",
() => new Promise(
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
)
);
</script>
<svelte:boundary>
<div>{await unresolved_hydratable}</div>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>

@ -0,0 +1,21 @@
import { ok, test } from '../../test';
export default test({
skip_no_async: true,
skip_mode: ['server'],
server_props: { environment: 'server' },
ssrHtml: '<p>The current environment is: server</p>',
props: { environment: 'browser' },
async test({ assert, target, variant }) {
const p = target.querySelector('p');
ok(p);
if (variant === 'hydrate') {
assert.htmlEqual(p.outerHTML, '<p>The current environment is: server</p>');
} else {
assert.htmlEqual(p.outerHTML, '<p>The current environment is: browser</p>');
}
}
});

@ -0,0 +1,9 @@
<script lang="ts">
import { hydratable } from "svelte";
let { environment } = $props();
const value = hydratable("environment", () => environment)
</script>
<p>The current environment is: {value}</p>

@ -1,6 +1,7 @@
import { test } from '../../test';
export default test({
skip: true, // TODO it appears there might be an actual bug here; the promise isn't ever actually awaited in spite of being awaited in the component
mode: ['async'],
error: 'lifecycle_outside_component'
});

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
mode: ['async']
});

@ -0,0 +1,6 @@
<script lang="ts">
import { hydratable } from 'svelte';
await hydratable('key', () => 'first');
await hydratable('key', () => 'first');
</script>

@ -0,0 +1,6 @@
import { test } from '../../test';
export default test({
mode: ['async'],
error: 'hydratable_clobbering'
});

@ -0,0 +1,16 @@
<script lang="ts">
import { hydratable } from 'svelte';
hydratable('key', () => Promise.resolve({
nested: Promise.resolve({
one: Promise.resolve(1),
}),
two: Promise.resolve(2),
}));
hydratable('key', () => Promise.resolve({
nested: Promise.resolve({
one: Promise.resolve(2),
}),
two: Promise.resolve(2),
}));
</script>

@ -0,0 +1,6 @@
import { test } from '../../test';
export default test({
mode: ['async'],
error: 'hydratable_clobbering'
});

@ -0,0 +1,6 @@
<script lang="ts">
import { hydratable } from 'svelte';
hydratable('key', () => 'first');
hydratable('key', () => 'second');
</script>

@ -1,9 +1,11 @@
import { test } from '../../test';
export default test({
skip: true, // TODO: This test actually works, but the error message is printed, not thrown, so we need to have a way to test for that
compileOptions: {
dev: true
},
error:
'node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1) cannot be a child of `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
});

@ -73,6 +73,7 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
seen?.clear();
let rendered;
let errored = false;
try {
const render_result = render(Component, {
props: config.props || {},
@ -80,14 +81,19 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
});
rendered = is_async ? await render_result : render_result;
} catch (error) {
errored = true;
if (config.error) {
assert.deepEqual((error as Error).message, config.error);
assert.include((error as Error).message, config.error);
return;
} else {
throw error;
}
}
if (config.error && !errored) {
assert.fail('Expected an error to be thrown, but rendering succeeded.');
}
const { body, head } = rendered;
fs.writeFileSync(

@ -450,6 +450,7 @@ declare module 'svelte' {
* @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead
* */
export function afterUpdate(fn: () => void): void;
export function hydratable<T>(key: string, fn: () => T): T;
/**
* Create a snippet programmatically
* */

@ -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

Loading…
Cancel
Save