feat: add `Warp` and `withWarp`, built on devalue's `unevalStream`, and deprecate `hydratable`

elliott/warp-core
Elliott Johnson 6 hours ago
parent 707c28146b
commit 59cdcbcb8f
No known key found for this signature in database

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `Warp` and `withWarp`, and deprecate `hydratable`

@ -1,123 +0,0 @@
---
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}
```
## CSP
`hydratable` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const nonce = crypto.randomUUID();
const { head, body } = await render(App, {
csp: { nonce }
});
```
This will add the `nonce` to the script block, on the assumption that you will later add the same nonce to the CSP header of the document that contains it:
```js
/// file: server.js
let response = new Response();
let nonce = 'xyz123';
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);
```
It's essential that a `nonce` — which, British slang definition aside, means 'number used once' — is only used when dynamically server rendering an individual response.
If instead you are generating static HTML ahead of time, you must use hashes instead:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const { head, body, hashes } = await render(App, {
csp: { hash: true }
});
```
`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:
```js
/// file: server.js
let response = new Response();
let hashes = { script: ['sha256-xyz123'] };
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`
);
```
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.

@ -0,0 +1,195 @@
---
title: Warp
---
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. `Warp` 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).
A `Warp` is a [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) whose contents are sent from the server to the client. Anything you add to it while rendering on the server is serialized into the `head` returned from `render`, and is available in the `Warp` with the same id on the client. To fix the example above:
```svelte
<script>
import { Warp } from 'svelte';
import { getUser } from 'my-database-library';
const warp = new Warp('my-app');
// On the server, this calls `getUser` and stores the result. On the client,
// the result from the server is already there, so `getUser` isn't called.
const user = await warp.getOrInsertComputed('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 { Warp } from 'svelte';
const warp = new Warp<string, number>('my-app');
const rand = warp.getOrInsertComputed('random', () => Math.random());
```
On the client, a `Warp` behaves like any other `Map` — values you add to it stay there until you remove them with `delete` or `clear`. On the server, values can't be removed, since they may already be in use or on their way to the client.
Keys can be strings, numbers, booleans or bigints. If you're a library author, use your package name as the `Warp`'s id, so that it doesn't conflict with other libraries:
```js
import { Warp } from 'svelte';
const warp = new Warp('my-datafetching-library');
```
## Serialization
All data added to a `Warp` 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. You can also fearlessly use promises:
```svelte
<script>
import { Warp } from 'svelte';
const warp = new Warp('my-app');
const promises = warp.getOrInsertComputed('random', () => {
return {
one: Promise.resolve(1),
two: Promise.resolve(2)
}
});
</script>
{await promises.one}
{await promises.two}
```
If a promise rejects, the promise on the client will reject too. The rejection reason is passed through the [`transformError`](svelte-server#render) option of `render` first, so that sensitive information doesn't leak to the client — if you don't provide `transformError`, the client receives a generic error instead.
To serialize other kinds of values, pass a `replacer` to `render`. It works like the replacer for devalue's [`uneval`](https://github.com/sveltejs/devalue#custom-types), receiving each value and a `js` tag that you can use to return the JavaScript that recreates it on the client:
```js
/// file: server.js
// @noErrors
import { render } from 'svelte/server';
import App from './App.svelte';
import { Vector } from './vector.js';
// ---cut---
const { head, body } = await render(App, {
replacer: (value, js) => {
if (value instanceof Vector) {
return js`new Vector(${value.x}, ${value.y})`;
}
}
});
```
## Using `Warp` outside components
`Warp` can only be used on the server while rendering. If you need to add values before the render starts — for example, while loading data for a page — wrap the work in `withWarp`. A `render` inside `withWarp` will serialize everything added to `Warp` instances inside it:
```js
/// file: server.js
// @noErrors
import { render, withWarp } from 'svelte/server';
import { Warp } from 'svelte';
import App from './App.svelte';
import { getUser } from 'my-database-library';
// ---cut---
const warp = new Warp('my-app');
const { head, body } = await withWarp(async () => {
const user = await warp.getOrInsertComputed('user', () => getUser());
return render(App, { props: { user } });
});
```
`withWarp` also accepts a `replacer`, which is used for any values the `replacer` passed to `render` doesn't handle. Each `withWarp` can only contain one `render`.
## CSP
`Warp` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const nonce = crypto.randomUUID();
const { head, body } = await render(App, {
csp: { nonce }
});
```
This will add the `nonce` to the script block, on the assumption that you will later add the same nonce to the CSP header of the document that contains it:
```js
/// file: server.js
let response = new Response();
let nonce = 'xyz123';
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);
```
It's essential that a `nonce` — which, British slang definition aside, means 'number used once' — is only used when dynamically server rendering an individual response.
If instead you are generating static HTML ahead of time, you must use hashes instead:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const { head, body, hashes } = await render(App, {
csp: { hash: true }
});
```
`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:
```js
/// file: server.js
let response = new Response();
let hashes = { script: ['sha256-xyz123'] };
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`
);
```
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.
## `hydratable`
`hydratable` is the predecessor of `Warp`, and is deprecated. `hydratable(key, fn)` behaves like `warp.getOrInsertComputed(key, fn)`, except that on the client, it only uses the value from the server during hydration:
```js
// @noErrors
import { hydratable, Warp } from 'svelte';
// before
const user = await hydratable('user', () => getUser());
// after
const warp = new Warp('my-app');
const user = await warp.getOrInsertComputed('user', () => getUser());
```

@ -171,7 +171,7 @@ Use `createContext` rather than `setContext` and `getContext`, as it provides ty
## Async Svelte
If using version 5.36 or higher, you can use [await expressions](await-expressions) and [hydratable](hydratable) to use promises directly inside components. Note that these require the `experimental.async` option to be enabled in the plugin options in `vite.config.js` or in the `svelte.config.js` as they are not yet considered fully stable.
If using version 5.36 or higher, you can use [await expressions](await-expressions) and [`Warp`](warp) to use promises directly inside components. Note that these require the `experimental.async` option to be enabled in the plugin options in `vite.config.js` or in the `svelte.config.js` as they are not yet considered fully stable.
## Avoid legacy features

@ -52,17 +52,6 @@ This error occurs when using `hydratable` multiple times with the same key. To a
</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%
```
### invalid_csp
```
@ -89,4 +78,45 @@ Certain methods such as `mount` cannot be invoked while running in a server cont
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.
Certain functions such as `Warp` methods cannot be invoked outside of a `render(...)` or `withWarp(...)` call, such as at the top level of a module.
### warp_context_already_rendered
```
`render(...)` was called more than once inside the same `withWarp(...)` call
```
Each `withWarp` call can only contain one `render`, since all the values added to `Warp` instances inside it are serialized into that render's output. If you need to render more than once, use a separate `withWarp` call for each render.
### warp_context_nested
```
`withWarp(...)` cannot be called inside another `withWarp(...)` or `render(...)` call
```
### warp_method_unsupported
```
`warp.%method%(...)` is not supported on the server
```
Values added to a `Warp` on the server may already be in use (or on their way to the client), so they cannot be removed.
### warp_serialization_failed
```
Failed to serialize the value with key `%key%` in `Warp` `%id%`.
`Warp` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a `replacer` to `render` or `withWarp`.
Cause:
%stack%
```
### warp_set_after_render
```
Cannot set key `%key%` in `Warp` `%id%` after its values have been serialized
```
Values can be added to a `Warp` until the rendered HTML has been generated, after which they can no longer be sent to the client.

@ -1,23 +1,26 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### unresolved_hydratable
### unresolved_warp
```
A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during the render.
The `hydratable` was initialized in:
The value was set in:
%stack%
```
The most likely cause of this is creating a `hydratable` in the `script` block of your component and then `await`ing
The server has to wait for this value to resolve before it can send the rendered HTML, which delays the response even though the value was not needed for the render.
The most likely cause of this is creating a value 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 { Warp } from 'svelte';
import { getUser } from '$lib/get-user.js';
const user = hydratable('user', getUser);
const warp = new Warp('my-app');
const user = warp.getOrInsertComputed('user', getUser);
</script>
<svelte:boundary>
@ -29,6 +32,6 @@ the result inside a `svelte:boundary` with a `pending` snippet:
</svelte:boundary>
```
Consider inlining the `hydratable` call inside the boundary so that it's not called on the server.
Consider moving the `getOrInsertComputed` 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.
Note that this can also happen when a value contains multiple promises and some but not all of them have been used.

