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

elliott/hydratable
Elliott Johnson 3 days ago
parent 53ccd2e50a
commit 37f2e0ef48

@ -17,13 +17,18 @@ The `html` property of server render results has been deprecated. Use `body` ins
### hydratable_clobbering
```
Attempted to set hydratable with key `%key%` twice. This behavior is undefined.
Attempted to set hydratable with key `%key%` twice with different values.
First set occurred at:
First instance occurred at:
%stack%
Second instance occurred at:
%stack2%
```
This error occurs when using `hydratable` or `hydratable.set` multiple times with the same key. To avoid this, you can combine `hydratable` with `cache`, or check whether the value has already been set with `hydratable.has`.
This error occurs when using `hydratable` multiple times with the same key. To avoid this, you can:
- Ensure all invocations with the same key result in the same value
- Update the keys to make both instances unique
```svelte
<script>

@ -0,0 +1,32 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### unused_hydratable
```
A `hydratable` value with key `%key%` was created, but not used during the render.
Stack:
%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.

@ -10,12 +10,17 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render)
## hydratable_clobbering
> Attempted to set hydratable with key `%key%` twice. This behavior is undefined.
> Attempted to set hydratable with key `%key%` twice with different values.
>
> First set occurred at:
> First instance occurred at:
> %stack%
>
> Second instance occurred at:
> %stack2%
This error occurs when using `hydratable` or `hydratable.set` multiple times with the same key. To avoid this, you can combine `hydratable` with `cache`, or check whether the value has already been set with `hydratable.has`.
This error occurs when using `hydratable` multiple times with the same key. To avoid this, you can:
- Ensure all invocations with the same key result in the same value
- Update the keys to make both instances unique
```svelte
<script>

@ -0,0 +1,28 @@
## unused_hydratable
> A `hydratable` value with key `%key%` was created, but not used during the render.
>
> Stack:
> %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.

@ -22,12 +22,15 @@ export function hydratable(key, fn, transport) {
}
const store = window.__svelte?.h;
const unused_keys = window.__svelte?.uh;
if (!store?.has(key)) {
hydratable_missing_but_expected(key);
if (!unused_keys?.has(key)) {
hydratable_missing_but_expected(key);
}
return fn();
}
return decode(store.get(key), transport?.decode);
return decode(store?.get(key), transport?.decode);
}
/**

@ -7,6 +7,8 @@ declare global {
__svelte?: {
/** hydratables */
h?: Map<string, unknown>;
/** unused hydratable keys */
uh?: Set<string>;
};
}
}

@ -27,19 +27,26 @@ export function html_deprecated() {
}
/**
* Attempted to set hydratable with key `%key%` twice. This behavior is undefined.
* Attempted to set hydratable with key `%key%` twice with different values.
*
* First set occurred at:
* First instance occurred at:
* %stack%
*
* Second instance occurred at:
* %stack2%
* @param {string} key
* @param {string} stack
* @param {string} stack2
* @returns {never}
*/
export function hydratable_clobbering(key, stack) {
const error = new Error(`hydratable_clobbering\nAttempted to set hydratable with key \`${key}\` twice. This behavior is undefined.
export function hydratable_clobbering(key, stack, stack2) {
const error = new Error(`hydratable_clobbering\nAttempted to set hydratable with key \`${key}\` twice with different values.
First instance occurred at:
${stack}
First set occurred at:
${stack}\nhttps://svelte.dev/e/hydratable_clobbering`);
Second instance occurred at:
${stack2}\nhttps://svelte.dev/e/hydratable_clobbering`);
error.name = 'Svelte error';

