diff --git a/.changeset/big-masks-shave.md b/.changeset/big-masks-shave.md new file mode 100644 index 000000000000..96c18fdb6c73 --- /dev/null +++ b/.changeset/big-masks-shave.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: `hydratable` API diff --git a/documentation/docs/06-runtime/05-hydratable.md b/documentation/docs/06-runtime/05-hydratable.md new file mode 100644 index 000000000000..d6f5d9a1eb76 --- /dev/null +++ b/documentation/docs/06-runtime/05-hydratable.md @@ -0,0 +1,107 @@ +--- +title: Hydratable data +--- + +In Svelte, when you want to render asynchonous content data on the server, you can simply `await` it. This is great! However, it comes with a major pitall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes: + +```svelte + + +

{user.name}

+``` + +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 build to solve this problem. You probably won't need this very often -- it will probably 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 + + +

{user.name}

+``` + +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. + +## Imperative API + +If you're writing a library with separate server and client exports, it may be more convenient to use the imperative API: + +```ts +import { hydratable } from 'svelte'; + +const value = hydratable.get('foo'); // only works on the client +const hasValue = hydratable.has('foo'); +hydratable.set('foo', 'whatever value you want'); // only works on the server +``` + +## Custom serialization + +By default, Svelte uses [`devalue`](https://npmjs.com/package/devalue) to serialize your data on the server so that decoding it on the client requires no dependencies. If you need to serialize additional things not covered by `devalue`, you can provide your own transport mechanisms by writing custom `encode` and `decode` methods. + +### `encode` + +Encode receives a value and outputs _the JavaScript code necessary to create that value on the client_. For example, Svelte's built-in encoder looks like this: + +```js +import * as devalue from 'devalue'; + +/** + * @param {any} value + */ +function encode (value) { + return devalue.uneval(value); +} + +encode(['hello', 'world']); // outputs `['hello', 'world']` +``` + +### `decode` + +`decode` accepts whatever the JavaScript that `encode` outputs resolves to, and returns whatever the final value from `hydratable` should be. + +### Usage + +When using the isomorphic API, you must provide either `encode` or `decode`, depending on the environment. This enables your bundler to treeshake the unneeded code during your build: + +```svelte + +``` + +For the imperative API, you just provide `encode` or `decode` depending on which method you're using: + +```ts +import { hydratable } from 'svelte'; +import { encode, decode } from '$lib/encoders'; + +const random = hydratable.get('random', { decode }); +hydratable.set('random', Math.random(), { encode }); +``` diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 3f1cb8f76bab..4de99988e91a 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -130,12 +130,6 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. -### experimental_async_fork - -``` -Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` -``` - ### flush_sync_in_effect ``` @@ -146,6 +140,12 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +### fn_unavailable_on_client + +``` +`%name%`(...) is unavailable in the browser. +``` + ### fork_discarded ``` @@ -164,6 +164,23 @@ Cannot create a fork inside an effect or when state changes are pending `getAbortSignal()` can only be called inside an effect or derived ``` +### hydratable_missing_but_expected_e + +``` +Expected to find a hydratable with key `%key%` during hydration, but did not. +This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. +```svelte + +``` + ### hydration_failed ``` diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index c95ace22293d..d7de5998facf 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -140,6 +140,23 @@ The easiest way to log a value as it changes over time is to use the [`$inspect` %handler% should be a function. Did you mean to %suggestion%? ``` +### hydratable_missing_but_expected_w + +``` +Expected to find a hydratable with key `%key%` during hydration, but did not. +This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. +```svelte + +``` + ### hydration_attribute_changed ``` diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index 626303221248..0630e378f113 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -8,12 +8,40 @@ Encountered asynchronous work while rendering synchronously. You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +### fn_unavailable_on_server + +``` +`%name%`(...) is unavailable on the server. +``` + ### html_deprecated ``` The `html` property of server render results has been deprecated. Use `body` instead. ``` +### hydratable_clobbering + +``` +Attempted to set hydratable with key `%key%` twice. This behavior is undefined. + +First set occurred at: +%stack% +``` + +This error occurs when using `hydratable` or `hydratable.set` multiple times with the same key. To avoid this, you can combine `hydratable` with `cache`, or check whether the value has already been set with `hydratable.has`. + +```svelte + +``` + ### lifecycle_function_unavailable ``` @@ -21,3 +49,10 @@ The `html` property of server render results has been deprecated. Use `body` ins ``` Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render. + +### render_context_unavailable + +``` +Failed to retrieve `render` context. %addendum% +If `AsyncLocalStorage` is available, you're likely calling a function that needs access to the `render` context (`hydratable`, `cache`, or something that depends on these) from outside of `render`. If `AsyncLocalStorage` is not available, these functions must also be called synchronously from within `render` -- i.e. not after any `await`s. +``` diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 07e13dea459b..136b3f4957d6 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -1,5 +1,11 @@ +### experimental_async_required + +``` +Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` +``` + ### invalid_default_snippet ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index ae7d811b2e08..36d9caf4d41d 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -100,10 +100,6 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. -## experimental_async_fork - -> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` - ## flush_sync_in_effect > Cannot use `flushSync` inside an effect @@ -112,6 +108,10 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +## fn_unavailable_on_client + +> `%name%`(...) is unavailable in the browser. + ## fork_discarded > Cannot commit a fork that was already discarded @@ -124,6 +124,21 @@ This restriction only applies when using the `experimental.async` option, which > `getAbortSignal()` can only be called inside an effect or derived +## hydratable_missing_but_expected_e + +> Expected to find a hydratable with key `%key%` during hydration, but did not. +This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. +```svelte + +``` + ## hydration_failed > Failed to hydrate the application diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 9763c8df1ab8..f5e8f18005e1 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -124,6 +124,21 @@ The easiest way to log a value as it changes over time is to use the [`$inspect` > %handler% should be a function. Did you mean to %suggestion%? +## hydratable_missing_but_expected_w + +> Expected to find a hydratable with key `%key%` during hydration, but did not. +This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. +```svelte + +``` + ## hydration_attribute_changed > The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index 49d2a310f601..1b699f3390e1 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -4,12 +4,41 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +## fn_unavailable_on_server + +> `%name%`(...) is unavailable on the server. + ## html_deprecated > The `html` property of server render results has been deprecated. Use `body` instead. +## hydratable_clobbering + +> Attempted to set hydratable with key `%key%` twice. This behavior is undefined. +> +> First set occurred at: +> %stack% + +This error occurs when using `hydratable` or `hydratable.set` multiple times with the same key. To avoid this, you can combine `hydratable` with `cache`, or check whether the value has already been set with `hydratable.has`. + +```svelte + +``` + ## lifecycle_function_unavailable > `%name%(...)` is not available on the server Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render. + +## render_context_unavailable + +> Failed to retrieve `render` context. %addendum% +If `AsyncLocalStorage` is available, you're likely calling a function that needs access to the `render` context (`hydratable`, `cache`, or something that depends on these) from outside of `render`. If `AsyncLocalStorage` is not available, these functions must also be called synchronously from within `render` -- i.e. not after any `await`s. diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index e3959034a3c3..bf053283e434 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -1,3 +1,7 @@ +## experimental_async_required + +> Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` + ## invalid_default_snippet > Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 731ae6f00459..a874bedcf3d4 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -174,6 +174,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 4fcfff980dd8..0eb1b8031502 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -249,6 +249,7 @@ export { hasContext, setContext } from './internal/client/context.js'; +export { hydratable } from './internal/client/hydratable.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'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 61b0d98c0650..9fb810fd9ebd 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -51,4 +51,6 @@ export { setContext } from './internal/server/context.js'; +export { hydratable } from './internal/server/hydratable.js'; + export { createRawSnippet } from './internal/server/blocks/snippet.js'; diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index a1782f5b61a5..42507385d6fb 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -369,3 +369,4 @@ export interface Fork { } export * from './index-client.js'; +export { Hydratable, Transport, Encode, Decode } from '#shared'; diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8a5fde4f3b9a..1b3b4e97725c 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -230,34 +230,35 @@ export function effect_update_depth_exceeded() { } /** - * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + * Cannot use `flushSync` inside an effect * @returns {never} */ -export function experimental_async_fork() { +export function flush_sync_in_effect() { if (DEV) { - const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`); + const error = new Error(`flush_sync_in_effect\nCannot use \`flushSync\` inside an effect\nhttps://svelte.dev/e/flush_sync_in_effect`); error.name = 'Svelte error'; throw error; } else { - throw new Error(`https://svelte.dev/e/experimental_async_fork`); + throw new Error(`https://svelte.dev/e/flush_sync_in_effect`); } } /** - * Cannot use `flushSync` inside an effect + * `%name%`(...) is unavailable in the browser. + * @param {string} name * @returns {never} */ -export function flush_sync_in_effect() { +export function fn_unavailable_on_client(name) { if (DEV) { - const error = new Error(`flush_sync_in_effect\nCannot use \`flushSync\` inside an effect\nhttps://svelte.dev/e/flush_sync_in_effect`); + const error = new Error(`fn_unavailable_on_client\n\`${name}\`(...) is unavailable in the browser.\nhttps://svelte.dev/e/fn_unavailable_on_client`); error.name = 'Svelte error'; throw error; } else { - throw new Error(`https://svelte.dev/e/flush_sync_in_effect`); + throw new Error(`https://svelte.dev/e/fn_unavailable_on_client`); } } @@ -309,6 +310,31 @@ export function get_abort_signal_outside_reaction() { } } +/** + * Expected to find a hydratable with key `%key%` during hydration, but did not. + * This can happen if you render a hydratable on the client that was not rendered on the server, and means that it was forced to fall back to running its function blockingly during hydration. This is bad for performance, as it blocks hydration until the asynchronous work completes. + * ```svelte + * `; + } } export class SSRState { @@ -673,3 +716,33 @@ export class SSRState { } } } + +export class MemoizedUneval { + /** @type {Map} */ + #cache = new Map(); + + /** + * @param {unknown} value + * @returns {string} + */ + uneval = (value) => { + return uneval(value, (value, uneval) => { + const cached = this.#cache.get(value); + if (cached) { + // this breaks my brain a bit, but: + // - when the entry is defined but its value is `undefined`, calling `uneval` below will cause the custom replacer to be called again + // - because the custom replacer returns this, which is `undefined`, it will fall back to the default serialization + // - ...which causes it to return a string + // - ...which is then added to this cache before being returned + return cached.value; + } + + const stub = {}; + this.#cache.set(value, stub); + + const result = uneval(value); + stub.value = result; + return result; + }); + }; +} diff --git a/packages/svelte/src/internal/server/renderer.test.ts b/packages/svelte/src/internal/server/renderer.test.ts index 8e9a377a5b15..a2a979aeb4d4 100644 --- a/packages/svelte/src/internal/server/renderer.test.ts +++ b/packages/svelte/src/internal/server/renderer.test.ts @@ -1,7 +1,8 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { Renderer, SSRState } from './renderer.js'; +import { MemoizedUneval, Renderer, SSRState } from './renderer.js'; import type { Component } from 'svelte'; import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; +import { uneval } from 'devalue'; test('collects synchronous body content by default', () => { const component = (renderer: Renderer) => { @@ -355,3 +356,39 @@ describe('async', () => { expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']); }); }); + +describe('MemoizedDevalue', () => { + test.each([ + 1, + 'general kenobi', + { foo: 'bar' }, + [1, 2], + null, + undefined, + new Map([[1, '2']]) + ] as const)('has same behavior as unmemoized devalue for %s', (input) => { + expect(new MemoizedUneval().uneval(input)).toBe(uneval(input)); + }); + + test('caches results', () => { + const memoized = new MemoizedUneval(); + let calls = 0; + + const input = { + get only_once() { + calls++; + return 42; + } + }; + + const first = memoized.uneval(input); + const max_calls = calls; + const second = memoized.uneval(input); + memoized.uneval(input); + + expect(first).toBe(second); + // for reasons I don't quite comprehend, it does get called twice, but both calls happen in the first + // serialization, and don't increase afterwards + expect(calls).toBe(max_calls); + }); +}); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 53cefabc69c4..6b1cf6e1f2e0 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,3 +1,4 @@ +import type { Encode } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -14,6 +15,17 @@ export interface SSRContext { element?: Element; } +export interface HydratableEntry { + value: unknown; + encode: Encode | undefined; + stack?: string; +} + +export interface RenderContext { + hydratables: Map; + cache: Map>; +} + export interface SyncRenderOutput { /** HTML that goes into the `` */ head: string; diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index 669cdd96a7f3..b13a65b59865 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -2,6 +2,23 @@ import { DEV } from 'esm-env'; +/** + * Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true` + * @param {string} name + * @returns {never} + */ +export function experimental_async_required(name) { + if (DEV) { + const error = new Error(`experimental_async_required\nCannot use \`${name}(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_required`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/experimental_async_required`); + } +} + /** * Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead * @returns {never} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 4deeb76b2f4b..8365b529f9e1 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -8,3 +8,51 @@ export type Getters = { }; export type Snapshot = ReturnType>; + +export type MaybePromise = T | Promise; + +/** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */ +export type Decode = (value: any) => T; + +/** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */ +export type Encode = (value: T) => string; + +/** + * Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg: + * ```ts + * import { BROWSER } from 'esm-env'; + * const transport: Transport = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction }; + * ``` + */ +export type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + +/** Make the result of a function hydratable. This means it will be serialized on the server and available synchronously during hydration on the client. */ +export type Hydratable = { + ( + /** + * A key to identify this hydratable value. Each hydratable value must have a unique key. + * If writing a library that utilizes `hydratable`, prefix your keys with your library name to prevent naming collisions. + */ + key: string, + /** + * A function that returns the value to be hydrated. On the server, this value will be stashed and serialized. + * On the client during hydration, the value will be used synchronously instead of invoking the function. + */ + fn: () => T, + options?: { transport?: Transport } + ): T; + /** Get a hydratable value from the server-rendered store. If used after hydration, will always return `undefined`. Only works on the client. */ + get: (key: string, options?: { decode?: Decode }) => T | undefined; + /** Check if a hydratable value exists in the server-rendered store. */ + has: (key: string) => boolean; + /** Set a hydratable value. Only works on the server during `render`. */ + set: (key: string, value: T, options?: { encode?: Encode }) => void; +}; diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 10f8597520d9..659cd10040ac 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -48,7 +48,7 @@ export function run_all(arr) { /** * TODO replace with Promise.withResolvers once supported widely enough - * @template T + * @template [T=void] */ export function deferred() { /** @type {(value: T) => void} */ diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index e0f10ca2ddf0..4ff5b03de0b4 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -5,7 +5,7 @@ import { createClassComponent } from 'svelte/legacy'; import { proxy } from 'svelte/internal/client'; import { flushSync, hydrate, mount, unmount } from 'svelte'; import { render } from 'svelte/server'; -import { afterAll, assert, beforeAll } from 'vitest'; +import { afterAll, assert, beforeAll, beforeEach } from 'vitest'; import { async_mode, compile_directory, fragments } from '../helpers.js'; import { assert_html_equal, assert_html_equal_with_options } from '../html_equal.js'; import { raf } from '../animation-helpers.js'; @@ -102,6 +102,14 @@ export interface RuntimeTest = Record { process.prependListener('unhandledRejection', unhandled_rejection_handler); }); +beforeEach(() => { + delete globalThis?.__svelte?.h; +}); + afterAll(() => { process.removeListener('unhandledRejection', unhandled_rejection_handler); }); @@ -403,6 +415,15 @@ async function run_test_variant( throw new Error('Ensure dom mode is skipped'); }; + const run_hydratables_init = () => { + if (variant !== 'hydrate') return; + const script = [...document.head.querySelectorAll('script').values()].find((script) => + script.textContent?.includes('(window.__svelte ??= {}).h') + )?.textContent; + if (!script) return; + (0, eval)(script); + }; + if (runes) { props = proxy({ ...(config.props || {}) }); @@ -411,6 +432,7 @@ async function run_test_variant( if (manual_hydrate && variant === 'hydrate') { hydrate_fn = () => { + run_hydratables_init(); instance = hydrate(mod.default, { target, props, @@ -419,6 +441,7 @@ async function run_test_variant( }); }; } else { + run_hydratables_init(); const render = variant === 'hydrate' ? hydrate : mount; instance = render(mod.default, { target, @@ -428,6 +451,7 @@ async function run_test_variant( }); } } else { + run_hydratables_init(); instance = createClassComponent({ component: mod.default, props: config.props, @@ -527,6 +551,7 @@ async function run_test_variant( } } catch (err) { if (config.runtime_error) { + console.log(err); assert.include((err as Error).message, config.runtime_error); } else if (config.error && !unintended_error) { assert.include((err as Error).message, config.error); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js new file mode 100644 index 000000000000..57904ef57608 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/_config.js @@ -0,0 +1,21 @@ +import { ok, test } from '../../test'; + +export default test({ + skip_no_async: true, + skip_mode: ['server'], + + server_props: { environment: 'server' }, + ssrHtml: '

