mirror of https://github.com/sveltejs/svelte
elliott/warp-core
parent
707c28146b
commit
59cdcbcb8f
@ -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());
|
||||||
|
```
|
||||||
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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*\]/);
|
|
||||||
});
|
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1 +1,2 @@
|
|||||||
export { render } from '../internal/server/index.js';
|
export { render } from '../internal/server/index.js';
|
||||||
|
export { withWarp } from '../internal/server/render-context.js';
|
||||||
|
|||||||
@ -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>
|
||||||
@ -1,12 +1,16 @@
|
|||||||
<script>
|
|
||||||
{
|
|
||||||
const r = (v) => Promise.resolve(v);
|
|
||||||
const h = (window.__svelte ??= {}).h ??= new Map();
|
|
||||||
|
|
||||||
for (const [k, v] of [
|
<script>
|
||||||
["key",r("bar")]
|
{
|
||||||
]) {
|
const w = (window.__svelte ??= {}).w ??= new Map();
|
||||||
h.set(k, v);
|
|
||||||
}
|
for (const [id, values] of new Map([["svelte:hydratable",new Map([["key",Promise.resolve("bar")]])]])) {
|
||||||
}
|
const existing = w.get(id);
|
||||||
</script>
|
|
||||||
|
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 [
|
<script nonce="test-nonce">
|
||||||
["key",r("bar")]
|
{
|
||||||
]) {
|
const w = (window.__svelte ??= {}).w ??= new Map();
|
||||||
h.set(k, v);
|
|
||||||
}
|
for (const [id, values] of new Map([["svelte:hydratable",new Map([["key",Promise.resolve("bar")]])]])) {
|
||||||
}
|
const existing = w.get(id);
|
||||||
</script>
|
|
||||||
|
if (existing) {
|
||||||
|
for (const [k, v] of values) existing.set(k, v);
|
||||||
|
} else {
|
||||||
|
w.set(id, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { hydratable } from 'svelte';
|
import { hydratable } from 'svelte';
|
||||||
|
|
||||||
hydratable('key', () => new Promise(() => { throw new Error('nope') }));
|
hydratable('key', () => Promise.resolve(() => {}));
|
||||||
</script>
|
</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>
|
||||||
Loading…
Reference in new issue