elliott/hydratable
Elliott Johnson 1 week ago
parent e238e6611e
commit 91882ee9db

@ -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. 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 ### 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. 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 ### 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 `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 ### hydration_failed
``` ```

@ -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%? %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 ### hydration_attribute_changed
``` ```

@ -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>`](svelte-boundary) with a `pending` snippet. 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>`](svelte-boundary) with a `pending` snippet.
### fn_unavailable_on_server
```
`%name%`(...) is unavailable on the server.
```
### html_deprecated ### html_deprecated
``` ```
The `html` property of server render results has been deprecated. Use `body` instead. 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
<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 ### 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. 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.
```

@ -1,5 +1,11 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! --> <!-- 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 ### invalid_default_snippet
``` ```

@ -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. 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 ## flush_sync_in_effect
> Cannot use `flushSync` inside an 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. 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 ## fork_discarded
> Cannot commit a fork that was already 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 > `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 ## hydration_failed
> Failed to hydrate the application > Failed to hydrate the application

@ -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%? > %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 ## 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 > 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

@ -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>`](svelte-boundary) with a `pending` snippet. 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>`](svelte-boundary) with a `pending` snippet.
## fn_unavailable_on_server
> `%name%`(...) is unavailable on the server.
## html_deprecated ## html_deprecated
> The `html` property of server render results has been deprecated. Use `body` instead. > 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
<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 ## lifecycle_function_unavailable
> `%name%(...)` is not available on the server > `%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. 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.

@ -1,3 +1,7 @@
## experimental_async_required
> Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true`
## invalid_default_snippet ## invalid_default_snippet
> Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead > 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", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.5.0",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.1.0", "esrap": "^2.1.0",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",

@ -249,6 +249,7 @@ export {
hasContext, hasContext,
setContext setContext
} from './internal/client/context.js'; } from './internal/client/context.js';
export { hydratable } from './internal/client/hydratable.js';
export { hydrate, mount, unmount } from './internal/client/render.js'; export { hydrate, mount, unmount } from './internal/client/render.js';
export { tick, untrack, settled } from './internal/client/runtime.js'; export { tick, untrack, settled } from './internal/client/runtime.js';
export { createRawSnippet } from './internal/client/dom/blocks/snippet.js'; export { createRawSnippet } from './internal/client/dom/blocks/snippet.js';