The current environment is: server

', + + props: { environment: 'browser' }, + + async test({ assert, target, variant }) { + const p = target.querySelector('p'); + ok(p); + if (variant === 'hydrate') { + assert.htmlEqual(p.outerHTML, '

The current environment is: server

'); + } else { + assert.htmlEqual(p.outerHTML, '

The current environment is: browser

'); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte new file mode 100644 index 000000000000..18b7f834676b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-custom-transport/main.svelte @@ -0,0 +1,15 @@ + + +

The current environment is: {value}

diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js new file mode 100644 index 000000000000..3990b65087e3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/_config.js @@ -0,0 +1,13 @@ +import { test } from '../../test'; + +export default test({ + skip_no_async: true, + mode: ['async-server', 'hydrate'], + + server_props: { environment: 'server' }, + ssrHtml: '

The current environment is: server

', + + props: { environment: 'browser' }, + + runtime_error: 'hydratable_missing_but_expected_e' +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte new file mode 100644 index 000000000000..4784dd13b2a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing-imperative/main.svelte @@ -0,0 +1,14 @@ + + +

The current environment is: {value}

diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js new file mode 100644 index 000000000000..b04d81d63914 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/_config.js @@ -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: '

The current environment is: server

', + + props: { environment: 'browser' }, + + runtime_error: 'hydratable_missing_but_expected_e' +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/main.svelte new file mode 100644 index 000000000000..b7dfc0e7e252 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable-error-on-missing/main.svelte @@ -0,0 +1,14 @@ + + +

The current environment is: {value}

diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js b/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js new file mode 100644 index 000000000000..57904ef57608 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable/_config.js @@ -0,0 +1,21 @@ +import { ok, test } from '../../test'; + +export default test({ + skip_no_async: true, + skip_mode: ['server'], + + server_props: { environment: 'server' }, + ssrHtml: '

The current environment is: server

', + + props: { environment: 'browser' }, + + async test({ assert, target, variant }) { + const p = target.querySelector('p'); + ok(p); + if (variant === 'hydrate') { + assert.htmlEqual(p.outerHTML, '

The current environment is: server

'); + } else { + assert.htmlEqual(p.outerHTML, '

The current environment is: browser

'); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/hydratable/main.svelte b/packages/svelte/tests/runtime-runes/samples/hydratable/main.svelte new file mode 100644 index 000000000000..53b9c24f91c1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/hydratable/main.svelte @@ -0,0 +1,9 @@ + + +

The current environment is: {value}

diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js new file mode 100644 index 000000000000..404260cc66d8 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + error: 'hydratable_clobbering' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte new file mode 100644 index 000000000000..25a1166f8324 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering-imperative/main.svelte @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js new file mode 100644 index 000000000000..404260cc66d8 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + error: 'hydratable_clobbering' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte new file mode 100644 index 000000000000..764c2c241557 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/hydratable-clobbering/main.svelte @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index 7eede332a741..20997cdf6260 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -81,7 +81,7 @@ const { test, run } = suite_with_variants void): void; + type Getters = { + [K in keyof T]: () => T[K]; + }; + + /** Decode a value. The value will be whatever the evaluated JavaScript emitted by the corresponding {@link Encode} function evaluates to. */ + export type Decode = (value: any) => T; + + /** Encode a value as a string. The string should be _valid JavaScript code_ -- for example, the output of `devalue`'s `uneval` function. */ + export type Encode = (value: T) => string; + + /** + * Custom encode and decode options. This must be used in combination with an environment variable to enable treeshaking, eg: + * ```ts + * import { BROWSER } from 'esm-env'; + * const transport: Transport = BROWSER ? { decode: myDecodeFunction } : { encode: myEncodeFunction }; + * ``` + */ + export type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + + /** Make the result of a function hydratable. This means it will be serialized on the server and available synchronously during hydration on the client. */ + export type Hydratable = { + ( + /** + * A key to identify this hydratable value. Each hydratable value must have a unique key. + * If writing a library that utilizes `hydratable`, prefix your keys with your library name to prevent naming collisions. + */ + key: string, + /** + * A function that returns the value to be hydrated. On the server, this value will be stashed and serialized. + * On the client during hydration, the value will be used synchronously instead of invoking the function. + */ + fn: () => T, + options?: { transport?: Transport } + ): T; + /** Get a hydratable value from the server-rendered store. If used after hydration, will always return `undefined`. Only works on the client. */ + get: (key: string, options?: { decode?: Decode }) => T | undefined; + /** Check if a hydratable value exists in the server-rendered store. */ + has: (key: string) => boolean; + /** Set a hydratable value. Only works on the server during `render`. */ + set: (key: string, value: T, options?: { encode?: Encode }) => void; + }; + export const hydratable: Hydratable; /** * Create a snippet programmatically * */ @@ -591,9 +641,6 @@ declare module 'svelte' { * ``` * */ export function untrack(fn: () => T): T; - type Getters = { - [K in keyof T]: () => T[K]; - }; export {}; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0afaef0ceb2f..0b1f57213d31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + devalue: + specifier: ^5.5.0 + version: 5.5.0 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1515,6 +1518,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + devalue@5.5.0: + resolution: {integrity: sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3990,6 +3996,8 @@ snapshots: detect-libc@1.0.3: optional: true + devalue@5.5.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0