diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 3f1cb8f76bab..4fa8e1fc4382 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 on the client. +``` + ### fork_discarded ``` diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index c95ace22293d..4deb338521c6 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -140,6 +140,25 @@ 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 + +``` +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..101dc5db9e34 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -8,16 +8,52 @@ 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 `setHydratableValue` 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 `hasHydratableValue`. + +```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. ``` -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..31de4b91a57a 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 diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 9763c8df1ab8..b51fc6b53c20 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -124,6 +124,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 + +> 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..cff1f5df8789 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -4,12 +4,42 @@ 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 0988828c44c2..7bceb6eed065 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.4.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 0ee6004d4a2c..e304d94ac9f3 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -42,7 +42,7 @@ await createBundle({ [`${pkg.name}/easing`]: `${dir}/src/easing/index.js`, [`${pkg.name}/legacy`]: `${dir}/src/legacy/legacy-client.js`, [`${pkg.name}/motion`]: `${dir}/src/motion/public.d.ts`, - [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`, + [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/public.d.ts`, [`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`, [`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`, [`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`, 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/internal/client/context.js b/packages/svelte/src/internal/client/context.js index ffdb342adb07..ad446bbcb2ac 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js'; import { create_user_effect } from './reactivity/effects.js'; import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; import { FILENAME } from '../../constants.js'; -import { BRANCH_EFFECT, EFFECT_RAN } from './constants.js'; +import { BRANCH_EFFECT } from './constants.js'; /** @type {ComponentContext | null} */ export let component_context = null; diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8a5fde4f3b9a..d067ff95a31c 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 on the client. + * @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 on the client.\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`); } } diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js new file mode 100644 index 000000000000..15e3dc5ba8ab --- /dev/null +++ b/packages/svelte/src/internal/client/hydratable.js @@ -0,0 +1,97 @@ +/** @import { Decode, Hydratable, Transport } from '#shared' */ +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'; + +/** + * @template T + * @param {string} key + * @param {() => T} fn + * @param {Transport} [options] + * @returns {T} + */ +function isomorphic_hydratable(key, fn, options) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable'); + } + + return access_hydratable_store( + key, + (val, has) => { + if (!has) { + w.hydratable_missing_but_expected(key); + return fn(); + } + return decode(val, options?.decode); + }, + fn + ); +} + +isomorphic_hydratable['get'] = get_hydratable_value; +isomorphic_hydratable['has'] = has_hydratable_value; +isomorphic_hydratable['set'] = () => e.fn_unavailable_on_client('hydratable.set'); + +/** @type {Hydratable} */ +const hydratable = isomorphic_hydratable; + +export { hydratable }; + +/** + * @template T + * @param {string} key + * @param {{ decode?: Decode }} [options] + * @returns {T | undefined} + */ +function get_hydratable_value(key, options = {}) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable.get'); + } + + return access_hydratable_store( + key, + (val) => decode(val, options.decode), + () => undefined + ); +} + +/** + * @param {string} key + * @returns {boolean} + */ +function has_hydratable_value(key) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable.set'); + } + return access_hydratable_store( + key, + (_, has) => has, + () => false + ); +} + +/** + * @template T + * @param {string} key + * @param {(val: unknown, has: boolean) => T} on_hydrating + * @param {() => T} on_not_hydrating + * @returns {T} + */ +function access_hydratable_store(key, on_hydrating, on_not_hydrating) { + if (!hydrating) { + return on_not_hydrating(); + } + var store = window.__svelte?.h; + return on_hydrating(store?.get(key), store?.has(key) ?? false); +} + +/** + * @template T + * @param {unknown} val + * @param {Decode | undefined} decode + * @returns {T} + */ +function decode(val, decode) { + return (decode ?? ((val) => /** @type {T} */ (val)))(val); +} diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 57aa185a31db..68d620c546e4 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -895,7 +895,7 @@ export function eager(fn) { */ export function fork(fn) { if (!async_mode_flag) { - e.experimental_async_fork(); + e.experimental_async_required('fork'); } if (current_batch !== null) { diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js new file mode 100644 index 000000000000..c1bf1fe4bb9e --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -0,0 +1,83 @@ +/** @import { CacheEntry } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; +import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { tick } from '../runtime.js'; +import { get_effect_validation_error_code, render_effect } from './effects.js'; +import * as e from '../errors.js'; + +/** @typedef {{ count: number, item: any }} Entry */ +/** @type {Map} */ +const client_cache = new Map(); + +/** + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} + */ +export function cache(key, fn) { + if (!async_mode_flag) { + e.experimental_async_required('cache'); + } + + const entry = client_cache.get(key); + const maybe_remove = create_remover(key); + + const tracking = get_effect_validation_error_code() === null; + if (tracking) { + render_effect(() => { + if (entry) entry.count++; + return () => { + const entry = client_cache.get(key); + if (!entry) return; + entry.count--; + maybe_remove(entry); + }; + }); + } + + if (entry !== undefined) { + return entry?.item; + } + + const item = fn(); + const new_entry = { + item, + count: tracking ? 1 : 0 + }; + client_cache.set(key, new_entry); + + Promise.resolve(item).then( + () => maybe_remove(new_entry), + () => maybe_remove(new_entry) + ); + return item; +} + +/** + * @param {string} key + */ +function create_remover(key) { + /** + * @param {Entry | undefined} entry + */ + return (entry) => + tick().then(() => { + if (!entry?.count && entry === client_cache.get(key)) { + client_cache.delete(key); + } + }); +} + +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + if (!async_mode_flag) { + e.experimental_async_required('CacheObserver'); + } + super(() => client_cache, prefix); + } +} diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 5d7c0ef871fd..b2fa6172b071 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -48,17 +48,27 @@ import { without_reactive_context } from '../dom/elements/bindings/shared.js'; * @param {'$effect' | '$effect.pre' | '$inspect'} rune */ export function validate_effect(rune) { + const code = get_effect_validation_error_code(); + if (code === null) return; + e[code](rune); +} + +/** + * @returns {'effect_orphan' | 'effect_in_unowned_derived' | 'effect_in_teardown' | null} + */ +export function get_effect_validation_error_code() { if (active_effect === null) { if (active_reaction === null) { - e.effect_orphan(rune); + return 'effect_orphan'; } - - e.effect_in_unowned_derived(); + return 'effect_in_unowned_derived'; } if (is_destroying_effect) { - e.effect_in_teardown(rune); + return 'effect_in_teardown'; } + + return null; } /** diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js new file mode 100644 index 000000000000..6d447ef19dda --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -0,0 +1,22 @@ +/** @import { GetRequestInit, Resource } from '#shared' */ +import { cache } from './cache'; +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../hydratable'; +import { resource } from './resource'; +import { async_mode_flag } from '../../flags'; +import * as e from '../errors.js'; + +/** + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} + */ +export function fetcher(url, init) { + if (!async_mode_flag) { + e.experimental_async_required('fetcher'); + } + + const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; + return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); +} diff --git a/packages/svelte/src/internal/client/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js new file mode 100644 index 000000000000..7f3780e68022 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -0,0 +1,185 @@ +/** @import { Source, Derived } from '#client' */ +/** @import { Resource as ResourceType } from '#shared' */ +import { state, derived, set, get, tick } from '../index.js'; +import { deferred } from '../../shared/utils.js'; +import { async_mode_flag } from '../../flags/index.js'; +import * as e from '../errors.js'; + +/** + * @template T + * @param {() => T} fn + * @returns {ResourceType} + */ +export function resource(fn) { + if (!async_mode_flag) { + e.experimental_async_required('resource'); + } + return /** @type {ResourceType} */ (new Resource(fn)); +} + +/** + * @template T + * @implements {Partial>>} + */ +class Resource { + #init = false; + + /** @type {() => T} */ + #fn; + + /** @type {Source} */ + #loading = state(true); + + /** @type {Array<(...args: any[]) => void>} */ + #latest = []; + + /** @type {Source} */ + #ready = state(false); + + /** @type {Source | undefined>} */ + #raw = state(undefined); + + /** @type {Source>} */ + #promise; + + /** @type {Derived | undefined>} */ + #current = derived(() => { + if (!get(this.#ready)) return undefined; + return get(this.#raw); + }); + + /** {@type Source} */ + #error = state(undefined); + + /** @type {Derived>['then']>} */ + // @ts-expect-error - I feel this might actually be incorrect but I can't prove it yet. + // we are technically not returning a promise that resolves to the correct type... but it _is_ a promise that resolves at the correct time + #then = derived(() => { + const p = get(this.#promise); + + return async (resolve, reject) => { + const result = /** @type {Promise>} */ ( + (async () => { + await p; + await tick(); + return get(this.#current); + })() + ); + + if (resolve || reject) { + return result.then(resolve, reject); + } + + return result; + }; + }); + + /** + * @param {() => T} fn + */ + constructor(fn) { + this.#fn = fn; + this.#promise = state(this.#run()); + } + + #run() { + if (this.#init) { + tick().then(() => { + // opt this out of async coordination + set(this.#loading, true); + }); + } else { + this.#init = true; + } + + const { resolve, reject, promise } = deferred(); + + this.#latest.push(resolve); + + Promise.resolve(this.#fn()) + .then((value) => { + // Skip the response if resource was refreshed with a later promise while we were waiting for this one to resolve + const idx = this.#latest.indexOf(resolve); + if (idx === -1) return; + + this.#latest.splice(0, idx).forEach((r) => r()); + set(this.#ready, true); + set(this.#loading, false); + set(this.#raw, value); + set(this.#error, undefined); + + resolve(undefined); + }) + .catch((e) => { + const idx = this.#latest.indexOf(resolve); + if (idx === -1) return; + + this.#latest.splice(0, idx).forEach((r) => r()); + set(this.#error, e); + set(this.#loading, false); + reject(e); + }); + + return promise; + } + + get then() { + return get(this.#then); + } + + get catch() { + get(this.#then); + return (/** @type {any} */ reject) => { + return get(this.#then)(undefined, reject); + }; + } + + get finally() { + get(this.#then); + return (/** @type {any} */ fn) => { + return get(this.#then)().finally(fn); + }; + } + + get current() { + return get(this.#current); + } + + get error() { + return get(this.#error); + } + + /** + * Returns true if the resource is loading or reloading. + */ + get loading() { + return get(this.#loading); + } + + /** + * Returns true once the resource has been loaded for the first time. + */ + get ready() { + return get(this.#ready); + } + + /** + * @returns {Promise} + */ + refresh = async () => { + const promise = this.#run(); + set(this.#promise, promise); + await promise; + }; + + /** + * @param {Awaited} value + */ + set = (value) => { + set(this.#ready, true); + set(this.#loading, false); + set(this.#error, undefined); + set(this.#raw, value); + set(this.#promise, Promise.resolve()); + }; +} diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index deb3e829860f..409a2ba3174b 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -2,6 +2,15 @@ import type { Store } from '#shared'; import { STATE_SYMBOL } from './constants.js'; import type { Effect, Source, Value } from './reactivity/types.js'; +declare global { + interface Window { + __svelte?: { + /** hydratables */ + h?: Map; + }; + } +} + type EventCallback = (event: Event) => boolean; export type EventCallbackMap = Record; diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index 1081ef58618e..a9a50c57d6be 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -87,6 +87,18 @@ export function event_handler_invalid(handler, suggestion) { } } +/** + * Expected to find a hydratable with key `%key%` during hydration, but did not. + * @param {string} key + */ +export function hydratable_missing_but_expected(key) { + if (DEV) { + console.warn(`%c[svelte] hydratable_missing_but_expected\n%cExpected to find a hydratable with key \`${key}\` during hydration, but did not.\nhttps://svelte.dev/e/hydratable_missing_but_expected`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/hydratable_missing_but_expected`); + } +} + /** * 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 * @param {string} attribute diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 7779da4c1d09..c321981d1ab2 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,6 +1,7 @@ /** @import { SSRContext } from '#server' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; +import { save_render_context } from './render-context.js'; /** @type {SSRContext | null} */ export var ssr_context = null; @@ -113,10 +114,10 @@ function get_parent_context(ssr_context) { */ export async function save(promise) { var previous_context = ssr_context; - var value = await promise; + const restore_render_context = await save_render_context(promise); return () => { ssr_context = previous_context; - return value; + return restore_render_context(); }; } diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index bde49fe935a7..043c710efc21 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -14,6 +14,19 @@ export function await_invalid() { throw error; } +/** + * `%name%`(...) is unavailable on the server. + * @param {string} name + * @returns {never} + */ +export function fn_unavailable_on_server(name) { + const error = new Error(`fn_unavailable_on_server\n\`${name}\`(...) is unavailable on the server.\nhttps://svelte.dev/e/fn_unavailable_on_server`); + + error.name = 'Svelte error'; + + throw error; +} + /** * The `html` property of server render results has been deprecated. Use `body` instead. * @returns {never} @@ -26,13 +39,48 @@ export function html_deprecated() { throw error; } +/** + * Attempted to set hydratable with key `%key%` twice. This behavior is undefined. + * + * First set occurred at: + * %stack% + * @param {string} key + * @param {string} stack + * @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. + +First set occurred at: +${stack}\nhttps://svelte.dev/e/hydratable_clobbering`); + + error.name = 'Svelte error'; + + throw error; +} + /** * `%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. * @param {string} name * @returns {never} */ export function lifecycle_function_unavailable(name) { - const error = new Error(`lifecycle_function_unavailable\n\`${name}(...)\` is not available on the server\nhttps://svelte.dev/e/lifecycle_function_unavailable`); + const error = new Error(`lifecycle_function_unavailable\n\`${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.\nhttps://svelte.dev/e/lifecycle_function_unavailable`); + + error.name = 'Svelte error'; + + throw error; +} + +/** + * Failed to retrieve `render` context. %addendum% + * @param {string} addendum + * @returns {never} + */ +export function render_context_unavailable(addendum) { + const error = new Error(`render_context_unavailable\nFailed to retrieve \`render\` context. ${addendum}\nhttps://svelte.dev/e/render_context_unavailable`); error.name = 'Svelte error'; diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js new file mode 100644 index 000000000000..2af1ef776295 --- /dev/null +++ b/packages/svelte/src/internal/server/hydratable.js @@ -0,0 +1,90 @@ +/** @import { Encode, Hydratable, Transport } from '#shared' */ +/** @import { HydratableEntry } 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 { DEV } from 'esm-env'; + +/** + * @template T + * @param {string} key + * @param {() => T} fn + * @param {Transport} [options] + * @returns {T} + */ +function isomorphic_hydratable(key, fn, options) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable'); + } + + 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(), options?.encode); + store.hydratables.set(key, entry); + return entry.value; +} + +isomorphic_hydratable['get'] = () => e.fn_unavailable_on_server('hydratable.get'); +isomorphic_hydratable['has'] = has_hydratable_value; +isomorphic_hydratable['set'] = set_hydratable_value; + +/** @type {Hydratable} */ +const hydratable = isomorphic_hydratable; + +export { hydratable }; + +/** + * @template T + * @param {string} key + * @param {T} value + * @param {{ encode?: Encode }} [options] + */ +function set_hydratable_value(key, value, options = {}) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable.set'); + } + + const store = get_render_context(); + + if (store.hydratables.has(key)) { + e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown'); + } + + store.hydratables.set(key, create_entry(value, options?.encode)); +} + +/** + * @param {string} key + * @returns {boolean} + */ +function has_hydratable_value(key) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable.has'); + } + const store = get_render_context(); + return store.hydratables.has(key); +} + +/** + * @template T + * @param {T} value + * @param {Encode | undefined} encode + */ +function create_entry(value, encode) { + /** @type {Omit & { value: T }} */ + const entry = { + value, + encode + }; + + if (DEV) { + entry.stack = new Error().stack; + } + + return entry; +} diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js new file mode 100644 index 000000000000..019a10f65562 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -0,0 +1,38 @@ +import { async_mode_flag } from '../../flags/index.js'; +import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { get_render_context } from '../render-context.js'; +import * as e from '../errors.js'; + +/** + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} + */ +export function cache(key, fn) { + if (!async_mode_flag) { + e.experimental_async_required('cache'); + } + + const cache = get_render_context().cache; + const entry = cache.get(key); + if (entry) { + return /** @type {ReturnType} */ (entry); + } + const new_entry = fn(); + cache.set(key, new_entry); + return new_entry; +} + +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + if (!async_mode_flag) { + e.experimental_async_required('CacheObserver'); + } + super(() => get_render_context().cache, prefix); + } +} diff --git a/packages/svelte/src/internal/server/reactivity/fetcher.js b/packages/svelte/src/internal/server/reactivity/fetcher.js new file mode 100644 index 000000000000..ee674a8faa03 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -0,0 +1,21 @@ +/** @import { GetRequestInit, Resource } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../hydratable.js'; +import { cache } from './cache'; +import { resource } from './resource.js'; +import * as e from '../errors.js'; + +/** + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} + */ +export function fetcher(url, init) { + if (!async_mode_flag) { + e.experimental_async_required('fetcher'); + } + const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; + return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); +} diff --git a/packages/svelte/src/internal/server/reactivity/resource.js b/packages/svelte/src/internal/server/reactivity/resource.js new file mode 100644 index 000000000000..d354c76359f3 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -0,0 +1,102 @@ +/** @import { Resource as ResourceType } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; +import * as e from '../errors.js'; + +/** + * @template T + * @param {() => T} fn + * @returns {ResourceType} + */ +export function resource(fn) { + if (!async_mode_flag) { + e.experimental_async_required('resource'); + } + return /** @type {ResourceType} */ (new Resource(fn)); +} + +/** + * @template T + * @implements {Partial>>} + */ +class Resource { + /** @type {Promise} */ + #promise; + #ready = false; + #loading = true; + + /** @type {Awaited | undefined} */ + #current = undefined; + #error = undefined; + + /** + * @param {() => T} fn + */ + constructor(fn) { + this.#promise = Promise.resolve(fn()).then( + (val) => { + this.#ready = true; + this.#loading = false; + this.#current = val; + this.#error = undefined; + }, + (error) => { + this.#error = error; + this.#loading = false; + } + ); + } + + get then() { + // @ts-expect-error + return (onfulfilled, onrejected) => + this.#promise.then( + () => onfulfilled?.(this.#current), + () => onrejected?.(this.#error) + ); + } + + get catch() { + return (/** @type {any} */ onrejected) => this.then(undefined, onrejected); + } + + get finally() { + return (/** @type {any} */ onfinally) => this.then(onfinally, onfinally); + } + + get current() { + return this.#current; + } + + get error() { + return this.#error; + } + + /** + * Returns true if the resource is loading or reloading. + */ + get loading() { + return this.#loading; + } + + /** + * Returns true once the resource has been loaded for the first time. + */ + get ready() { + return this.#ready; + } + + refresh = () => { + throw new Error('TODO Cannot refresh a resource on the server'); + }; + + /** + * @param {Awaited} value + */ + set = (value) => { + this.#ready = true; + this.#loading = false; + this.#error = undefined; + this.#current = value; + this.#promise = Promise.resolve(); + }; +} diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js new file mode 100644 index 000000000000..4ec0274693b6 --- /dev/null +++ b/packages/svelte/src/internal/server/render-context.js @@ -0,0 +1,93 @@ +// @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 | null} */ +let current_render = null; + +/** @type {RenderContext | null} */ +let sync_context = null; + +/** + * @template T + * @param {Promise} promise + * @returns {Promise<() => T>} + */ +export async function save_render_context(promise) { + var previous_context = sync_context; + var value = await promise; + + return () => { + sync_context = previous_context; + return value; + }; +} + +/** @returns {RenderContext | null} */ +export function try_get_render_context() { + if (sync_context !== null) { + return sync_context; + } + return als?.getStore() ?? null; +} + +/** @returns {RenderContext} */ +export function get_render_context() { + const store = try_get_render_context(); + + if (!store) { + e.render_context_unavailable( + `\`AsyncLocalStorage\` is ${als ? 'available' : 'not available'}.` + ); + } + + return store; +} + +/** + * @template T + * @param {() => Promise} fn + * @returns {Promise} + */ +export async function with_render_context(fn) { + try { + sync_context = { + hydratables: new Map(), + cache: new Map() + }; + if (in_webcontainer()) { + const { promise, resolve } = deferred(); + const previous_render = current_render; + current_render = promise; + await previous_render; + return fn().finally(resolve); + } + return als ? als.run(sync_context, fn) : fn(); + } finally { + if (!in_webcontainer()) { + sync_context = null; + } + } +} + +/** @type {AsyncLocalStorage | 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; +} diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 479175c2eb12..ecae07bdfa02 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -1,18 +1,17 @@ /** @import { Component } from 'svelte' */ /** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */ +/** @import { MaybePromise } from '#shared' */ import { async_mode_flag } from '../flags/index.js'; import { abort } from './abort-signal.js'; -import { pop, push, set_ssr_context, ssr_context } from './context.js'; +import { pop, push, set_ssr_context, ssr_context, save } from './context.js'; import * as e from './errors.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'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ -/** - * @template T - * @typedef {T | Promise} MaybePromise - */ /** * @typedef {string | Renderer} RendererItem */ @@ -423,7 +422,9 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= Renderer.#render_async(component, options); + async ??= init_render_context().then(() => + with_render_context(() => Renderer.#render_async(component, options)) + ); return async.then((result) => { Object.defineProperty(result, 'html', { // eslint-disable-next-line getter-return @@ -515,16 +516,24 @@ export class Renderer { * @returns {Promise} */ static async #render_async(component, options) { - var previous_context = ssr_context; - try { - const renderer = Renderer.#open_render('async', component, options); + const restore = await save( + (async () => { + try { + const renderer = Renderer.#open_render('async', component, options); - const content = await renderer.#collect_content_async(); - return Renderer.#close_render(content, renderer); - } finally { - abort(); - set_ssr_context(previous_context); - } + const content = await renderer.#collect_content_async(); + const hydratables = await renderer.#collect_hydratables(); + if (hydratables !== null) { + content.head = hydratables + content.head; + } + return Renderer.#close_render(content, renderer); + } finally { + abort(); + } + })() + ); + + return restore(); } /** @@ -564,6 +573,22 @@ export class Renderer { return content; } + async #collect_hydratables() { + const map = get_render_context().hydratables; + /** @type {(value: unknown) => string} */ + const default_encode = new MemoizedUneval().uneval; + + /** @type {[string, unknown][]} */ + let entries = []; + for (const [k, v] of map) { + const encode = v.encode ?? default_encode; + // sequential await is okay here -- all the work is already kicked off + entries.push([k, encode(await v.value)]); + } + if (entries.length === 0) return null; + return Renderer.#hydratable_block(entries); + } + /** * @template {Record} Props * @param {'sync' | 'async'} mode @@ -617,6 +642,24 @@ export class Renderer { body }; } + + /** @param {[string, unknown][]} serialized */ + static #hydratable_block(serialized) { + let entries = []; + for (const [k, v] of serialized) { + entries.push(`["${k}",${v}]`); + } + // TODO csp -- have discussed but not implemented + return ` +`; + } } 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..6a27af22a6db 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 { CacheEntry, 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/cache-observer.js b/packages/svelte/src/internal/shared/cache-observer.js new file mode 100644 index 000000000000..18f50226e6d7 --- /dev/null +++ b/packages/svelte/src/internal/shared/cache-observer.js @@ -0,0 +1,78 @@ +/** @import { CacheEntry } from '#shared' */ + +/** + * @template T + * @implements {ReadonlyMap} */ +export class BaseCacheObserver { + /** + * This is a function so that you can create an ObservableCache instance globally and as long as you don't actually + * use it until you're inside the server render lifecycle you'll be okay + * @type {() => Map} + */ + #get_cache; + + /** @type {string} */ + #prefix; + + /** + * @param {() => Map} get_cache + * @param {string} [prefix] + */ + constructor(get_cache, prefix = '') { + this.#get_cache = get_cache; + this.#prefix = prefix; + } + + /** @param {string} key */ + get(key) { + const entry = this.#get_cache().get(this.#key(key)); + return entry?.item; + } + + /** @param {string} key */ + has(key) { + return this.#get_cache().has(this.#key(key)); + } + + get size() { + return [...this.keys()].length; + } + + /** @param {(item: T, key: string, map: ReadonlyMap) => void} cb */ + forEach(cb) { + for (const [key, entry] of this.entries()) { + cb(entry, key, this); + } + } + + *entries() { + for (const [key, entry] of this.#get_cache().entries()) { + if (!key.startsWith(this.#prefix)) continue; + yield /** @type {[string, T]} */ ([key, entry.item]); + } + return undefined; + } + + *keys() { + for (const [key] of this.entries()) { + yield key; + } + return undefined; + } + + *values() { + for (const [, entry] of this.entries()) { + yield entry; + } + return undefined; + } + + [Symbol.iterator]() { + return this.entries(); + } + + /** @param {string} key */ + #key(key) { + return this.#prefix + key; + } +} 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..668ee7f78596 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -8,3 +8,49 @@ export type Getters = { }; export type Snapshot = ReturnType>; + +export type MaybePromise = T | Promise; + +export type Decode = (value: any) => T; + +export type Encode = (value: T) => unknown; + +export type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + +export type Hydratable = { + (key: string, fn: () => T, options?: Transport): T; + get: (key: string, options?: { decode?: Decode }) => T | undefined; + has: (key: string) => boolean; + set: (key: string, value: T, options?: { encode?: Encode }) => void; +}; + +export type Resource = { + then: Promise>['then']; + catch: Promise>['catch']; + finally: Promise>['finally']; + refresh: () => Promise; + set: (value: Awaited) => void; + loading: boolean; + error: any; +} & ( + | { + ready: false; + current: undefined; + } + | { + ready: true; + current: Awaited; + } +); + +export type GetRequestInit = Omit & { method?: 'GET' }; + +export type CacheEntry = { count: number; item: any }; diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 10f8597520d9..53700df8e8d7 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -1,3 +1,5 @@ +/** @import { GetRequestInit } from '#shared' */ + // Store the references to globals in case someone tries to monkey patch these, causing the below // to de-opt (this occurs often when using popular extensions). export var is_array = Array.isArray; @@ -48,7 +50,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} */ @@ -116,3 +118,17 @@ export function to_array(value, n) { return array; } + +/** + * @template [TReturn=any] + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Promise} + */ +export async function fetch_json(url, init) { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`TODO error: Fetch error: ${response.status} ${response.statusText}`); + } + return response.json(); +} diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 3eb9b95333ab..e33e9f18174b 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -5,3 +5,6 @@ export { SvelteURL } from './url.js'; export { SvelteURLSearchParams } from './url-search-params.js'; export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; +export { resource } from '../internal/client/reactivity/resource.js'; +export { cache, CacheObserver } from '../internal/client/reactivity/cache.js'; +export { fetcher } from '../internal/client/reactivity/fetcher.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 6a6c9dcf1360..0dcc459e64eb 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,3 +1,8 @@ +/** @import { Resource as ResourceType } from '#shared' */ +export { resource } from '../internal/server/reactivity/resource.js'; +export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; +export { fetcher } from '../internal/server/reactivity/fetcher.js'; + export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; export const SvelteMap = globalThis.Map; @@ -21,3 +26,8 @@ export class MediaQuery { export function createSubscriber(_) { return () => {}; } + +/** + * @template T + * @typedef {ResourceType} Resource + */ diff --git a/packages/svelte/src/reactivity/public.d.ts b/packages/svelte/src/reactivity/public.d.ts new file mode 100644 index 000000000000..e25bb5c91019 --- /dev/null +++ b/packages/svelte/src/reactivity/public.d.ts @@ -0,0 +1,2 @@ +export { Resource } from '../internal/shared/types'; +export * from './index-client'; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5e3ca77eb5cd..be91465c30f3 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -450,6 +450,7 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; + export const hydratable: Hydratable; /** * Create a snippet programmatically * */ @@ -595,6 +596,27 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; + type Decode = (value: any) => T; + + type Encode = (value: T) => unknown; + + type Transport = + | { + encode: Encode; + decode?: undefined; + } + | { + encode?: undefined; + decode: Decode; + }; + + type Hydratable = { + (key: string, fn: () => T, options?: Transport): T; + get: (key: string, options?: { decode?: Decode }) => T | undefined; + has: (key: string) => boolean; + set: (key: string, value: T, options?: { encode?: Encode }) => void; + }; + export {}; } @@ -2173,6 +2195,28 @@ declare module 'svelte/motion' { } declare module 'svelte/reactivity' { + export type Resource = { + then: Promise>['then']; + catch: Promise>['catch']; + finally: Promise>['finally']; + refresh: () => Promise; + set: (value: Awaited) => void; + loading: boolean; + error: any; + } & ( + | { + ready: false; + current: undefined; + } + | { + ready: true; + current: Awaited; + } + ); + + type GetRequestInit = Omit & { method?: 'GET' }; + + type CacheEntry = { count: number; item: any }; /** * A reactive version of the built-in [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object. * Reading the date (whether with methods like `date.getTime()` or `date.toString()`, or via things like [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)) @@ -2436,12 +2480,35 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; + export function resource(fn: () => T): Resource; + export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource; + export function cache any>(key: string, fn: TFn): ReturnType; + + export class CacheObserver extends BaseCacheObserver { + constructor(prefix?: string); + } class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; } + class BaseCacheObserver implements ReadonlyMap { + + constructor(get_cache: () => Map, prefix?: string | undefined); + + get(key: string): any; + + has(key: string): boolean; + get size(): number; + + forEach(cb: (item: T, key: string, map: ReadonlyMap) => void): void; + entries(): Generator<[string, T], undefined, unknown>; + keys(): Generator; + values(): Generator; + [Symbol.iterator](): Generator<[string, T], undefined, unknown>; + #private; + } export {}; } diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 8a0c063d4751..0ad2e5b2b8b6 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -27,7 +27,6 @@ polka() const { default: App } = await vite.ssrLoadModule('/src/App.svelte'); const { head, body } = await render(App); - const html = transformed_template .replace(``, head) .replace(``, body) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0afaef0ceb2f..f60fe383af75 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.4.1 + version: 5.4.1 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1515,6 +1518,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + devalue@5.4.1: + resolution: {integrity: sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==} + 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.4.1: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0