@ -51,4 +51,6 @@ export {
setContext setContext
} from './internal/server/context.js'; } from './internal/server/context.js';
export { hydratable } from './internal/server/hydratable.js';
export { createRawSnippet } from './internal/server/blocks/snippet.js'; export { createRawSnippet } from './internal/server/blocks/snippet.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} * @returns {never}
*/ */
export function experimental_async_fork() { export function flush_sync_in_effect() {
if (DEV) { 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'; error.name = 'Svelte error';
throw error; throw error;
} else { } 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} * @returns {never}
*/ */
export function flush_sync_in_effect() { export function fn_unavailable_on_client(name) {
if (DEV) { 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'; error.name = 'Svelte error';
throw error; throw error;
} else { } 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
* <script>
* import { hydratable } from 'svelte';
* @param {string} key
* @returns {never}
*/
export function hydratable_missing_but_expected_e(key) {
if (DEV) {
const error = new Error(`hydratable_missing_but_expected_e\nExpected 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';\nhttps://svelte.dev/e/hydratable_missing_but_expected_e`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/hydratable_missing_but_expected_e`);
}
}
/** /**
* Failed to hydrate the application * Failed to hydrate the application
* @returns {never} * @returns {never}

@ -0,0 +1,112 @@
/** @import { Decode, Hydratable, Transport } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { hydrating } from './dom/hydration.js';
import * as w from './warnings.js';
import * as e from './errors.js';
import { DEV } from 'esm-env';
/**
* @template T
* @param {string} key
* @param {() => T} fn
* @param {{ transport?: Transport<T> }} [options]
* @returns {T}
*/
function isomorphic_hydratable(key, fn, options) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable');
}
return access_hydratable_store(
key,
(val, has) => {
if (!has) {
hydratable_missing_but_expected(key);
return fn();
}
return decode(val, options?.transport?.decode);
},
fn
);
}
isomorphic_hydratable['get'] = get_hydratable_value;
isomorphic_hydratable['has'] = has_hydratable_value;
isomorphic_hydratable['set'] = () => e.fn_unavailable_on_client('hydratable.set');
/** @type {Hydratable} */
const hydratable = isomorphic_hydratable;
export { hydratable };
/**
* @template T
* @param {string} key
* @param {{ decode?: Decode<T> }} [options]
* @returns {T | undefined}
*/
function get_hydratable_value(key, options = {}) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable.get');
}
return access_hydratable_store(
key,
(val, has) => {
if (!has) {
hydratable_missing_but_expected(key);
}
return decode(val, options.decode);
},
() => undefined
);
}
/**
* @param {string} key
* @returns {boolean}
*/
function has_hydratable_value(key) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable.set');
}
return access_hydratable_store(
key,
(_, has) => has,
() => false
);
}
/**
* @template T
* @param {string} key
* @param {(val: unknown, has: boolean) => T} on_hydrating
* @param {() => T} on_not_hydrating
* @returns {T}
*/
function access_hydratable_store(key, on_hydrating, on_not_hydrating) {
if (!hydrating) {
return on_not_hydrating();
}
var store = window.__svelte?.h;
return on_hydrating(store?.get(key), store?.has(key) ?? false);
}
/**
* @template T
* @param {unknown} val
* @param {Decode<T> | undefined} decode
* @returns {T}
*/
function decode(val, decode) {
return (decode ?? ((val) => /** @type {T} */ (val)))(val);
}
/** @param {string} key */
function hydratable_missing_but_expected(key) {
if (DEV) {
e.hydratable_missing_but_expected_e(key);
} else {
w.hydratable_missing_but_expected_w(key);
}
}

@ -899,7 +899,7 @@ export function eager(fn) {
*/ */
export function fork(fn) { export function fork(fn) {
if (!async_mode_flag) { if (!async_mode_flag) {
e.experimental_async_fork(); e.experimental_async_required('fork');
} }
if (current_batch !== null) { if (current_batch !== null) {

@ -2,6 +2,15 @@ import type { Store } from '#shared';
import { STATE_SYMBOL } from './constants.js'; import { STATE_SYMBOL } from './constants.js';
import type { Effect, Source, Value } from './reactivity/types.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; type EventCallback = (event: Event) => boolean;
export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>; export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>;

@ -87,6 +87,30 @@ export function event_handler_invalid(handler, suggestion) {
} }
} }
/**
* 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';
* @param {string} key
*/
export function hydratable_missing_but_expected_w(key) {
if (DEV) {
console.warn(
`%c[svelte] hydratable_missing_but_expected_w\n%cExpected 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';\nhttps://svelte.dev/e/hydratable_missing_but_expected_w`,
bold,
normal
);
} else {
console.warn(`https://svelte.dev/e/hydratable_missing_but_expected_w`);
}
}
/** /**
* 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 * 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 * @param {string} attribute

@ -1,6 +1,7 @@
/** @import { SSRContext } from '#server' */ /** @import { SSRContext } from '#server' */
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import * as e from './errors.js'; import * as e from './errors.js';
import { save_render_context } from './render-context.js';
/** @type {SSRContext | null} */ /** @type {SSRContext | null} */
export var ssr_context = null; export var ssr_context = null;
@ -113,10 +114,10 @@ function get_parent_context(ssr_context) {
*/ */
export async function save(promise) { export async function save(promise) {
var previous_context = ssr_context; var previous_context = ssr_context;
var value = await promise; const restore_render_context = await save_render_context(promise);
return () => { return () => {
ssr_context = previous_context; ssr_context = previous_context;
return value; return restore_render_context();
}; };
} }

@ -14,6 +14,19 @@ export function await_invalid() {
throw error; throw error;
} }
/**
* `%name%`(...) is unavailable on the server.
* @param {string} name
* @returns {never}
*/
export function fn_unavailable_on_server(name) {
const error = new Error(`fn_unavailable_on_server\n\`${name}\`(...) is unavailable on the server.\nhttps://svelte.dev/e/fn_unavailable_on_server`);
error.name = 'Svelte error';
throw error;
}
/** /**
* The `html` property of server render results has been deprecated. Use `body` instead. * The `html` property of server render results has been deprecated. Use `body` instead.
* @returns {never} * @returns {never}
@ -26,6 +39,26 @@ export function html_deprecated() {
throw error; throw error;
} }
/**
* Attempted to set hydratable with key `%key%` twice. This behavior is undefined.
*
* First set occurred at:
* %stack%
* @param {string} key
* @param {string} stack
* @returns {never}
*/
export function hydratable_clobbering(key, stack) {
const error = new Error(`hydratable_clobbering\nAttempted to set hydratable with key \`${key}\` twice. This behavior is undefined.
First set occurred at:
${stack}\nhttps://svelte.dev/e/hydratable_clobbering`);
error.name = 'Svelte error';
throw error;
}
/** /**
* `%name%(...)` is not available on the server * `%name%(...)` is not available on the server
* @param {string} name * @param {string} name
@ -38,3 +71,18 @@ export function lifecycle_function_unavailable(name) {
throw error; throw error;
} }
/**
* 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.
* @param {string} addendum
* @returns {never}
*/
export function render_context_unavailable(addendum) {
const error = new Error(`render_context_unavailable\nFailed 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.\nhttps://svelte.dev/e/render_context_unavailable`);
error.name = 'Svelte error';
throw error;
}

@ -0,0 +1,89 @@
/** @import { Encode, Hydratable, Transport } from '#shared' */
/** @import { HydratableEntry } 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 { DEV } from 'esm-env';
/**
* @template T
* @param {string} key
* @param {() => T} fn
* @param {{ transport?: Transport<T> }} [options]
* @returns {T}
*/
function isomorphic_hydratable(key, fn, options) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable');
}
const store = get_render_context();
if (store.hydratables.has(key)) {
e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown');
}
const entry = create_entry(fn(), options?.transport?.encode);
store.hydratables.set(key, entry);
return entry.value;
}
isomorphic_hydratable['get'] = () => e.fn_unavailable_on_server('hydratable.get');
isomorphic_hydratable['has'] = has_hydratable_value;
isomorphic_hydratable['set'] = set_hydratable_value;
/** @type {Hydratable} */
const hydratable = isomorphic_hydratable;
export { hydratable };
/**
* @template T
* @param {string} key
* @param {T} value
* @param {{ encode?: Encode<T> }} [options]
*/
function set_hydratable_value(key, value, options = {}) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable.set');
}
const store = get_render_context();
if (store.hydratables.has(key)) {
e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown');
}
store.hydratables.set(key, create_entry(value, options?.encode));
}
/**
* @param {string} key
* @returns {boolean}
*/
function has_hydratable_value(key) {
if (!async_mode_flag) {
e.experimental_async_required('hydratable.has');
}
const store = get_render_context();
return store.hydratables.has(key);
}
/**
* @template T
* @param {T} value
* @param {Encode<T> | undefined} encode
*/
function create_entry(value, encode) {
/** @type {Omit<HydratableEntry, 'value'> & { value: T }} */
const entry = {
value,
encode
};
if (DEV) {
entry.stack = new Error().stack;
}
return entry;
}