@ -40,15 +40,6 @@ This error occurs when using `hydratable` multiple times with the same key. To a
</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%
## invalid_csp
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
@ -67,4 +58,35 @@ Certain methods such as `mount` cannot be invoked while running in a server cont
> 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.
Certain functions such as `Warp` methods cannot be invoked outside of a `render(...)` or `withWarp(...)` call, such as at the top level of a module.
## warp_context_already_rendered
> `render(...)` was called more than once inside the same `withWarp(...)` call
Each `withWarp` call can only contain one `render`, since all the values added to `Warp` instances inside it are serialized into that render's output. If you need to render more than once, use a separate `withWarp` call for each render.
## warp_context_nested
> `withWarp(...)` cannot be called inside another `withWarp(...)` or `render(...)` call
## warp_method_unsupported
> `warp.%method%(...)` is not supported on the server
Values added to a `Warp` on the server may already be in use (or on their way to the client), so they cannot be removed.
## warp_serialization_failed
> Failed to serialize the value with key `%key%` in `Warp` `%id%`.
>
> `Warp` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a `replacer` to `render` or `withWarp`.
>
> Cause:
> %stack%
## warp_set_after_render
> Cannot set key `%key%` in `Warp` `%id%` after its values have been serialized
Values can be added to a `Warp` until the rendered HTML has been generated, after which they can no longer be sent to the client.

@ -1,19 +1,22 @@
## unresolved_hydratable
## unresolved_warp
> A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
> The value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during the render.
>
> The `hydratable` was initialized in:
> The value was set in:
> %stack%
The most likely cause of this is creating a `hydratable` in the `script` block of your component and then `await`ing
The server has to wait for this value to resolve before it can send the rendered HTML, which delays the response even though the value was not needed for the render.
The most likely cause of this is creating a value 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 { Warp } from 'svelte';
import { getUser } from '$lib/get-user.js';
const user = hydratable('user', getUser);
const warp = new Warp('my-app');
const user = warp.getOrInsertComputed('user', getUser);
</script>
<svelte:boundary>
@ -25,6 +28,6 @@ the result inside a `svelte:boundary` with a `pending` snippet:
</svelte:boundary>
```
Consider inlining the `hydratable` call inside the boundary so that it's not called on the server.
Consider moving the `getOrInsertComputed` 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.
Note that this can also happen when a value contains multiple promises and some but not all of them have been used.

@ -179,7 +179,7 @@
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.9.2",
"devalue": "https://pkg.svelte.dev/devalue/c/7e38de619f957ea23f1bcfb115b04dd0c2c0482f",
"esm-env": "^1.2.1",
"esrap": "^2.3.6",
"is-reference": "^3.0.3",

@ -250,6 +250,7 @@ export {
setContext
} from './internal/client/context.js';
export { hydratable } from './internal/client/hydratable.js';
export { Warp } from './internal/client/warp.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';

@ -52,5 +52,6 @@ export {
} from './internal/server/context.js';
export { hydratable } from './internal/server/hydratable.js';
export { Warp } from './internal/server/warp.js';
export { createRawSnippet } from './internal/server/blocks/snippet.js';

@ -1,10 +1,15 @@
import { async_mode_flag } from '../flags/index.js';
import { hydrating } from './dom/hydration.js';
import { Warp } from './warp.js';
import * as w from './warnings.js';
import * as e from './errors.js';
import { DEV } from 'esm-env';
/** @type {Warp<string, any>} */
const warp = new Warp('svelte:hydratable');
/**
* @deprecated Use [`Warp`](https://svelte.dev/docs/svelte/warp) instead
* @template T
* @param {string} key
* @param {() => T} fn
@ -16,10 +21,8 @@ export function hydratable(key, fn) {
}
if (hydrating) {
const store = window.__svelte?.h;
if (store?.has(key)) {
return /** @type {T} */ (store.get(key));
if (warp.has(key)) {
return warp.get(key);
}
if (DEV) {

@ -1,4 +1,4 @@
import type { Store } from '#shared';
import type { Store, WarpKey } from '#shared';
import { STATE_SYMBOL } from './constants.js';
import type { Batch } from './reactivity/batch.js';
import type { Effect, Source, Value } from './reactivity/types.js';
@ -6,8 +6,8 @@ import type { Effect, Source, Value } from './reactivity/types.js';
declare global {
interface Window {
__svelte?: {
/** hydratables */
h?: Map<string, unknown>;
/** `Warp` values, keyed by the `Warp`'s id */
w?: Map<string, Map<WarpKey, unknown>>;
};
}
}

@ -0,0 +1,142 @@
/** @import { WarpKey } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import * as e from './errors.js';
/**
* A `Map` whose contents are sent from the server to the client. Values added to it
* during server rendering (or inside `withWarp`) are serialized into the rendered
* HTML, so that the same `Warp` can read them on the client during hydration.
*
* Values can be anything [`devalue`](https://github.com/sveltejs/devalue) can serialize,
* including promises.
*
* @template {WarpKey} K
* @template V
* @implements {Map<K, V>}
*/
export class Warp {
/** @type {string} */
#id;
/**
* @param {string} id A unique identifier for this `Warp`, which must match on the server and the client.
* Libraries should prefix it with their package name to avoid collisions.
*/
constructor(id) {
this.#id = id;
}
/** @returns {Map<K, V>} */
#values() {
if (!async_mode_flag) {
e.experimental_async_required('Warp');
}
const store = ((window.__svelte ??= {}).w ??= new Map());
let values = store.get(this.#id);
if (values === undefined) {
values = new Map();
store.set(this.#id, values);
}
return /** @type {Map<K, V>} */ (values);
}
/**
* @param {K} key
* @returns {V | undefined}
*/
get(key) {
return this.#values().get(key);
}
/**
* @param {K} key
* @returns {boolean}
*/
has(key) {
return this.#values().has(key);
}
/**
* @param {K} key
* @param {V} value
* @returns {this}
*/
set(key, value) {
this.#values().set(key, value);
return this;
}
/**
* Returns the value for `key` if it exists, otherwise adds `value` and returns it.
* @param {K} key
* @param {V} value
* @returns {V}
*/
getOrInsert(key, value) {
const values = this.#values();
if (values.has(key)) return /** @type {V} */ (values.get(key));
values.set(key, value);
return value;
}
/**
* Returns the value for `key` if it exists, otherwise calls `callback` and adds the result.
* @param {K} key
* @param {(key: K) => V} callback
* @returns {V}
*/
getOrInsertComputed(key, callback) {
const values = this.#values();
if (values.has(key)) return /** @type {V} */ (values.get(key));
const value = callback(key);
values.set(key, value);
return value;
}
/**
* @param {K} key
* @returns {boolean}
*/
delete(key) {
return this.#values().delete(key);
}
clear() {
this.#values().clear();
}
/**
* @param {(value: V, key: K, map: Map<K, V>) => void} callback
* @param {any} [this_arg]
*/
forEach(callback, this_arg) {
this.#values().forEach(callback, this_arg);
}
entries() {
return this.#values().entries();
}
keys() {
return this.#values().keys();
}
values() {
return this.#values().values();
}
get size() {
return this.#values().size;
}
[Symbol.iterator]() {
return this.#values()[Symbol.iterator]();
}
get [Symbol.toStringTag]() {
return 'Warp';
}
}

@ -69,30 +69,6 @@ ${stack}\nhttps://svelte.dev/e/hydratable_clobbering`);
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;
}
/**
* `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
* @returns {never}
@ -139,5 +115,81 @@ export function server_context_required() {
error.name = 'Svelte error';
throw error;
}
/**
* `render(...)` was called more than once inside the same `withWarp(...)` call
* @returns {never}
*/
export function warp_context_already_rendered() {
const error = new Error(`warp_context_already_rendered\n\`render(...)\` was called more than once inside the same \`withWarp(...)\` call\nhttps://svelte.dev/e/warp_context_already_rendered`);
error.name = 'Svelte error';
throw error;
}
/**
* `withWarp(...)` cannot be called inside another `withWarp(...)` or `render(...)` call
* @returns {never}
*/
export function warp_context_nested() {
const error = new Error(`warp_context_nested\n\`withWarp(...)\` cannot be called inside another \`withWarp(...)\` or \`render(...)\` call\nhttps://svelte.dev/e/warp_context_nested`);
error.name = 'Svelte error';
throw error;
}
/**
* `warp.%method%(...)` is not supported on the server
* @param {string} method
* @returns {never}
*/
export function warp_method_unsupported(method) {
const error = new Error(`warp_method_unsupported\n\`warp.${method}(...)\` is not supported on the server\nhttps://svelte.dev/e/warp_method_unsupported`);
error.name = 'Svelte error';
throw error;
}
/**
* Failed to serialize the value with key `%key%` in `Warp` `%id%`.
*
* `Warp` can serialize anything [`uneval` from `devalue`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a `replacer` to `render` or `withWarp`.
*
* Cause:
* %stack%
* @param {string} key
* @param {string} id
* @param {string} stack
* @returns {never}
*/
export function warp_serialization_failed(key, id, stack) {
const error = new Error(`warp_serialization_failed\nFailed to serialize the value with key \`${key}\` in \`Warp\` \`${id}\`.
\`Warp\` can serialize anything [\`uneval\` from \`devalue\`](https://npmjs.com/package/devalue) can, including promises. To serialize other values, pass a \`replacer\` to \`render\` or \`withWarp\`.
Cause:
${stack}\nhttps://svelte.dev/e/warp_serialization_failed`);
error.name = 'Svelte error';
throw error;
}
/**
* Cannot set key `%key%` in `Warp` `%id%` after its values have been serialized
* @param {string} key
* @param {string} id
* @returns {never}
*/
export function warp_set_after_render(key, id) {
const error = new Error(`warp_set_after_render\nCannot set key \`${key}\` in \`Warp\` \`${id}\` after its values have been serialized\nhttps://svelte.dev/e/warp_set_after_render`);
error.name = 'Svelte error';
throw error;
}

