mirror of https://github.com/sveltejs/svelte
feat: `hydratable` (#17154)
* feat: * doc comments * types * types * changeset * tests * docs * hopefully * lint * finally figured out test issues * get docs building * the easy stuff * prune errors * feat: capture clobbering better, capture unused keys, don't block on unused keys * progress on serializing nested promises * fix * idk man but the tests are passing so i'ma checkpoint this * fix tests * compare resolved serialized values * robustify * thunkify * fixes * ajsldkfjalsdfkjasd * tests * docs * ugh * ugh ugh ugh * Update documentation/docs/06-runtime/05-hydratable.md Co-authored-by: kaysef <25851116+kaysef@users.noreply.github.com> * make errors better * tweak * remove context restoration * fix * trim * simplify hydratable (#17230) * simplify hydratable * tidy up * unused --------- Co-authored-by: Elliott Johnson <sejohnson@torchcloudconsulting.com> * improve errors * include all non-internal frames, handle identical stacks * tweak example * tweak message — setTimeout is covered by ALS * cut out the middleman * unused * tidy * Apply suggestions from code review --------- Co-authored-by: Rich Harris <rich.harris@vercel.com> Co-authored-by: kaysef <25851116+kaysef@users.noreply.github.com>pull/17234/head
parent
129c4086f7
commit
c2a110cd81
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'svelte': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat: `hydratable` API
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: Hydratable data
|
||||||
|
---
|
||||||
|
|
||||||
|
In Svelte, when you want to render asynchronous content data on the server, you can simply `await` it. This is great! However, it comes with a pitfall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { getUser } from 'my-database-library';
|
||||||
|
|
||||||
|
// This will get the user on the server, render the user's name into the h1,
|
||||||
|
// and then, during hydration on the client, it will get the user _again_,
|
||||||
|
// blocking hydration until it's done.
|
||||||
|
const user = await getUser();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{user.name}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
That's silly, though. If we've already done the hard work of getting the data on the server, we don't want to get it again during hydration on the client. `hydratable` is a low-level API built to solve this problem. You probably won't need this very often -- it will be used behind the scenes by whatever datafetching library you use. For example, it powers [remote functions in SvelteKit](/docs/kit/remote-functions).
|
||||||
|
|
||||||
|
To fix the example above:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
import { getUser } from 'my-database-library';
|
||||||
|
|
||||||
|
// During server rendering, this will serialize and stash the result of `getUser`, associating
|
||||||
|
// it with the provided key and baking it into the `head` content. During hydration, it will
|
||||||
|
// look for the serialized version, returning it instead of running `getUser`. After hydration
|
||||||
|
// is done, if it's called again, it'll simply invoke `getUser`.
|
||||||
|
const user = await hydratable('user', () => getUser());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{user.name}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
This API can also be used to provide access to random or time-based values that are stable between server rendering and hydration. For example, to get a random number that doesn't update on hydration:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
const rand = hydratable('random', () => Math.random());
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're a library author, be sure to prefix the keys of your `hydratable` values with the name of your library so that your keys don't conflict with other libraries.
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
All data returned from a `hydratable` function must be serializable. But this doesn't mean you're limited to JSON — Svelte uses [`devalue`](https://npmjs.com/package/devalue), which can serialize all sorts of things including `Map`, `Set`, `URL`, and `BigInt`. Check the documentation page for a full list. In addition to these, thanks to some Svelte magic, you can also fearlessly use promises:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
const promises = hydratable('random', () => {
|
||||||
|
return {
|
||||||
|
one: Promise.resolve(1),
|
||||||
|
two: Promise.resolve(2)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{await promises.one}
|
||||||
|
{await promises.two}
|
||||||
|
```
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
|
||||||
|
|
||||||
|
### unresolved_hydratable
|
||||||
|
|
||||||
|
```
|
||||||
|
A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
|
||||||
|
|
||||||
|
The `hydratable` was initialized in:
|
||||||
|
%stack%
|
||||||
|
```
|
||||||
|
|
||||||
|
The most likely cause of this is creating a `hydratable` in the `script` block of your component and then `await`ing
|
||||||
|
the result inside a `svelte:boundary` with a `pending` snippet:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
import { getUser } from '$lib/get-user.js';
|
||||||
|
|
||||||
|
const user = hydratable('user', getUser);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:boundary>
|
||||||
|
<h1>{(await user).name}</h1>
|
||||||
|
|
||||||
|
{#snippet pending()}
|
||||||
|
<div>Loading...</div>
|
||||||
|
{/snippet}
|
||||||
|
</svelte:boundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Consider inlining the `hydratable` call inside the boundary so that it's not called on the server.
|
||||||
|
|
||||||
|
Note that this can also happen when a `hydratable` contains multiple promises and some but not all of them have been used.
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
## unresolved_hydratable
|
||||||
|
|
||||||
|
> A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
|
||||||
|
>
|
||||||
|
> The `hydratable` was initialized in:
|
||||||
|
> %stack%
|
||||||
|
|
||||||
|
The most likely cause of this is creating a `hydratable` in the `script` block of your component and then `await`ing
|
||||||
|
the result inside a `svelte:boundary` with a `pending` snippet:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
import { getUser } from '$lib/get-user.js';
|
||||||
|
|
||||||
|
const user = hydratable('user', getUser);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:boundary>
|
||||||
|
<h1>{(await user).name}</h1>
|
||||||
|
|
||||||
|
{#snippet pending()}
|
||||||
|
<div>Loading...</div>
|
||||||
|
{/snippet}
|
||||||
|
</svelte:boundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Consider inlining the `hydratable` call inside the boundary so that it's not called on the server.
|
||||||
|
|
||||||
|
Note that this can also happen when a `hydratable` contains multiple promises and some but not all of them have been used.
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { async_mode_flag } from '../flags/index.js';
|
||||||
|
import { hydrating } from './dom/hydration.js';
|
||||||
|
import * as w from './warnings.js';
|
||||||
|
import * as e from './errors.js';
|
||||||
|
import { DEV } from 'esm-env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {string} key
|
||||||
|
* @param {() => T} fn
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
|
export function hydratable(key, fn) {
|
||||||
|
if (!async_mode_flag) {
|
||||||
|
e.experimental_async_required('hydratable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hydrating) {
|
||||||
|
const store = window.__svelte?.h;
|
||||||
|
|
||||||
|
if (store?.has(key)) {
|
||||||
|
return /** @type {T} */ (store.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV) {
|
||||||
|
e.hydratable_missing_but_required(key);
|
||||||
|
} else {
|
||||||
|
w.hydratable_missing_but_expected(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
/** @import { HydratableLookupEntry } from '#server' */
|
||||||
|
import { async_mode_flag } from '../flags/index.js';
|
||||||
|
import { get_render_context } from './render-context.js';
|
||||||
|
import * as e from './errors.js';
|
||||||
|
import * as devalue from 'devalue';
|
||||||
|
import { get_stack } from '../shared/dev.js';
|
||||||
|
import { DEV } from 'esm-env';
|
||||||
|
import { get_user_code_location } from './dev.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {string} key
|
||||||
|
* @param {() => T} fn
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
|
export function hydratable(key, fn) {
|
||||||
|
if (!async_mode_flag) {
|
||||||
|
e.experimental_async_required('hydratable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hydratable } = get_render_context();
|
||||||
|
|
||||||
|
let entry = hydratable.lookup.get(key);
|
||||||
|
|
||||||
|
if (entry !== undefined) {
|
||||||
|
if (DEV) {
|
||||||
|
const comparison = compare(key, entry, encode(key, fn()));
|
||||||
|
comparison.catch(() => {});
|
||||||
|
hydratable.comparisons.push(comparison);
|
||||||
|
}
|
||||||
|
|
||||||
|
return /** @type {T} */ (entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = fn();
|
||||||
|
|
||||||
|
entry = encode(key, value, hydratable.unresolved_promises);
|
||||||
|
hydratable.lookup.set(key, entry);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} value
|
||||||
|
* @param {Map<Promise<any>, string>} [unresolved]
|
||||||
|
*/
|
||||||
|
function encode(key, value, unresolved) {
|
||||||
|
/** @type {HydratableLookupEntry} */
|
||||||
|
const entry = { value, serialized: '' };
|
||||||
|
|
||||||
|
if (DEV) {
|
||||||
|
entry.stack = get_user_code_location();
|
||||||
|
}
|
||||||
|
|
||||||
|
let uid = 1;
|
||||||
|
|
||||||
|
entry.serialized = devalue.uneval(entry.value, (value, uneval) => {
|
||||||
|
if (value instanceof Promise) {
|
||||||
|
const p = value
|
||||||
|
.then((v) => `r(${uneval(v)})`)
|
||||||
|
.catch((devalue_error) =>
|
||||||
|
e.hydratable_serialization_failed(
|
||||||
|
key,
|
||||||
|
serialization_stack(entry.stack, devalue_error?.stack)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// prevent unhandled rejections from crashing the server
|
||||||
|
p.catch(() => {});
|
||||||
|
|
||||||
|
// track which promises are still resolving when render is complete
|
||||||
|
unresolved?.set(p, key);
|
||||||
|
p.finally(() => unresolved?.delete(p));
|
||||||
|
|
||||||
|
// we serialize promises as `"${i}"`, because it's impossible for that string
|
||||||
|
// to occur 'naturally' (since the quote marks would have to be escaped)
|
||||||
|
const placeholder = `"${uid++}"`;
|
||||||
|
|
||||||
|
(entry.promises ??= []).push(
|
||||||
|
p.then((s) => {
|
||||||
|
entry.serialized = entry.serialized.replace(placeholder, s);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {HydratableLookupEntry} a
|
||||||
|
* @param {HydratableLookupEntry} b
|
||||||
|
*/
|
||||||
|
async function compare(key, a, b) {
|
||||||
|
// note: these need to be loops (as opposed to Promise.all) because
|
||||||
|
// additional promises can get pushed to them while we're awaiting
|
||||||
|
// an earlier one
|
||||||
|
for (const p of a?.promises ?? []) {
|
||||||
|
await p;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of b?.promises ?? []) {
|
||||||
|
await p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.serialized !== b.serialized) {
|
||||||
|
const a_stack = /** @type {string} */ (a.stack);
|
||||||
|
const b_stack = /** @type {string} */ (b.stack);
|
||||||
|
|
||||||
|
const stack =
|
||||||
|
a_stack === b_stack
|
||||||
|
? `Occurred at:\n${a_stack}`
|
||||||
|
: `First occurrence at:\n${a_stack}\n\nSecond occurrence at:\n${b_stack}`;
|
||||||
|
|
||||||
|
e.hydratable_clobbering(key, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined} root_stack
|
||||||
|
* @param {string | undefined} uneval_stack
|
||||||
|
*/
|
||||||
|
function serialization_stack(root_stack, uneval_stack) {
|
||||||
|
let out = '';
|
||||||
|
if (root_stack) {
|
||||||
|
out += root_stack + '\n';
|
||||||
|
}
|
||||||
|
if (uneval_stack) {
|
||||||
|
out += 'Caused by:\n' + uneval_stack + '\n';
|
||||||
|
}
|
||||||
|
return out || '<missing stack trace>';
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
// @ts-ignore -- we don't include node types in the production build
|
||||||
|
/** @import { AsyncLocalStorage } from 'node:async_hooks' */
|
||||||
|
/** @import { RenderContext } from '#server' */
|
||||||
|
|
||||||
|
import { deferred } from '../shared/utils.js';
|
||||||
|
import * as e from './errors.js';
|
||||||
|
|
||||||
|
/** @type {Promise<void> | null} */
|
||||||
|
let current_render = null;
|
||||||
|
|
||||||
|
/** @type {RenderContext | null} */
|
||||||
|
let context = null;
|
||||||
|
|
||||||
|
/** @returns {RenderContext} */
|
||||||
|
export function get_render_context() {
|
||||||
|
const store = context ?? als?.getStore();
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
e.server_context_required();
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {() => Promise<T>} fn
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
export async function with_render_context(fn) {
|
||||||
|
context = {
|
||||||
|
hydratable: {
|
||||||
|
lookup: new Map(),
|
||||||
|
comparisons: [],
|
||||||
|
unresolved_promises: new Map()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (in_webcontainer()) {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
const previous_render = current_render;
|
||||||
|
current_render = promise;
|
||||||
|
await previous_render;
|
||||||
|
return fn().finally(resolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (als === null) {
|
||||||
|
e.async_local_storage_unavailable();
|
||||||
|
}
|
||||||
|
return als.run(context, fn);
|
||||||
|
} finally {
|
||||||
|
context = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {AsyncLocalStorage<RenderContext | null> | null} */
|
||||||
|
let als = null;
|
||||||
|
|
||||||
|
export async function init_render_context() {
|
||||||
|
if (als !== null) return;
|
||||||
|
try {
|
||||||
|
// @ts-ignore -- we don't include node types in the production build
|
||||||
|
const { AsyncLocalStorage } = await import('node:async_hooks');
|
||||||
|
als = new AsyncLocalStorage();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this has to be a function because rollup won't treeshake it if it's a constant
|
||||||
|
function in_webcontainer() {
|
||||||
|
// @ts-ignore -- this will fail when we run typecheck because we exclude node types
|
||||||
|
// eslint-disable-next-line n/prefer-global/process
|
||||||
|
return !!globalThis.process?.versions?.webcontainer;
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { define_property } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} label
|
||||||
|
* @returns {Error & { stack: string } | null}
|
||||||
|
*/
|
||||||
|
export function get_error(label) {
|
||||||
|
const error = new Error();
|
||||||
|
const stack = get_stack();
|
||||||
|
|
||||||
|
if (stack.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.unshift('\n');
|
||||||
|
|
||||||
|
define_property(error, 'stack', {
|
||||||
|
value: stack.join('\n')
|
||||||
|
});
|
||||||
|
|
||||||
|
define_property(error, 'name', {
|
||||||
|
value: label
|
||||||
|
});
|
||||||
|
|
||||||
|
return /** @type {Error & { stack: string }} */ (error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function get_stack() {
|
||||||
|
// @ts-ignore - doesn't exist everywhere
|
||||||
|
const limit = Error.stackTraceLimit;
|
||||||
|
// @ts-ignore - doesn't exist everywhere
|
||||||
|
Error.stackTraceLimit = Infinity;
|
||||||
|
const stack = new Error().stack;
|
||||||
|
// @ts-ignore - doesn't exist everywhere
|
||||||
|
Error.stackTraceLimit = limit;
|
||||||
|
|
||||||
|
if (!stack) return [];
|
||||||
|
|
||||||
|
const lines = stack.split('\n');
|
||||||
|
const new_lines = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const posixified = line.replaceAll('\\', '/');
|
||||||
|
|
||||||
|
if (line.trim() === 'Error') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('validate_each_keys')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posixified.includes('svelte/src/internal') || posixified.includes('node_modules/.vite')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
new_lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new_lines;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { tick } from 'svelte';
|
||||||
|
import { ok, test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
skip_no_async: true,
|
||||||
|
skip_mode: ['server'],
|
||||||
|
|
||||||
|
server_props: { environment: 'server' },
|
||||||
|
ssrHtml: '<p>The current environment is: server</p>',
|
||||||
|
|
||||||
|
props: { environment: 'browser' },
|
||||||
|
|
||||||
|
async test({ assert, target, variant }) {
|
||||||
|
// make sure hydration has a chance to finish
|
||||||
|
await tick();
|
||||||
|
const p = target.querySelector('p');
|
||||||
|
ok(p);
|
||||||
|
if (variant === 'hydrate') {
|
||||||
|
assert.htmlEqual(p.outerHTML, '<p>The current environment is: server</p>');
|
||||||
|
} else {
|
||||||
|
assert.htmlEqual(p.outerHTML, '<p>The current environment is: browser</p>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from "svelte";
|
||||||
|
|
||||||
|
let { environment } = $props();
|
||||||
|
|
||||||
|
const value = hydratable("environment", () => Promise.resolve({
|
||||||
|
nested: Promise.resolve({
|
||||||
|
environment
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>The current environment is: {await value.then(res => res.nested).then(res => res.environment)}</p>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { ok, test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
skip_no_async: true,
|
||||||
|
mode: ['async-server', 'hydrate'],
|
||||||
|
|
||||||
|
server_props: { environment: 'server' },
|
||||||
|
ssrHtml: '<p>The current environment is: server</p>',
|
||||||
|
|
||||||
|
props: { environment: 'browser' },
|
||||||
|
|
||||||
|
runtime_error: 'hydratable_missing_but_required'
|
||||||
|
});
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from "svelte";
|
||||||
|
|
||||||
|
let { environment }: { environment: 'server' | 'browser' } = $props();
|
||||||
|
|
||||||
|
let value;
|
||||||
|
if (environment === 'server') {
|
||||||
|
value = 'server';
|
||||||
|
} else {
|
||||||
|
value = hydratable('environment', () => environment);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>The current environment is: {value}</p>
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { tick } from 'svelte';
|
||||||
|
import { test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
skip_no_async: true,
|
||||||
|
mode: ['async-server', 'hydrate'],
|
||||||
|
|
||||||
|
server_props: { environment: 'server' },
|
||||||
|
ssrHtml:
|
||||||
|
'<div>did you ever hear the tragedy of darth plagueis the wise?</div><div>Loading...</div>',
|
||||||
|
|
||||||
|
test_ssr({ assert, warnings }) {
|
||||||
|
assert.strictEqual(warnings.length, 1);
|
||||||
|
// for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
|
||||||
|
assert.include(warnings[0], 'A `hydratable` value with key `partially_used`');
|
||||||
|
},
|
||||||
|
|
||||||
|
async test({ assert, target }) {
|
||||||
|
// make sure the hydratable promise on the client has a chance to run and reject (it shouldn't, because the server data should be used)
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
assert.htmlEqual(
|
||||||
|
target.innerHTML,
|
||||||
|
'<div>did you ever hear the tragedy of darth plagueis the wise?</div><div>no, sith daddy, please tell me</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from "svelte";
|
||||||
|
|
||||||
|
const { environment } = $props();
|
||||||
|
|
||||||
|
const partially_used_hydratable = hydratable(
|
||||||
|
"partially_used",
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
used: new Promise(
|
||||||
|
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
|
||||||
|
),
|
||||||
|
unused: new Promise(
|
||||||
|
(res, rej) => environment === 'server' ? setTimeout(() => res('no, sith daddy, please tell me'), 0) : rej('should not run')
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>{await partially_used_hydratable.used}</div>
|
||||||
|
<svelte:boundary>
|
||||||
|
<div>{await partially_used_hydratable.unused}</div>
|
||||||
|
{#snippet pending()}
|
||||||
|
<div>Loading...</div>
|
||||||
|
{/snippet}
|
||||||
|
</svelte:boundary>
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { tick } from 'svelte';
|
||||||
|
import { test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
skip_no_async: true,
|
||||||
|
mode: ['async-server', 'hydrate'],
|
||||||
|
|
||||||
|
server_props: { environment: 'server' },
|
||||||
|
ssrHtml: '<div>Loading...</div>',
|
||||||
|
|
||||||
|
test_ssr({ assert, warnings }) {
|
||||||
|
assert.strictEqual(warnings.length, 1);
|
||||||
|
// for some strange reason we trim the error code off the beginning of warnings so I can't actually assert it
|
||||||
|
assert.include(warnings[0], 'A `hydratable` value with key `unused_key`');
|
||||||
|
},
|
||||||
|
|
||||||
|
async test({ assert, target }) {
|
||||||
|
// make sure the hydratable promise on the client has a chance to run and reject (it shouldn't, because the server data should be used)
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
assert.htmlEqual(
|
||||||
|
target.innerHTML,
|
||||||
|
'<div>did you ever hear the tragedy of darth plagueis the wise?</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from "svelte";
|
||||||
|
|
||||||
|
const { environment } = $props();
|
||||||
|
|
||||||
|
const unresolved_hydratable = hydratable(
|
||||||
|
"unused_key",
|
||||||
|
() => new Promise(
|
||||||
|
(res, rej) => environment === 'server' ? setTimeout(() => res('did you ever hear the tragedy of darth plagueis the wise?'), 0) : rej('should not run')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:boundary>
|
||||||
|
<div>{await unresolved_hydratable}</div>
|
||||||
|
{#snippet pending()}
|
||||||
|
<div>Loading...</div>
|
||||||
|
{/snippet}
|
||||||
|
</svelte:boundary>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { ok, test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
skip_no_async: true,
|
||||||
|
skip_mode: ['server'],
|
||||||
|
|
||||||
|
server_props: { environment: 'server' },
|
||||||
|
ssrHtml: '<p>The current environment is: server</p>',
|
||||||
|
|
||||||
|
props: { environment: 'browser' },
|
||||||
|
|
||||||
|
async test({ assert, target, variant }) {
|
||||||
|
const p = target.querySelector('p');
|
||||||
|
ok(p);
|
||||||
|
if (variant === 'hydrate') {
|
||||||
|
assert.htmlEqual(p.outerHTML, '<p>The current environment is: server</p>');
|
||||||
|
} else {
|
||||||
|
assert.htmlEqual(p.outerHTML, '<p>The current environment is: browser</p>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from "svelte";
|
||||||
|
|
||||||
|
let { environment } = $props();
|
||||||
|
|
||||||
|
const value = hydratable("environment", () => environment)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>The current environment is: {value}</p>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { test } from '../../test';
|
import { test } from '../../test';
|
||||||
|
|
||||||
export default test({
|
export default test({
|
||||||
|
skip: true, // TODO it appears there might be an actual bug here; the promise isn't ever actually awaited in spite of being awaited in the component
|
||||||
mode: ['async'],
|
mode: ['async'],
|
||||||
error: 'lifecycle_outside_component'
|
error: 'lifecycle_outside_component'
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
mode: ['async']
|
||||||
|
});
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
|
||||||
|
await hydratable('key', () => 'first');
|
||||||
|
await hydratable('key', () => 'first');
|
||||||
|
</script>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
mode: ['async'],
|
||||||
|
error: 'hydratable_clobbering'
|
||||||
|
});
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
|
||||||
|
hydratable('key', () => Promise.resolve({
|
||||||
|
nested: Promise.resolve({
|
||||||
|
one: Promise.resolve(1),
|
||||||
|
}),
|
||||||
|
two: Promise.resolve(2),
|
||||||
|
}));
|
||||||
|
hydratable('key', () => Promise.resolve({
|
||||||
|
nested: Promise.resolve({
|
||||||
|
one: Promise.resolve(2),
|
||||||
|
}),
|
||||||
|
two: Promise.resolve(2),
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { test } from '../../test';
|
||||||
|
|
||||||
|
export default test({
|
||||||
|
mode: ['async'],
|
||||||
|
error: 'hydratable_clobbering'
|
||||||
|
});
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { hydratable } from 'svelte';
|
||||||
|
|
||||||
|
hydratable('key', () => 'first');
|
||||||
|
hydratable('key', () => 'second');
|
||||||
|
</script>
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import { test } from '../../test';
|
import { test } from '../../test';
|
||||||
|
|
||||||
export default test({
|
export default test({
|
||||||
|
skip: true, // TODO: This test actually works, but the error message is printed, not thrown, so we need to have a way to test for that
|
||||||
compileOptions: {
|
compileOptions: {
|
||||||
dev: true
|
dev: true
|
||||||
},
|
},
|
||||||
|
|
||||||
error:
|
error:
|
||||||
'node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1) cannot be a child of `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
|
'node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1) cannot be a child of `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in new issue