@ -0,0 +1,93 @@
// @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 sync_context = null;
/**
* @template T
* @param {Promise<T>} promise
* @returns {Promise<() => T>}
*/
export async function save_render_context(promise) {
var previous_context = sync_context;
var value = await promise;
return () => {
sync_context = previous_context;
return value;
};
}
/** @returns {RenderContext | null} */
export function try_get_render_context() {
if (sync_context !== null) {
return sync_context;
}
return als?.getStore() ?? null;
}
/** @returns {RenderContext} */
export function get_render_context() {
const store = try_get_render_context();
if (!store) {
e.render_context_unavailable(
`\`AsyncLocalStorage\` is ${als ? 'available' : 'not available'}.`
);
}
return store;
}
/**
* @template T
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
export async function with_render_context(fn) {
try {
sync_context = {
hydratables: new Map(),
cache: new Map()
};
if (in_webcontainer()) {
const { promise, resolve } = deferred();
const previous_render = current_render;
current_render = promise;
await previous_render;
return fn().finally(resolve);
}
return als ? als.run(sync_context, fn) : fn();
} finally {
if (!in_webcontainer()) {
sync_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,18 @@
/** @import { Component } from 'svelte' */ /** @import { Component } from 'svelte' */
/** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */ /** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js'; import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.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 e from './errors.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js'; import { attributes } from './index.js';
import { uneval } from 'devalue';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
/** @typedef {'head' | 'body'} RendererType */ /** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
/**
* @template T
* @typedef {T | Promise<T>} MaybePromise<T>
*/
/** /**
* @typedef {string | Renderer} RendererItem * @typedef {string | Renderer} RendererItem
*/ */
@ -423,7 +423,9 @@ export class Renderer {
}); });
return Promise.resolve(user_result); 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) => { return async.then((result) => {
Object.defineProperty(result, 'html', { Object.defineProperty(result, 'html', {
// eslint-disable-next-line getter-return // eslint-disable-next-line getter-return
@ -515,16 +517,23 @@ export class Renderer {
* @returns {Promise<AccumulatedContent>} * @returns {Promise<AccumulatedContent>}
*/ */
static async #render_async(component, options) { static async #render_async(component, options) {
var previous_context = ssr_context; const restore = await save(
try { (async () => {
const renderer = Renderer.#open_render('async', component, options); 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();
}
})()
);
const content = await renderer.#collect_content_async(); return restore();
return Renderer.#close_render(content, renderer);
} finally {
abort();
set_ssr_context(previous_context);
}
} }
/** /**
@ -564,6 +573,22 @@ export class Renderer {
return content; return content;
} }
async #collect_hydratables() {
const map = get_render_context().hydratables;
/** @type {(value: unknown) => string} */
const default_encode = new MemoizedUneval().uneval;
/** @type {[string, string][]} */
let entries = [];
for (const [k, v] of map) {
const encode = v.encode ?? default_encode;
// sequential await is okay here -- all the work is already kicked off
entries.push([k, encode(await v.value)]);
}
if (entries.length === 0) return null;
return Renderer.#hydratable_block(entries);
}
/** /**
* @template {Record<string, any>} Props * @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode * @param {'sync' | 'async'} mode
@ -617,6 +642,24 @@ export class Renderer {
body body
}; };
} }
/** @param {[string, string][]} serialized */
static #hydratable_block(serialized) {
let entries = [];
for (const [k, v] of serialized) {
entries.push(`[${JSON.stringify(k)},${v}]`);
}
// TODO csp -- have discussed but not implemented
return `
<script>
{
const store = (window.__svelte ??= {}).h ??= new Map();
for (const [k,v] of [${entries.join(',')}]) {
store.set(k, v);
}
}
</script>`;
}
} }
export class SSRState { export class SSRState {
@ -673,3 +716,33 @@ export class SSRState {
} }
} }
} }
export class MemoizedUneval {
/** @type {Map<unknown, { value?: string }>} */
#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;
});
};
}