@ -1,12 +1,18 @@
/** @import { HydratableLookupEntry } from '#server' */
import { async_mode_flag } from '../flags/index.js';
import { get_render_context } from './render-context.js';
import { Warp, get_stack, is_promise } from './warp.js';
import * as e from './errors.js';
import * as devalue from 'devalue';
import { DEV } from 'esm-env';
import { get_user_code_location } from './dev.js';
export const HYDRATABLE_ID = 'svelte:hydratable';
/** @type {Warp<string, any>} */
const warp = new Warp(HYDRATABLE_ID);
/**
* @deprecated Use [`Warp`](https://svelte.dev/docs/svelte/warp) instead
* @template T
* @param {string} key
* @param {() => T} fn
@ -17,111 +23,79 @@ export function hydratable(key, fn) {
e.experimental_async_required('hydratable');
}
const { hydratable } = get_render_context();
let entry = hydratable.lookup.get(key);
if (warp.has(key)) {
const value = warp.get(key);
if (entry !== undefined) {
if (DEV) {
const comparison = compare(key, entry, encode(key, fn()));
const store = get_render_context().warp;
const comparison = compare(key, value, fn(), get_stack(store, HYDRATABLE_ID, key));
comparison.catch(() => {});
hydratable.comparisons.push(comparison);
store.comparisons.push(comparison);
}
return /** @type {T} */ (entry.value);
return value;
}
const value = fn();
entry = encode(key, value, hydratable.unresolved_promises);
hydratable.lookup.set(key, entry);
warp.set(key, value);
return value;
}
/**
* @param {string} key
* @param {any} value
* @param {Map<Promise<any>, string>} [unresolved]
* Serializes a value, waiting for any promises inside it to resolve
* @param {unknown} value
* @returns {Promise<string>}
*/
function encode(key, value, unresolved) {
/** @type {HydratableLookupEntry} */
const entry = { value, serialized: '' };
if (DEV) {
entry.stack = get_user_code_location();
async function serialize(value) {
/** @type {Map<Promise<unknown>, unknown>} */
const resolved = new Map();
/** @type {unknown[]} */
const pending = [value];
while (pending.length > 0) {
/** @type {Promise<unknown>[]} */
const promises = [];
devalue.uneval(pending.splice(0), (thing, js) => {
if (is_promise(thing)) {
if (!resolved.has(thing)) promises.push(thing);
return js`0`;
}
});
for (const promise of promises) {
const v = await promise;
resolved.set(promise, v);
pending.push(v);
}
}
let uid = 1;
entry.serialized = devalue.uneval(entry.value, (value, uneval) => {
if (is_promise(value)) {
// we serialize promises as `"${i}"`, because it's impossible for that string
// to occur 'naturally' (since the quote marks would have to be escaped)
// this placeholder is returned synchronously from `uneval`, which includes it in the
// serialized string. Later (at least one microtask from now), when `p.then` runs, it'll
// be replaced.
const placeholder = `"${uid++}"`;
const p = value
.then((v) => {
entry.serialized = entry.serialized.replace(
placeholder,
// use the function form here to prevent any string replacement characters from being interpreted
// in `v`, as it's potentially user-controlled and therefore potentially malicious.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement
() => `r(${uneval(v)})`
);
})
.catch((devalue_error) =>
e.hydratable_serialization_failed(
key,
serialization_stack(entry.stack, devalue_error?.stack)
)
);
unresolved?.set(p, key);
// prevent unhandled rejections from crashing the server, track which promises are still resolving when render is complete
p.catch(() => {}).finally(() => unresolved?.delete(p));
(entry.promises ??= []).push(p);
return placeholder;
}
return devalue.uneval(value, (thing, js) => {
if (is_promise(thing)) return js`r(${resolved.get(thing)})`;
});
return entry;
}
/**
* @param {any} value
* @returns {value is Promise<any>}
*/
function is_promise(value) {
// we use this check rather than `instanceof Promise`
// because it works cross-realm
return Object.prototype.toString.call(value) === '[object Promise]';
}
/**
* @param {string} key
* @param {HydratableLookupEntry} a
* @param {HydratableLookupEntry} b
* @param {unknown} a
* @param {unknown} b
* @param {string} a_stack
*/
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;
async function compare(key, a, b, a_stack) {
const b_stack = get_user_code_location();
let a_serialized;
let b_serialized;
try {
a_serialized = await serialize(a);
b_serialized = await serialize(b);
} catch {
// serialization errors are surfaced separately, when the value is serialized for real
return;
}
if (a.serialized !== b.serialized) {
const a_stack = /** @type {string} */ (a.stack);
const b_stack = /** @type {string} */ (b.stack);
if (a_serialized !== b_serialized) {
const stack =
a_stack === b_stack
? `Occurred at:\n${a_stack}`
@ -130,18 +104,3 @@ async function compare(key, a, b) {
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>';
}

@ -1,33 +0,0 @@
import { afterAll, beforeAll, expect, test } from 'vitest';
import { Renderer } from './renderer.js';
import type { Component } from 'svelte';
import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js';
import { hydratable } from './hydratable.js';
beforeAll(() => {
enable_async_mode_flag();
});
afterAll(() => {
disable_async_mode_flag();
});
test('treats replacement tokens in hydratable promise values as literals', async () => {
const component = (renderer: Renderer) => {
hydratable('key', () => Promise.resolve(`$'`));
renderer.child(async () => {
await Promise.resolve();
});
renderer.push('ok');
};
const { head } = await Renderer.render(component as unknown as Component);
const script_match = head.match(/<script(?:\s[^>]*)?>([\s\S]*)<\/script>/);
expect(script_match, 'expected hydratable script in head output').toBeTruthy();
const script_content = script_match![1];
expect(script_content).toContain('const h = (window.__svelte ??= {}).h ??= new Map();');
expect(script_content).toContain('r("$\'")');
expect(script_content).toMatch(/\[\s*"key"\s*,\s*r\("\$'"\)\s*\]/);
});

@ -1,4 +1,5 @@
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
/** @import { UnevalReplacer } from 'devalue' */
/** @import { Csp, RenderOutput } from '../../server/public.js' */
/** @import { Store } from '#shared' */
export { FILENAME, HMR } from '../../constants.js';
@ -65,7 +66,7 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
* @template {Record<string, any>} Props
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown; replacer?: UnevalReplacer }} [options]
* @returns {RenderOutput}
*/
export function render(component, options = {}) {

@ -1,5 +1,6 @@
// @ts-ignore -- we don't include node types in the production build
/** @import { AsyncLocalStorage } from 'node:async_hooks' */
/** @import { UnevalReplacer } from 'devalue' */
/** @import { RenderContext } from '#server' */
import { deferred, noop } from '../shared/utils.js';
@ -13,7 +14,7 @@ let context = null;
/** @returns {RenderContext} */
export function get_render_context() {
const store = context ?? als?.getStore();
const store = get_render_context_safe();
if (!store) {
e.server_context_required();
@ -22,33 +23,99 @@ export function get_render_context() {
return store;
}
/** @returns {RenderContext | null} */
function get_render_context_safe() {
return context ?? als?.getStore() ?? null;
}
/**
* @param {RenderContext['owner']} owner
* @param {UnevalReplacer | undefined} replacer
* @returns {RenderContext}
*/
function create_render_context(owner, replacer) {
return {
warp: {
values: new Map(),
stacks: new Map(),
comparisons: [],
emitted: false
},
owner,
rendered: owner === 'render',
replacer
};
}
/**
* Runs `fn` with a render context. If `fn` is running inside `withWarp`,
* that context is used — but only for one render.
* @template T
* @param {() => Promise<T>} fn
* @param {(context: RenderContext) => Promise<T>} fn
* @returns {Promise<T>}
*/
export async function with_render_context(fn) {
context = {
hydratable: {
lookup: new Map(),
comparisons: [],
unresolved_promises: new Map()
const existing = get_render_context_safe();
// a `render` inside another `render` (rather than inside `withWarp`) gets its own context
if (existing?.owner === 'withWarp') {
if (existing.rendered) {
e.warp_context_already_rendered();
}
};
existing.rendered = true;
return fn(existing);
}
await init_render_context();
return run(create_render_context('render', undefined), fn);
}
/**
* Only available on the server. Runs `fn` with a context in which `Warp` instances
* can be used. A `render` call inside `fn` will use the same context, and serialize
* all the values added to `Warp` instances inside `fn` — whether they were added
* before or during the render. Only one `render` can happen inside a given `withWarp`.
* @template T
* @param {() => T | Promise<T>} fn
* @param {{ replacer?: UnevalReplacer }} [options]
* @returns {Promise<T>}
*/
export async function withWarp(fn, options = {}) {
if (get_render_context_safe()) {
e.warp_context_nested();
}
await init_render_context();
return run(create_render_context('withWarp', options.replacer), async () => fn());
}
/**
* @template T
* @param {RenderContext} ctx
* @param {(context: RenderContext) => Promise<T>} fn
* @returns {Promise<T>}
*/
async function run(ctx, fn) {
if (in_webcontainer()) {
const { promise, resolve } = deferred();
const previous_render = current_render;
current_render = promise;
await previous_render;
return fn().finally(resolve);
context = ctx;
return fn(ctx).finally(() => {
context = null;
resolve();
});
}
try {
if (als === null) {
e.async_local_storage_unavailable();
}
return als.run(context, fn);
context = ctx;
return als.run(ctx, () => fn(ctx));
} finally {
context = null;
}

@ -1,5 +1,7 @@
/** @import { Component } from 'svelte' */
/** @import { HydratableContext, SSRContext } from './types.js' */
/** @import { UnevalReplacer } from 'devalue' */
/** @import { RenderContext, SSRContext, WarpStore } from './types.js' */
/** @import { WarpKey } from '#shared' */
/** @import { Csp, RenderOutput, SyncRenderOutput, Sha256Source } from '../../server/public.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
@ -10,10 +12,12 @@ import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { HYDRATION_START_FAILED } from '../../constants.js';
import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { with_render_context } from './render-context.js';
import { get_stack, is_promise } from './warp.js';
import { sha256 } from './crypto.js';
import * as devalue from 'devalue';
import { has_own_property, is_array, noop } from '../shared/utils.js';
import { DEV } from 'esm-env';
import { escape_html } from '../../escaping.js';
/** @typedef {'head' | 'body'} RendererType */
@ -611,7 +615,7 @@ export class Renderer {
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown; replacer?: UnevalReplacer }} [options]
* @returns {RenderOutput}
*/
static render(component, options = {}) {
@ -620,9 +624,7 @@ export class Renderer {
new RenderResult(
() => Renderer.#render(component, options),
() =>
init_render_context().then(() =>
with_render_context(() => Renderer.#render_async(component, options))
)
with_render_context((context) => Renderer.#render_async(component, options, context))
)
)
);
@ -756,10 +758,11 @@ export class Renderer {
*
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; replacer?: UnevalReplacer }} options
* @param {RenderContext} context
* @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>}
*/
static async #render_async(component, options) {
static async #render_async(component, options, context) {
const previous_context = ssr_context;
const renderer = Renderer.#create('async', options);
/** @type {(AccumulatedContent & { hashes: { script: Sha256Source[] } }) | undefined} */
@ -771,9 +774,12 @@ export class Renderer {
try {
Renderer.#open_render(renderer, component, options);
const content = await renderer.#collect_content_async();
const hydratables = await renderer.#collect_hydratables();
if (hydratables !== null) {
content.head = hydratables + content.head;
const warp = await renderer.#collect_warp(
context.warp,
compose_replacers(options.replacer, context.replacer)
);
if (warp !== null) {
content.head = warp + content.head;
}
result = Renderer.#close_render(content, renderer);
} catch (error) {
@ -862,21 +868,94 @@ export class Renderer {
return content;
}
async #collect_hydratables() {
const ctx = get_render_context().hydratable;
/**
* Waits for the values added to `Warp` instances to resolve, then serializes them
* into a `<script>` that recreates them on the client.
* @param {WarpStore} store
* @param {UnevalReplacer | undefined} replacer
* @returns {Promise<string | null>}
*/
async #collect_warp(store, replacer) {
// these reject if there's a mismatch. a loop, as more can be added while we're awaiting
for (let i = 0; i < store.comparisons.length; i += 1) {
await store.comparisons[i];
}
/** @type {Map<Promise<unknown>, unknown>} */
const resolved = new Map();
/** @type {Set<Promise<unknown>>} */
const visited = new Set();
// values can be added while we're awaiting, and Map iteration includes them
for (const [id, values] of store.values) {
for (const [key, value] of values) {
await resolve_warp_value(store, id, key, value, replacer, resolved, visited);
}
}
store.emitted = true;
/** @type {Map<string, Map<WarpKey, unknown>>} */
const payload = new Map();
for (const [id, values] of store.values) {
if (values.size > 0) payload.set(id, values);
}
if (payload.size === 0) {
return null;
}
const { head, tail } = devalue.unevalStream(
payload,
(thing, js) => {
if (is_promise(thing) && resolved.has(thing)) {
return js`Promise.resolve(${resolved.get(thing)})`;
}
return replacer?.(thing, js);
},
{
id: `${this.global.id_prefix}w`,
scope: 'window.__svelte.d',
// rejected promises reject on the client too, with whatever `transformError` returns
transformError: (error) => this.global.transformError(error)
}
);
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>');
// every promise has settled, so the tail is only rejections and finishes right away
let blocks = '';
for await (const block of tail) {
blocks += `\n\t\t\t${block}`;
}
for (const comparison of ctx.comparisons) {
// these reject if there's a mismatch
await comparison;
const body = `
{
const w = (window.__svelte ??= {}).w ??= new Map();
for (const [id, values] of ${head}) {
const existing = w.get(id);
if (existing) {
for (const [k, v] of values) existing.set(k, v);
} else {
w.set(id, values);
}
}${blocks}
}
`;
let csp_attr = '';
if (this.global.csp.nonce) {
csp_attr = ` nonce="${this.global.csp.nonce}"`;
} else if (this.global.csp.hash) {
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// because the it's impossible for identical data to occur multiple times in a single render
const hash = await sha256(body);
this.global.csp.script_hashes.push(`sha256-${hash}`);
}
return await this.#hydratable_block(ctx);
return `\n\t\t<script${csp_attr}>${body}</script>`;
}
/**
@ -924,59 +1003,116 @@ export class Renderer {
}
};
}
}
/**
* @param {HydratableContext} ctx
*/
async #hydratable_block(ctx) {
if (ctx.lookup.size === 0) {
return null;
}
const PENDING = Symbol('pending');
let entries = [];
let has_promises = false;
/**
* Returns the outcome of `promise` if it has already settled, or `PENDING` otherwise
* @param {Promise<unknown>} promise
* @returns {Promise<{ ok: boolean, value: unknown } | typeof PENDING>}
*/
function peek(promise) {
// if `promise` has settled, its reaction is queued before the one for the already-resolved `PENDING`
return Promise.race([promise, Promise.resolve(PENDING)]).then(
(value) => (value === PENDING ? PENDING : { ok: true, value }),
(value) => ({ ok: false, value })
);
}
for (const [k, v] of ctx.lookup) {
if (v.promises) {
has_promises = true;
for (const p of v.promises) await p;
}
/**
* Finds the promises inside a `Warp` value (including inside the values those promises resolve to),
* waits for them to settle, and records the values of the ones that resolved
* @param {WarpStore} store
* @param {string} id
* @param {WarpKey} key
* @param {unknown} value
* @param {UnevalReplacer | undefined} replacer
* @param {Map<Promise<unknown>, unknown>} resolved
* @param {Set<Promise<unknown>>} visited
*/
async function resolve_warp_value(store, id, key, value, replacer, resolved, visited) {
let warned = false;
const queue = [value];
entries.push(`[${devalue.uneval(k)},${v.serialized}]`);
}
while (queue.length > 0) {
/** @type {Promise<unknown>[]} */
const promises = [];
let prelude = `const h = (window.__svelte ??= {}).h ??= new Map();`;
try {
// we only care about the promises this finds, and whether it throws
devalue.uneval(queue.splice(0), (thing, js) => {
if (is_promise(thing)) {
if (!visited.has(thing)) {
visited.add(thing);
promises.push(thing);
}
if (has_promises) {
prelude = `const r = (v) => Promise.resolve(v);
${prelude}`;
return js`0`;
}
return replacer?.(thing, js);
});
} catch (error) {
e.warp_serialization_failed(
String(key),
id,
serialization_stack(
DEV ? get_stack(store, id, key) : undefined,
/** @type {any} */ (error)?.stack
)
);
}
const body = `
{
${prelude}
for (const promise of promises) {
let outcome = await peek(promise);
for (const [k, v] of [
${entries.join(',\n\t\t\t\t\t')}
]) {
h.set(k, v);
if (outcome === PENDING) {
if (!warned) {
// 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.
warned = true;
w.unresolved_warp(String(key), id, get_stack(store, id, key));
}
outcome = await promise.then(
(value) => ({ ok: true, value }),
(value) => ({ ok: false, value })
);
}
`;
let csp_attr = '';
if (this.global.csp.nonce) {
csp_attr = ` nonce="${this.global.csp.nonce}"`;
} else if (this.global.csp.hash) {
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// because the it's impossible for identical data to occur multiple times in a single render
// (this would require the same hydratable key:value pair to be serialized multiple times)
const hash = await sha256(body);
this.global.csp.script_hashes.push(`sha256-${hash}`);
// rejected promises are left for `unevalStream`, which rejects them on the client
if (outcome.ok) {
resolved.set(promise, outcome.value);
queue.push(outcome.value);
}
}
}
}
return `\n\t\t<script${csp_attr}>${body}</script>`;
/**
* @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>';
}
/**
* @param {UnevalReplacer | undefined} a
* @param {UnevalReplacer | undefined} b
* @returns {UnevalReplacer | undefined}
*/
function compose_replacers(a, b) {
if (!a || !b) return a ?? b;
return (value, js) => a(value, js) || b(value, js);
}
export class SSRState {
@ -989,6 +1125,9 @@ export class SSRState {
/** @readonly @type {() => string} */
uid;
/** @readonly @type {string} */
id_prefix;
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set();
@ -1026,6 +1165,8 @@ export class SSRState {
throw error;
});
this.id_prefix = id_prefix;
let uid = 1;
this.uid = () => `${id_prefix}s${uid++}`;
}

@ -1,4 +1,5 @@
import type { MaybePromise } from '#shared';
import type { UnevalReplacer } from 'devalue';
import type { MaybePromise, WarpKey } from '#shared';
import type { Element } from './dev';
import type { Renderer } from './renderer';
@ -17,20 +18,23 @@ 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>;
export interface WarpStore {
/** The values stored in each `Warp`, keyed by the `Warp`'s id */
values: Map<string, Map<WarpKey, unknown>>;
/** dev-only: where each value was set, keyed by the `Warp`'s id */
stacks: Map<string, Map<WarpKey, string>>;
/** dev-only: `hydratable` clobbering checks, which reject on mismatch */
comparisons: Promise<void>[];
unresolved_promises: Map<Promise<string>, string>;
/** Whether the values have been serialized, after which no more can be added */
emitted: boolean;
}
export interface RenderContext {
hydratable: HydratableContext;
warp: WarpStore;
/** Whether this context was created by `render` or by `withWarp` */
owner: 'render' | 'withWarp';
/** Whether a `render` has claimed this context. Each context can only be rendered once */
rendered: boolean;
/** The `replacer` passed to `withWarp` */
replacer: UnevalReplacer | undefined;
}

@ -6,24 +6,25 @@ var bold = 'font-weight: bold';
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 value with key `%key%` in `Warp` `%id%` was created, but at least part of it was not used during the render.
*
* The `hydratable` was initialized in:
* The value was set in:
* %stack%
* @param {string} key
* @param {string} id
* @param {string} stack
*/
export function unresolved_hydratable(key, stack) {
export function unresolved_warp(key, id, 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.
`%c[svelte] unresolved_warp\n%cThe value with key \`${key}\` in \`Warp\` \`${id}\` 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`,
The value was set in:
${stack}\nhttps://svelte.dev/e/unresolved_warp`,
bold,
normal
);
} else {
console.warn(`https://svelte.dev/e/unresolved_hydratable`);
console.warn(`https://svelte.dev/e/unresolved_warp`);
}
}

@ -0,0 +1,212 @@
/** @import { WarpStore } from '#server' */
/** @import { WarpKey } from '#shared' */
import { DEV } from 'esm-env';
import { async_mode_flag } from '../flags/index.js';
import { get_render_context } from './render-context.js';
import { get_user_code_location } from './dev.js';
import * as e from './errors.js';
/**
* A `Map` whose contents are sent from the server to the client. Values added to it
* during server rendering (or inside `withWarp`) are serialized into the rendered
* HTML, so that the same `Warp` can read them on the client during hydration.
*
* Values can be anything [`devalue`](https://github.com/sveltejs/devalue) can serialize,
* including promises.
*
* @template {WarpKey} K
* @template V
* @implements {Map<K, V>}
*/
export class Warp {
/** @type {string} */
#id;
/**
* @param {string} id A unique identifier for this `Warp`, which must match on the server and the client.
* Libraries should prefix it with their package name to avoid collisions.
*/
constructor(id) {
this.#id = id;
}
/** @returns {Map<K, V>} */
#values() {
return /** @type {Map<K, V>} */ (get_values(get_store(), this.#id));
}
/**
* @param {K} key
* @returns {V | undefined}
*/
get(key) {
return this.#values().get(key);
}
/**
* @param {K} key
* @returns {boolean}
*/
has(key) {
return this.#values().has(key);
}
/**
* @param {K} key
* @param {V} value
* @returns {this}
*/
set(key, value) {
set(get_store(), this.#id, key, value);
return this;
}
/**
* Returns the value for `key` if it exists, otherwise adds `value` and returns it.
* @param {K} key
* @param {V} value
* @returns {V}
*/
getOrInsert(key, value) {
const store = get_store();
const values = /** @type {Map<K, V>} */ (get_values(store, this.#id));
if (values.has(key)) return /** @type {V} */ (values.get(key));
set(store, this.#id, key, value);
return value;
}
/**
* Returns the value for `key` if it exists, otherwise calls `callback` and adds the result.
* @param {K} key
* @param {(key: K) => V} callback
* @returns {V}
*/
getOrInsertComputed(key, callback) {
const store = get_store();
const values = /** @type {Map<K, V>} */ (get_values(store, this.#id));
if (values.has(key)) return /** @type {V} */ (values.get(key));
const value = callback(key);
set(store, this.#id, key, value);
return value;
}
/**
* Not supported on the server, since the value may already be in use.
* @param {K} key
* @returns {boolean}
*/
delete(key) {
e.warp_method_unsupported('delete');
}
/**
* Not supported on the server, since the values may already be in use.
* @returns {void}
*/
clear() {
e.warp_method_unsupported('clear');
}
/**
* @param {(value: V, key: K, map: Map<K, V>) => void} callback
* @param {any} [this_arg]
*/
forEach(callback, this_arg) {
this.#values().forEach(callback, this_arg);
}
entries() {
return this.#values().entries();
}
keys() {
return this.#values().keys();
}
values() {
return this.#values().values();
}
get size() {
return this.#values().size;
}
[Symbol.iterator]() {
return this.#values()[Symbol.iterator]();
}
get [Symbol.toStringTag]() {
return 'Warp';
}
}
function get_store() {
if (!async_mode_flag) {
e.experimental_async_required('Warp');
}
return get_render_context().warp;
}
/**
* @param {WarpStore} store
* @param {string} id
*/
function get_values(store, id) {
let values = store.values.get(id);
if (values === undefined) {
values = new Map();
store.values.set(id, values);
}
return values;
}
/**
* @param {WarpStore} store
* @param {string} id
* @param {WarpKey} key
* @param {unknown} value
*/
function set(store, id, key, value) {
if (store.emitted) {
e.warp_set_after_render(id, String(key));
}
get_values(store, id).set(key, value);
if (DEV) {
let stacks = store.stacks.get(id);
if (stacks === undefined) {
stacks = new Map();
store.stacks.set(id, stacks);
}
stacks.set(key, get_user_code_location());
}
}
/**
* dev-only: the location at which a value was set
* @param {WarpStore} store
* @param {string} id
* @param {WarpKey} key
*/
export function get_stack(store, id, key) {
return store.stacks.get(id)?.get(key) ?? '<missing stack trace>';
}
/**
* @param {unknown} value
* @returns {value is Promise<unknown>}
*/
export function is_promise(value) {
// we use this check rather than `instanceof Promise` because it works cross-realm
return Object.prototype.toString.call(value) === '[object Promise]';
}

@ -0,0 +1,293 @@
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
import type { Component } from 'svelte';
import { Renderer } from './renderer.js';
import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js';
import { withWarp } from './render-context.js';
import { Warp } from './warp.js';
import { hydratable } from './hydratable.js';
beforeAll(() => {
enable_async_mode_flag();
});
afterAll(() => {
disable_async_mode_flag();
});
const warp = new Warp<string, any>('test');
function as_component(fn: (renderer: Renderer) => void) {
return fn as unknown as Component;
}
function render(fn: (renderer: Renderer) => void, options?: Parameters<typeof Renderer.render>[1]) {
return Renderer.render(as_component(fn), options);
}
/** Runs the warp `<script>`s in `head` against a fake `window`, and returns the revived values */
function revive(head: string) {
const window: { __svelte?: { w?: Map<string, Map<unknown, unknown>> } } = {};
for (const [, script] of head.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)) {
new Function('window', script)(window);
}
return window.__svelte?.w;
}
describe('Warp', () => {
test('serializes values into the head', async () => {
const { head } = await render(() => {
warp.set('a', 1);
warp.set('b', { nested: [new Date(0)] });
});
const values = revive(head)?.get('test');
expect(values?.get('a')).toBe(1);
expect(values?.get('b')).toEqual({ nested: [new Date(0)] });
});
test('omits the script when there are no values', async () => {
const { head } = await render(() => {});
expect(head).toBe('');
});
test('supports Map methods on the server', async () => {
await render(() => {
expect(warp.getOrInsert('a', 1)).toBe(1);
expect(warp.getOrInsert('a', 2)).toBe(1);
expect(warp.getOrInsertComputed('b', () => 3)).toBe(3);
expect(warp.getOrInsertComputed('b', () => 4)).toBe(3);
expect(warp.has('a')).toBe(true);
expect(warp.get('b')).toBe(3);
expect(warp.size).toBe(2);
expect([...warp]).toEqual([
['a', 1],
['b', 3]
]);
expect(() => warp.delete('a')).toThrow('warp_method_unsupported');
expect(() => warp.clear()).toThrow('warp_method_unsupported');
});
});
test('keeps different Warps separate', async () => {
const other = new Warp<string, number>('other');
const { head } = await render(() => {
warp.set('a', 1);
other.set('a', 2);
});
const values = revive(head);
expect(values?.get('test')?.get('a')).toBe(1);
expect(values?.get('other')?.get('a')).toBe(2);
});
test('waits for promises, including nested ones, and inlines them as resolved', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { head } = await render(() => {
warp.set(
'a',
Promise.resolve({ nested: new Promise((fulfil) => setTimeout(() => fulfil(42))) })
);
});
const a = await revive(head)?.get('test')?.get('a');
expect(head).toContain('Promise.resolve(');
expect(await (a as any).nested).toBe(42);
// the nested promise was still pending when the render finished
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain('unresolved_warp');
} finally {
warn.mockRestore();
}
});
test('treats replacement tokens in promise values as literals', async () => {
const { head } = await render(() => {
warp.set('a', Promise.resolve(`$'`));
});
expect(await revive(head)?.get('test')?.get('a')).toBe(`$'`);
});
test('escapes values that could close the script', async () => {
const { head } = await render(() => {
warp.set('</script>', '</script><script>throw new Error("pwned")</script>');
});
expect(head.match(/<\/script>/g)).toHaveLength(1);
expect(revive(head)?.get('test')?.get('</script>')).toBe(
'</script><script>throw new Error("pwned")</script>'
);
});
test('rejects promises on the client with the result of transformError', async () => {
const { head } = await render(
() => {
const promise = Promise.reject(new Error('secret'));
promise.catch(() => {});
warp.set('a', promise);
},
{ transformError: (error) => ({ message: (error as Error).message.toUpperCase() }) }
);
await expect(revive(head)?.get('test')?.get('a')).rejects.toEqual({ message: 'SECRET' });
});
test('rejects promises on the client with a generic error by default', async () => {
const { head } = await render(() => {
const promise = Promise.reject(new Error('secret'));
promise.catch(() => {});
warp.set('a', promise);
});
expect(head).not.toContain('secret');
await expect(revive(head)?.get('test')?.get('a')).rejects.toThrow(
'devalue: failed to serialize asynchronous value'
);
});
test('fails the render for unserializable values', async () => {
await expect(
render(() => {
warp.set(
'a',
Promise.resolve(() => {})
);
})
).rejects.toThrow('warp_serialization_failed');
});
test('cannot be used outside a render', () => {
expect(() => warp.get('a')).toThrow('server_context_required');
});
test('cannot add values after they have been serialized', async () => {
let promise: Promise<void> | undefined;
await render(() => {
promise = Promise.resolve().then(() =>
new Promise((fulfil) => setTimeout(fulfil)).then(() => {
warp.set('a', 1);
})
);
});
await expect(promise).rejects.toThrow('warp_set_after_render');
});
test('uses the replacer passed to render', async () => {
class Vector {
constructor(
public x: number,
public y: number
) {}
}
const { head } = await render(
() => {
warp.set('a', Promise.resolve(new Vector(1, 2)));
},
{
replacer: (value, js) => {
if (value instanceof Vector) return js`{ vector: [${value.x}, ${value.y}] }`;
}
}
);
expect(await revive(head)?.get('test')?.get('a')).toEqual({ vector: [1, 2] });
});
});
describe('withWarp', () => {
test('shares its context with the render inside it', async () => {
const { head } = await withWarp(async () => {
warp.set('before', 1);
await Promise.resolve();
return render(() => {
expect(warp.get('before')).toBe(1);
warp.set('during', 2);
});
});
const values = revive(head)?.get('test');
expect(values?.get('before')).toBe(1);
expect(values?.get('during')).toBe(2);
});
test('returns the result of the function', async () => {
expect(await withWarp(() => 42)).toBe(42);
});
test('only allows one render', async () => {
await expect(
withWarp(async () => {
await render(() => {});
await render(() => {});
})
).rejects.toThrow('warp_context_already_rendered');
});
test('cannot be nested', async () => {
await expect(withWarp(() => withWarp(() => {}))).rejects.toThrow('warp_context_nested');
});
test('keeps concurrent contexts separate', async () => {
const [a, b] = await Promise.all(
['a', 'b'].map((key) =>
withWarp(async () => {
warp.set(key, key);
await new Promise((fulfil) => setTimeout(fulfil));
return render(() => {});
})
)
);
expect([...(revive(a.head)?.get('test') ?? [])]).toEqual([['a', 'a']]);
expect([...(revive(b.head)?.get('test') ?? [])]).toEqual([['b', 'b']]);
});
test('runs the render replacer before its own', async () => {
const { head } = await withWarp(
() =>
render(
() => {
warp.set('a', new URL('https://svelte.dev'));
warp.set('b', new Error('b'));
},
{
replacer: (value, js) => {
if (value instanceof URL) return js`"render"`;
}
}
),
{
replacer: (value, js) => {
if (value instanceof URL) return js`"withWarp"`;
if (value instanceof Error) return js`"withWarp"`;
}
}
);
const values = revive(head)?.get('test');
expect(values?.get('a')).toBe('render');
expect(values?.get('b')).toBe('withWarp');
});
});
describe('hydratable', () => {
test('is stored in its own Warp', async () => {
const { head } = await render(() => {
hydratable('a', () => 1);
warp.set('a', 2);
});
const values = revive(head);
expect(values?.get('svelte:hydratable')?.get('a')).toBe(1);
expect(values?.get('test')?.get('a')).toBe(2);
});
});

@ -10,3 +10,5 @@ export type Getters<T> = {
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;
export type MaybePromise<T> = T | Promise<T>;
export type WarpKey = string | number | boolean | bigint;

@ -1,3 +1,4 @@
import type { UnevalReplacer } from 'devalue';
import type { Csp, RenderOutput } from './public.js';
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
@ -20,6 +21,11 @@ export function render<
idPrefix?: string;
csp?: Csp;
transformError?: (error: unknown) => unknown | Promise<unknown>;
/**
* Customizes how values added to `Warp` instances are serialized. See [`devalue`](https://github.com/sveltejs/devalue#custom-types) for details.
* If the render happens inside `withWarp`, this replacer runs before the one passed to `withWarp`.
*/
replacer?: UnevalReplacer;
}
]
: [
@ -30,6 +36,27 @@ export function render<
idPrefix?: string;
csp?: Csp;
transformError?: (error: unknown) => unknown | Promise<unknown>;
/**
* Customizes how values added to `Warp` instances are serialized. See [`devalue`](https://github.com/sveltejs/devalue#custom-types) for details.
* If the render happens inside `withWarp`, this replacer runs before the one passed to `withWarp`.
*/
replacer?: UnevalReplacer;
}
]
): RenderOutput;
/**
* Only available on the server. Runs `fn` with a context in which `Warp` instances can be used.
* A `render` call inside `fn` will use the same context, and serialize all the values added to
* `Warp` instances inside `fn` — whether they were added before or during the render.
* Only one `render` can happen inside a given `withWarp`.
*/
export function withWarp<T>(
fn: () => T | Promise<T>,
options?: {
/**
* Customizes how values added to `Warp` instances are serialized. See [`devalue`](https://github.com/sveltejs/devalue#custom-types) for details.
*/
replacer?: UnevalReplacer;
}
): Promise<T>;

@ -1 +1,2 @@
export { render } from '../internal/server/index.js';
export { withWarp } from '../internal/server/render-context.js';

@ -127,7 +127,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
declare global {
var __svelte:
| {
h?: Map<string, unknown>;
w?: Map<string, Map<string | number | boolean | bigint, unknown>>;
}
| undefined;
}
@ -156,7 +156,7 @@ beforeAll(() => {
});
beforeEach(() => {
delete globalThis?.__svelte?.h;
delete globalThis?.__svelte?.w;
});
afterAll(() => {
@ -487,7 +487,7 @@ async function run_test_variant(
throw new Error('Ensure dom mode is skipped');
};
const run_hydratables_init = () => {
const run_warp_init = () => {
if (variant !== 'hydrate') return;
const script = [...document.head.querySelectorAll('script').values()].find((script) =>
script.textContent?.includes('window.__svelte ??= {}')
@ -504,7 +504,7 @@ async function run_test_variant(
if (manual_hydrate && variant === 'hydrate') {
hydrate_fn = () => {
run_hydratables_init();
run_warp_init();
instance = hydrate(mod.default, {
target,
props,
@ -513,7 +513,7 @@ async function run_test_variant(
});
};
} else {
run_hydratables_init();
run_warp_init();
const render = variant === 'hydrate' ? hydrate : mount;
instance = render(mod.default, {
target,
@ -524,7 +524,7 @@ async function run_test_variant(
});
}
} else {
run_hydratables_init();
run_warp_init();
instance = createClassComponent({
component: mod.default,
props: config.props,

@ -12,7 +12,10 @@ export default test({
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`');
assert.include(
warnings[0],
'The value with key `partially_used` in `Warp` `svelte:hydratable`'
);
},
async test({ assert, target }) {

@ -11,7 +11,7 @@ export default test({
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`');
assert.include(warnings[0], 'The value with key `unused_key` in `Warp` `svelte:hydratable`');
},
async test({ assert, target }) {

@ -0,0 +1,12 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip_no_async: true,
skip_mode: ['server'],
async test({ assert, window }) {
await tick();
const h1 = window.document.querySelector('h1');
assert.equal(h1?.textContent, 'hello after await');
}
});

@ -0,0 +1,10 @@
<script>
import { Warp } from "svelte";
await Promise.resolve();
const warp = new Warp('app');
const value = warp.getOrInsert('value', 'hello after await');
</script>
<h1>{value}</h1>

@ -0,0 +1,12 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip_no_async: true,
skip_mode: ['server'],
async test({ assert, window }) {
await tick();
const h1 = window.document.querySelector('h1');
assert.equal(h1?.textContent, 'server');
}
});

@ -0,0 +1,16 @@
<script>
import { Warp } from "svelte";
const warp = new Warp('app');
const value = warp.getOrInsertComputed('environment', () => Promise.resolve({
nested: Promise.resolve({
environment: 'server'
})
}));
const resolved = await value;
const nested = await resolved.nested;
const environment = await nested.environment;
</script>
<h1>{environment}</h1>

@ -0,0 +1,25 @@
import { test } from '../../test';
export default test({
skip_no_async: true,
mode: ['async-server', 'hydrate'],
server_props: { environment: 'server' },
props: { environment: 'browser' },
ssrHtml: '<p>loading</p>',
transformError: (error) =>
error instanceof Error ? { message: error.message.toUpperCase() } : error,
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
assert.include(warnings[0], 'The value with key `data` in `Warp` `app`');
},
async test({ assert, target }) {
await new Promise((fulfil) => setTimeout(fulfil, 10));
// the client uses the rejection from the server, passed through `transformError`
assert.htmlEqual(target.innerHTML, '<p>failed: FROM SERVER</p>');
}
});

@ -0,0 +1,23 @@
<script lang="ts">
import { Warp } from 'svelte';
const { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp<string, Promise<string>>('app');
const data = warp.getOrInsertComputed(
'data',
() => new Promise((_, reject) => setTimeout(() => reject(new Error(`from ${environment}`))))
);
</script>
<svelte:boundary>
<p>{await data}</p>
{#snippet pending()}
<p>loading</p>
{/snippet}
{#snippet failed(error)}
<p>failed: {(error as { message: string }).message}</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,13 @@
import { test } from '../../test';
export default test({
skip_no_async: true,
mode: ['hydrate'],
props: {
key: '</script><script>throw new Error("pwned")</script>'
},
test({ assert, window }) {
const h1 = window.document.querySelector('h1');
assert.equal(h1?.textContent, 'safe');
}
});

@ -0,0 +1,10 @@
<script>
import { Warp } from "svelte";
let { key } = $props();
const warp = new Warp('app');
const value = await warp.getOrInsertComputed(key, () => Promise.resolve('safe'));
</script>
<h1>{value}</h1>

@ -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>did you ever hear the tragedy of darth plagueis the wise?</div><div>Loading...</div>',
test_ssr({ assert, warnings }) {
assert.strictEqual(warnings.length, 1);
assert.include(warnings[0], 'The value with key `partially_used` in `Warp` `app`');
},
async test({ assert, target }) {
// make sure the warp 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,23 @@
<script lang="ts">
import { Warp } from "svelte";
const { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp('app');
const partially_used = warp.getOrInsertComputed('partially_used', () => ({
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.used}</div>
<svelte:boundary>
<div>{await partially_used.unused}</div>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>

@ -0,0 +1,25 @@
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);
assert.include(warnings[0], 'The value with key `unused_key` in `Warp` `app`');
},
async test({ assert, target }) {
// make sure the warp 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,18 @@
<script lang="ts">
import { Warp } from "svelte";
const { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp('app');
const unresolved = warp.getOrInsertComputed('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}</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' },
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,10 @@
<script lang="ts">
import { Warp } from "svelte";
let { environment }: { environment: 'server' | 'browser' } = $props();
const warp = new Warp<string, string>('app');
const value = warp.getOrInsert('environment', environment);
</script>
<p>The current environment is: {value}</p>

@ -3,5 +3,5 @@ import { test } from '../../test';
export default test({
mode: ['async'],
csp: { hash: true },
script_hashes: ['sha256-J0xwNm40i0NVEdHYeMRThG7y90X+P/I1ElZGnpQ0AbU=']
script_hashes: ['sha256-kcOu9IHb0N7eeK7eTBoi3kn39HD0+3o4IiIQcydJV3U=']
});

@ -1,12 +1,16 @@
<script>
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>
<script>
{
const w = (window.__svelte ??= {}).w ??= new Map();
for (const [id, values] of new Map([["svelte:hydratable",new Map([["key",Promise.resolve("bar")]])]])) {
const existing = w.get(id);
if (existing) {
for (const [k, v] of values) existing.set(k, v);
} else {
w.set(id, values);
}
}
}
</script>

@ -1,12 +1,16 @@
<script nonce="test-nonce">
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>
<script nonce="test-nonce">
{
const w = (window.__svelte ??= {}).w ??= new Map();
for (const [id, values] of new Map([["svelte:hydratable",new Map([["key",Promise.resolve("bar")]])]])) {
const existing = w.get(id);
if (existing) {
for (const [k, v] of values) existing.set(k, v);
} else {
w.set(id, values);
}
}
}
</script>

@ -2,5 +2,5 @@ import { test } from '../../test';
export default test({
mode: ['async'],
error: 'hydratable_serialization_failed'
error: 'warp_serialization_failed'
});

@ -1,5 +1,5 @@
<script lang="ts">
import { hydratable } from 'svelte';
hydratable('key', () => new Promise(() => { throw new Error('nope') }));
</script>
hydratable('key', () => Promise.resolve(() => {}));
</script>

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

@ -0,0 +1,6 @@
<script>
import { Warp } from 'svelte';
const warp = new Warp('app');
warp.set('key', Promise.resolve({ fn: () => {} }));
</script>

@ -458,7 +458,53 @@ declare module 'svelte' {
* @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead
* */
export function afterUpdate(fn: () => void): void;
/**
* @deprecated Use [`Warp`](https://svelte.dev/docs/svelte/warp) instead
* */
export function hydratable<T>(key: string, fn: () => T): T;
/**
* A `Map` whose contents are sent from the server to the client. Values added to it
* during server rendering (or inside `withWarp`) are serialized into the rendered
* HTML, so that the same `Warp` can read them on the client during hydration.
*
* Values can be anything [`devalue`](https://github.com/sveltejs/devalue) can serialize,
* including promises.
*
*
*/
export class Warp<K extends WarpKey, V> implements Map<K, V> {
/**
* @param id A unique identifier for this `Warp`, which must match on the server and the client.
* Libraries should prefix it with their package name to avoid collisions.
*/
constructor(id: string);
get(key: K): V | undefined;
has(key: K): boolean;
set(key: K, value: V): this;
/**
* Returns the value for `key` if it exists, otherwise adds `value` and returns it.
* */
getOrInsert(key: K, value: V): V;
/**
* Returns the value for `key` if it exists, otherwise calls `callback` and adds the result.
* */
getOrInsertComputed(key: K, callback: (key: K) => V): V;
delete(key: K): boolean;
clear(): void;
forEach(callback: (value: V, key: K, map: Map<K, V>) => void, this_arg?: any): void;
entries(): IterableIterator<[K, V]>;
keys(): IterableIterator<K>;
values(): IterableIterator<V>;
get size(): number;
[Symbol.iterator](): IterableIterator<[K, V]>;
get [Symbol.toStringTag](): string;
#private;
}
/**
* Create a snippet programmatically
* */
@ -609,6 +655,8 @@ declare module 'svelte' {
[K in keyof T]: () => T[K];
};
type WarpKey = string | number | boolean | bigint;
export {};
}
@ -2683,6 +2731,7 @@ declare module 'svelte/reactivity/window' {
}
declare module 'svelte/server' {
import type { UnevalReplacer } from 'devalue';
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
/**
* Only available on the server and when compiling with the `server` option.
@ -2701,6 +2750,11 @@ declare module 'svelte/server' {
idPrefix?: string;
csp?: Csp;
transformError?: (error: unknown) => unknown | Promise<unknown>;
/**
* Customizes how values added to `Warp` instances are serialized. See [`devalue`](https://github.com/sveltejs/devalue#custom-types) for details.
* If the render happens inside `withWarp`, this replacer runs before the one passed to `withWarp`.
*/
replacer?: UnevalReplacer;
}
]
: [
@ -2711,9 +2765,30 @@ declare module 'svelte/server' {
idPrefix?: string;
csp?: Csp;
transformError?: (error: unknown) => unknown | Promise<unknown>;
/**
* Customizes how values added to `Warp` instances are serialized. See [`devalue`](https://github.com/sveltejs/devalue#custom-types) for details.
* If the render happens inside `withWarp`, this replacer runs before the one passed to `withWarp`.
*/
replacer?: UnevalReplacer;
}
]
): RenderOutput;
/**
* Only available on the server. Runs `fn` with a context in which `Warp` instances can be used.
* A `render` call inside `fn` will use the same context, and serialize all the values added to
* `Warp` instances inside `fn` — whether they were added before or during the render.
* Only one `render` can happen inside a given `withWarp`.
*/
export function withWarp<T>(
fn: () => T | Promise<T>,
options?: {
/**
* Customizes how values added to `Warp` instances are serialized. See [`devalue`](https://github.com/sveltejs/devalue#custom-types) for details.
*/
replacer?: UnevalReplacer;
}
): Promise<T>;
export type Csp = { nonce?: string; hash?: boolean };
export type Sha256Source = `sha256-${string}`;

@ -93,8 +93,8 @@ importers:
specifier: ^2.1.1
version: 2.1.1
devalue:
specifier: ^5.9.2
version: 5.9.2
specifier: https://pkg.svelte.dev/devalue/c/7e38de619f957ea23f1bcfb115b04dd0c2c0482f
version: https://pkg.svelte.dev/devalue/c/7e38de619f957ea23f1bcfb115b04dd0c2c0482f
esm-env:
specifier: ^1.2.1
version: 1.2.1
@ -1282,8 +1282,10 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
devalue@5.9.2:
resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==}
devalue@https://pkg.svelte.dev/devalue/c/7e38de619f957ea23f1bcfb115b04dd0c2c0482f:
resolution: {integrity: sha512-nx+dAVm+IeO2iWbskpJXZu7NjTzppUYLXgPFRgzyqtmHArlkaWWhND2XihYjHVqyBgrPjJBN8/USrefrEhRXTQ==, tarball: https://pkg.svelte.dev/devalue/c/7e38de619f957ea23f1bcfb115b04dd0c2c0482f}
version: 6.0.2
engines: {node: '>=22.17'}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
@ -3433,7 +3435,7 @@ snapshots:
detect-libc@1.0.3:
optional: true
devalue@5.9.2: {}
devalue@https://pkg.svelte.dev/devalue/c/7e38de619f957ea23f1bcfb115b04dd0c2c0482f: {}
dir-glob@3.0.1:
dependencies:

Loading…
Cancel
Save