@ -5,6 +5,9 @@ import { get_render_context } from './render-context.js';
import * as e from './errors.js';
import { DEV } from 'esm-env';
/** @type {WeakSet<HydratableEntry>} */
export const unresolved_hydratables = new WeakSet();
/**
* @template T
* @param {string} key
@ -19,11 +22,12 @@ export function hydratable(key, fn, transport) {
const store = get_render_context();
if (store.hydratables.has(key)) {
e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown');
}
const entry = create_entry(fn(), transport?.encode);
const existing_entry = store.hydratables.get(key);
if (DEV && existing_entry !== undefined) {
(existing_entry.dev_competing_entries ??= []).push(entry);
return entry.value;
}
store.hydratables.set(key, entry);
return entry.value;
}
@ -42,6 +46,16 @@ function create_entry(value, encode) {
if (DEV) {
entry.stack = new Error().stack;
if (
typeof value === 'object' &&
value !== null &&
'then' in value &&
typeof value.then === 'function'
) {
unresolved_hydratables.add(entry);
value.then(() => unresolved_hydratables.delete(entry));
}
}
return entry;

@ -5,10 +5,13 @@ import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js';
import { pop, push, set_ssr_context, ssr_context, save } from './context.js';
import * as e from './errors.js';
import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js';
import { uneval } from 'devalue';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { unresolved_hydratables } from './hydratable.js';
import { DEV } from 'esm-env';
/** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@ -575,17 +578,38 @@ export class Renderer {
async #collect_hydratables() {
const map = get_render_context().hydratables;
/** @type {(value: unknown) => string} */
/** @type {[string, string][]} */
let entries = [];
/** @type {string[]} */
let unused_keys = [];
for (const [k, v] of map) {
const encode = v.encode ?? uneval;
// sequential await is okay here -- all the work is already kicked off
entries.push([k, encode(await v.value)]);
if (unresolved_hydratables.has(v)) {
// this is a problem -- it means we've finished the render but somehow not consumed a hydratable, which means we've done
// extra work that won't get used on the client
w.unused_hydratable(k, v.stack ?? 'unavailable');
unused_keys.push(k);
continue;
}
const encoded = encode(await v.value);
if (DEV && v.dev_competing_entries?.length) {
for (const competing_entry of v.dev_competing_entries) {
const competing_encoded = (competing_entry.encode ?? uneval)(await competing_entry.value);
if (encoded !== competing_encoded) {
e.hydratable_clobbering(
k,
v.stack ?? 'unavailable',
competing_entry.stack ?? 'unavailable'
);
}
}
}
entries.push([k, encoded]);
}
if (entries.length === 0) return null;
return Renderer.#hydratable_block(entries);
if (entries.length === 0 && unused_keys.length === 0) return null;
return Renderer.#hydratable_block(entries, unused_keys);
}
/**
@ -642,23 +666,46 @@ export class Renderer {
};
}
/** @param {[string, string][]} serialized */
static #hydratable_block(serialized) {
/**
* @param {[string, string][]} serialized_entries
* @param {string[]} unused_keys
*/
static #hydratable_block(serialized_entries, unused_keys) {
let entries = [];
for (const [k, v] of serialized) {
for (const [k, v] of serialized_entries) {
entries.push(`[${JSON.stringify(k)},${v}]`);
}
// TODO csp -- have discussed but not implemented
return `
<script>
{
const store = (window.__svelte ??= {}).h ??= new Map();
for (const [k,v] of [${entries.join(',')}]) {
store.set(k, v);
}
const sv = window.__svelte ??= {};${Renderer.#used_hydratables(serialized_entries)}${Renderer.#unused_hydratables(unused_keys)}
}
</script>`;
}
/** @param {[string, string][]} serialized_entries */
static #used_hydratables(serialized_entries) {
let entries = [];
for (const [k, v] of serialized_entries) {
entries.push(`[${JSON.stringify(k)},${v}]`);
}
return `
const store = sv.h ??= new Map();
for (const [k,v] of [${entries.join(',')}]) {
store.set(k, v);
}`;
}
/** @param {string[]} unused_keys */
static #unused_hydratables(unused_keys) {
if (unused_keys.length === 0) return '';
return `
const unused = sv.uh ??= new Set();
for (const k of ${JSON.stringify(unused_keys)}) {
unused.add(k);
}`;
}
}
export class SSRState {

@ -19,6 +19,7 @@ export interface HydratableEntry {
value: unknown;
encode: Encode<any> | undefined;
stack?: string;
dev_competing_entries?: Omit<HydratableEntry, 'dev_competing_entries'>[];
}
export interface RenderContext {

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

@ -106,6 +106,7 @@ declare global {
var __svelte:
| {
h?: Map<string, unknown>;
uh?: Set<string>;
}
| undefined;
}
@ -125,6 +126,7 @@ beforeAll(() => {
beforeEach(() => {
delete globalThis?.__svelte?.h;
delete globalThis?.__svelte?.uh;
});
afterAll(() => {
@ -418,7 +420,7 @@ async function run_test_variant(
const run_hydratables_init = () => {
if (variant !== 'hydrate') return;
const script = [...document.head.querySelectorAll('script').values()].find((script) =>
script.textContent?.includes('(window.__svelte ??= {}).h')
script.textContent?.includes('const sv = window.__svelte ??= {}')
)?.textContent;
if (!script) return;
(0, eval)(script);

@ -1,13 +0,0 @@
import { 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_expected_e'
});

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

@ -0,0 +1,20 @@
import { tick } from 'svelte';
import { ok, test } from '../../test';
export default test({
skip_no_async: true,
mode: ['async-server', 'hydrate'],
server_props: { environment: 'server' },
ssrHtml: '<div>Loading...</div>',
async test({ assert, target }) {
// let it hydrate and resolve the promise on the client
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) => environment === 'server' ? undefined : res('did you ever hear the tragedy of darth plagueis the wise?')
)
);
</script>
<svelte:boundary>
<div>{await unresolved_hydratable}</div>
{#snippet pending()}
<div>Loading...</div>
{/snippet}
</svelte:boundary>

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

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

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