@ -1,7 +1,8 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest'; 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 type { Component } from 'svelte';
import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js';
import { uneval } from 'devalue';
test('collects synchronous body content by default', () => { test('collects synchronous body content by default', () => {
const component = (renderer: Renderer) => { const component = (renderer: Renderer) => {
@ -355,3 +356,39 @@ describe('async', () => {
expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']); 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);
});
});

@ -1,3 +1,4 @@
import type { Encode } from '#shared';
import type { Element } from './dev'; import type { Element } from './dev';
import type { Renderer } from './renderer'; import type { Renderer } from './renderer';
@ -14,6 +15,17 @@ export interface SSRContext {
element?: Element; element?: Element;
} }
export interface HydratableEntry {
value: unknown;
encode: Encode<any> | undefined;
stack?: string;
}
export interface RenderContext {
hydratables: Map<string, HydratableEntry>;
cache: Map<symbol, Map<any, any>>;
}
export interface SyncRenderOutput { export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */ /** HTML that goes into the `<head>` */
head: string; head: string;

@ -2,6 +2,23 @@
import { DEV } from 'esm-env'; 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 * Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead
* @returns {never} * @returns {never}

@ -8,3 +8,45 @@ export type Getters<T> = {
}; };
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>; export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;
export type MaybePromise<T> = T | Promise<T>;
export type Decode<T> = (value: any) => T;
export type Encode<T> = (value: T) => string;
export type Transport<T> =
| {
encode: Encode<T>;
decode?: undefined;
}
| {
encode?: undefined;
decode: Decode<T>;
};
export type Hydratable = {
<T>(key: string, fn: () => T, options?: { transport?: Transport<T> }): T;
get: <T>(key: string, options?: { decode?: Decode<T> }) => T | undefined;
has: (key: string) => boolean;
set: <T>(key: string, value: T, options?: { encode?: Encode<T> }) => void;
};
export type Resource<T> = {
then: Promise<Awaited<T>>['then'];
catch: Promise<Awaited<T>>['catch'];
finally: Promise<Awaited<T>>['finally'];
refresh: () => Promise<void>;
set: (value: Awaited<T>) => void;
loading: boolean;
error: any;
} & (
| {
ready: false;
current: undefined;
}
| {
ready: true;
current: Awaited<T>;
}
);

@ -48,7 +48,7 @@ export function run_all(arr) {
/** /**
* TODO replace with Promise.withResolvers once supported widely enough * TODO replace with Promise.withResolvers once supported widely enough
* @template T * @template [T=void]
*/ */
export function deferred() { export function deferred() {
/** @type {(value: T) => void} */ /** @type {(value: T) => void} */

@ -0,0 +1,19 @@
import { tick } from 'svelte';
import { ok, test } from '../../test';
export default test({
skip_mode: ['server'],
server_props: { environment: 'server' },
ssrHtml: '<p>The current environment is: server</p>',
props: { environment: 'browser' },
html: '<p>The current environment is: server</p>',
async test({ assert, target }) {
await tick();
const p = target.querySelector('p');
ok(p);
assert.htmlEqual(p.outerHTML, '<p>The current environment is: server</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>

@ -450,6 +450,7 @@ declare module 'svelte' {
* @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead
* */ * */
export function afterUpdate(fn: () => void): void; export function afterUpdate(fn: () => void): void;
export const hydratable: Hydratable;
/** /**
* Create a snippet programmatically * Create a snippet programmatically
* */ * */
@ -595,6 +596,27 @@ declare module 'svelte' {
[K in keyof T]: () => T[K]; [K in keyof T]: () => T[K];
}; };
type Decode<T> = (value: any) => T;
type Encode<T> = (value: T) => string;
type Transport<T> =
| {
encode: Encode<T>;
decode?: undefined;
}
| {
encode?: undefined;
decode: Decode<T>;
};
type Hydratable = {
<T>(key: string, fn: () => T, options?: { transport?: Transport<T> }): T;
get: <T>(key: string, options?: { decode?: Decode<T> }) => T | undefined;
has: (key: string) => boolean;
set: <T>(key: string, value: T, options?: { encode?: Encode<T> }) => void;
};
export {}; export {};
} }

@ -89,6 +89,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
devalue:
specifier: ^5.5.0
version: 5.5.0
esm-env: esm-env:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
@ -1515,6 +1518,9 @@ packages:
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
hasBin: true hasBin: true
devalue@5.5.0:
resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==}
dir-glob@3.0.1: dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -3990,6 +3996,8 @@ snapshots:
detect-libc@1.0.3: detect-libc@1.0.3:
optional: true optional: true
devalue@5.5.0: {}
dir-glob@3.0.1: dir-glob@3.0.1:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0

Loading…
Cancel
Save