From da3260f99a724838193cff284a0c22d05b538978 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 8 Oct 2025 17:38:30 -0600 Subject: [PATCH 01/46] checkpoint; hydratable and base resource work --- packages/svelte/package.json | 1 + packages/svelte/src/index-client.js | 8 +- packages/svelte/src/index-server.js | 8 +- .../svelte/src/internal/client/context.js | 25 +++ .../svelte/src/internal/server/context.js | 16 ++ .../svelte/src/internal/server/renderer.js | 41 +++++ .../svelte/src/reactivity/index-client.js | 1 + .../svelte/src/reactivity/index-server.js | 88 +++++++++ packages/svelte/src/reactivity/resource.js | 172 ++++++++++++++++++ packages/svelte/types/index.d.ts | 24 +++ pnpm-lock.yaml | 8 + 11 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 packages/svelte/src/reactivity/resource.js diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 21752e2d4b13..d806aa09a5ac 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.3.2", "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 85eeab7de989..0dd330ea422a 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -242,7 +242,13 @@ function init_update_callbacks(context) { } export { flushSync } from './internal/client/reactivity/batch.js'; -export { getContext, getAllContexts, hasContext, setContext } from './internal/client/context.js'; +export { + getContext, + getAllContexts, + hasContext, + setContext, + hydratable +} from './internal/client/context.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 f193c4689474..760612949345 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -39,6 +39,12 @@ export async function settled() {} export { getAbortSignal } from './internal/server/abort-signal.js'; -export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js'; +export { + getAllContexts, + getContext, + hasContext, + setContext, + hydratable +} from './internal/server/context.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 cad75546d4b4..d7586515dd00 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -6,6 +6,7 @@ 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 { hydrating } from './dom/hydration.js'; /** @type {ComponentContext | null} */ export let component_context = null; @@ -194,6 +195,30 @@ export function is_runes() { return !legacy_mode_flag || (component_context !== null && component_context.l === null); } +/** + * @template T + * @param {string} key + * @param {() => Promise} fn + * @returns {Promise} + */ +export function hydratable(key, fn) { + if (!hydrating) { + return fn(); + } + /** @type {Map | undefined} */ + // @ts-expect-error + var store = window.__svelte?.h; + if (store === undefined) { + throw new Error('TODO this should be impossible?'); + } + if (!store.has(key)) { + throw new Error( + `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` + ); + } + return /** @type {Promise} */ (store.get(key)); +} + /** * @param {string} name * @returns {Map} diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index c59b2d260afb..09d29d195f91 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -110,3 +110,19 @@ export async function save(promise) { return value; }; } + +/** + * @template T + * @param {string} key + * @param {() => Promise} fn + * @returns {Promise} + */ +export function hydratable(key, fn) { + if (ssr_context === null || ssr_context.r === null) { + // TODO probably should make this a different error like await_reactivity_loss + // also when can context be defined but r be null? just when context isn't used at all? + e.lifecycle_outside_component('hydratable'); + } + + return ssr_context.r.register_hydratable(key, fn); +} diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index bbb43a6f3b35..b2f1f4536338 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -7,6 +7,7 @@ import * as e from './errors.js'; import * as w from './warnings.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { attributes } from './index.js'; +import { uneval } from 'devalue'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ @@ -266,6 +267,25 @@ export class Renderer { } } + /** + * @template T + * @param {string} key + * @param {() => Promise} fn + */ + register_hydratable(key, fn) { + if (this.global.mode === 'sync') { + // TODO + throw new Error('no no'); + } + if (this.global.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + const result = fn(); + this.global.hydratables.set(key, { blocking: true, value: result }); + return result; + } + /** * @param {() => void} fn */ @@ -467,6 +487,7 @@ export class Renderer { const renderer = Renderer.#open_render('async', component, options); const content = await renderer.#collect_content_async(); + content.head = (await renderer.#collect_hydratables()) + content.head; return Renderer.#close_render(content, renderer); } finally { abort(); @@ -511,6 +532,23 @@ export class Renderer { return content; } + async #collect_hydratables() { + const map = this.global.hydratables; + if (!map) return ''; + + // TODO add namespacing for multiple apps, nonce, csp, whatever -- not sure what we need to do there + /** @type {string} */ + let resolved = ''; + return resolved; + } + /** * @template {Record} Props * @param {'sync' | 'async'} mode @@ -576,6 +614,9 @@ export class SSRState { /** @readonly @type {Set<{ hash: string; code: string }>} */ css = new Set(); + /** @type {Map }>} */ + hydratables = new Map(); + /** @type {{ path: number[], value: string }} */ #title = { path: [], value: '' }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 3eb9b95333ab..beadbe9d10b3 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -5,3 +5,4 @@ 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 './resource.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 6a6c9dcf1360..49c2f8597b7f 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -21,3 +21,91 @@ export class MediaQuery { export function createSubscriber(_) { return () => {}; } + +/** + * @template T + * @implements {Partial>} + */ +export class Resource { + /** @type {Promise} */ + #promise; + #ready = false; + #loading = true; + + /** @type {T | undefined} */ + #current = undefined; + #error = undefined; + + /** + * @param {() => Promise} fn + * @param {() => Promise} [init] + */ + constructor(fn, init = fn) { + this.#promise = Promise.resolve(init()).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 {T} 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/reactivity/resource.js b/packages/svelte/src/reactivity/resource.js new file mode 100644 index 000000000000..4d423ec2b0d5 --- /dev/null +++ b/packages/svelte/src/reactivity/resource.js @@ -0,0 +1,172 @@ +/** @import { Source, Derived } from '#client' */ +import { state, derived, set, get, tick } from 'svelte/internal/client'; +import { deferred, noop } from '../internal/shared/utils'; + +/** + * @template T + * @implements {Partial>} + */ +export class Resource { + #init = false; + + /** @type {() => Promise} */ + #fn; + + /** @type {Source} */ + #loading = state(true); + + /** @type {Array<(...args: any[]) => void>} */ + #latest = []; + + /** @type {Source} */ + #ready = state(false); + + /** @type {Source} */ + #raw = state(undefined); + + /** @type {Source>} */ + #promise; + + /** @type {Derived} */ + #current = derived(() => { + if (!get(this.#ready)) return undefined; + return get(this.#raw); + }); + + #onrefresh; + + /** {@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) => { + try { + await p; + await tick(); + + resolve?.(/** @type {T} */ (get(this.#current))); + } catch (error) { + reject?.(error); + } + }; + }); + + /** + * @param {() => Promise} fn + * @param {() => Promise} [init] + * @param {() => void} [onrefresh] + */ + constructor(fn, init = fn, onrefresh = noop) { + this.#fn = fn; + this.#promise = state(this.#run(init)); + this.#onrefresh = onrefresh; + } + + /** @param {() => Promise} fn */ + #run(fn = this.#fn) { + if (this.#init) { + set(this.#loading, true); + } else { + this.#init = true; + } + + const { resolve, reject, promise } = deferred(); + + this.#latest.push(resolve); + + Promise.resolve(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)( + () => fn(), + () => 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() { + this.#onrefresh(); + const promise = this.#run(); + set(this.#promise, promise); + return promise; + } + + /** + * @param {T} 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/types/index.d.ts b/packages/svelte/types/index.d.ts index 6dc6629faad5..08875314eaa3 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -476,6 +476,8 @@ declare module 'svelte' { * * */ export function getAllContexts = Map>(): T; + + export function hydratable(key: string, fn: () => Promise): Promise; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -2390,6 +2392,28 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; + export class Resource implements Partial> { + + constructor(fn: () => Promise, init?: (() => Promise) | undefined); + get then(): (onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined) => Promise; + get catch(): (reject: any) => Promise; + get finally(): (fn: any) => Promise; + get current(): T | undefined; + get error(): undefined; + /** + * Returns true if the resource is loading or reloading. + */ + get loading(): boolean; + /** + * Returns true once the resource has been loaded for the first time. + */ + get ready(): boolean; + + refresh(): Promise; + + set(value: T): void; + #private; + } class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5856192528b..973896f406da 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.3.2 + version: 5.3.2 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1236,6 +1239,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + devalue@5.3.2: + resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3546,6 +3552,8 @@ snapshots: detect-libc@1.0.3: optional: true + devalue@5.3.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 From 4d766f87b9c2653203190a44f146daa13cb9a271 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 10 Oct 2025 18:01:42 -0600 Subject: [PATCH 02/46] checkpoint --- packages/svelte/package.json | 2 + .../reactivity/resources/create-fetcher.js | 66 +++++++++++ .../reactivity/resources/create-resource.js | 75 +++++++++++++ .../client/reactivity/resources}/resource.js | 32 +++--- .../svelte/src/reactivity/index-client.js | 4 +- .../svelte/src/reactivity/index-server.js | 106 ++++++++++++++++-- packages/svelte/types/index.d.ts | 24 +++- playgrounds/sandbox/ssr-dev.js | 2 +- pnpm-lock.yaml | 16 +++ 9 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js create mode 100644 packages/svelte/src/internal/client/reactivity/resources/create-resource.js rename packages/svelte/src/{reactivity => internal/client/reactivity/resources}/resource.js (85%) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d806aa09a5ac..580887623404 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -168,6 +168,7 @@ "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", + "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", @@ -180,6 +181,7 @@ "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", + "path-to-regexp": "^8.3.0", "zimmerframe": "^1.1.2" } } diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js new file mode 100644 index 000000000000..4e99300f1c0c --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js @@ -0,0 +1,66 @@ +/** @import { Resource } from './resource.js' */ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ + +import { compile } from 'path-to-regexp'; +import { create_resource } from './create-resource.js'; + +/** + * @template {Record} TPathParams + * @typedef {{ searchParams?: ConstructorParameters[0], pathParams?: TPathParams } & RequestInit} FetcherInit + */ +/** + * @template TReturn + * @template {Record} TPathParams + * @typedef {(init: FetcherInit) => Resource} Fetcher + */ + +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support? + * @returns {Fetcher | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {StandardSchemaV1} TSchema + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options + * @returns {Fetcher, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @template {StandardSchemaV1} TSchema + * @param {string} url + * @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options] + */ +export function create_fetcher(url, options) { + const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? ''; + const populate_path = compile(raw_pathname); + /** + * @param {Parameters>[0]} args + * @returns {Promise} + */ + const fn = async (args) => { + const cloned_url = new URL(url); + const new_params = new URLSearchParams(args.searchParams); + const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]); + cloned_url.search = combined_params.toString(); + cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API + // TODO how to populate path params + const resp = await fetch(cloned_url, args); + if (!resp.ok) { + throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + if (options?.schema) { + return options.schema['~standard'].validate(json); + } + return json; + }; + return create_resource(url.toString(), fn, options); +} diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-resource.js b/packages/svelte/src/internal/client/reactivity/resources/create-resource.js new file mode 100644 index 000000000000..1f849f9df300 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/resources/create-resource.js @@ -0,0 +1,75 @@ +import { hydratable } from '../../context.js'; +import { tick } from '../../runtime'; +import { render_effect } from '../effects'; +import { Resource } from './resource.js'; + +/** @typedef {{ count: number, resource: Resource }} Entry */ +/** @type {Map} */ +const cache = new Map(); + +/** + * @template TReturn + * @template {unknown[]} [TArgs=[]] + * @template {typeof Resource} [TResource=typeof Resource] + * @param {string} name + * @param {(...args: TArgs) => Promise} fn + * @param {{ Resource?: TResource }} [options] + * @returns {(...args: TArgs) => Resource} + */ +export function create_resource(name, fn, options) { + const ResolvedResource = options?.Resource ?? Resource; + return (...args) => { + const stringified_args = JSON.stringify(args); + const cache_key = `${name}:${stringified_args}`; + let entry = cache.get(cache_key); + const maybe_remove = create_remover(cache_key); + + let tracking = true; + try { + render_effect(() => { + if (entry) entry.count++; + return () => { + const entry = cache.get(cache_key); + if (!entry) return; + entry.count--; + maybe_remove(entry, cache); + }; + }); + } catch { + tracking = false; + } + + let resource = entry?.resource; + if (!resource) { + resource = new ResolvedResource(() => hydratable(cache_key, () => fn(...args))); + const entry = { + resource, + count: tracking ? 1 : 0 + }; + cache.set(cache_key, entry); + + resource.then( + () => maybe_remove(entry, cache), + () => maybe_remove(entry, cache) + ); + } + + return resource; + }; +} + +/** + * @param {string} key + */ +function create_remover(key) { + /** + * @param {Entry | undefined} entry + * @param {Map} cache + */ + return (entry, cache) => + tick().then(() => { + if (!entry?.count && entry === cache.get(key)) { + cache.delete(key); + } + }); +} diff --git a/packages/svelte/src/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resources/resource.js similarity index 85% rename from packages/svelte/src/reactivity/resource.js rename to packages/svelte/src/internal/client/reactivity/resources/resource.js index 4d423ec2b0d5..48e6a383ea41 100644 --- a/packages/svelte/src/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resources/resource.js @@ -1,6 +1,6 @@ /** @import { Source, Derived } from '#client' */ -import { state, derived, set, get, tick } from 'svelte/internal/client'; -import { deferred, noop } from '../internal/shared/utils'; +import { state, derived, set, get, tick } from '../../index.js'; +import { deferred, noop } from '../../../shared/utils.js'; /** * @template T @@ -33,8 +33,6 @@ export class Resource { return get(this.#raw); }); - #onrefresh; - /** {@type Source} */ #error = state(undefined); @@ -58,19 +56,18 @@ export class Resource { /** * @param {() => Promise} fn - * @param {() => Promise} [init] - * @param {() => void} [onrefresh] */ - constructor(fn, init = fn, onrefresh = noop) { + constructor(fn) { this.#fn = fn; - this.#promise = state(this.#run(init)); - this.#onrefresh = onrefresh; + this.#promise = state(this.#run()); } - /** @param {() => Promise} fn */ - #run(fn = this.#fn) { + #run() { if (this.#init) { - set(this.#loading, true); + tick().then(() => { + // opt this out of async coordination + set(this.#loading, true); + }); } else { this.#init = true; } @@ -79,7 +76,7 @@ export class Resource { this.#latest.push(resolve); - Promise.resolve(fn()) + 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); @@ -152,21 +149,20 @@ export class Resource { /** * @returns {Promise} */ - refresh() { - this.#onrefresh(); + refresh = () => { const promise = this.#run(); set(this.#promise, promise); return promise; - } + }; /** * @param {T} value */ - set(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/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index beadbe9d10b3..42cd28658b8e 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -5,4 +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 './resource.js'; +export { Resource } from '../internal/client/reactivity/resources/resource.js'; +export { create_resource as createResource } from '../internal/client/reactivity/resources/create-resource.js'; +export { create_fetcher as createFetcher } from '../internal/client/reactivity/resources/create-fetcher.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 49c2f8597b7f..29da64111005 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,3 +1,7 @@ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ +import { compile } from 'path-to-regexp'; +import { hydratable } from '../internal/server/context.js'; + export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; export const SvelteMap = globalThis.Map; @@ -38,10 +42,9 @@ export class Resource { /** * @param {() => Promise} fn - * @param {() => Promise} [init] */ - constructor(fn, init = fn) { - this.#promise = Promise.resolve(init()).then( + constructor(fn) { + this.#promise = Promise.resolve(fn()).then( (val) => { this.#ready = true; this.#loading = false; @@ -94,18 +97,107 @@ export class Resource { return this.#ready; } - refresh() { + refresh = () => { throw new Error('TODO Cannot refresh a resource on the server'); - } + }; /** * @param {T} value */ - set(value) { + set = (value) => { this.#ready = true; this.#loading = false; this.#error = undefined; this.#current = value; this.#promise = Promise.resolve(); - } + }; +} + +/** @type {Map>} */ +// TODO scope to render, clear after render +const cache = new Map(); + +/** + * @template TReturn + * @template {unknown[]} [TArgs=[]] + * @template {typeof Resource} [TResource=typeof Resource] + * @param {string} name + * @param {(...args: TArgs) => Promise} fn + * @param {{ Resource?: TResource }} [options] + * @returns {(...args: TArgs) => Resource} + */ +export function createResource(name, fn, options) { + const ResolvedResource = options?.Resource ?? Resource; + return (...args) => { + const stringified_args = JSON.stringify(args); + const cache_key = `${name}:${stringified_args}`; + const entry = cache.get(cache_key); + if (entry) { + return entry; + } + const resource = new ResolvedResource(() => hydratable(cache_key, () => fn(...args))); + cache.set(cache_key, resource); + return resource; + }; +} + +/** + * @template {Record} TPathParams + * @typedef {{ searchParams?: ConstructorParameters[0], pathParams?: TPathParams } & RequestInit} FetcherInit + */ +/** + * @template TReturn + * @template {Record} TPathParams + * @typedef {(init: FetcherInit) => Resource} Fetcher + */ + +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support? + * @returns {Fetcher | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {StandardSchemaV1} TSchema + * @template {typeof Resource} TResource + * @overload + * @param {string} url + * @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options + * @returns {Fetcher, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) + */ +/** + * @template {Record} TPathParams + * @template {typeof Resource} TResource + * @template {StandardSchemaV1} TSchema + * @param {string} url + * @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options] + */ +export function createFetcher(url, options) { + const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? ''; + const populate_path = compile(raw_pathname); + /** + * @param {Parameters>[0]} args + * @returns {Promise} + */ + const fn = async (args) => { + const cloned_url = new URL(url); + const new_params = new URLSearchParams(args.searchParams); + const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]); + cloned_url.search = combined_params.toString(); + cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API + // TODO how to populate path params + const resp = await fetch(cloned_url, args); + if (!resp.ok) { + throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + if (options?.schema) { + return options.schema['~standard'].validate(json); + } + return json; + }; + return createResource(url.toString(), fn, options); } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 08875314eaa3..f0dee993aefb 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2129,6 +2129,7 @@ declare module 'svelte/motion' { } declare module 'svelte/reactivity' { + import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * 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)) @@ -2394,7 +2395,7 @@ declare module 'svelte/reactivity' { export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export class Resource implements Partial> { - constructor(fn: () => Promise, init?: (() => Promise) | undefined); + constructor(fn: () => Promise); get then(): (onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined) => Promise; get catch(): (reject: any) => Promise; get finally(): (fn: any) => Promise; @@ -2409,11 +2410,28 @@ declare module 'svelte/reactivity' { */ get ready(): boolean; - refresh(): Promise; + refresh: () => Promise; - set(value: T): void; + set: (value: T) => void; #private; } + export function createResource(name: string, fn: (...args: TArgs) => Promise, options?: { + Resource?: TResource; + } | undefined): (...args: TArgs) => Resource; + export function createFetcher, TResource extends typeof Resource>(url: string, options?: { + Resource?: TResource; + schema?: undefined; + } | undefined): Fetcher | unknown[] | boolean | null, TPathParams>; + + export function createFetcher, TSchema extends StandardSchemaV1, TResource extends typeof Resource>(url: string, options: { + Resource?: TResource; + schema: StandardSchemaV1; + }): Fetcher, TPathParams>; + type FetcherInit> = { + searchParams?: ConstructorParameters[0]; + pathParams?: TPathParams; + } & RequestInit; + type Fetcher> = (init: FetcherInit) => Resource; class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 8a0c063d4751..9486d2304e3a 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -27,7 +27,7 @@ polka() const { default: App } = await vite.ssrLoadModule('/src/App.svelte'); const { head, body } = await render(App); - + console.log(head); const html = transformed_template .replace(``, head) .replace(``, body) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 973896f406da..e58abc06967c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@jridgewell/sourcemap-codec': specifier: ^1.5.0 version: 1.5.0 + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 '@sveltejs/acorn-typescript': specifier: ^1.0.5 version: 1.0.5(acorn@8.15.0) @@ -107,6 +110,9 @@ importers: magic-string: specifier: ^0.30.11 version: 0.30.17 + path-to-regexp: + specifier: ^8.3.0 + version: 8.3.0 zimmerframe: specifier: ^1.1.2 version: 1.1.2 @@ -825,6 +831,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stylistic/eslint-plugin-js@1.8.0': resolution: {integrity: sha512-jdvnzt+pZPg8TfclZlTZPiUbbima93ylvQ+wNgHLNmup3obY6heQvgewSu9i2CfS61BnRByv+F9fxQLPoNeHag==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1921,6 +1930,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3102,6 +3114,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true + '@standard-schema/spec@1.0.0': {} + '@stylistic/eslint-plugin-js@1.8.0(eslint@9.9.1)': dependencies: '@types/eslint': 8.56.12 @@ -4283,6 +4297,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} pathe@1.1.2: {} From 7c8c1ad0e6f0b16b99271a8daab09879c23a507b Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 12:19:54 -0600 Subject: [PATCH 03/46] maximum hydration --- packages/svelte/package.json | 3 +- .../svelte/src/internal/client/context.js | 15 ++- .../svelte/src/internal/client/types.d.ts | 11 +- .../svelte/src/internal/server/context.js | 82 ++++++++++-- .../svelte/src/internal/server/renderer.js | 118 ++++++++++++------ .../svelte/src/internal/server/types.d.ts | 11 ++ .../svelte/src/internal/shared/types.d.ts | 13 ++ packages/svelte/types/index.d.ts | 4 +- playgrounds/sandbox/ssr-dev.js | 1 - pnpm-lock.yaml | 18 +-- 10 files changed, 203 insertions(+), 73 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 580887623404..c2fd676a1b72 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -175,13 +175,12 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.3.2", + "devalue": "^5.4.0", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", - "path-to-regexp": "^8.3.0", "zimmerframe": "^1.1.2" } } diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index d7586515dd00..0e516f48b6dd 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -1,4 +1,5 @@ /** @import { ComponentContext, DevStackEntry, Effect } from '#client' */ +/** @import { Hydratable, Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; import { active_effect, active_reaction } from './runtime.js'; @@ -197,16 +198,12 @@ export function is_runes() { /** * @template T - * @param {string} key - * @param {() => Promise} fn - * @returns {Promise} + * @type {Hydratable} */ -export function hydratable(key, fn) { +export function hydratable(key, fn, { transport } = {}) { if (!hydrating) { - return fn(); + return Promise.resolve(fn()); } - /** @type {Map | undefined} */ - // @ts-expect-error var store = window.__svelte?.h; if (store === undefined) { throw new Error('TODO this should be impossible?'); @@ -216,7 +213,9 @@ export function hydratable(key, fn) { `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` ); } - return /** @type {Promise} */ (store.get(key)); + const entry = /** @type {string} */ (store.get(key)); + const parse = transport?.parse ?? ((val) => new Function(`return (${val})`)()); + return Promise.resolve(/** @type {T} */ (parse(entry))); } /** diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index d24218c4d3b0..b4780b44e46a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -1,6 +1,15 @@ import type { Store } from '#shared'; import { STATE_SYMBOL } from './constants.js'; -import type { Effect, Source, Value, Reaction } from './reactivity/types.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/server/context.js b/packages/svelte/src/internal/server/context.js index 09d29d195f91..2b3e142ec150 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,4 +1,6 @@ -/** @import { SSRContext } from '#server' */ +/** @import { ALSContext, SSRContext } from '#server' */ +/** @import { AsyncLocalStorage } from 'node:async_hooks' */ +/** @import { Hydratable } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; @@ -103,26 +105,88 @@ function get_parent_context(ssr_context) { */ export async function save(promise) { var previous_context = ssr_context; + var previous_sync_store = sync_store; var value = await promise; return () => { ssr_context = previous_context; + sync_store = previous_sync_store; return value; }; } /** * @template T - * @param {string} key + * @type {Hydratable} + */ +export async function hydratable(key, fn, { transport } = {}) { + const store = await get_render_store(); + + if (store.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + + const result = fn(); + store.hydratables.set(key, { value: result, transport }); + return result; +} + +/** @type {ALSContext | null} */ +export let sync_store = null; + +/** @param {ALSContext | null} store */ +export function set_sync_store(store) { + sync_store = store; +} + +/** @type {Promise | null>} */ +const als = import('node:async_hooks') + .then((hooks) => new hooks.AsyncLocalStorage()) + .catch(() => { + // can't use ALS but can still use manual context preservation + return null; + }); + +/** @returns {Promise} */ +async function try_get_render_store() { + return sync_store ?? (await als)?.getStore() ?? null; +} + +/** @returns {Promise} */ +export async function get_render_store() { + const store = await try_get_render_store(); + + if (!store) { + // TODO make this a proper e.error + let message = 'Could not get rendering context.'; + + if (await als) { + message += ' This is an internal error.'; + } else { + message += + ' In environments without `AsyncLocalStorage`, `hydratable` must be accessed synchronously, not after an `await`.' + + ' If it was accessed synchronously then this is an internal error.'; + } + + throw new Error(message); + } + + return store; +} + +/** + * @template T + * @param {ALSContext} store * @param {() => Promise} fn * @returns {Promise} */ -export function hydratable(key, fn) { - if (ssr_context === null || ssr_context.r === null) { - // TODO probably should make this a different error like await_reactivity_loss - // also when can context be defined but r be null? just when context isn't used at all? - e.lifecycle_outside_component('hydratable'); +export async function with_render_store(store, fn) { + try { + sync_store = store; + const storage = await als; + return storage ? storage.run(store, fn) : fn(); + } finally { + sync_store = null; } - - return ssr_context.r.register_hydratable(key, fn); } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index b2f1f4536338..ed759a6503d8 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -1,8 +1,18 @@ /** @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 { + get_render_store, + pop, + push, + set_ssr_context, + set_sync_store, + ssr_context, + sync_store, + with_render_store +} from './context.js'; import * as e from './errors.js'; import * as w from './warnings.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; @@ -11,10 +21,6 @@ import { uneval } from 'devalue'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ -/** - * @template T - * @typedef {T | Promise} MaybePromise - */ /** * @typedef {string | Renderer} RendererItem */ @@ -267,25 +273,6 @@ export class Renderer { } } - /** - * @template T - * @param {string} key - * @param {() => Promise} fn - */ - register_hydratable(key, fn) { - if (this.global.mode === 'sync') { - // TODO - throw new Error('no no'); - } - if (this.global.hydratables.has(key)) { - // TODO error - throw new Error("can't have two hydratables with the same key"); - } - const result = fn(); - this.global.hydratables.set(key, { blocking: true, value: result }); - return result; - } - /** * @param {() => void} fn */ @@ -390,7 +377,9 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= Renderer.#render_async(component, options); + async ??= with_render_store({ hydratables: new Map() }, () => + Renderer.#render_async(component, options) + ); return async.then((result) => { Object.defineProperty(result, 'html', { // eslint-disable-next-line getter-return @@ -483,6 +472,8 @@ export class Renderer { */ static async #render_async(component, options) { var previous_context = ssr_context; + var previous_sync_store = sync_store; + try { const renderer = Renderer.#open_render('async', component, options); @@ -492,6 +483,7 @@ export class Renderer { } finally { abort(); set_ssr_context(previous_context); + set_sync_store(previous_sync_store); } } @@ -533,20 +525,19 @@ export class Renderer { } async #collect_hydratables() { - const map = this.global.hydratables; - if (!map) return ''; + const map = (await get_render_store()).hydratables; + /** @type {(value: unknown) => string} */ + let default_stringify; - // TODO add namespacing for multiple apps, nonce, csp, whatever -- not sure what we need to do there - /** @type {string} */ - let resolved = ''; - return resolved; + return Renderer.#hydratable_block(JSON.stringify(entries)); } /** @@ -602,6 +593,27 @@ export class Renderer { body }; } + + /** @param {string} serialized */ + static #hydratable_block(serialized) { + // TODO csp? + // TODO how can we communicate this error better? Is there a way to not just send it to the console? + // (it is probably very rare so... not too worried) + return ` +`; + } } export class SSRState { @@ -614,9 +626,6 @@ export class SSRState { /** @readonly @type {Set<{ hash: string; code: string }>} */ css = new Set(); - /** @type {Map }>} */ - hydratables = new Map(); - /** @type {{ path: number[], value: string }} */ #title = { path: [], value: '' }; @@ -661,3 +670,36 @@ export class SSRState { } } } + +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); + // TODO upgrade uneval, this should always be a string + if (typeof result === 'string') { + stub.value = result; + return result; + } + }); + }; +} diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 53cefabc69c4..d8698fd4a47e 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 { MaybePromise, Transport } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -14,6 +15,16 @@ export interface SSRContext { element?: Element; } +export interface ALSContext { + hydratables: Map< + string, + { + value: MaybePromise; + transport: Transport | undefined; + } + >; +} + export interface SyncRenderOutput { /** HTML that goes into the `` */ head: string; diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 4deeb76b2f4b..3823eec5c90d 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -8,3 +8,16 @@ export type Getters = { }; export type Snapshot = ReturnType>; + +export type MaybePromise = T | Promise; + +export type Hydratable = ( + key: string, + fn: () => T, + options?: { transport?: Transport } +) => Promise; + +export type Transport = { + stringify: (value: T) => string; + parse: (value: string) => T; +}; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index f0dee993aefb..2aedd1d92323 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -448,6 +448,8 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; + + type MaybePromise = T | Promise; /** * Retrieves the context that belongs to the closest parent component with the specified `key`. * Must be called during component initialisation. @@ -477,7 +479,7 @@ declare module 'svelte' { * */ export function getAllContexts = Map>(): T; - export function hydratable(key: string, fn: () => Promise): Promise; + export function hydratable(key: string, fn: () => T): MaybePromise; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 9486d2304e3a..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); - console.log(head); const html = transformed_template .replace(``, head) .replace(``, body) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e58abc06967c..ebf900a9b848 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.4.0 + version: 5.4.0 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -110,9 +110,6 @@ importers: magic-string: specifier: ^0.30.11 version: 0.30.17 - path-to-regexp: - specifier: ^8.3.0 - version: 8.3.0 zimmerframe: specifier: ^1.1.2 version: 1.1.2 @@ -1248,8 +1245,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.3.2: - resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} + devalue@5.4.0: + resolution: {integrity: sha512-n0h6iYbR4IyHe4SdGzm0yV0q4v1aD/maadVkjZC+wICyhWrf1fyNEJTaF7BhlDcKar46tCn5wIMRgTLrrPgmQQ==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -1930,9 +1927,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3566,7 +3560,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.3.2: {} + devalue@5.4.0: {} dir-glob@3.0.1: dependencies: @@ -4297,8 +4291,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@8.3.0: {} - path-type@4.0.0: {} pathe@1.1.2: {} From 83643ce0824caa2d43eb2d2785ebc849325240e9 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 12:27:43 -0600 Subject: [PATCH 04/46] upgrade devalue --- packages/svelte/package.json | 2 +- packages/svelte/src/internal/server/renderer.js | 7 ++----- pnpm-lock.yaml | 10 +++++----- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index c2fd676a1b72..3764cc7040f9 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -175,7 +175,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.4.0", + "devalue": "^5.4.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index ed759a6503d8..9de02d6a794c 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -695,11 +695,8 @@ class MemoizedUneval { this.#cache.set(value, stub); const result = uneval(value); - // TODO upgrade uneval, this should always be a string - if (typeof result === 'string') { - stub.value = result; - return result; - } + stub.value = result; + return result; }); }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebf900a9b848..85a958f3357a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.4.0 - version: 5.4.0 + specifier: ^5.4.1 + version: 5.4.1 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1245,8 +1245,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.4.0: - resolution: {integrity: sha512-n0h6iYbR4IyHe4SdGzm0yV0q4v1aD/maadVkjZC+wICyhWrf1fyNEJTaF7BhlDcKar46tCn5wIMRgTLrrPgmQQ==} + devalue@5.4.1: + resolution: {integrity: sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -3560,7 +3560,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.4.0: {} + devalue@5.4.1: {} dir-glob@3.0.1: dependencies: From bca87b92bfec91191f684d501fb8e0eac4e8d97d Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 16:00:44 -0600 Subject: [PATCH 05/46] checkpoint --- packages/svelte/src/internal/client/context.js | 5 ++++- .../reactivity/resources/create-fetcher.js | 2 +- .../{create-resource.js => define-resource.js} | 17 ++++++++++------- .../client/reactivity/resources/resource.js | 2 +- packages/svelte/src/internal/server/context.js | 7 +++++-- packages/svelte/src/internal/server/types.d.ts | 2 +- packages/svelte/src/internal/shared/types.d.ts | 12 +++--------- packages/svelte/src/reactivity/index-client.js | 2 +- packages/svelte/src/reactivity/index-server.js | 15 +++++++++------ 9 files changed, 35 insertions(+), 29 deletions(-) rename packages/svelte/src/internal/client/reactivity/resources/{create-resource.js => define-resource.js} (74%) diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index be17fd5cd653..0965da2260fa 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -226,7 +226,10 @@ export function is_runes() { /** * @template T - * @type {Hydratable} + * @param {string} key + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise} */ export function hydratable(key, fn, { transport } = {}) { if (!hydrating) { diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js index 4e99300f1c0c..bba3d46529ea 100644 --- a/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js @@ -2,7 +2,7 @@ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { compile } from 'path-to-regexp'; -import { create_resource } from './create-resource.js'; +import { create_resource } from './define-resource.js'; /** * @template {Record} TPathParams diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-resource.js b/packages/svelte/src/internal/client/reactivity/resources/define-resource.js similarity index 74% rename from packages/svelte/src/internal/client/reactivity/resources/create-resource.js rename to packages/svelte/src/internal/client/reactivity/resources/define-resource.js index 1f849f9df300..25fe537d58ee 100644 --- a/packages/svelte/src/internal/client/reactivity/resources/create-resource.js +++ b/packages/svelte/src/internal/client/reactivity/resources/define-resource.js @@ -1,6 +1,7 @@ +/** @import { Transport } from '#shared' */ import { hydratable } from '../../context.js'; -import { tick } from '../../runtime'; -import { render_effect } from '../effects'; +import { tick } from '../../runtime.js'; +import { render_effect } from '../effects.js'; import { Resource } from './resource.js'; /** @typedef {{ count: number, resource: Resource }} Entry */ @@ -12,14 +13,14 @@ const cache = new Map(); * @template {unknown[]} [TArgs=[]] * @template {typeof Resource} [TResource=typeof Resource] * @param {string} name - * @param {(...args: TArgs) => Promise} fn - * @param {{ Resource?: TResource }} [options] + * @param {(...args: TArgs) => TReturn} fn + * @param {{ Resource?: TResource, transport?: Transport }} [options] * @returns {(...args: TArgs) => Resource} */ -export function create_resource(name, fn, options) { +export function define_resource(name, fn, options = {}) { const ResolvedResource = options?.Resource ?? Resource; return (...args) => { - const stringified_args = JSON.stringify(args); + const stringified_args = (options.transport?.stringify ?? JSON.stringify)(args); const cache_key = `${name}:${stringified_args}`; let entry = cache.get(cache_key); const maybe_remove = create_remover(cache_key); @@ -41,7 +42,9 @@ export function create_resource(name, fn, options) { let resource = entry?.resource; if (!resource) { - resource = new ResolvedResource(() => hydratable(cache_key, () => fn(...args))); + resource = new ResolvedResource(() => + hydratable(cache_key, () => fn(...args), { transport: options.transport }) + ); const entry = { resource, count: tracking ? 1 : 0 diff --git a/packages/svelte/src/internal/client/reactivity/resources/resource.js b/packages/svelte/src/internal/client/reactivity/resources/resource.js index 48e6a383ea41..6fda26124010 100644 --- a/packages/svelte/src/internal/client/reactivity/resources/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resources/resource.js @@ -1,6 +1,6 @@ /** @import { Source, Derived } from '#client' */ import { state, derived, set, get, tick } from '../../index.js'; -import { deferred, noop } from '../../../shared/utils.js'; +import { deferred } from '../../../shared/utils.js'; /** * @template T diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 93bd3479be6b..91299b12c12c 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,6 +1,6 @@ /** @import { ALSContext, SSRContext } from '#server' */ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ -/** @import { Hydratable } from '#shared' */ +/** @import { Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; @@ -127,7 +127,10 @@ export async function save(promise) { /** * @template T - * @type {Hydratable} + * @param {string} key + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise} */ export async function hydratable(key, fn, { transport } = {}) { const store = await get_render_store(); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index d8698fd4a47e..0c45c0adc50d 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -20,7 +20,7 @@ export interface ALSContext { string, { value: MaybePromise; - transport: Transport | undefined; + transport: Transport | undefined; } >; } diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 3823eec5c90d..52456a814d15 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,13 +11,7 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; -export type Hydratable = ( - key: string, - fn: () => T, - options?: { transport?: Transport } -) => Promise; - -export type Transport = { - stringify: (value: T) => string; - parse: (value: string) => T; +export type Transport = { + stringify: (value: unknown) => string; + parse: (value: string) => unknown; }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 42cd28658b8e..fe5318dae3d5 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -6,5 +6,5 @@ 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/resources/resource.js'; -export { create_resource as createResource } from '../internal/client/reactivity/resources/create-resource.js'; +export { define_resource as defineResource } from '../internal/client/reactivity/resources/define-resource.js'; export { create_fetcher as createFetcher } from '../internal/client/reactivity/resources/create-fetcher.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 29da64111005..1d0d5c82263b 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,5 +1,6 @@ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { compile } from 'path-to-regexp'; +/** @import { Transport } from '#shared' */ +import { uneval } from 'devalue'; import { hydratable } from '../internal/server/context.js'; export const SvelteDate = globalThis.Date; @@ -122,20 +123,22 @@ const cache = new Map(); * @template {unknown[]} [TArgs=[]] * @template {typeof Resource} [TResource=typeof Resource] * @param {string} name - * @param {(...args: TArgs) => Promise} fn - * @param {{ Resource?: TResource }} [options] + * @param {(...args: TArgs) => TReturn} fn + * @param {{ Resource?: TResource, transport?: Transport }} [options] * @returns {(...args: TArgs) => Resource} */ -export function createResource(name, fn, options) { +export function defineResource(name, fn, options = {}) { const ResolvedResource = options?.Resource ?? Resource; return (...args) => { - const stringified_args = JSON.stringify(args); + const stringified_args = (options.transport?.stringify ?? JSON.stringify)(args); const cache_key = `${name}:${stringified_args}`; const entry = cache.get(cache_key); if (entry) { return entry; } - const resource = new ResolvedResource(() => hydratable(cache_key, () => fn(...args))); + const resource = new ResolvedResource(() => + hydratable(cache_key, () => fn(...args), { transport: options.transport }) + ); cache.set(cache_key, resource); return resource; }; From 82be3881b2fc464566cdbe404706f15f2f23871d Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 16:17:41 -0600 Subject: [PATCH 06/46] chore: temporarily remove fetcher --- .../reactivity/resources/create-fetcher.js | 66 ------------------ .../svelte/src/internal/server/context.js | 32 +++++---- .../svelte/src/internal/server/renderer.js | 4 +- .../svelte/src/internal/server/types.d.ts | 2 + .../svelte/src/reactivity/index-client.js | 1 - .../svelte/src/reactivity/index-server.js | 68 +------------------ packages/svelte/types/index.d.ts | 33 ++++----- 7 files changed, 37 insertions(+), 169 deletions(-) delete mode 100644 packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js diff --git a/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js b/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js deleted file mode 100644 index bba3d46529ea..000000000000 --- a/packages/svelte/src/internal/client/reactivity/resources/create-fetcher.js +++ /dev/null @@ -1,66 +0,0 @@ -/** @import { Resource } from './resource.js' */ -/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ - -import { compile } from 'path-to-regexp'; -import { create_resource } from './define-resource.js'; - -/** - * @template {Record} TPathParams - * @typedef {{ searchParams?: ConstructorParameters[0], pathParams?: TPathParams } & RequestInit} FetcherInit - */ -/** - * @template TReturn - * @template {Record} TPathParams - * @typedef {(init: FetcherInit) => Resource} Fetcher - */ - -/** - * @template {Record} TPathParams - * @template {typeof Resource} TResource - * @overload - * @param {string} url - * @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support? - * @returns {Fetcher | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) - */ -/** - * @template {Record} TPathParams - * @template {StandardSchemaV1} TSchema - * @template {typeof Resource} TResource - * @overload - * @param {string} url - * @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options - * @returns {Fetcher, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) - */ -/** - * @template {Record} TPathParams - * @template {typeof Resource} TResource - * @template {StandardSchemaV1} TSchema - * @param {string} url - * @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options] - */ -export function create_fetcher(url, options) { - const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? ''; - const populate_path = compile(raw_pathname); - /** - * @param {Parameters>[0]} args - * @returns {Promise} - */ - const fn = async (args) => { - const cloned_url = new URL(url); - const new_params = new URLSearchParams(args.searchParams); - const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]); - cloned_url.search = combined_params.toString(); - cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API - // TODO how to populate path params - const resp = await fetch(cloned_url, args); - if (!resp.ok) { - throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`); - } - const json = await resp.json(); - if (options?.schema) { - return options.schema['~standard'].validate(json); - } - return json; - }; - return create_resource(url.toString(), fn, options); -} diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 91299b12c12c..f9addbb24b87 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -132,8 +132,8 @@ export async function save(promise) { * @param {{ transport?: Transport }} [options] * @returns {Promise} */ -export async function hydratable(key, fn, { transport } = {}) { - const store = await get_render_store(); +export function hydratable(key, fn, { transport } = {}) { + const store = get_render_store(); if (store.hydratables.has(key)) { // TODO error @@ -142,7 +142,7 @@ export async function hydratable(key, fn, { transport } = {}) { const result = fn(); store.hydratables.set(key, { value: result, transport }); - return result; + return Promise.resolve(result); } /** @type {ALSContext | null} */ @@ -153,28 +153,30 @@ export function set_sync_store(store) { sync_store = store; } -/** @type {Promise | null>} */ -const als = import('node:async_hooks') - .then((hooks) => new hooks.AsyncLocalStorage()) +/** @type {AsyncLocalStorage | null} */ +let als = null; + +import('node:async_hooks') + .then((hooks) => (als = new hooks.AsyncLocalStorage())) .catch(() => { // can't use ALS but can still use manual context preservation return null; }); -/** @returns {Promise} */ -async function try_get_render_store() { - return sync_store ?? (await als)?.getStore() ?? null; +/** @returns {ALSContext | null} */ +function try_get_render_store() { + return sync_store ?? als?.getStore() ?? null; } -/** @returns {Promise} */ -export async function get_render_store() { - const store = await try_get_render_store(); +/** @returns {ALSContext} */ +export function get_render_store() { + const store = try_get_render_store(); if (!store) { // TODO make this a proper e.error let message = 'Could not get rendering context.'; - if (await als) { + if (als) { message += ' This is an internal error.'; } else { message += @@ -194,10 +196,10 @@ export async function get_render_store() { * @param {() => Promise} fn * @returns {Promise} */ -export async function with_render_store(store, fn) { +export function with_render_store(store, fn) { try { sync_store = store; - const storage = await als; + const storage = als; return storage ? storage.run(store, fn) : fn(); } finally { sync_store = null; diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 7dc2583379d9..3d6e8d6cb07a 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -375,7 +375,7 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= with_render_store({ hydratables: new Map() }, () => + async ??= with_render_store({ hydratables: new Map(), resources: new Map() }, () => Renderer.#render_async(component, options) ); return async.then((result) => { @@ -523,7 +523,7 @@ export class Renderer { } async #collect_hydratables() { - const map = (await get_render_store()).hydratables; + const map = get_render_store().hydratables; /** @type {(value: unknown) => string} */ let default_stringify; diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 0c45c0adc50d..8840c329bd68 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,5 @@ import type { MaybePromise, Transport } from '#shared'; +import type { Resource } from '../../reactivity/index-server'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -23,6 +24,7 @@ export interface ALSContext { transport: Transport | undefined; } >; + resources: Map>; } export interface SyncRenderOutput { diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index fe5318dae3d5..9b8f4da8274d 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,4 +7,3 @@ export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; export { Resource } from '../internal/client/reactivity/resources/resource.js'; export { define_resource as defineResource } from '../internal/client/reactivity/resources/define-resource.js'; -export { create_fetcher as createFetcher } from '../internal/client/reactivity/resources/create-fetcher.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 1d0d5c82263b..b088874e8a8a 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,7 +1,7 @@ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ /** @import { Transport } from '#shared' */ import { uneval } from 'devalue'; -import { hydratable } from '../internal/server/context.js'; +import { get_render_store, hydratable } from '../internal/server/context.js'; export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; @@ -114,10 +114,6 @@ export class Resource { }; } -/** @type {Map>} */ -// TODO scope to render, clear after render -const cache = new Map(); - /** * @template TReturn * @template {unknown[]} [TArgs=[]] @@ -130,6 +126,7 @@ const cache = new Map(); export function defineResource(name, fn, options = {}) { const ResolvedResource = options?.Resource ?? Resource; return (...args) => { + const cache = get_render_store().resources; const stringified_args = (options.transport?.stringify ?? JSON.stringify)(args); const cache_key = `${name}:${stringified_args}`; const entry = cache.get(cache_key); @@ -143,64 +140,3 @@ export function defineResource(name, fn, options = {}) { return resource; }; } - -/** - * @template {Record} TPathParams - * @typedef {{ searchParams?: ConstructorParameters[0], pathParams?: TPathParams } & RequestInit} FetcherInit - */ -/** - * @template TReturn - * @template {Record} TPathParams - * @typedef {(init: FetcherInit) => Resource} Fetcher - */ - -/** - * @template {Record} TPathParams - * @template {typeof Resource} TResource - * @overload - * @param {string} url - * @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support? - * @returns {Fetcher | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) - */ -/** - * @template {Record} TPathParams - * @template {StandardSchemaV1} TSchema - * @template {typeof Resource} TResource - * @overload - * @param {string} url - * @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options - * @returns {Fetcher, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc) - */ -/** - * @template {Record} TPathParams - * @template {typeof Resource} TResource - * @template {StandardSchemaV1} TSchema - * @param {string} url - * @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options] - */ -export function createFetcher(url, options) { - const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? ''; - const populate_path = compile(raw_pathname); - /** - * @param {Parameters>[0]} args - * @returns {Promise} - */ - const fn = async (args) => { - const cloned_url = new URL(url); - const new_params = new URLSearchParams(args.searchParams); - const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]); - cloned_url.search = combined_params.toString(); - cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API - // TODO how to populate path params - const resp = await fetch(cloned_url, args); - if (!resp.ok) { - throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`); - } - const json = await resp.json(); - if (options?.schema) { - return options.schema['~standard'].validate(json); - } - return json; - }; - return createResource(url.toString(), fn, options); -} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 0531d750a66c..2c8912d44087 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -448,8 +448,6 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; - - type MaybePromise = T | Promise; /** * Returns a `[get, set]` pair of functions for working with context in a type-safe way. * @@ -491,7 +489,9 @@ declare module 'svelte' { * */ export function getAllContexts = Map>(): T; - export function hydratable(key: string, fn: () => T): MaybePromise; + export function hydratable(key: string, fn: () => T, { transport }?: { + transport?: Transport; + } | undefined): Promise; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -565,6 +565,11 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; + type Transport = { + stringify: (value: unknown) => string; + parse: (value: string) => unknown; + }; + export {}; } @@ -2143,7 +2148,6 @@ declare module 'svelte/motion' { } declare module 'svelte/reactivity' { - import type { StandardSchemaV1 } from '@standard-schema/spec'; /** * 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)) @@ -2429,29 +2433,20 @@ declare module 'svelte/reactivity' { set: (value: T) => void; #private; } - export function createResource(name: string, fn: (...args: TArgs) => Promise, options?: { + export function defineResource(name: string, fn: (...args: TArgs) => TReturn, options?: { Resource?: TResource; + transport?: Transport; } | undefined): (...args: TArgs) => Resource; - export function createFetcher, TResource extends typeof Resource>(url: string, options?: { - Resource?: TResource; - schema?: undefined; - } | undefined): Fetcher | unknown[] | boolean | null, TPathParams>; - - export function createFetcher, TSchema extends StandardSchemaV1, TResource extends typeof Resource>(url: string, options: { - Resource?: TResource; - schema: StandardSchemaV1; - }): Fetcher, TPathParams>; - type FetcherInit> = { - searchParams?: ConstructorParameters[0]; - pathParams?: TPathParams; - } & RequestInit; - type Fetcher> = (init: FetcherInit) => Resource; class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; } + type Transport = { + stringify: (value: unknown) => string; + parse: (value: string) => unknown; + }; export {}; } From 25210c2cfa8a01eda0d9543a57f2822dfe1ff23a Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 16:27:07 -0600 Subject: [PATCH 07/46] types --- packages/svelte/src/internal/client/context.js | 2 +- .../src/internal/client/reactivity/resources/resource.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 0965da2260fa..e84a50a40982 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -1,5 +1,5 @@ /** @import { ComponentContext, DevStackEntry, Effect } from '#client' */ -/** @import { Hydratable, Transport } from '#shared' */ +/** @import { Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; import { active_effect, active_reaction } from './runtime.js'; diff --git a/packages/svelte/src/internal/client/reactivity/resources/resource.js b/packages/svelte/src/internal/client/reactivity/resources/resource.js index 6fda26124010..ea6f6a5a1816 100644 --- a/packages/svelte/src/internal/client/reactivity/resources/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resources/resource.js @@ -24,7 +24,7 @@ export class Resource { /** @type {Source} */ #raw = state(undefined); - /** @type {Source>} */ + /** @type {Source>} */ #promise; /** @type {Derived} */ @@ -149,10 +149,10 @@ export class Resource { /** * @returns {Promise} */ - refresh = () => { + refresh = async () => { const promise = this.#run(); set(this.#promise, promise); - return promise; + await promise; }; /** From 9c7da6c9eb54e81f1bd2495be045b8a9af1b86fb Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 16:32:14 -0600 Subject: [PATCH 08/46] only generate hydratables when there's some amount of content --- packages/svelte/src/internal/server/renderer.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 3d6e8d6cb07a..583b444b580e 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -476,7 +476,10 @@ export class Renderer { const renderer = Renderer.#open_render('async', component, options); const content = await renderer.#collect_content_async(); - content.head = (await renderer.#collect_hydratables()) + content.head; + const hydratables = await renderer.#collect_hydratables(); + if (hydratables !== null) { + content.head = hydratables + content.head; + } return Renderer.#close_render(content, renderer); } finally { abort(); @@ -535,6 +538,7 @@ export class Renderer { // sequential await is okay here -- all the work is already kicked off entries.push([k, serialize(await v.value)]); } + if (entries.length === 0) return null; return Renderer.#hydratable_block(JSON.stringify(entries)); } From 5de63834dccd64a2f0111c049ff095c27dcd4aa7 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 15 Oct 2025 17:02:32 -0600 Subject: [PATCH 09/46] add hash --- .../internal/client/reactivity/resources/define-resource.js | 4 ++-- packages/svelte/src/reactivity/index-server.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/resources/define-resource.js b/packages/svelte/src/internal/client/reactivity/resources/define-resource.js index 25fe537d58ee..f68b106fffaa 100644 --- a/packages/svelte/src/internal/client/reactivity/resources/define-resource.js +++ b/packages/svelte/src/internal/client/reactivity/resources/define-resource.js @@ -14,13 +14,13 @@ const cache = new Map(); * @template {typeof Resource} [TResource=typeof Resource] * @param {string} name * @param {(...args: TArgs) => TReturn} fn - * @param {{ Resource?: TResource, transport?: Transport }} [options] + * @param {{ Resource?: TResource, transport?: Transport, hash?: (args: TArgs) => string }} [options] * @returns {(...args: TArgs) => Resource} */ export function define_resource(name, fn, options = {}) { const ResolvedResource = options?.Resource ?? Resource; return (...args) => { - const stringified_args = (options.transport?.stringify ?? JSON.stringify)(args); + const stringified_args = (options.hash ?? JSON.stringify)(args); const cache_key = `${name}:${stringified_args}`; let entry = cache.get(cache_key); const maybe_remove = create_remover(cache_key); diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index b088874e8a8a..12550d37434a 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -120,14 +120,14 @@ export class Resource { * @template {typeof Resource} [TResource=typeof Resource] * @param {string} name * @param {(...args: TArgs) => TReturn} fn - * @param {{ Resource?: TResource, transport?: Transport }} [options] + * @param {{ Resource?: TResource, transport?: Transport, hash?: (args: TArgs) => string }} [options] * @returns {(...args: TArgs) => Resource} */ export function defineResource(name, fn, options = {}) { const ResolvedResource = options?.Resource ?? Resource; return (...args) => { const cache = get_render_store().resources; - const stringified_args = (options.transport?.stringify ?? JSON.stringify)(args); + const stringified_args = (options.hash ?? JSON.stringify)(args); const cache_key = `${name}:${stringified_args}`; const entry = cache.get(cache_key); if (entry) { From 8449ea76d886e57cf0e2d3a02bbe09b9f4015792 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 20 Oct 2025 21:06:38 -0600 Subject: [PATCH 10/46] making progress i think --- .../svelte/src/internal/client/context.js | 2 +- .../src/internal/client/reactivity/cache.js | 134 ++++++++++++++++++ .../src/internal/client/reactivity/fetcher.js | 44 ++++++ .../reactivity/{resources => }/resource.js | 16 ++- .../reactivity/resources/define-resource.js | 78 ---------- .../svelte/src/internal/server/context.js | 2 +- .../src/internal/server/reactivity/cache.js | 35 +++++ .../internal/server/reactivity/resource.js | 97 +++++++++++++ .../svelte/src/internal/server/types.d.ts | 5 +- .../svelte/src/internal/shared/types.d.ts | 26 +++- .../svelte/src/reactivity/index-client.js | 4 +- .../svelte/src/reactivity/index-server.js | 120 +--------------- 12 files changed, 354 insertions(+), 209 deletions(-) create mode 100644 packages/svelte/src/internal/client/reactivity/cache.js create mode 100644 packages/svelte/src/internal/client/reactivity/fetcher.js rename packages/svelte/src/internal/client/reactivity/{resources => }/resource.js (89%) delete mode 100644 packages/svelte/src/internal/client/reactivity/resources/define-resource.js create mode 100644 packages/svelte/src/internal/server/reactivity/cache.js create mode 100644 packages/svelte/src/internal/server/reactivity/resource.js diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index e84a50a40982..84a4d7034a5b 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -228,7 +228,7 @@ export function is_runes() { * @template T * @param {string} key * @param {() => T} fn - * @param {{ transport?: Transport }} [options] + * @param {{ transport?: Transport }} [options] * @returns {Promise} */ export function hydratable(key, fn, { transport } = {}) { 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..316dc9844eb2 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -0,0 +1,134 @@ +import { tick } from '../runtime.js'; +import { render_effect } from './effects.js'; + +/** @typedef {{ count: number, item: any }} Entry */ +/** @type {Map} */ +const client_cache = new Map(); + +/** + * @template TReturn + * @template {unknown} TArg + * @param {string} name + * @param {(arg: TArg, key: string) => TReturn} fn + * @param {{ hash?: (arg: TArg) => string }} [options] + * @returns {(arg: TArg) => TReturn} + */ +export function cache(name, fn, { hash = default_hash } = {}) { + return (arg) => { + const key = `${name}::::${hash(arg)}`; + const cached = client_cache.has(key); + const entry = client_cache.get(key); + const maybe_remove = create_remover(key); + + let tracking = true; + try { + render_effect(() => { + if (entry) entry.count++; + return () => { + const entry = client_cache.get(key); + if (!entry) return; + entry.count--; + maybe_remove(entry); + }; + }); + } catch { + tracking = false; + } + + if (cached) { + return entry?.item; + } + + const item = fn(arg, key); + 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); + } + }); +} + +/** @implements {ReadonlyMap} */ +class ReadonlyCache { + /** @type {ReadonlyMap['get']} */ + get(key) { + const entry = client_cache.get(key); + return entry?.item; + } + + /** @type {ReadonlyMap['has']} */ + has(key) { + return client_cache.has(key); + } + + /** @type {ReadonlyMap['size']} */ + get size() { + return client_cache.size; + } + + /** @type {ReadonlyMap['forEach']} */ + forEach(cb) { + client_cache.forEach((entry, key) => cb(entry.item, key, this)); + } + + /** @type {ReadonlyMap['entries']} */ + *entries() { + for (const [key, entry] of client_cache.entries()) { + yield [key, entry.item]; + } + } + + /** @type {ReadonlyMap['keys']} */ + *keys() { + for (const key of client_cache.keys()) { + yield key; + } + } + + /** @type {ReadonlyMap['values']} */ + *values() { + for (const entry of client_cache.values()) { + yield entry.item; + } + } + + [Symbol.iterator]() { + return this.entries(); + } +} + +const readonly_cache = new ReadonlyCache(); + +/** @returns {ReadonlyMap} */ +export function get_cache() { + return readonly_cache; +} + +/** + * @param {...any} args + * @returns + */ +function default_hash(...args) { + return JSON.stringify(args); +} 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..15df1e9d0c51 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -0,0 +1,44 @@ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ +import { cache } from './cache'; + +/** + * @template {StandardSchemaV1} TSchema + * @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} args + * @param {string} [key] + */ +async function fetcher_impl({ schema, url, init }, key) { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`Fetch error: ${response.status} ${response.statusText}`); + } + if (schema) { + const data = await response.json(); + return schema['~standard'].validate(data); + } + return response.json(); +} + +const cached_fetch = cache('svelte/fetcher', fetcher_impl, { + hash: (arg) => { + return `${typeof arg.url === 'string' ? arg.url : arg.url.toString()}}`; + } +}); + +/** + * @template {StandardSchemaV1} TSchema + * @overload + * @param {{ schema: TSchema, url: string | URL, init?: RequestInit }} arg + * @returns {Promise>} + */ +/** + * @overload + * @param {{ schema?: undefined, url: string | URL, init?: RequestInit }} arg + * @returns {Promise} + */ +/** + * @template {StandardSchemaV1} TSchema + * @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} arg + */ +export function fetcher(arg) { + return cached_fetch(arg); +} diff --git a/packages/svelte/src/internal/client/reactivity/resources/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js similarity index 89% rename from packages/svelte/src/internal/client/reactivity/resources/resource.js rename to packages/svelte/src/internal/client/reactivity/resource.js index ea6f6a5a1816..97ab83566459 100644 --- a/packages/svelte/src/internal/client/reactivity/resources/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -1,12 +1,22 @@ /** @import { Source, Derived } from '#client' */ -import { state, derived, set, get, tick } from '../../index.js'; -import { deferred } from '../../../shared/utils.js'; +/** @import { Resource as ResourceType } from '#shared' */ +import { state, derived, set, get, tick } from '../index.js'; +import { deferred } from '../../shared/utils.js'; + +/** + * @template T + * @param {() => Promise} fn + * @returns {ResourceType} + */ +export function resource(fn) { + return /** @type {ResourceType} */ (/** @type {unknown} */ (new Resource(fn))); +} /** * @template T * @implements {Partial>} */ -export class Resource { +class Resource { #init = false; /** @type {() => Promise} */ diff --git a/packages/svelte/src/internal/client/reactivity/resources/define-resource.js b/packages/svelte/src/internal/client/reactivity/resources/define-resource.js deleted file mode 100644 index f68b106fffaa..000000000000 --- a/packages/svelte/src/internal/client/reactivity/resources/define-resource.js +++ /dev/null @@ -1,78 +0,0 @@ -/** @import { Transport } from '#shared' */ -import { hydratable } from '../../context.js'; -import { tick } from '../../runtime.js'; -import { render_effect } from '../effects.js'; -import { Resource } from './resource.js'; - -/** @typedef {{ count: number, resource: Resource }} Entry */ -/** @type {Map} */ -const cache = new Map(); - -/** - * @template TReturn - * @template {unknown[]} [TArgs=[]] - * @template {typeof Resource} [TResource=typeof Resource] - * @param {string} name - * @param {(...args: TArgs) => TReturn} fn - * @param {{ Resource?: TResource, transport?: Transport, hash?: (args: TArgs) => string }} [options] - * @returns {(...args: TArgs) => Resource} - */ -export function define_resource(name, fn, options = {}) { - const ResolvedResource = options?.Resource ?? Resource; - return (...args) => { - const stringified_args = (options.hash ?? JSON.stringify)(args); - const cache_key = `${name}:${stringified_args}`; - let entry = cache.get(cache_key); - const maybe_remove = create_remover(cache_key); - - let tracking = true; - try { - render_effect(() => { - if (entry) entry.count++; - return () => { - const entry = cache.get(cache_key); - if (!entry) return; - entry.count--; - maybe_remove(entry, cache); - }; - }); - } catch { - tracking = false; - } - - let resource = entry?.resource; - if (!resource) { - resource = new ResolvedResource(() => - hydratable(cache_key, () => fn(...args), { transport: options.transport }) - ); - const entry = { - resource, - count: tracking ? 1 : 0 - }; - cache.set(cache_key, entry); - - resource.then( - () => maybe_remove(entry, cache), - () => maybe_remove(entry, cache) - ); - } - - return resource; - }; -} - -/** - * @param {string} key - */ -function create_remover(key) { - /** - * @param {Entry | undefined} entry - * @param {Map} cache - */ - return (entry, cache) => - tick().then(() => { - if (!entry?.count && entry === cache.get(key)) { - cache.delete(key); - } - }); -} diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index f9addbb24b87..7ab3e2665b0a 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -129,7 +129,7 @@ export async function save(promise) { * @template T * @param {string} key * @param {() => T} fn - * @param {{ transport?: Transport }} [options] + * @param {{ transport?: Transport }} [options] * @returns {Promise} */ export function hydratable(key, fn, { transport } = {}) { 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..ed93539e3f5a --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -0,0 +1,35 @@ +import { get_render_store } from '../context'; + +/** + * @template TReturn + * @template {unknown} TArg + * @param {string} name + * @param {(arg: TArg, key: string) => TReturn} fn + * @param {{ hash?: (arg: TArg) => string }} [options] + * @returns {(arg: TArg) => TReturn} + */ +export function cache(name, fn, { hash = default_hash } = {}) { + return (arg) => { + const cache = get_render_store().cache; + const key = `${name}::::${hash(arg)}`; + const entry = cache.get(key); + if (entry) { + return /** @type {TReturn} */ (entry); + } + const new_entry = fn(arg, key); + cache.set(key, new_entry); + return new_entry; + }; +} + +/** + * @param {any} arg + * @returns {string} + */ +function default_hash(arg) { + return JSON.stringify(arg); +} + +export function get_cache() { + throw new Error('TODO: cannot get cache on the server'); +} 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..e542f5e3decc --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -0,0 +1,97 @@ +/** @import { Resource as ResourceType } from '#shared' */ + +/** + * @template T + * @param {() => Promise} fn + * @returns {ResourceType} + */ +export function resource(fn) { + return /** @type {ResourceType} */ (/** @type {unknown} */ (new Resource(fn))); +} + +/** + * @template T + * @implements {Partial>} + */ +class Resource { + /** @type {Promise} */ + #promise; + #ready = false; + #loading = true; + + /** @type {T | undefined} */ + #current = undefined; + #error = undefined; + + /** + * @param {() => Promise} 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 {T} 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/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 8840c329bd68..cbc0a385a96b 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,5 +1,4 @@ import type { MaybePromise, Transport } from '#shared'; -import type { Resource } from '../../reactivity/index-server'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -21,10 +20,10 @@ export interface ALSContext { string, { value: MaybePromise; - transport: Transport | undefined; + transport: Transport | undefined; } >; - resources: Map>; + cache: Map; } export interface SyncRenderOutput { diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 52456a814d15..f092a8c93188 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,7 +11,27 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; -export type Transport = { - stringify: (value: unknown) => string; - parse: (value: string) => unknown; +export type Transport = { + stringify: (value: T) => string; + parse: (value: string) => T; }; + +export type Resource = { + then: Promise['then']; + catch: Promise['catch']; + finally: Promise['finally']; + refresh: () => Promise; + set: (value: T) => void; + loading: boolean; +} & ( + | { + ready: false; + value: undefined; + error: undefined; + } + | { + ready: true; + value: T; + error: any; + } +); diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 9b8f4da8274d..e5be6774bd6f 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -5,5 +5,5 @@ 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/resources/resource.js'; -export { define_resource as defineResource } from '../internal/client/reactivity/resources/define-resource.js'; +export { resource } from '../internal/client/reactivity/resource.js'; +export { cache, get_cache as getCache } from '../internal/client/reactivity/cache.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 12550d37434a..67ea76f65aee 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,7 +1,5 @@ -/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -/** @import { Transport } from '#shared' */ -import { uneval } from 'devalue'; -import { get_render_store, hydratable } from '../internal/server/context.js'; +export { resource } from '../internal/server/reactivity/resource.js'; +export { cache, get_cache as getCache } from '../internal/server/reactivity/cache.js'; export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; @@ -26,117 +24,3 @@ export class MediaQuery { export function createSubscriber(_) { return () => {}; } - -/** - * @template T - * @implements {Partial>} - */ -export class Resource { - /** @type {Promise} */ - #promise; - #ready = false; - #loading = true; - - /** @type {T | undefined} */ - #current = undefined; - #error = undefined; - - /** - * @param {() => Promise} 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 {T} value - */ - set = (value) => { - this.#ready = true; - this.#loading = false; - this.#error = undefined; - this.#current = value; - this.#promise = Promise.resolve(); - }; -} - -/** - * @template TReturn - * @template {unknown[]} [TArgs=[]] - * @template {typeof Resource} [TResource=typeof Resource] - * @param {string} name - * @param {(...args: TArgs) => TReturn} fn - * @param {{ Resource?: TResource, transport?: Transport, hash?: (args: TArgs) => string }} [options] - * @returns {(...args: TArgs) => Resource} - */ -export function defineResource(name, fn, options = {}) { - const ResolvedResource = options?.Resource ?? Resource; - return (...args) => { - const cache = get_render_store().resources; - const stringified_args = (options.hash ?? JSON.stringify)(args); - const cache_key = `${name}:${stringified_args}`; - const entry = cache.get(cache_key); - if (entry) { - return entry; - } - const resource = new ResolvedResource(() => - hydratable(cache_key, () => fn(...args), { transport: options.transport }) - ); - cache.set(cache_key, resource); - return resource; - }; -} From ef11dae8cef82c512631a9098599529d947fd3ae Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 20 Oct 2025 21:07:03 -0600 Subject: [PATCH 11/46] typegen --- packages/svelte/types/index.d.ts | 63 +++++++++++++++----------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 2c8912d44087..e8e3d02c3a61 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -490,7 +490,7 @@ declare module 'svelte' { export function getAllContexts = Map>(): T; export function hydratable(key: string, fn: () => T, { transport }?: { - transport?: Transport; + transport?: Transport; } | undefined): Promise; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. @@ -565,9 +565,9 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; - type Transport = { - stringify: (value: unknown) => string; - parse: (value: string) => unknown; + type Transport = { + stringify: (value: T) => string; + parse: (value: string) => T; }; export {}; @@ -2411,42 +2411,37 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; - export class Resource implements Partial> { - - constructor(fn: () => Promise); - get then(): (onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined) => Promise; - get catch(): (reject: any) => Promise; - get finally(): (fn: any) => Promise; - get current(): T | undefined; - get error(): undefined; - /** - * Returns true if the resource is loading or reloading. - */ - get loading(): boolean; - /** - * Returns true once the resource has been loaded for the first time. - */ - get ready(): boolean; - - refresh: () => Promise; - - set: (value: T) => void; - #private; - } - export function defineResource(name: string, fn: (...args: TArgs) => TReturn, options?: { - Resource?: TResource; - transport?: Transport; - } | undefined): (...args: TArgs) => Resource; + export function resource(fn: () => Promise): Resource; + export function cache(name: string, fn: (arg: TArg, key: string) => TReturn, { hash }?: { + hash?: (arg: TArg) => string; + } | undefined): (arg: TArg) => TReturn; + + export function getCache(): ReadonlyMap; class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; } - type Transport = { - stringify: (value: unknown) => string; - parse: (value: string) => unknown; - }; + type Resource = { + then: Promise['then']; + catch: Promise['catch']; + finally: Promise['finally']; + refresh: () => Promise; + set: (value: T) => void; + loading: boolean; + } & ( + | { + ready: false; + value: undefined; + error: undefined; + } + | { + ready: true; + value: T; + error: any; + } + ); export {}; } From d36894a5c031c389f09a16991f7aaace01e497e9 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 21 Oct 2025 12:19:24 -0600 Subject: [PATCH 12/46] it at least basically works --- .../svelte/src/internal/client/context.js | 52 ++++++++++- .../src/internal/client/reactivity/cache.js | 90 +++++++++---------- .../src/internal/client/reactivity/fetcher.js | 51 +++-------- .../svelte/src/internal/server/context.js | 52 ++++++++++- .../src/internal/server/reactivity/cache.js | 43 ++++----- .../src/internal/server/reactivity/fetcher.js | 17 ++++ .../svelte/src/internal/server/renderer.js | 2 +- .../svelte/src/internal/shared/types.d.ts | 2 + packages/svelte/src/internal/shared/utils.js | 14 +++ .../svelte/src/reactivity/index-client.js | 1 + .../svelte/src/reactivity/index-server.js | 1 + packages/svelte/types/index.d.ts | 15 ++-- 12 files changed, 212 insertions(+), 128 deletions(-) create mode 100644 packages/svelte/src/internal/server/reactivity/fetcher.js diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 84a4d7034a5b..76865b0bbb50 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -224,14 +224,60 @@ export function is_runes() { return !legacy_mode_flag || (component_context !== null && component_context.l === null); } +/** @type {string | null} */ +export let hydratable_key = null; + +/** @param {string | null} key */ +export function set_hydratable_key(key) { + hydratable_key = key; +} + /** * @template T + * @overload * @param {string} key * @param {() => T} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise} + * @returns {Promise>} + */ +/** + * @template T + * @overload + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise>} + */ +/** + * @template T + * @param {string | (() => T)} key_or_fn + * @param {(() => T) | { transport?: Transport }} [fn_or_options] + * @param {{ transport?: Transport }} [maybe_options] + * @returns {Promise>} */ -export function hydratable(key, fn, { transport } = {}) { +export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { + /** @type {string} */ + let key; + /** @type {() => T} */ + let fn; + /** @type {{ transport?: Transport }} */ + let options; + + if (typeof key_or_fn === 'string') { + key = key_or_fn; + fn = /** @type {() => T} */ (fn_or_options); + options = /** @type {{ transport?: Transport }} */ (maybe_options); + } else { + if (hydratable_key === null) { + throw new Error( + 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' + ); + } else { + key = hydratable_key; + } + fn = /** @type {() => T} */ (key_or_fn); + options = /** @type {{ transport?: Transport }} */ (fn_or_options); + } + if (!hydrating) { return Promise.resolve(fn()); } @@ -245,7 +291,7 @@ export function hydratable(key, fn, { transport } = {}) { ); } const entry = /** @type {string} */ (store.get(key)); - const parse = transport?.parse ?? ((val) => new Function(`return (${val})`)()); + const parse = options.transport?.parse ?? ((val) => new Function(`return (${val})`)()); return Promise.resolve(/** @type {T} */ (parse(entry))); } diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 316dc9844eb2..224de6bd9b21 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,3 +1,4 @@ +import { set_hydratable_key } from '../context.js'; import { tick } from '../runtime.js'; import { render_effect } from './effects.js'; @@ -6,52 +7,49 @@ import { render_effect } from './effects.js'; const client_cache = new Map(); /** - * @template TReturn - * @template {unknown} TArg - * @param {string} name - * @param {(arg: TArg, key: string) => TReturn} fn - * @param {{ hash?: (arg: TArg) => string }} [options] - * @returns {(arg: TArg) => TReturn} + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} */ -export function cache(name, fn, { hash = default_hash } = {}) { - return (arg) => { - const key = `${name}::::${hash(arg)}`; - const cached = client_cache.has(key); - const entry = client_cache.get(key); - const maybe_remove = create_remover(key); - - let tracking = true; - try { - render_effect(() => { - if (entry) entry.count++; - return () => { - const entry = client_cache.get(key); - if (!entry) return; - entry.count--; - maybe_remove(entry); - }; - }); - } catch { - tracking = false; - } +export function cache(key, fn) { + const cached = client_cache.has(key); + const entry = client_cache.get(key); + const maybe_remove = create_remover(key); + + let tracking = true; + try { + render_effect(() => { + if (entry) entry.count++; + return () => { + const entry = client_cache.get(key); + if (!entry) return; + entry.count--; + maybe_remove(entry); + }; + }); + } catch { + tracking = false; + } - if (cached) { - return entry?.item; - } + if (cached) { + return entry?.item; + } - const item = fn(arg, key); - 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; + set_hydratable_key(key); + const item = fn(); + set_hydratable_key(null); + 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; } /** @@ -124,11 +122,3 @@ const readonly_cache = new ReadonlyCache(); export function get_cache() { return readonly_cache; } - -/** - * @param {...any} args - * @returns - */ -function default_hash(...args) { - return JSON.stringify(args); -} diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js index 15df1e9d0c51..dc3671be188a 100644 --- a/packages/svelte/src/internal/client/reactivity/fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -1,44 +1,17 @@ -/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ +/** @import { GetRequestInit, Resource } from '#shared' */ import { cache } from './cache'; +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../context'; +import { resource } from './resource'; /** - * @template {StandardSchemaV1} TSchema - * @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} args - * @param {string} [key] + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} */ -async function fetcher_impl({ schema, url, init }, key) { - const response = await fetch(url, init); - if (!response.ok) { - throw new Error(`Fetch error: ${response.status} ${response.statusText}`); - } - if (schema) { - const data = await response.json(); - return schema['~standard'].validate(data); - } - return response.json(); -} - -const cached_fetch = cache('svelte/fetcher', fetcher_impl, { - hash: (arg) => { - return `${typeof arg.url === 'string' ? arg.url : arg.url.toString()}}`; - } -}); - -/** - * @template {StandardSchemaV1} TSchema - * @overload - * @param {{ schema: TSchema, url: string | URL, init?: RequestInit }} arg - * @returns {Promise>} - */ -/** - * @overload - * @param {{ schema?: undefined, url: string | URL, init?: RequestInit }} arg - * @returns {Promise} - */ -/** - * @template {StandardSchemaV1} TSchema - * @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} arg - */ -export function fetcher(arg) { - return cached_fetch(arg); +export function fetcher(url, init) { + return cache(`svelte/fetcher::::${typeof url === 'string' ? url : url.toString()}`, () => + resource(() => hydratable(() => fetch_json(url, init))) + ); } diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 7ab3e2665b0a..54fc12207783 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -125,14 +125,60 @@ export async function save(promise) { }; } +/** @type {string | null} */ +export let hydratable_key = null; + +/** @param {string | null} key */ +export function set_hydratable_key(key) { + hydratable_key = key; +} + /** * @template T + * @overload * @param {string} key * @param {() => T} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise} + * @returns {Promise>} */ -export function hydratable(key, fn, { transport } = {}) { +/** + * @template T + * @overload + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise>} + */ +/** + * @template T + * @param {string | (() => T)} key_or_fn + * @param {(() => T) | { transport?: Transport }} [fn_or_options] + * @param {{ transport?: Transport }} [maybe_options] + * @returns {Promise>} + */ +export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { + // TODO DRY out with #shared + /** @type {string} */ + let key; + /** @type {() => T} */ + let fn; + /** @type {{ transport?: Transport }} */ + let options; + + if (typeof key_or_fn === 'string') { + key = key_or_fn; + fn = /** @type {() => T} */ (fn_or_options); + options = /** @type {{ transport?: Transport }} */ (maybe_options); + } else { + if (hydratable_key === null) { + throw new Error( + 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' + ); + } else { + key = hydratable_key; + } + fn = /** @type {() => T} */ (key_or_fn); + options = /** @type {{ transport?: Transport }} */ (fn_or_options); + } const store = get_render_store(); if (store.hydratables.has(key)) { @@ -141,7 +187,7 @@ export function hydratable(key, fn, { transport } = {}) { } const result = fn(); - store.hydratables.set(key, { value: result, transport }); + store.hydratables.set(key, { value: result, transport: options.transport }); return Promise.resolve(result); } diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index ed93539e3f5a..490463386302 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,33 +1,22 @@ -import { get_render_store } from '../context'; +import { get_render_store, set_hydratable_key } from '../context'; /** - * @template TReturn - * @template {unknown} TArg - * @param {string} name - * @param {(arg: TArg, key: string) => TReturn} fn - * @param {{ hash?: (arg: TArg) => string }} [options] - * @returns {(arg: TArg) => TReturn} + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} */ -export function cache(name, fn, { hash = default_hash } = {}) { - return (arg) => { - const cache = get_render_store().cache; - const key = `${name}::::${hash(arg)}`; - const entry = cache.get(key); - if (entry) { - return /** @type {TReturn} */ (entry); - } - const new_entry = fn(arg, key); - cache.set(key, new_entry); - return new_entry; - }; -} - -/** - * @param {any} arg - * @returns {string} - */ -function default_hash(arg) { - return JSON.stringify(arg); +export function cache(key, fn) { + const cache = get_render_store().cache; + const entry = cache.get(key); + if (entry) { + return /** @type {ReturnType} */ (entry); + } + set_hydratable_key(key); + const new_entry = fn(); + set_hydratable_key(null); + cache.set(key, new_entry); + return new_entry; } export function get_cache() { 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..9e4870cf2b41 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -0,0 +1,17 @@ +/** @import { GetRequestInit, Resource } from '#shared' */ +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../context.js'; +import { cache } from './cache'; +import { resource } from './resource.js'; + +/** + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} + */ +export function fetcher(url, init) { + return cache(`svelte/fetcher::::${typeof url === 'string' ? url : url.toString()}`, () => + resource(() => hydratable(() => fetch_json(url, init))) + ); +} diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 583b444b580e..598bcd73a356 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -375,7 +375,7 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= with_render_store({ hydratables: new Map(), resources: new Map() }, () => + async ??= with_render_store({ hydratables: new Map(), cache: new Map() }, () => Renderer.#render_async(component, options) ); return async.then((result) => { diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index f092a8c93188..549d870d88fc 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -35,3 +35,5 @@ export type Resource = { error: any; } ); + +export type GetRequestInit = Omit & { method?: 'GET' }; diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 10f8597520d9..1f375c4001eb 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; @@ -116,3 +118,15 @@ export function to_array(value, n) { return array; } + +/** + * @param {string | URL} url + * @param {GetRequestInit} [init] + */ +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 e5be6774bd6f..cc65588c8968 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,3 +7,4 @@ export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; export { resource } from '../internal/client/reactivity/resource.js'; export { cache, get_cache as getCache } 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 67ea76f65aee..231741028d5c 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,5 +1,6 @@ export { resource } from '../internal/server/reactivity/resource.js'; export { cache, get_cache as getCache } from '../internal/server/reactivity/cache.js'; +export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index e8e3d02c3a61..e5cfbdb0ac01 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -489,9 +489,13 @@ declare module 'svelte' { * */ export function getAllContexts = Map>(): T; - export function hydratable(key: string, fn: () => T, { transport }?: { + export function hydratable(key: string, fn: () => T, options?: { transport?: Transport; - } | undefined): Promise; + } | undefined): Promise>; + + export function hydratable(fn: () => T, options?: { + transport?: Transport; + } | undefined): Promise>; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -2412,9 +2416,8 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export function resource(fn: () => Promise): Resource; - export function cache(name: string, fn: (arg: TArg, key: string) => TReturn, { hash }?: { - hash?: (arg: TArg) => string; - } | undefined): (arg: TArg) => TReturn; + export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource; + export function cache any>(key: string, fn: TFn): ReturnType; export function getCache(): ReadonlyMap; class ReactiveValue { @@ -2443,6 +2446,8 @@ declare module 'svelte/reactivity' { } ); + type GetRequestInit = Omit & { method?: 'GET' }; + export {}; } From 0c4ce5a9ec831bc65d9c693165bc9bcaab6c0b31 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 24 Oct 2025 16:59:18 -0600 Subject: [PATCH 13/46] misc improvements --- .../svelte/src/internal/client/context.js | 20 +++--- .../src/internal/client/reactivity/cache.js | 57 ++-------------- .../internal/client/reactivity/resource.js | 2 +- .../svelte/src/internal/server/context.js | 34 +++++----- .../src/internal/server/reactivity/cache.js | 7 +- .../internal/server/reactivity/resource.js | 2 +- .../svelte/src/internal/server/types.d.ts | 6 +- .../src/internal/shared/cache-observer.js | 56 +++++++++++++++ .../svelte/src/internal/shared/types.d.ts | 20 +++--- packages/svelte/src/internal/shared/utils.js | 4 +- .../svelte/src/reactivity/index-client.js | 8 ++- .../svelte/src/reactivity/index-server.js | 8 ++- packages/svelte/types/index.d.ts | 68 ++++++++++++------- 13 files changed, 170 insertions(+), 122 deletions(-) create mode 100644 packages/svelte/src/internal/shared/cache-observer.js diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 76865b0bbb50..6fb708c04ce5 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -236,35 +236,35 @@ export function set_hydratable_key(key) { * @template T * @overload * @param {string} key - * @param {() => T} fn + * @param {() => Promise} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise>} + * @returns {Promise} */ /** * @template T * @overload - * @param {() => T} fn + * @param {() => Promise} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise>} + * @returns {Promise} */ /** * @template T - * @param {string | (() => T)} key_or_fn - * @param {(() => T) | { transport?: Transport }} [fn_or_options] + * @param {string | (() => Promise)} key_or_fn + * @param {(() => Promise) | { transport?: Transport }} [fn_or_options] * @param {{ transport?: Transport }} [maybe_options] - * @returns {Promise>} + * @returns {Promise} */ export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { /** @type {string} */ let key; - /** @type {() => T} */ + /** @type {() => Promise} */ let fn; /** @type {{ transport?: Transport }} */ let options; if (typeof key_or_fn === 'string') { key = key_or_fn; - fn = /** @type {() => T} */ (fn_or_options); + fn = /** @type {() => Promise} */ (fn_or_options); options = /** @type {{ transport?: Transport }} */ (maybe_options); } else { if (hydratable_key === null) { @@ -274,7 +274,7 @@ export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { } else { key = hydratable_key; } - fn = /** @type {() => T} */ (key_or_fn); + fn = /** @type {() => Promise} */ (key_or_fn); options = /** @type {{ transport?: Transport }} */ (fn_or_options); } diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 224de6bd9b21..379ae978cbdb 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,3 +1,4 @@ +import { BaseCacheObserver } from '../../shared/cache-observer.js'; import { set_hydratable_key } from '../context.js'; import { tick } from '../runtime.js'; import { render_effect } from './effects.js'; @@ -67,58 +68,8 @@ function create_remover(key) { }); } -/** @implements {ReadonlyMap} */ -class ReadonlyCache { - /** @type {ReadonlyMap['get']} */ - get(key) { - const entry = client_cache.get(key); - return entry?.item; - } - - /** @type {ReadonlyMap['has']} */ - has(key) { - return client_cache.has(key); - } - - /** @type {ReadonlyMap['size']} */ - get size() { - return client_cache.size; - } - - /** @type {ReadonlyMap['forEach']} */ - forEach(cb) { - client_cache.forEach((entry, key) => cb(entry.item, key, this)); - } - - /** @type {ReadonlyMap['entries']} */ - *entries() { - for (const [key, entry] of client_cache.entries()) { - yield [key, entry.item]; - } +export class CacheObserver extends BaseCacheObserver { + constructor() { + super(client_cache); } - - /** @type {ReadonlyMap['keys']} */ - *keys() { - for (const key of client_cache.keys()) { - yield key; - } - } - - /** @type {ReadonlyMap['values']} */ - *values() { - for (const entry of client_cache.values()) { - yield entry.item; - } - } - - [Symbol.iterator]() { - return this.entries(); - } -} - -const readonly_cache = new ReadonlyCache(); - -/** @returns {ReadonlyMap} */ -export function get_cache() { - return readonly_cache; } diff --git a/packages/svelte/src/internal/client/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js index 97ab83566459..e231a8a862dc 100644 --- a/packages/svelte/src/internal/client/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -9,7 +9,7 @@ import { deferred } from '../../shared/utils.js'; * @returns {ResourceType} */ export function resource(fn) { - return /** @type {ResourceType} */ (/** @type {unknown} */ (new Resource(fn))); + return /** @type {ResourceType} */ (new Resource(fn)); } /** diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 54fc12207783..8783d5522f01 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,4 +1,4 @@ -/** @import { ALSContext, SSRContext } from '#server' */ +/** @import { RenderContext, SSRContext } from '#server' */ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ /** @import { Transport } from '#shared' */ import { DEV } from 'esm-env'; @@ -137,36 +137,36 @@ export function set_hydratable_key(key) { * @template T * @overload * @param {string} key - * @param {() => T} fn + * @param {() => Promise} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise>} + * @returns {Promise} */ /** * @template T * @overload - * @param {() => T} fn + * @param {() => Promise} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise>} + * @returns {Promise} */ /** * @template T - * @param {string | (() => T)} key_or_fn - * @param {(() => T) | { transport?: Transport }} [fn_or_options] + * @param {string | (() => Promise)} key_or_fn + * @param {(() => Promise) | { transport?: Transport }} [fn_or_options] * @param {{ transport?: Transport }} [maybe_options] - * @returns {Promise>} + * @returns {Promise} */ export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { // TODO DRY out with #shared /** @type {string} */ let key; - /** @type {() => T} */ + /** @type {() => Promise} */ let fn; /** @type {{ transport?: Transport }} */ let options; if (typeof key_or_fn === 'string') { key = key_or_fn; - fn = /** @type {() => T} */ (fn_or_options); + fn = /** @type {() => Promise} */ (fn_or_options); options = /** @type {{ transport?: Transport }} */ (maybe_options); } else { if (hydratable_key === null) { @@ -176,7 +176,7 @@ export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { } else { key = hydratable_key; } - fn = /** @type {() => T} */ (key_or_fn); + fn = /** @type {() => Promise} */ (key_or_fn); options = /** @type {{ transport?: Transport }} */ (fn_or_options); } const store = get_render_store(); @@ -191,15 +191,15 @@ export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { return Promise.resolve(result); } -/** @type {ALSContext | null} */ +/** @type {RenderContext | null} */ export let sync_store = null; -/** @param {ALSContext | null} store */ +/** @param {RenderContext | null} store */ export function set_sync_store(store) { sync_store = store; } -/** @type {AsyncLocalStorage | null} */ +/** @type {AsyncLocalStorage | null} */ let als = null; import('node:async_hooks') @@ -209,12 +209,12 @@ import('node:async_hooks') return null; }); -/** @returns {ALSContext | null} */ +/** @returns {RenderContext | null} */ function try_get_render_store() { return sync_store ?? als?.getStore() ?? null; } -/** @returns {ALSContext} */ +/** @returns {RenderContext} */ export function get_render_store() { const store = try_get_render_store(); @@ -238,7 +238,7 @@ export function get_render_store() { /** * @template T - * @param {ALSContext} store + * @param {RenderContext} store * @param {() => Promise} fn * @returns {Promise} */ diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index 490463386302..a387570f4d6c 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,3 +1,4 @@ +import { BaseCacheObserver } from '../../shared/cache-observer'; import { get_render_store, set_hydratable_key } from '../context'; /** @@ -19,6 +20,8 @@ export function cache(key, fn) { return new_entry; } -export function get_cache() { - throw new Error('TODO: cannot get cache on the server'); +export class CacheObserver extends BaseCacheObserver { + constructor() { + super(get_render_store().cache); + } } diff --git a/packages/svelte/src/internal/server/reactivity/resource.js b/packages/svelte/src/internal/server/reactivity/resource.js index e542f5e3decc..7484fd9f53ea 100644 --- a/packages/svelte/src/internal/server/reactivity/resource.js +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -6,7 +6,7 @@ * @returns {ResourceType} */ export function resource(fn) { - return /** @type {ResourceType} */ (/** @type {unknown} */ (new Resource(fn))); + return /** @type {ResourceType} */ (new Resource(fn)); } /** diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index cbc0a385a96b..6563fdf6f960 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,4 @@ -import type { MaybePromise, Transport } from '#shared'; +import type { Transport } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -15,11 +15,11 @@ export interface SSRContext { element?: Element; } -export interface ALSContext { +export interface RenderContext { hydratables: Map< string, { - value: MaybePromise; + value: Promise; transport: Transport | undefined; } >; 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..1dd72b2b34df --- /dev/null +++ b/packages/svelte/src/internal/shared/cache-observer.js @@ -0,0 +1,56 @@ +/** @implements {ReadonlyMap} */ +export class BaseCacheObserver { + /** @type {ReadonlyMap} */ + #cache; + + /** @param {Map} cache */ + constructor(cache) { + this.#cache = cache; + } + + /** @type {ReadonlyMap['get']} */ + get(key) { + const entry = this.#cache.get(key); + return entry?.item; + } + + /** @type {ReadonlyMap['has']} */ + has(key) { + return this.#cache.has(key); + } + + /** @type {ReadonlyMap['size']} */ + get size() { + return this.#cache.size; + } + + /** @type {ReadonlyMap['forEach']} */ + forEach(cb) { + this.#cache.forEach((entry, key) => cb(entry.item, key, this)); + } + + /** @type {ReadonlyMap['entries']} */ + *entries() { + for (const [key, entry] of this.#cache.entries()) { + yield [key, entry.item]; + } + } + + /** @type {ReadonlyMap['keys']} */ + *keys() { + for (const key of this.#cache.keys()) { + yield key; + } + } + + /** @type {ReadonlyMap['values']} */ + *values() { + for (const entry of this.#cache.values()) { + yield entry.item; + } + } + + [Symbol.iterator]() { + return this.entries(); + } +} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 549d870d88fc..781e6e1e5eb6 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,10 +11,15 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; -export type Transport = { - stringify: (value: T) => string; - parse: (value: string) => T; -}; +export type Transport = + | { + stringify: (value: T) => string; + parse?: undefined; + } + | { + stringify?: undefined; + parse: (value: string) => T; + }; export type Resource = { then: Promise['then']; @@ -23,16 +28,15 @@ export type Resource = { refresh: () => Promise; set: (value: T) => void; loading: boolean; + error: any; } & ( | { ready: false; - value: undefined; - error: undefined; + current: undefined; } | { ready: true; - value: T; - error: any; + current: T; } ); diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 1f375c4001eb..53700df8e8d7 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -50,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} */ @@ -120,8 +120,10 @@ export function to_array(value, n) { } /** + * @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); diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index cc65588c8968..8b2abd9076ba 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -1,3 +1,4 @@ +/** @import { Resource as ResourceType } from '#shared' */ export { SvelteDate } from './date.js'; export { SvelteSet } from './set.js'; export { SvelteMap } from './map.js'; @@ -6,5 +7,10 @@ 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, get_cache as getCache } from '../internal/client/reactivity/cache.js'; +export { cache, CacheObserver } from '../internal/client/reactivity/cache.js'; export { fetcher } from '../internal/client/reactivity/fetcher.js'; + +/** + * @template T + * @typedef {ResourceType} Resource + */ diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 231741028d5c..0dcc459e64eb 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,5 +1,6 @@ +/** @import { Resource as ResourceType } from '#shared' */ export { resource } from '../internal/server/reactivity/resource.js'; -export { cache, get_cache as getCache } from '../internal/server/reactivity/cache.js'; +export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date; @@ -25,3 +26,8 @@ export class MediaQuery { export function createSubscriber(_) { return () => {}; } + +/** + * @template T + * @typedef {ResourceType} Resource + */ diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index e5cfbdb0ac01..fd55a7649c5b 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -489,13 +489,13 @@ declare module 'svelte' { * */ export function getAllContexts = Map>(): T; - export function hydratable(key: string, fn: () => T, options?: { + export function hydratable(key: string, fn: () => Promise, options?: { transport?: Transport; - } | undefined): Promise>; + } | undefined): Promise; - export function hydratable(fn: () => T, options?: { + export function hydratable(fn: () => Promise, options?: { transport?: Transport; - } | undefined): Promise>; + } | undefined): Promise; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -569,10 +569,15 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; - type Transport = { - stringify: (value: T) => string; - parse: (value: string) => T; - }; + type Transport = + | { + stringify: (value: T) => string; + parse?: undefined; + } + | { + stringify?: undefined; + parse: (value: string) => T; + }; export {}; } @@ -2152,6 +2157,7 @@ declare module 'svelte/motion' { } declare module 'svelte/reactivity' { + export type Resource = Resource_1; /** * 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)) @@ -2415,38 +2421,52 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; - export function resource(fn: () => Promise): Resource; - export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource; - export function cache any>(key: string, fn: TFn): ReturnType; - - export function getCache(): ReadonlyMap; - class ReactiveValue { - - constructor(fn: () => T, onsubscribe: (update: () => void) => void); - get current(): T; - #private; - } - type Resource = { + export function resource(fn: () => Promise): Resource_1; + export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; + type Resource_1 = { then: Promise['then']; catch: Promise['catch']; finally: Promise['finally']; refresh: () => Promise; set: (value: T) => void; loading: boolean; + error: any; } & ( | { ready: false; - value: undefined; - error: undefined; + current: undefined; } | { ready: true; - value: T; - error: any; + current: T; } ); type GetRequestInit = Omit & { method?: 'GET' }; + export function cache any>(key: string, fn: TFn): ReturnType; + export class CacheObserver extends BaseCacheObserver { + constructor(); + } + class ReactiveValue { + + constructor(fn: () => T, onsubscribe: (update: () => void) => void); + get current(): T; + #private; + } + class BaseCacheObserver implements ReadonlyMap { + + constructor(cache: Map); + get(key: string): any; + has(key: string): boolean; + + get size(): number; + forEach(callbackfn: (value: any, key: string, map: ReadonlyMap) => void, thisArg?: any): void; + entries(): IterableIterator<[string, any]>; + keys(): IterableIterator; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, any]>; + #private; + } export {}; } From a2bff0c0b5e3144d9a656aee0c8349ac45ad79fd Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 29 Oct 2025 10:30:17 -0600 Subject: [PATCH 14/46] split stuff out, fix treeshaking --- .../src/internal/client/reactivity/cache.js | 10 +- .../svelte/src/internal/server/context.js | 134 +----------------- .../svelte/src/internal/server/hydratable.js | 69 +++++++++ .../src/internal/server/reactivity/cache.js | 17 +-- .../src/internal/server/reactivity/fetcher.js | 2 +- .../src/internal/server/render-context.js | 97 +++++++++++++ .../svelte/src/internal/server/renderer.js | 51 +++---- .../svelte/src/reactivity/index-client.js | 2 +- .../svelte/src/reactivity/index-server.js | 2 +- packages/svelte/types/index.d.ts | 19 +-- 10 files changed, 209 insertions(+), 194 deletions(-) create mode 100644 packages/svelte/src/internal/server/hydratable.js create mode 100644 packages/svelte/src/internal/server/render-context.js diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 379ae978cbdb..e61d13b5e2dc 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -68,8 +68,8 @@ function create_remover(key) { }); } -export class CacheObserver extends BaseCacheObserver { - constructor() { - super(client_cache); - } -} +// export class CacheObserver extends BaseCacheObserver { +// constructor() { +// super(client_cache); +// } +// } diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 8783d5522f01..eeb7fdaa6111 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -3,6 +3,7 @@ /** @import { Transport } from '#shared' */ 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; @@ -115,139 +116,10 @@ function get_parent_context(ssr_context) { */ export async function save(promise) { var previous_context = ssr_context; - var previous_sync_store = sync_store; - var value = await promise; + const restore_render_context = await save_render_context(promise); return () => { ssr_context = previous_context; - sync_store = previous_sync_store; - return value; + return restore_render_context(); }; } - -/** @type {string | null} */ -export let hydratable_key = null; - -/** @param {string | null} key */ -export function set_hydratable_key(key) { - hydratable_key = key; -} - -/** - * @template T - * @overload - * @param {string} key - * @param {() => Promise} fn - * @param {{ transport?: Transport }} [options] - * @returns {Promise} - */ -/** - * @template T - * @overload - * @param {() => Promise} fn - * @param {{ transport?: Transport }} [options] - * @returns {Promise} - */ -/** - * @template T - * @param {string | (() => Promise)} key_or_fn - * @param {(() => Promise) | { transport?: Transport }} [fn_or_options] - * @param {{ transport?: Transport }} [maybe_options] - * @returns {Promise} - */ -export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { - // TODO DRY out with #shared - /** @type {string} */ - let key; - /** @type {() => Promise} */ - let fn; - /** @type {{ transport?: Transport }} */ - let options; - - if (typeof key_or_fn === 'string') { - key = key_or_fn; - fn = /** @type {() => Promise} */ (fn_or_options); - options = /** @type {{ transport?: Transport }} */ (maybe_options); - } else { - if (hydratable_key === null) { - throw new Error( - 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' - ); - } else { - key = hydratable_key; - } - fn = /** @type {() => Promise} */ (key_or_fn); - options = /** @type {{ transport?: Transport }} */ (fn_or_options); - } - const store = get_render_store(); - - if (store.hydratables.has(key)) { - // TODO error - throw new Error("can't have two hydratables with the same key"); - } - - const result = fn(); - store.hydratables.set(key, { value: result, transport: options.transport }); - return Promise.resolve(result); -} - -/** @type {RenderContext | null} */ -export let sync_store = null; - -/** @param {RenderContext | null} store */ -export function set_sync_store(store) { - sync_store = store; -} - -/** @type {AsyncLocalStorage | null} */ -let als = null; - -import('node:async_hooks') - .then((hooks) => (als = new hooks.AsyncLocalStorage())) - .catch(() => { - // can't use ALS but can still use manual context preservation - return null; - }); - -/** @returns {RenderContext | null} */ -function try_get_render_store() { - return sync_store ?? als?.getStore() ?? null; -} - -/** @returns {RenderContext} */ -export function get_render_store() { - const store = try_get_render_store(); - - if (!store) { - // TODO make this a proper e.error - let message = 'Could not get rendering context.'; - - if (als) { - message += ' This is an internal error.'; - } else { - message += - ' In environments without `AsyncLocalStorage`, `hydratable` must be accessed synchronously, not after an `await`.' + - ' If it was accessed synchronously then this is an internal error.'; - } - - throw new Error(message); - } - - return store; -} - -/** - * @template T - * @param {RenderContext} store - * @param {() => Promise} fn - * @returns {Promise} - */ -export function with_render_store(store, fn) { - try { - sync_store = store; - const storage = als; - return storage ? storage.run(store, fn) : fn(); - } finally { - sync_store = null; - } -} diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js new file mode 100644 index 000000000000..780f732f58e2 --- /dev/null +++ b/packages/svelte/src/internal/server/hydratable.js @@ -0,0 +1,69 @@ +/** @import { Transport } from '#shared' */ + +import { get_render_context } from './render-context'; + +/** @type {string | null} */ +export let hydratable_key = null; + +/** @param {string | null} key */ +export function set_hydratable_key(key) { + hydratable_key = key; +} + +/** + * @template T + * @overload + * @param {string} key + * @param {() => Promise} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise} + */ +/** + * @template T + * @overload + * @param {() => Promise} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise} + */ +/** + * @template T + * @param {string | (() => Promise)} key_or_fn + * @param {(() => Promise) | { transport?: Transport }} [fn_or_options] + * @param {{ transport?: Transport }} [maybe_options] + * @returns {Promise} + */ +export async function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { + // TODO DRY out with #shared + /** @type {string} */ + let key; + /** @type {() => Promise} */ + let fn; + /** @type {{ transport?: Transport }} */ + let options; + + if (typeof key_or_fn === 'string') { + key = key_or_fn; + fn = /** @type {() => Promise} */ (fn_or_options); + options = /** @type {{ transport?: Transport }} */ (maybe_options); + } else { + if (hydratable_key === null) { + throw new Error( + 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' + ); + } else { + key = hydratable_key; + } + fn = /** @type {() => Promise} */ (key_or_fn); + options = /** @type {{ transport?: Transport }} */ (fn_or_options); + } + const store = await get_render_context(); + + if (store.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + + const result = fn(); + store.hydratables.set(key, { value: result, transport: options.transport }); + return result; +} diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index a387570f4d6c..3e057cf92f5d 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,5 +1,5 @@ -import { BaseCacheObserver } from '../../shared/cache-observer'; -import { get_render_store, set_hydratable_key } from '../context'; +import { set_hydratable_key } from '../hydratable'; +import { get_render_context } from '../render-context'; /** * @template {(...args: any[]) => any} TFn @@ -8,7 +8,7 @@ import { get_render_store, set_hydratable_key } from '../context'; * @returns {ReturnType} */ export function cache(key, fn) { - const cache = get_render_store().cache; + const cache = get_render_context().cache; const entry = cache.get(key); if (entry) { return /** @type {ReturnType} */ (entry); @@ -20,8 +20,9 @@ export function cache(key, fn) { return new_entry; } -export class CacheObserver extends BaseCacheObserver { - constructor() { - super(get_render_store().cache); - } -} +// TODO, has to be async +// export class CacheObserver extends BaseCacheObserver { +// constructor() { +// super(get_render_store().cache); +// } +// } diff --git a/packages/svelte/src/internal/server/reactivity/fetcher.js b/packages/svelte/src/internal/server/reactivity/fetcher.js index 9e4870cf2b41..62c3513b43e9 100644 --- a/packages/svelte/src/internal/server/reactivity/fetcher.js +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -1,6 +1,6 @@ /** @import { GetRequestInit, Resource } from '#shared' */ import { fetch_json } from '../../shared/utils.js'; -import { hydratable } from '../context.js'; +import { hydratable } from '../hydratable.js'; import { cache } from './cache'; import { resource } from './resource.js'; 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..e86ee813c7e5 --- /dev/null +++ b/packages/svelte/src/internal/server/render-context.js @@ -0,0 +1,97 @@ +/** @import { AsyncLocalStorage } from 'node:async_hooks' */ +/** @import { RenderContext } from '#server' */ + +import { deferred } from '../shared/utils'; + +/** @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) { + // TODO make this a proper e.error + let message = 'Could not get rendering context.'; + + if (als) { + message += ' You may have called `hydratable` or `cache` outside of the render lifecycle.'; + } else { + message += + ' In environments without `AsyncLocalStorage`, `hydratable` must be accessed synchronously, not after an `await`.' + + ' If it was accessed synchronously then this is an internal error or you may have called `hydratable` or `cache` outside of the render lifecycle.'; + } + + throw new Error(message); + } + + 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 { + const { AsyncLocalStorage } = await import('node:async_hooks'); + als = new AsyncLocalStorage(); + } catch {} +} + +function in_webcontainer() { + // 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 598bcd73a356..29d1ea58c03c 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -3,20 +3,12 @@ /** @import { MaybePromise } from '#shared' */ import { async_mode_flag } from '../flags/index.js'; import { abort } from './abort-signal.js'; -import { - get_render_store, - pop, - push, - set_ssr_context, - set_sync_store, - ssr_context, - sync_store, - with_render_store -} 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 */ @@ -375,8 +367,8 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= with_render_store({ hydratables: new Map(), cache: new Map() }, () => - 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', { @@ -469,23 +461,24 @@ export class Renderer { * @returns {Promise} */ static async #render_async(component, options) { - var previous_context = ssr_context; - var previous_sync_store = sync_store; - - 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(); + const hydratables = await renderer.#collect_hydratables(); + if (hydratables !== null) { + content.head = hydratables + content.head; + } + return Renderer.#close_render(content, renderer); + } finally { + abort(); + } + })() + ); - 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(); - set_ssr_context(previous_context); - set_sync_store(previous_sync_store); - } + return restore(); } /** @@ -526,7 +519,7 @@ export class Renderer { } async #collect_hydratables() { - const map = get_render_store().hydratables; + const map = get_render_context().hydratables; /** @type {(value: unknown) => string} */ let default_stringify; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 8b2abd9076ba..d1c16b1f78d7 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,7 +7,7 @@ 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 { cache } 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 0dcc459e64eb..99bb469ae200 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,6 +1,6 @@ /** @import { Resource as ResourceType } from '#shared' */ export { resource } from '../internal/server/reactivity/resource.js'; -export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; +export { cache } from '../internal/server/reactivity/cache.js'; export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index fd55a7649c5b..b9db2f986120 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2422,6 +2422,7 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export function resource(fn: () => Promise): Resource_1; + export function cache any>(key: string, fn: TFn): ReturnType; export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; type Resource_1 = { then: Promise['then']; @@ -2443,30 +2444,12 @@ declare module 'svelte/reactivity' { ); type GetRequestInit = Omit & { method?: 'GET' }; - export function cache any>(key: string, fn: TFn): ReturnType; - export class CacheObserver extends BaseCacheObserver { - constructor(); - } class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; } - class BaseCacheObserver implements ReadonlyMap { - - constructor(cache: Map); - get(key: string): any; - has(key: string): boolean; - - get size(): number; - forEach(callbackfn: (value: any, key: string, map: ReadonlyMap) => void, thisArg?: any): void; - entries(): IterableIterator<[string, any]>; - keys(): IterableIterator; - values(): IterableIterator; - [Symbol.iterator](): IterableIterator<[string, any]>; - #private; - } export {}; } From 2e292b1a6d0e19512ed54ef0d9671a5d41eb5e36 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 29 Oct 2025 15:52:20 -0600 Subject: [PATCH 15/46] cache observer --- .../src/internal/client/reactivity/cache.js | 19 ++-- .../src/internal/server/reactivity/cache.js | 20 ++-- .../src/internal/server/render-context.js | 3 +- .../svelte/src/internal/server/types.d.ts | 3 +- .../src/internal/shared/cache-observer.js | 100 ++++++++++++++---- .../src/internal/shared/observable-cache.js | 88 +++++++++++++++ .../svelte/src/internal/shared/types.d.ts | 2 + .../svelte/src/reactivity/index-client.js | 2 +- .../svelte/src/reactivity/index-server.js | 2 +- packages/svelte/types/index.d.ts | 60 ++++++++++- 10 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 packages/svelte/src/internal/shared/observable-cache.js diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index e61d13b5e2dc..7b38271e66fe 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,11 +1,12 @@ import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { ObservableCache } from '../../shared/observable-cache.js'; import { set_hydratable_key } from '../context.js'; import { tick } from '../runtime.js'; import { render_effect } from './effects.js'; /** @typedef {{ count: number, item: any }} Entry */ -/** @type {Map} */ -const client_cache = new Map(); +/** @type {ObservableCache} */ +const client_cache = new ObservableCache(); /** * @template {(...args: any[]) => any} TFn @@ -68,8 +69,12 @@ function create_remover(key) { }); } -// export class CacheObserver extends BaseCacheObserver { -// constructor() { -// super(client_cache); -// } -// } +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + super(() => client_cache, prefix); + } +} diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index 3e057cf92f5d..d38be324eca2 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,5 +1,6 @@ -import { set_hydratable_key } from '../hydratable'; -import { get_render_context } from '../render-context'; +import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { set_hydratable_key } from '../hydratable.js'; +import { get_render_context } from '../render-context.js'; /** * @template {(...args: any[]) => any} TFn @@ -20,9 +21,12 @@ export function cache(key, fn) { return new_entry; } -// TODO, has to be async -// export class CacheObserver extends BaseCacheObserver { -// constructor() { -// super(get_render_store().cache); -// } -// } +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + super(() => get_render_context().cache, prefix); + } +} diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js index e86ee813c7e5..83d3b3b3f39c 100644 --- a/packages/svelte/src/internal/server/render-context.js +++ b/packages/svelte/src/internal/server/render-context.js @@ -1,6 +1,7 @@ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ /** @import { RenderContext } from '#server' */ +import { ObservableCache } from '../shared/observable-cache'; import { deferred } from '../shared/utils'; /** @type {Promise | null} */ @@ -63,7 +64,7 @@ export async function with_render_context(fn) { try { sync_context = { hydratables: new Map(), - cache: new Map() + cache: new ObservableCache() }; if (in_webcontainer()) { const { promise, resolve } = deferred(); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 6563fdf6f960..cfe7e92ad29c 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,5 @@ import type { Transport } from '#shared'; +import type { ObservableCache } from '../shared/observable-cache'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -23,7 +24,7 @@ export interface RenderContext { transport: Transport | undefined; } >; - cache: Map; + cache: ObservableCache; } export interface SyncRenderOutput { diff --git a/packages/svelte/src/internal/shared/cache-observer.js b/packages/svelte/src/internal/shared/cache-observer.js index 1dd72b2b34df..b51aa2e1a0c1 100644 --- a/packages/svelte/src/internal/shared/cache-observer.js +++ b/packages/svelte/src/internal/shared/cache-observer.js @@ -1,56 +1,112 @@ -/** @implements {ReadonlyMap} */ +/** @import { ObservableCache } from './observable-cache.js' */ + +/** + * @template T + * @implements {ReadonlyMap} */ export class BaseCacheObserver { - /** @type {ReadonlyMap} */ - #cache; + /** + * 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 {() => ObservableCache} + */ + #get_cache; + + /** @type {string} */ + #prefix; + + /** + * @param {() => ObservableCache} get_cache + * @param {string} [prefix] + */ + constructor(get_cache, prefix = '') { + this.#get_cache = get_cache; + this.#prefix = prefix; + } + + /** + * Register a callback to be called when a new key is inserted + * @param {(key: string, value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onInsert(callback) { + return this.#get_cache().on_insert((key, value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, value.item); + }); + } + + /** + * Register a callback to be called when an existing key is updated + * @param {(key: string, value: T, old_value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onUpdate(callback) { + return this.#get_cache().on_update((key, value, old_value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, value.item, old_value.item); + }); + } - /** @param {Map} cache */ - constructor(cache) { - this.#cache = cache; + /** + * Register a callback to be called when a key is deleted + * @param {(key: string, old_value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onDelete(callback) { + return this.#get_cache().on_delete((key, old_value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, old_value.item); + }); } - /** @type {ReadonlyMap['get']} */ + /** @param {string} key */ get(key) { - const entry = this.#cache.get(key); + const entry = this.#get_cache().get(this.#key(key)); return entry?.item; } - /** @type {ReadonlyMap['has']} */ + /** @param {string} key */ has(key) { - return this.#cache.has(key); + return this.#get_cache().has(this.#key(key)); } - /** @type {ReadonlyMap['size']} */ get size() { - return this.#cache.size; + return [...this.keys()].length; } - /** @type {ReadonlyMap['forEach']} */ + /** @param {(item: T, key: string, map: ReadonlyMap) => void} cb */ forEach(cb) { - this.#cache.forEach((entry, key) => cb(entry.item, key, this)); + this.entries().forEach(([key, entry]) => cb(entry, key, this)); } - /** @type {ReadonlyMap['entries']} */ *entries() { - for (const [key, entry] of this.#cache.entries()) { - yield [key, entry.item]; + for (const [key, entry] of this.#get_cache().entries()) { + if (!key.startsWith(this.#prefix)) continue; + yield /** @type {[string, T]} */ ([key, entry.item]); } + return undefined; } - /** @type {ReadonlyMap['keys']} */ *keys() { - for (const key of this.#cache.keys()) { + for (const [key] of this.entries()) { yield key; } + return undefined; } - /** @type {ReadonlyMap['values']} */ *values() { - for (const entry of this.#cache.values()) { - yield entry.item; + 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/observable-cache.js b/packages/svelte/src/internal/shared/observable-cache.js new file mode 100644 index 000000000000..30a68a4c673e --- /dev/null +++ b/packages/svelte/src/internal/shared/observable-cache.js @@ -0,0 +1,88 @@ +/** @import { CacheEntry } from '#shared' */ + +/** + * @extends {Map} + */ +export class ObservableCache extends Map { + /** @type {Set<(key: string, value: CacheEntry) => void>} */ + #insert_callbacks = new Set(); + + /** @type {Set<(key: string, value: CacheEntry, old_value: CacheEntry) => void>} */ + #update_callbacks = new Set(); + + /** @type {Set<(key: string, old_value: CacheEntry) => void>} */ + #delete_callbacks = new Set(); + + /** + * @param {(key: string, value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_insert(callback) { + this.#insert_callbacks.add(callback); + return () => this.#insert_callbacks.delete(callback); + } + + /** + * @param {(key: string, value: CacheEntry, old_value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_update(callback) { + this.#update_callbacks.add(callback); + return () => this.#update_callbacks.delete(callback); + } + + /** + * @param {(key: string, old_value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_delete(callback) { + this.#delete_callbacks.add(callback); + return () => this.#delete_callbacks.delete(callback); + } + + /** + * @param {string} key + * @param {CacheEntry} value + * @returns {this} + */ + set(key, value) { + const had = this.has(key); + if (had) { + const old_value = /** @type {CacheEntry} */ (super.get(key)); + super.set(key, value); + for (const callback of this.#update_callbacks) { + callback(key, value, old_value); + } + } else { + super.set(key, value); + for (const callback of this.#insert_callbacks) { + callback(key, value); + } + } + return this; + } + + /** + * @param {string} key + * @returns {boolean} + */ + delete(key) { + const old_value = super.get(key); + const deleted = super.delete(key); + if (deleted) { + for (const callback of this.#delete_callbacks) { + callback(key, /** @type {CacheEntry} */ (old_value)); + } + } + return deleted; + } + + clear() { + for (const [key, value] of this) { + for (const callback of this.#delete_callbacks) { + callback(key, value); + } + } + super.clear(); + } +} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 781e6e1e5eb6..bbe547086908 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -41,3 +41,5 @@ export type Resource = { ); export type GetRequestInit = Omit & { method?: 'GET' }; + +export type CacheEntry = { count: number; item: any }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index d1c16b1f78d7..8b2abd9076ba 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,7 +7,7 @@ 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 } from '../internal/client/reactivity/cache.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 99bb469ae200..0dcc459e64eb 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,6 +1,6 @@ /** @import { Resource as ResourceType } from '#shared' */ export { resource } from '../internal/server/reactivity/resource.js'; -export { cache } from '../internal/server/reactivity/cache.js'; +export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index b9db2f986120..11d349a27885 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2422,7 +2422,6 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export function resource(fn: () => Promise): Resource_1; - export function cache any>(key: string, fn: TFn): ReturnType; export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; type Resource_1 = { then: Promise['then']; @@ -2444,12 +2443,71 @@ declare module 'svelte/reactivity' { ); type GetRequestInit = Omit & { method?: 'GET' }; + + type CacheEntry = { count: number; item: any }; + 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: () => ObservableCache, prefix?: string | undefined); + /** + * Register a callback to be called when a new key is inserted + * @returns Function to unregister the callback + */ + onInsert(callback: (key: string, value: T) => void): () => void; + /** + * Register a callback to be called when an existing key is updated + * @returns Function to unregister the callback + */ + onUpdate(callback: (key: string, value: T, old_value: T) => void): () => void; + /** + * Register a callback to be called when a key is deleted + * @returns Function to unregister the callback + */ + onDelete(callback: (key: string, old_value: T) => void): () => void; + + 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; + } + class ObservableCache extends Map { + constructor(); + constructor(entries?: readonly (readonly [string, CacheEntry])[] | null | undefined); + constructor(); + constructor(iterable?: Iterable | null | undefined); + /** + * @returns Function to unregister the callback + */ + on_insert(callback: (key: string, value: CacheEntry) => void): () => void; + /** + * @returns Function to unregister the callback + */ + on_update(callback: (key: string, value: CacheEntry, old_value: CacheEntry) => void): () => void; + /** + * @returns Function to unregister the callback + */ + on_delete(callback: (key: string, old_value: CacheEntry) => void): () => void; + + set(key: string, value: CacheEntry): this; + #private; + } export {}; } From 7ee0ce8da714365c106670f048d7c6220ce8c73f Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 29 Oct 2025 16:58:29 -0600 Subject: [PATCH 16/46] fix export --- packages/svelte/src/index-server.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 663ab9a16f1b..87d7bb84d453 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -44,8 +44,9 @@ export { getAllContexts, getContext, hasContext, - setContext, - hydratable + setContext } from './internal/server/context.js'; +export { hydratable } from './internal/server/hydratable.js'; + export { createRawSnippet } from './internal/server/blocks/snippet.js'; From 90b85d152f04046bfd922f3ee9f912e220ea1f70 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 30 Oct 2025 12:12:54 -0600 Subject: [PATCH 17/46] add imperative hydratable API --- packages/svelte/package.json | 4 ++ packages/svelte/scripts/generate-types.js | 1 + packages/svelte/src/client/index.js | 1 + packages/svelte/src/index-client.js | 4 +- .../svelte/src/internal/client/context.js | 71 ------------------- .../svelte/src/internal/client/hydratable.js | 60 ++++++++++++++++ .../src/internal/client/reactivity/cache.js | 3 - .../src/internal/client/reactivity/fetcher.js | 7 +- .../internal/client/reactivity/resource.js | 22 +++--- .../svelte/src/internal/server/hydratable.js | 67 +++++++---------- .../src/internal/server/reactivity/fetcher.js | 5 +- .../internal/server/reactivity/resource.js | 14 ++-- .../svelte/src/internal/server/renderer.js | 3 +- .../svelte/src/internal/server/types.d.ts | 6 +- .../svelte/src/internal/shared/types.d.ts | 8 ++- packages/svelte/src/server/index.js | 1 + packages/svelte/types/index.d.ts | 30 +++++--- 17 files changed, 145 insertions(+), 162 deletions(-) create mode 100644 packages/svelte/src/client/index.js create mode 100644 packages/svelte/src/internal/client/hydratable.js diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 1595cc72d3fb..22459f02e808 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -95,6 +95,10 @@ "types": "./types/index.d.ts", "default": "./src/server/index.js" }, + "./client": { + "types": "./types/index.d.ts", + "default": "./src/client/index.js" + }, "./store": { "types": "./types/index.d.ts", "worker": "./src/store/index-server.js", diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 0ee6004d4a2c..10d3caafa3b3 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -45,6 +45,7 @@ await createBundle({ [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`, [`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`, [`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`, + [`${pkg.name}/client`]: `${dir}/src/client/index.d.ts`, [`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`, [`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`, [`${pkg.name}/events`]: `${dir}/src/events/public.d.ts`, diff --git a/packages/svelte/src/client/index.js b/packages/svelte/src/client/index.js new file mode 100644 index 000000000000..e92140b08ee7 --- /dev/null +++ b/packages/svelte/src/client/index.js @@ -0,0 +1 @@ +export { get_hydratable_value as getHydratableValue } from '../internal/client/hydratable.js'; diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 3341f81cf5fa..9d3703950e86 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -247,9 +247,9 @@ export { getContext, getAllContexts, hasContext, - setContext, - hydratable + 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/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 6fb708c04ce5..8330eb588c1a 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -224,77 +224,6 @@ export function is_runes() { return !legacy_mode_flag || (component_context !== null && component_context.l === null); } -/** @type {string | null} */ -export let hydratable_key = null; - -/** @param {string | null} key */ -export function set_hydratable_key(key) { - hydratable_key = key; -} - -/** - * @template T - * @overload - * @param {string} key - * @param {() => Promise} fn - * @param {{ transport?: Transport }} [options] - * @returns {Promise} - */ -/** - * @template T - * @overload - * @param {() => Promise} fn - * @param {{ transport?: Transport }} [options] - * @returns {Promise} - */ -/** - * @template T - * @param {string | (() => Promise)} key_or_fn - * @param {(() => Promise) | { transport?: Transport }} [fn_or_options] - * @param {{ transport?: Transport }} [maybe_options] - * @returns {Promise} - */ -export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { - /** @type {string} */ - let key; - /** @type {() => Promise} */ - let fn; - /** @type {{ transport?: Transport }} */ - let options; - - if (typeof key_or_fn === 'string') { - key = key_or_fn; - fn = /** @type {() => Promise} */ (fn_or_options); - options = /** @type {{ transport?: Transport }} */ (maybe_options); - } else { - if (hydratable_key === null) { - throw new Error( - 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' - ); - } else { - key = hydratable_key; - } - fn = /** @type {() => Promise} */ (key_or_fn); - options = /** @type {{ transport?: Transport }} */ (fn_or_options); - } - - if (!hydrating) { - return Promise.resolve(fn()); - } - var store = window.__svelte?.h; - if (store === undefined) { - throw new Error('TODO this should be impossible?'); - } - if (!store.has(key)) { - throw new Error( - `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` - ); - } - const entry = /** @type {string} */ (store.get(key)); - const parse = options.transport?.parse ?? ((val) => new Function(`return (${val})`)()); - return Promise.resolve(/** @type {T} */ (parse(entry))); -} - /** * @param {string} name * @returns {Map} diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js new file mode 100644 index 000000000000..f8c095eb0325 --- /dev/null +++ b/packages/svelte/src/internal/client/hydratable.js @@ -0,0 +1,60 @@ +/** @import { Parse, Transport } from '#shared' */ +import { hydrating } from './dom/hydration'; + +/** + * @template T + * @param {string} key + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {T} + */ +export function hydratable(key, fn, options = {}) { + if (!hydrating) { + return fn(); + } + var store = window.__svelte?.h; + if (store === undefined) { + throw new Error('TODO this should be impossible?'); + } + const val = store.get(key); + if (val === undefined) { + throw new Error( + `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` + ); + } + return parse(val, options.transport?.parse); +} + +/** + * @template T + * @param {string} key + * @param {{ parse?: Parse }} [options] + * @returns {T | undefined} + */ +export function get_hydratable_value(key, options = {}) { + // TODO probably can DRY this out with the above + if (!hydrating) { + return undefined; + } + + var store = window.__svelte?.h; + if (store === undefined) { + throw new Error('TODO this should be impossible?'); + } + const val = store.get(key); + if (val === undefined) { + return undefined; + } + + return parse(val, options.parse); +} + +/** + * @template T + * @param {string} val + * @param {Parse | undefined} parse + * @returns {T} + */ +function parse(val, parse) { + return (parse ?? ((val) => new Function(`return (${val})`)()))(val); +} diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 7b38271e66fe..132b904af8d1 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,6 +1,5 @@ import { BaseCacheObserver } from '../../shared/cache-observer.js'; import { ObservableCache } from '../../shared/observable-cache.js'; -import { set_hydratable_key } from '../context.js'; import { tick } from '../runtime.js'; import { render_effect } from './effects.js'; @@ -38,9 +37,7 @@ export function cache(key, fn) { return entry?.item; } - set_hydratable_key(key); const item = fn(); - set_hydratable_key(null); const new_entry = { item, count: tracking ? 1 : 0 diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js index dc3671be188a..49cecaf6d623 100644 --- a/packages/svelte/src/internal/client/reactivity/fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -1,7 +1,7 @@ /** @import { GetRequestInit, Resource } from '#shared' */ import { cache } from './cache'; import { fetch_json } from '../../shared/utils.js'; -import { hydratable } from '../context'; +import { hydratable } from '../hydratable'; import { resource } from './resource'; /** @@ -11,7 +11,6 @@ import { resource } from './resource'; * @returns {Resource} */ export function fetcher(url, init) { - return cache(`svelte/fetcher::::${typeof url === 'string' ? url : url.toString()}`, () => - resource(() => hydratable(() => fetch_json(url, init))) - ); + 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 index e231a8a862dc..4e1f5d0ef51b 100644 --- a/packages/svelte/src/internal/client/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -5,21 +5,21 @@ import { deferred } from '../../shared/utils.js'; /** * @template T - * @param {() => Promise} fn - * @returns {ResourceType} + * @param {() => T} fn + * @returns {ResourceType>} */ export function resource(fn) { - return /** @type {ResourceType} */ (new Resource(fn)); + return /** @type {ResourceType>} */ (new Resource(fn)); } /** * @template T - * @implements {Partial>} + * @implements {Partial>>} */ class Resource { #init = false; - /** @type {() => Promise} */ + /** @type {() => T} */ #fn; /** @type {Source} */ @@ -31,13 +31,13 @@ class Resource { /** @type {Source} */ #ready = state(false); - /** @type {Source} */ + /** @type {Source | undefined>} */ #raw = state(undefined); /** @type {Source>} */ #promise; - /** @type {Derived} */ + /** @type {Derived | undefined>} */ #current = derived(() => { if (!get(this.#ready)) return undefined; return get(this.#raw); @@ -46,7 +46,7 @@ class Resource { /** {@type Source} */ #error = state(undefined); - /** @type {Derived['then']>} */ + /** @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(() => { @@ -57,7 +57,7 @@ class Resource { await p; await tick(); - resolve?.(/** @type {T} */ (get(this.#current))); + resolve?.(/** @type {Awaited} */ (get(this.#current))); } catch (error) { reject?.(error); } @@ -65,7 +65,7 @@ class Resource { }); /** - * @param {() => Promise} fn + * @param {() => T} fn */ constructor(fn) { this.#fn = fn; @@ -166,7 +166,7 @@ class Resource { }; /** - * @param {T} value + * @param {Awaited} value */ set = (value) => { set(this.#ready, true); diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 780f732f58e2..f3855f4a7b21 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,4 +1,4 @@ -/** @import { Transport } from '#shared' */ +/** @import { Stringify, Transport } from '#shared' */ import { get_render_context } from './render-context'; @@ -12,58 +12,39 @@ export function set_hydratable_key(key) { /** * @template T - * @overload * @param {string} key - * @param {() => Promise} fn + * @param {() => T} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise} - */ -/** - * @template T - * @overload - * @param {() => Promise} fn - * @param {{ transport?: Transport }} [options] - * @returns {Promise} + * @returns {T} */ +export function hydratable(key, fn, options = {}) { + const store = get_render_context(); + + if (store.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + + const result = fn(); + store.hydratables.set(key, { value: result, stringify: options.transport?.stringify }); + return result; +} /** * @template T - * @param {string | (() => Promise)} key_or_fn - * @param {(() => Promise) | { transport?: Transport }} [fn_or_options] - * @param {{ transport?: Transport }} [maybe_options] - * @returns {Promise} + * @param {string} key + * @param {T} value + * @param {{ stringify?: Stringify }} [options] */ -export async function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { - // TODO DRY out with #shared - /** @type {string} */ - let key; - /** @type {() => Promise} */ - let fn; - /** @type {{ transport?: Transport }} */ - let options; - - if (typeof key_or_fn === 'string') { - key = key_or_fn; - fn = /** @type {() => Promise} */ (fn_or_options); - options = /** @type {{ transport?: Transport }} */ (maybe_options); - } else { - if (hydratable_key === null) { - throw new Error( - 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' - ); - } else { - key = hydratable_key; - } - fn = /** @type {() => Promise} */ (key_or_fn); - options = /** @type {{ transport?: Transport }} */ (fn_or_options); - } - const store = await get_render_context(); +export function set_hydratable_value(key, value, options = {}) { + const store = get_render_context(); if (store.hydratables.has(key)) { // TODO error throw new Error("can't have two hydratables with the same key"); } - const result = fn(); - store.hydratables.set(key, { value: result, transport: options.transport }); - return result; + store.hydratables.set(key, { + value, + stringify: options.stringify + }); } diff --git a/packages/svelte/src/internal/server/reactivity/fetcher.js b/packages/svelte/src/internal/server/reactivity/fetcher.js index 62c3513b43e9..5db1db00e7b7 100644 --- a/packages/svelte/src/internal/server/reactivity/fetcher.js +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -11,7 +11,6 @@ import { resource } from './resource.js'; * @returns {Resource} */ export function fetcher(url, init) { - return cache(`svelte/fetcher::::${typeof url === 'string' ? url : url.toString()}`, () => - resource(() => hydratable(() => fetch_json(url, init))) - ); + 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 index 7484fd9f53ea..9203c983c073 100644 --- a/packages/svelte/src/internal/server/reactivity/resource.js +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -2,16 +2,16 @@ /** * @template T - * @param {() => Promise} fn - * @returns {ResourceType} + * @param {() => T} fn + * @returns {ResourceType>} */ export function resource(fn) { - return /** @type {ResourceType} */ (new Resource(fn)); + return /** @type {ResourceType>} */ (new Resource(fn)); } /** * @template T - * @implements {Partial>} + * @implements {Partial>>} */ class Resource { /** @type {Promise} */ @@ -19,12 +19,12 @@ class Resource { #ready = false; #loading = true; - /** @type {T | undefined} */ + /** @type {Awaited | undefined} */ #current = undefined; #error = undefined; /** - * @param {() => Promise} fn + * @param {() => T} fn */ constructor(fn) { this.#promise = Promise.resolve(fn()).then( @@ -85,7 +85,7 @@ class Resource { }; /** - * @param {T} value + * @param {Awaited} value */ set = (value) => { this.#ready = true; diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 29d1ea58c03c..675930550b3c 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -526,8 +526,7 @@ export class Renderer { /** @type {[string, string][]} */ let entries = []; for (const [k, v] of map) { - const serialize = - v.transport?.stringify ?? (default_stringify ??= new MemoizedUneval().uneval); + const serialize = v.stringify ?? (default_stringify ??= new MemoizedUneval().uneval); // sequential await is okay here -- all the work is already kicked off entries.push([k, serialize(await v.value)]); } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index cfe7e92ad29c..c45c8a74a8fc 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,4 @@ -import type { Transport } from '#shared'; +import type { Stringify, Transport } from '#shared'; import type { ObservableCache } from '../shared/observable-cache'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -20,8 +20,8 @@ export interface RenderContext { hydratables: Map< string, { - value: Promise; - transport: Transport | undefined; + value: unknown; + stringify: Stringify | undefined; } >; cache: ObservableCache; diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index bbe547086908..9c7fc6fd163c 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,14 +11,18 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; +export type Parse = (value: string) => T; + +export type Stringify = (value: T) => string; + export type Transport = | { - stringify: (value: T) => string; + stringify: Stringify; parse?: undefined; } | { stringify?: undefined; - parse: (value: string) => T; + parse: Parse; }; export type Resource = { diff --git a/packages/svelte/src/server/index.js b/packages/svelte/src/server/index.js index c02e9d05fb38..04d80400887f 100644 --- a/packages/svelte/src/server/index.js +++ b/packages/svelte/src/server/index.js @@ -1 +1,2 @@ export { render } from '../internal/server/index.js'; +export { set_hydratable_value as setHydratableValue } from '../internal/server/hydratable.js'; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 11d349a27885..d376e484084d 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -439,6 +439,9 @@ declare module 'svelte' { * Returns void if no callback is provided, otherwise returns the result of calling the callback. * */ export function flushSync(fn?: (() => T) | undefined): T; + export function hydratable(key: string, fn: () => T, options?: { + transport?: Transport; + } | undefined): T; /** * Create a snippet programmatically * */ @@ -488,14 +491,6 @@ declare module 'svelte' { * * */ export function getAllContexts = Map>(): T; - - export function hydratable(key: string, fn: () => Promise, options?: { - transport?: Transport; - } | undefined): Promise; - - export function hydratable(fn: () => Promise, options?: { - transport?: Transport; - } | undefined): Promise; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -569,14 +564,18 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; + type Parse = (value: string) => T; + + type Stringify = (value: T) => string; + type Transport = | { - stringify: (value: T) => string; + stringify: Stringify; parse?: undefined; } | { stringify?: undefined; - parse: (value: string) => T; + parse: Parse; }; export {}; @@ -2421,7 +2420,7 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; - export function resource(fn: () => Promise): Resource_1; + export function resource(fn: () => T): Resource_1>; export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; type Resource_1 = { then: Promise['then']; @@ -2619,6 +2618,15 @@ declare module 'svelte/server' { export {}; } +declare module 'svelte/client' { + export function getHydratableValue(key: string, options?: { + parse?: Parse; + } | undefined): T | undefined; + type Parse = (value: string) => T; + + export {}; +} + declare module 'svelte/store' { /** Callback to inform of a value updates. */ export type Subscriber = (value: T) => void; From 598dc302973dd379a18cbcb4e9fb02c814f41f2b Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 30 Oct 2025 12:19:38 -0600 Subject: [PATCH 18/46] fix types --- packages/svelte/src/server/index.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/svelte/src/server/index.d.ts b/packages/svelte/src/server/index.d.ts index d5a3b813e6cb..7eed5ea951bc 100644 --- a/packages/svelte/src/server/index.d.ts +++ b/packages/svelte/src/server/index.d.ts @@ -27,3 +27,5 @@ export function render< } ] ): RenderOutput; + +export type { set_hydratable_value as setHydratableValue } from '../internal/server/hydratable.js'; From 2c08d4f0bc3ca3f06072b812390bed89b75a6ac2 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 30 Oct 2025 15:23:58 -0600 Subject: [PATCH 19/46] fix types --- .../svelte/src/internal/client/reactivity/resource.js | 4 ++-- .../svelte/src/internal/server/reactivity/resource.js | 4 ++-- packages/svelte/src/internal/shared/types.d.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js index 4e1f5d0ef51b..c0267cb1e4bc 100644 --- a/packages/svelte/src/internal/client/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -6,10 +6,10 @@ import { deferred } from '../../shared/utils.js'; /** * @template T * @param {() => T} fn - * @returns {ResourceType>} + * @returns {ResourceType} */ export function resource(fn) { - return /** @type {ResourceType>} */ (new Resource(fn)); + return /** @type {ResourceType} */ (new Resource(fn)); } /** diff --git a/packages/svelte/src/internal/server/reactivity/resource.js b/packages/svelte/src/internal/server/reactivity/resource.js index 9203c983c073..57c651152fb2 100644 --- a/packages/svelte/src/internal/server/reactivity/resource.js +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -3,10 +3,10 @@ /** * @template T * @param {() => T} fn - * @returns {ResourceType>} + * @returns {ResourceType} */ export function resource(fn) { - return /** @type {ResourceType>} */ (new Resource(fn)); + return /** @type {ResourceType} */ (new Resource(fn)); } /** diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 9c7fc6fd163c..f5e3665db6e8 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -26,11 +26,11 @@ export type Transport = }; export type Resource = { - then: Promise['then']; - catch: Promise['catch']; - finally: Promise['finally']; + then: Promise>['then']; + catch: Promise>['catch']; + finally: Promise>['finally']; refresh: () => Promise; - set: (value: T) => void; + set: (value: Awaited) => void; loading: boolean; error: any; } & ( @@ -40,7 +40,7 @@ export type Resource = { } | { ready: true; - current: T; + current: Awaited; } ); From 5adb3f1df690d3118881f92f71069dffd217c37d Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 30 Oct 2025 19:49:31 -0600 Subject: [PATCH 20/46] test --- packages/svelte/src/internal/server/renderer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 675930550b3c..a9540d86cef4 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -526,7 +526,7 @@ export class Renderer { /** @type {[string, string][]} */ let entries = []; for (const [k, v] of map) { - const serialize = v.stringify ?? (default_stringify ??= new MemoizedUneval().uneval); + const serialize = v.stringify ?? (default_stringify ??= uneval); // sequential await is okay here -- all the work is already kicked off entries.push([k, serialize(await v.value)]); } From 8179c6a6438f9943cf1b3fe268cfbe18810546a0 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 31 Oct 2025 13:56:31 -0600 Subject: [PATCH 21/46] tests --- packages/svelte/src/client/index.js | 5 ++- .../svelte/src/internal/client/hydratable.js | 15 +++++++ .../svelte/src/internal/server/renderer.js | 2 +- .../src/internal/server/renderer.test.ts | 39 ++++++++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/client/index.js b/packages/svelte/src/client/index.js index e92140b08ee7..1cd028d123d6 100644 --- a/packages/svelte/src/client/index.js +++ b/packages/svelte/src/client/index.js @@ -1 +1,4 @@ -export { get_hydratable_value as getHydratableValue } from '../internal/client/hydratable.js'; +export { + get_hydratable_value as getHydratableValue, + has_hydratable_value as hasHydratableValue +} from '../internal/client/hydratable.js'; diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index f8c095eb0325..0c61d4f504da 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -49,6 +49,21 @@ export function get_hydratable_value(key, options = {}) { return parse(val, options.parse); } +/** + * @param {string} key + * @returns {boolean} + */ +export function has_hydratable_value(key) { + if (!hydrating) { + return false; + } + var store = window.__svelte?.h; + if (store === undefined) { + throw new Error('TODO this should be impossible?'); + } + return store.has(key); +} + /** * @template T * @param {string} val diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index a9540d86cef4..7d8dd4eb785e 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -665,7 +665,7 @@ export class SSRState { } } -class MemoizedUneval { +export class MemoizedUneval { /** @type {Map} */ #cache = new Map(); 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); + }); +}); From 6873685b2f705a053837f6fcc0b3f6f6869b7b52 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 31 Oct 2025 16:53:51 -0600 Subject: [PATCH 22/46] temp fix --- .../svelte/src/internal/client/hydratable.js | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 0c61d4f504da..84523e581bed 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -13,14 +13,11 @@ export function hydratable(key, fn, options = {}) { return fn(); } var store = window.__svelte?.h; - if (store === undefined) { - throw new Error('TODO this should be impossible?'); - } - const val = store.get(key); + const val = store?.get(key); if (val === undefined) { - throw new Error( - `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` - ); + // TODO this should really be an error or at least a warning because it would be disastrous to expect + // something to be synchronously hydratable and then have it not be + return fn(); } return parse(val, options.transport?.parse); } @@ -38,10 +35,7 @@ export function get_hydratable_value(key, options = {}) { } var store = window.__svelte?.h; - if (store === undefined) { - throw new Error('TODO this should be impossible?'); - } - const val = store.get(key); + const val = store?.get(key); if (val === undefined) { return undefined; } @@ -58,10 +52,7 @@ export function has_hydratable_value(key) { return false; } var store = window.__svelte?.h; - if (store === undefined) { - throw new Error('TODO this should be impossible?'); - } - return store.has(key); + return store?.has(key) ?? false; } /** From 8939bbfbc288bdc31dfb884a946855979b56700b Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 3 Nov 2025 18:19:54 -0700 Subject: [PATCH 23/46] fix never-expiring cache entries --- .../src/internal/client/reactivity/cache.js | 8 +++----- .../src/internal/client/reactivity/effects.js | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 132b904af8d1..471d780a7040 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,7 +1,7 @@ import { BaseCacheObserver } from '../../shared/cache-observer.js'; import { ObservableCache } from '../../shared/observable-cache.js'; import { tick } from '../runtime.js'; -import { render_effect } from './effects.js'; +import { get_effect_validation_error_code, render_effect } from './effects.js'; /** @typedef {{ count: number, item: any }} Entry */ /** @type {ObservableCache} */ @@ -18,8 +18,8 @@ export function cache(key, fn) { const entry = client_cache.get(key); const maybe_remove = create_remover(key); - let tracking = true; - try { + const tracking = get_effect_validation_error_code() === null; + if (tracking) { render_effect(() => { if (entry) entry.count++; return () => { @@ -29,8 +29,6 @@ export function cache(key, fn) { maybe_remove(entry); }; }); - } catch { - tracking = false; } if (cached) { diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 2c9e4db911aa..922d53a00e79 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -48,17 +48,28 @@ 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 && active_reaction === null) { - e.effect_orphan(rune); + return 'effect_orphan'; } if (active_reaction !== null && (active_reaction.f & UNOWNED) !== 0 && active_effect === null) { - 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; } /** From 0f6001d15238d997ce5f73dabf4fae31eab503d1 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 11:23:37 -0700 Subject: [PATCH 24/46] misc --- .../internal/client/reactivity/resource.js | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js index c0267cb1e4bc..8aad0b23d824 100644 --- a/packages/svelte/src/internal/client/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -53,14 +53,19 @@ class Resource { const p = get(this.#promise); return async (resolve, reject) => { - try { - await p; - await tick(); + const result = /** @type {Promise>} */ ( + (async () => { + await p; + await tick(); + return get(this.#current); + })() + ); - resolve?.(/** @type {Awaited} */ (get(this.#current))); - } catch (error) { - reject?.(error); + if (resolve || reject) { + return result.then(resolve, reject); } + + return result; }; }); @@ -127,10 +132,7 @@ class Resource { get finally() { get(this.#then); return (/** @type {any} */ fn) => { - return get(this.#then)( - () => fn(), - () => fn() - ); + return get(this.#then)().finally(fn); }; } From 200b011a4b54d6894381266c7ef60606affdfc20 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 11:29:04 -0700 Subject: [PATCH 25/46] types --- packages/svelte/package.json | 1 - packages/svelte/types/index.d.ts | 23 ++++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5efdcd89e87e..0b94e2b7cc87 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -172,7 +172,6 @@ "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 16f322244f2f..a4d73743ce72 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -450,11 +450,6 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; - /** - * Synchronously flush any pending updates. - * Returns void if no callback is provided, otherwise returns the result of calling the callback. - * */ - export function flushSync(fn?: (() => T) | undefined): T; export function hydratable(key: string, fn: () => T, options?: { transport?: Transport; } | undefined): T; @@ -2459,14 +2454,14 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; - export function resource(fn: () => T): Resource_1>; + export function resource(fn: () => T): Resource_1; export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; type Resource_1 = { - then: Promise['then']; - catch: Promise['catch']; - finally: Promise['finally']; + then: Promise>['then']; + catch: Promise>['catch']; + finally: Promise>['finally']; refresh: () => Promise; - set: (value: T) => void; + set: (value: Awaited) => void; loading: boolean; error: any; } & ( @@ -2476,7 +2471,7 @@ declare module 'svelte/reactivity' { } | { ready: true; - current: T; + current: Awaited; } ); @@ -2653,6 +2648,10 @@ declare module 'svelte/server' { } type RenderOutput = SyncRenderOutput & PromiseLike; + export function setHydratableValue(key: string, value: T, options?: { + stringify?: Stringify; + } | undefined): void; + type Stringify = (value: T) => string; export {}; } @@ -2661,6 +2660,8 @@ declare module 'svelte/client' { export function getHydratableValue(key: string, options?: { parse?: Parse; } | undefined): T | undefined; + + export function hasHydratableValue(key: string): boolean; type Parse = (value: string) => T; export {}; From 816ddcafa283ac95cd679ee437768ede1b3a9c1d Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 11:31:34 -0700 Subject: [PATCH 26/46] import --- packages/svelte/src/internal/client/hydratable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 84523e581bed..9628bdb1dd92 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -1,5 +1,5 @@ /** @import { Parse, Transport } from '#shared' */ -import { hydrating } from './dom/hydration'; +import { hydrating } from './dom/hydration.js'; /** * @template T From 7d44a1d1a3a8bf3bfc8ca160237da1fda1539434 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 11:47:09 -0700 Subject: [PATCH 27/46] remove cruft, fix some type errors --- .../src/internal/client/reactivity/cache.js | 6 +- .../src/internal/server/render-context.js | 4 +- .../svelte/src/internal/server/types.d.ts | 5 +- .../src/internal/shared/cache-observer.js | 46 ++-------- .../src/internal/shared/observable-cache.js | 88 ------------------- packages/svelte/types/index.d.ts | 38 +------- 6 files changed, 14 insertions(+), 173 deletions(-) delete mode 100644 packages/svelte/src/internal/shared/observable-cache.js diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 471d780a7040..ef4844fb6c78 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,11 +1,11 @@ +/** @import { CacheEntry } from '#shared' */ import { BaseCacheObserver } from '../../shared/cache-observer.js'; -import { ObservableCache } from '../../shared/observable-cache.js'; import { tick } from '../runtime.js'; import { get_effect_validation_error_code, render_effect } from './effects.js'; /** @typedef {{ count: number, item: any }} Entry */ -/** @type {ObservableCache} */ -const client_cache = new ObservableCache(); +/** @type {Map} */ +const client_cache = new Map(); /** * @template {(...args: any[]) => any} TFn diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js index 83d3b3b3f39c..a79852b373e8 100644 --- a/packages/svelte/src/internal/server/render-context.js +++ b/packages/svelte/src/internal/server/render-context.js @@ -1,7 +1,6 @@ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ /** @import { RenderContext } from '#server' */ -import { ObservableCache } from '../shared/observable-cache'; import { deferred } from '../shared/utils'; /** @type {Promise | null} */ @@ -64,7 +63,7 @@ export async function with_render_context(fn) { try { sync_context = { hydratables: new Map(), - cache: new ObservableCache() + cache: new Map() }; if (in_webcontainer()) { const { promise, resolve } = deferred(); @@ -93,6 +92,7 @@ export async function init_render_context() { } 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/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index c45c8a74a8fc..8eea679069a7 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,5 +1,4 @@ -import type { Stringify, Transport } from '#shared'; -import type { ObservableCache } from '../shared/observable-cache'; +import type { CacheEntry, Stringify } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -24,7 +23,7 @@ export interface RenderContext { stringify: Stringify | undefined; } >; - cache: ObservableCache; + cache: Map; } export interface SyncRenderOutput { diff --git a/packages/svelte/src/internal/shared/cache-observer.js b/packages/svelte/src/internal/shared/cache-observer.js index b51aa2e1a0c1..18f50226e6d7 100644 --- a/packages/svelte/src/internal/shared/cache-observer.js +++ b/packages/svelte/src/internal/shared/cache-observer.js @@ -1,4 +1,4 @@ -/** @import { ObservableCache } from './observable-cache.js' */ +/** @import { CacheEntry } from '#shared' */ /** * @template T @@ -7,7 +7,7 @@ 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 {() => ObservableCache} + * @type {() => Map} */ #get_cache; @@ -15,7 +15,7 @@ export class BaseCacheObserver { #prefix; /** - * @param {() => ObservableCache} get_cache + * @param {() => Map} get_cache * @param {string} [prefix] */ constructor(get_cache, prefix = '') { @@ -23,42 +23,6 @@ export class BaseCacheObserver { this.#prefix = prefix; } - /** - * Register a callback to be called when a new key is inserted - * @param {(key: string, value: T) => void} callback - * @returns {() => void} Function to unregister the callback - */ - onInsert(callback) { - return this.#get_cache().on_insert((key, value) => { - if (!key.startsWith(this.#prefix)) return; - callback(key, value.item); - }); - } - - /** - * Register a callback to be called when an existing key is updated - * @param {(key: string, value: T, old_value: T) => void} callback - * @returns {() => void} Function to unregister the callback - */ - onUpdate(callback) { - return this.#get_cache().on_update((key, value, old_value) => { - if (!key.startsWith(this.#prefix)) return; - callback(key, value.item, old_value.item); - }); - } - - /** - * Register a callback to be called when a key is deleted - * @param {(key: string, old_value: T) => void} callback - * @returns {() => void} Function to unregister the callback - */ - onDelete(callback) { - return this.#get_cache().on_delete((key, old_value) => { - if (!key.startsWith(this.#prefix)) return; - callback(key, old_value.item); - }); - } - /** @param {string} key */ get(key) { const entry = this.#get_cache().get(this.#key(key)); @@ -76,7 +40,9 @@ export class BaseCacheObserver { /** @param {(item: T, key: string, map: ReadonlyMap) => void} cb */ forEach(cb) { - this.entries().forEach(([key, entry]) => cb(entry, key, this)); + for (const [key, entry] of this.entries()) { + cb(entry, key, this); + } } *entries() { diff --git a/packages/svelte/src/internal/shared/observable-cache.js b/packages/svelte/src/internal/shared/observable-cache.js deleted file mode 100644 index 30a68a4c673e..000000000000 --- a/packages/svelte/src/internal/shared/observable-cache.js +++ /dev/null @@ -1,88 +0,0 @@ -/** @import { CacheEntry } from '#shared' */ - -/** - * @extends {Map} - */ -export class ObservableCache extends Map { - /** @type {Set<(key: string, value: CacheEntry) => void>} */ - #insert_callbacks = new Set(); - - /** @type {Set<(key: string, value: CacheEntry, old_value: CacheEntry) => void>} */ - #update_callbacks = new Set(); - - /** @type {Set<(key: string, old_value: CacheEntry) => void>} */ - #delete_callbacks = new Set(); - - /** - * @param {(key: string, value: CacheEntry) => void} callback - * @returns {() => void} Function to unregister the callback - */ - on_insert(callback) { - this.#insert_callbacks.add(callback); - return () => this.#insert_callbacks.delete(callback); - } - - /** - * @param {(key: string, value: CacheEntry, old_value: CacheEntry) => void} callback - * @returns {() => void} Function to unregister the callback - */ - on_update(callback) { - this.#update_callbacks.add(callback); - return () => this.#update_callbacks.delete(callback); - } - - /** - * @param {(key: string, old_value: CacheEntry) => void} callback - * @returns {() => void} Function to unregister the callback - */ - on_delete(callback) { - this.#delete_callbacks.add(callback); - return () => this.#delete_callbacks.delete(callback); - } - - /** - * @param {string} key - * @param {CacheEntry} value - * @returns {this} - */ - set(key, value) { - const had = this.has(key); - if (had) { - const old_value = /** @type {CacheEntry} */ (super.get(key)); - super.set(key, value); - for (const callback of this.#update_callbacks) { - callback(key, value, old_value); - } - } else { - super.set(key, value); - for (const callback of this.#insert_callbacks) { - callback(key, value); - } - } - return this; - } - - /** - * @param {string} key - * @returns {boolean} - */ - delete(key) { - const old_value = super.get(key); - const deleted = super.delete(key); - if (deleted) { - for (const callback of this.#delete_callbacks) { - callback(key, /** @type {CacheEntry} */ (old_value)); - } - } - return deleted; - } - - clear() { - for (const [key, value] of this) { - for (const callback of this.#delete_callbacks) { - callback(key, value); - } - } - super.clear(); - } -} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index a4d73743ce72..5e000126bbcc 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2491,22 +2491,7 @@ declare module 'svelte/reactivity' { } class BaseCacheObserver implements ReadonlyMap { - constructor(get_cache: () => ObservableCache, prefix?: string | undefined); - /** - * Register a callback to be called when a new key is inserted - * @returns Function to unregister the callback - */ - onInsert(callback: (key: string, value: T) => void): () => void; - /** - * Register a callback to be called when an existing key is updated - * @returns Function to unregister the callback - */ - onUpdate(callback: (key: string, value: T, old_value: T) => void): () => void; - /** - * Register a callback to be called when a key is deleted - * @returns Function to unregister the callback - */ - onDelete(callback: (key: string, old_value: T) => void): () => void; + constructor(get_cache: () => Map, prefix?: string | undefined); get(key: string): any; @@ -2520,27 +2505,6 @@ declare module 'svelte/reactivity' { [Symbol.iterator](): Generator<[string, T], undefined, unknown>; #private; } - class ObservableCache extends Map { - constructor(); - constructor(entries?: readonly (readonly [string, CacheEntry])[] | null | undefined); - constructor(); - constructor(iterable?: Iterable | null | undefined); - /** - * @returns Function to unregister the callback - */ - on_insert(callback: (key: string, value: CacheEntry) => void): () => void; - /** - * @returns Function to unregister the callback - */ - on_update(callback: (key: string, value: CacheEntry, old_value: CacheEntry) => void): () => void; - /** - * @returns Function to unregister the callback - */ - on_delete(callback: (key: string, old_value: CacheEntry) => void): () => void; - - set(key: string, value: CacheEntry): this; - #private; - } export {}; } From cc094c556c6049c085949333adca604ccf003130 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 14:14:22 -0700 Subject: [PATCH 28/46] .js .js .js wah wah wah --- packages/svelte/src/internal/server/hydratable.js | 2 +- packages/svelte/src/internal/server/render-context.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index f3855f4a7b21..d538cfdb32e6 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,6 +1,6 @@ /** @import { Stringify, Transport } from '#shared' */ -import { get_render_context } from './render-context'; +import { get_render_context } from './render-context.js'; /** @type {string | null} */ export let hydratable_key = null; diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js index a79852b373e8..7c7856dba023 100644 --- a/packages/svelte/src/internal/server/render-context.js +++ b/packages/svelte/src/internal/server/render-context.js @@ -1,7 +1,7 @@ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ /** @import { RenderContext } from '#server' */ -import { deferred } from '../shared/utils'; +import { deferred } from '../shared/utils.js'; /** @type {Promise | null} */ let current_render = null; From c6da91f23b03fe08b28aa1f86357770dffee163a Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 14:15:41 -0700 Subject: [PATCH 29/46] if you ignore your problems they go away --- packages/svelte/src/internal/server/render-context.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js index 7c7856dba023..66fe3bb5b822 100644 --- a/packages/svelte/src/internal/server/render-context.js +++ b/packages/svelte/src/internal/server/render-context.js @@ -1,3 +1,4 @@ +// @ts-ignore -- we don't include node types in the production build /** @import { AsyncLocalStorage } from 'node:async_hooks' */ /** @import { RenderContext } from '#server' */ @@ -86,6 +87,7 @@ 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 {} From b36ba6d3cf72368f9e070ec8a04484cbc20eb68d Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 14:28:54 -0700 Subject: [PATCH 30/46] unused --- packages/svelte/src/internal/server/context.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index eeb7fdaa6111..c321981d1ab2 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,6 +1,4 @@ -/** @import { RenderContext, SSRContext } from '#server' */ -/** @import { AsyncLocalStorage } from 'node:async_hooks' */ -/** @import { Transport } from '#shared' */ +/** @import { SSRContext } from '#server' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; import { save_render_context } from './render-context.js'; From d6f240a94965f474a6470f9aa7619f8c635a9787 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 15:21:00 -0700 Subject: [PATCH 31/46] better serialization --- .../svelte/src/internal/client/hydratable.js | 12 +++++----- .../svelte/src/internal/server/hydratable.js | 8 +++---- .../svelte/src/internal/server/renderer.js | 23 ++++++++----------- .../svelte/src/internal/server/types.d.ts | 4 ++-- .../svelte/src/internal/shared/types.d.ts | 12 +++++----- packages/svelte/types/index.d.ts | 20 ++++++++-------- 6 files changed, 38 insertions(+), 41 deletions(-) diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 9628bdb1dd92..780bbed37cf0 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -1,4 +1,4 @@ -/** @import { Parse, Transport } from '#shared' */ +/** @import { Decode, Transport } from '#shared' */ import { hydrating } from './dom/hydration.js'; /** @@ -19,13 +19,13 @@ export function hydratable(key, fn, options = {}) { // something to be synchronously hydratable and then have it not be return fn(); } - return parse(val, options.transport?.parse); + return parse(val, options.transport?.decode); } /** * @template T * @param {string} key - * @param {{ parse?: Parse }} [options] + * @param {{ parse?: Decode }} [options] * @returns {T | undefined} */ export function get_hydratable_value(key, options = {}) { @@ -57,10 +57,10 @@ export function has_hydratable_value(key) { /** * @template T - * @param {string} val - * @param {Parse | undefined} parse + * @param {unknown} val + * @param {Decode | undefined} parse * @returns {T} */ function parse(val, parse) { - return (parse ?? ((val) => new Function(`return (${val})`)()))(val); + return (parse ?? ((val) => /** @type {T} */ (val)))(val); } diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index d538cfdb32e6..2d6600a7aea9 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,4 +1,4 @@ -/** @import { Stringify, Transport } from '#shared' */ +/** @import { Encode, Transport } from '#shared' */ import { get_render_context } from './render-context.js'; @@ -26,14 +26,14 @@ export function hydratable(key, fn, options = {}) { } const result = fn(); - store.hydratables.set(key, { value: result, stringify: options.transport?.stringify }); + store.hydratables.set(key, { value: result, encode: options.transport?.encode }); return result; } /** * @template T * @param {string} key * @param {T} value - * @param {{ stringify?: Stringify }} [options] + * @param {{ encode?: Encode }} [options] */ export function set_hydratable_value(key, value, options = {}) { const store = get_render_context(); @@ -45,6 +45,6 @@ export function set_hydratable_value(key, value, options = {}) { store.hydratables.set(key, { value, - stringify: options.stringify + encode: options.encode }); } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index d4360635c09b..5cccbc602b9b 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -578,15 +578,15 @@ export class Renderer { /** @type {(value: unknown) => string} */ let default_stringify; - /** @type {[string, string][]} */ + /** @type {[string, unknown][]} */ let entries = []; for (const [k, v] of map) { - const serialize = v.stringify ?? (default_stringify ??= uneval); + const encode = v.encode ?? (default_stringify ??= new MemoizedUneval().uneval); // sequential await is okay here -- all the work is already kicked off - entries.push([k, serialize(await v.value)]); + entries.push([k, encode(await v.value)]); } if (entries.length === 0) return null; - return Renderer.#hydratable_block(JSON.stringify(entries)); + return Renderer.#hydratable_block(entries); } /** @@ -643,23 +643,20 @@ export class Renderer { }; } - /** @param {string} serialized */ + /** @param {[string, unknown][]} serialized */ static #hydratable_block(serialized) { + let entries = ''; + for (const [k, v] of serialized) { + entries += `["${k}",${v}],`; + } // TODO csp? // TODO how can we communicate this error better? Is there a way to not just send it to the console? // (it is probably very rare so... not too worried) return ` `; } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 8eea679069a7..2cdf2df2775a 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,4 @@ -import type { CacheEntry, Stringify } from '#shared'; +import type { CacheEntry, Encode } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -20,7 +20,7 @@ export interface RenderContext { string, { value: unknown; - stringify: Stringify | undefined; + encode: Encode | undefined; } >; cache: Map; diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index f5e3665db6e8..89cb28f6ec44 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,18 +11,18 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; -export type Parse = (value: string) => T; +export type Decode = (value: unknown) => T; -export type Stringify = (value: T) => string; +export type Encode = (value: T) => unknown; export type Transport = | { - stringify: Stringify; - parse?: undefined; + encode: Encode; + decode?: undefined; } | { - stringify?: undefined; - parse: Parse; + encode?: undefined; + decode: Decode; }; export type Resource = { diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5e000126bbcc..72ec09567ecd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -598,18 +598,18 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; - type Parse = (value: string) => T; + type Decode = (value: unknown) => T; - type Stringify = (value: T) => string; + type Encode = (value: T) => unknown; type Transport = | { - stringify: Stringify; - parse?: undefined; + encode: Encode; + decode?: undefined; } | { - stringify?: undefined; - parse: Parse; + encode?: undefined; + decode: Decode; }; export {}; @@ -2613,20 +2613,20 @@ declare module 'svelte/server' { type RenderOutput = SyncRenderOutput & PromiseLike; export function setHydratableValue(key: string, value: T, options?: { - stringify?: Stringify; + encode?: Encode; } | undefined): void; - type Stringify = (value: T) => string; + type Encode = (value: T) => unknown; export {}; } declare module 'svelte/client' { export function getHydratableValue(key: string, options?: { - parse?: Parse; + parse?: Decode; } | undefined): T | undefined; export function hasHydratableValue(key: string): boolean; - type Parse = (value: string) => T; + type Decode = (value: unknown) => T; export {}; } From 7d0451e9f7566dd34ca0449672f1714652dbbab9 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 4 Nov 2025 15:25:17 -0700 Subject: [PATCH 32/46] oops --- packages/svelte/src/internal/server/renderer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 5cccbc602b9b..7162253ce1c3 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -645,9 +645,9 @@ export class Renderer { /** @param {[string, unknown][]} serialized */ static #hydratable_block(serialized) { - let entries = ''; + let entries = []; for (const [k, v] of serialized) { - entries += `["${k}",${v}],`; + entries.push(`["${k}",${v}]`); } // TODO csp? // TODO how can we communicate this error better? Is there a way to not just send it to the console? @@ -655,7 +655,7 @@ export class Renderer { return ` `; From cd7a71f92a5ef8fcacc85887a0d98e5fa07dda29 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Nov 2025 11:21:32 -0700 Subject: [PATCH 33/46] oops --- packages/svelte/src/internal/client/hydratable.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 780bbed37cf0..b35f3269d9fa 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -19,13 +19,13 @@ export function hydratable(key, fn, options = {}) { // something to be synchronously hydratable and then have it not be return fn(); } - return parse(val, options.transport?.decode); + return decode(val, options.transport?.decode); } /** * @template T * @param {string} key - * @param {{ parse?: Decode }} [options] + * @param {{ decode?: Decode }} [options] * @returns {T | undefined} */ export function get_hydratable_value(key, options = {}) { @@ -40,7 +40,7 @@ export function get_hydratable_value(key, options = {}) { return undefined; } - return parse(val, options.parse); + return decode(val, options.decode); } /** @@ -58,9 +58,9 @@ export function has_hydratable_value(key) { /** * @template T * @param {unknown} val - * @param {Decode | undefined} parse + * @param {Decode | undefined} decode * @returns {T} */ -function parse(val, parse) { - return (parse ?? ((val) => /** @type {T} */ (val)))(val); +function decode(val, decode) { + return (decode ?? ((val) => /** @type {T} */ (val)))(val); } From 0581bb9094e9a312bd400718f785fc79d4b244dd Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Nov 2025 12:56:58 -0700 Subject: [PATCH 34/46] tweak --- packages/svelte/src/internal/client/context.js | 4 +--- packages/svelte/src/internal/client/hydratable.js | 6 +++--- packages/svelte/src/internal/server/hydratable.js | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index ce47fcb5f4db..ad446bbcb2ac 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -1,13 +1,11 @@ /** @import { ComponentContext, DevStackEntry, Effect } from '#client' */ -/** @import { Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; 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 { hydrating } from './dom/hydration.js'; +import { BRANCH_EFFECT } from './constants.js'; /** @type {ComponentContext | null} */ export let component_context = null; diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index b35f3269d9fa..f513989091bc 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -5,10 +5,10 @@ import { hydrating } from './dom/hydration.js'; * @template T * @param {string} key * @param {() => T} fn - * @param {{ transport?: Transport }} [options] + * @param {Transport} [options] * @returns {T} */ -export function hydratable(key, fn, options = {}) { +export function hydratable(key, fn, options) { if (!hydrating) { return fn(); } @@ -19,7 +19,7 @@ export function hydratable(key, fn, options = {}) { // something to be synchronously hydratable and then have it not be return fn(); } - return decode(val, options.transport?.decode); + return decode(val, options?.decode); } /** diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 2d6600a7aea9..1f3cbee3a850 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -14,10 +14,10 @@ export function set_hydratable_key(key) { * @template T * @param {string} key * @param {() => T} fn - * @param {{ transport?: Transport }} [options] + * @param {Transport} [options] * @returns {T} */ -export function hydratable(key, fn, options = {}) { +export function hydratable(key, fn, options) { const store = get_render_context(); if (store.hydratables.has(key)) { @@ -26,7 +26,7 @@ export function hydratable(key, fn, options = {}) { } const result = fn(); - store.hydratables.set(key, { value: result, encode: options.transport?.encode }); + store.hydratables.set(key, { value: result, encode: options?.encode }); return result; } /** From 5aa7598f4bf19f89ac824cd30b77fa4afab59dde Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Nov 2025 12:58:14 -0700 Subject: [PATCH 35/46] tweak --- packages/svelte/src/internal/shared/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 89cb28f6ec44..c239e3fd5df8 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -11,7 +11,7 @@ export type Snapshot = ReturnType>; export type MaybePromise = T | Promise; -export type Decode = (value: unknown) => T; +export type Decode = (value: any) => T; export type Encode = (value: T) => unknown; From 08d755ba71195e5faff96ef068fc47ede8bf96b0 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Nov 2025 13:00:48 -0700 Subject: [PATCH 36/46] tweak --- packages/svelte/types/index.d.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 72ec09567ecd..ccc282458e7a 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -450,9 +450,7 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; - export function hydratable(key: string, fn: () => T, options?: { - transport?: Transport; - } | undefined): T; + export function hydratable(key: string, fn: () => T, options?: Transport | undefined): T; /** * Create a snippet programmatically * */ @@ -598,7 +596,7 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; - type Decode = (value: unknown) => T; + type Decode = (value: any) => T; type Encode = (value: T) => unknown; @@ -2622,11 +2620,11 @@ declare module 'svelte/server' { declare module 'svelte/client' { export function getHydratableValue(key: string, options?: { - parse?: Decode; + decode?: Decode; } | undefined): T | undefined; export function hasHydratableValue(key: string): boolean; - type Decode = (value: unknown) => T; + type Decode = (value: any) => T; export {}; } From 9a424cd784d52891201fb8649c40e4afbad2dc63 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Nov 2025 15:23:36 -0700 Subject: [PATCH 37/46] add errors --- .../98-reference/.generated/client-errors.md | 6 -- .../.generated/client-warnings.md | 19 ++++++ .../98-reference/.generated/server-errors.md | 23 +++++++ .../98-reference/.generated/shared-errors.md | 6 ++ .../svelte/messages/client-errors/errors.md | 4 -- .../messages/client-warnings/warnings.md | 17 +++++ .../svelte/messages/server-errors/errors.md | 21 ++++++ .../svelte/messages/shared-errors/errors.md | 4 ++ packages/svelte/src/internal/client/errors.js | 16 ----- .../svelte/src/internal/client/hydratable.js | 65 ++++++++++++------- .../src/internal/client/reactivity/batch.js | 2 +- .../src/internal/client/reactivity/cache.js | 9 +++ .../src/internal/client/reactivity/fetcher.js | 6 ++ .../internal/client/reactivity/resource.js | 5 ++ .../svelte/src/internal/client/types.d.ts | 2 +- .../svelte/src/internal/client/warnings.js | 12 ++++ packages/svelte/src/internal/server/errors.js | 20 ++++++ .../svelte/src/internal/server/hydratable.js | 54 ++++++++++----- .../src/internal/server/reactivity/cache.js | 12 +++- .../src/internal/server/reactivity/fetcher.js | 5 ++ .../internal/server/reactivity/resource.js | 5 ++ .../svelte/src/internal/server/types.d.ts | 14 ++-- packages/svelte/src/internal/shared/errors.js | 17 +++++ 23 files changed, 266 insertions(+), 78 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 3f1cb8f76bab..789e01238604 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 ``` 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..c60d43f43070 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -14,6 +14,29 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) 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 ``` 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..20442bd2bc9c 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 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..4f2491a25ae7 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -8,6 +8,27 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) > 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 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/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8a5fde4f3b9a..8d0e47a94d0b 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -229,22 +229,6 @@ export function effect_update_depth_exceeded() { } } -/** - * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` - * @returns {never} - */ -export function experimental_async_fork() { - 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`); - - error.name = 'Svelte error'; - - throw error; - } else { - throw new Error(`https://svelte.dev/e/experimental_async_fork`); - } -} - /** * Cannot use `flushSync` inside an effect * @returns {never} diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index f513989091bc..4045eb94ceda 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -1,5 +1,8 @@ /** @import { Decode, 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 @@ -9,17 +12,20 @@ import { hydrating } from './dom/hydration.js'; * @returns {T} */ export function hydratable(key, fn, options) { - if (!hydrating) { - return fn(); - } - var store = window.__svelte?.h; - const val = store?.get(key); - if (val === undefined) { - // TODO this should really be an error or at least a warning because it would be disastrous to expect - // something to be synchronously hydratable and then have it not be - return fn(); + if (!async_mode_flag) { + e.experimental_async_required('hydratable'); } - return decode(val, options?.decode); + + return access_hydratable_store( + key, + (val, has) => { + if (!has) { + w.hydratable_missing_but_expected(key); + } + return decode(val, options?.decode); + }, + fn + ); } /** @@ -29,18 +35,15 @@ export function hydratable(key, fn, options) { * @returns {T | undefined} */ export function get_hydratable_value(key, options = {}) { - // TODO probably can DRY this out with the above - if (!hydrating) { - return undefined; - } - - var store = window.__svelte?.h; - const val = store?.get(key); - if (val === undefined) { - return undefined; + if (!async_mode_flag) { + e.experimental_async_required('getHydratableValue'); } - return decode(val, options.decode); + return access_hydratable_store( + key, + (val) => decode(val, options.decode), + () => undefined + ); } /** @@ -48,11 +51,29 @@ export function get_hydratable_value(key, options = {}) { * @returns {boolean} */ export function has_hydratable_value(key) { + if (!async_mode_flag) { + e.experimental_async_required('hasHydratableValue'); + } + 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 false; + return on_not_hydrating(); } var store = window.__svelte?.h; - return store?.has(key) ?? false; + return on_hydrating(store?.get(key), store?.has(key) ?? false); } /** 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 index ef4844fb6c78..ee2283dea370 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,7 +1,9 @@ /** @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} */ @@ -14,6 +16,10 @@ const client_cache = new Map(); * @returns {ReturnType} */ export function cache(key, fn) { + if (!async_mode_flag) { + e.experimental_async_required('cache'); + } + const cached = client_cache.has(key); const entry = client_cache.get(key); const maybe_remove = create_remover(key); @@ -70,6 +76,9 @@ function create_remover(key) { */ 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/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js index 49cecaf6d623..6d447ef19dda 100644 --- a/packages/svelte/src/internal/client/reactivity/fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -3,6 +3,8 @@ 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 @@ -11,6 +13,10 @@ import { resource } from './resource'; * @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 index 8aad0b23d824..7f3780e68022 100644 --- a/packages/svelte/src/internal/client/reactivity/resource.js +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -2,6 +2,8 @@ /** @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 @@ -9,6 +11,9 @@ import { deferred } from '../../shared/utils.js'; * @returns {ResourceType} */ export function resource(fn) { + if (!async_mode_flag) { + e.experimental_async_required('resource'); + } return /** @type {ResourceType} */ (new Resource(fn)); } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index e07a5ee798f4..409a2ba3174b 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -6,7 +6,7 @@ declare global { interface Window { __svelte?: { /** hydratables */ - h?: Map; + h?: Map; }; } } 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/errors.js b/packages/svelte/src/internal/server/errors.js index bde49fe935a7..d229b44ec0fb 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -26,6 +26,26 @@ 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 * @param {string} name diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 1f3cbee3a850..1b70353f40ca 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,14 +1,10 @@ /** @import { Encode, Transport } from '#shared' */ +/** @import { HydratableEntry } from '#server' */ +import { async_mode_flag } from '../flags/index.js'; import { get_render_context } from './render-context.js'; - -/** @type {string | null} */ -export let hydratable_key = null; - -/** @param {string | null} key */ -export function set_hydratable_key(key) { - hydratable_key = key; -} +import * as e from './errors.js'; +import { DEV } from 'esm-env'; /** * @template T @@ -18,16 +14,19 @@ export function set_hydratable_key(key) { * @returns {T} */ export function hydratable(key, fn, options) { + if (!async_mode_flag) { + e.experimental_async_required('hydratable'); + } + const store = get_render_context(); if (store.hydratables.has(key)) { - // TODO error - throw new Error("can't have two hydratables with the same key"); + e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown'); } - const result = fn(); - store.hydratables.set(key, { value: result, encode: options?.encode }); - return result; + const entry = create_entry(fn(), options?.encode); + store.hydratables.set(key, entry); + return entry.value; } /** * @template T @@ -36,15 +35,34 @@ export function hydratable(key, fn, options) { * @param {{ encode?: Encode }} [options] */ export function set_hydratable_value(key, value, options = {}) { + if (!async_mode_flag) { + e.experimental_async_required('setHydratableValue'); + } + const store = get_render_context(); if (store.hydratables.has(key)) { - // TODO error - throw new Error("can't have two hydratables with the same key"); + e.hydratable_clobbering(key, store.hydratables.get(key)?.stack || 'unknown'); } - store.hydratables.set(key, { + store.hydratables.set(key, create_entry(value, options?.encode)); +} + +/** + * @template T + * @param {T} value + * @param {Encode | undefined} encode + */ +function create_entry(value, encode) { + /** @type {Omit & { value: T }} */ + const entry = { value, - encode: options.encode - }); + 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 index d38be324eca2..019a10f65562 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,6 +1,7 @@ +import { async_mode_flag } from '../../flags/index.js'; import { BaseCacheObserver } from '../../shared/cache-observer.js'; -import { set_hydratable_key } from '../hydratable.js'; import { get_render_context } from '../render-context.js'; +import * as e from '../errors.js'; /** * @template {(...args: any[]) => any} TFn @@ -9,14 +10,16 @@ import { get_render_context } from '../render-context.js'; * @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); } - set_hydratable_key(key); const new_entry = fn(); - set_hydratable_key(null); cache.set(key, new_entry); return new_entry; } @@ -27,6 +30,9 @@ export function cache(key, fn) { */ 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 index 5db1db00e7b7..ee674a8faa03 100644 --- a/packages/svelte/src/internal/server/reactivity/fetcher.js +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -1,8 +1,10 @@ /** @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 @@ -11,6 +13,9 @@ import { resource } from './resource.js'; * @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 index 57c651152fb2..d354c76359f3 100644 --- a/packages/svelte/src/internal/server/reactivity/resource.js +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -1,4 +1,6 @@ /** @import { Resource as ResourceType } from '#shared' */ +import { async_mode_flag } from '../../flags/index.js'; +import * as e from '../errors.js'; /** * @template T @@ -6,6 +8,9 @@ * @returns {ResourceType} */ export function resource(fn) { + if (!async_mode_flag) { + e.experimental_async_required('resource'); + } return /** @type {ResourceType} */ (new Resource(fn)); } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 2cdf2df2775a..6a27af22a6db 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -15,14 +15,14 @@ export interface SSRContext { element?: Element; } +export interface HydratableEntry { + value: unknown; + encode: Encode | undefined; + stack?: string; +} + export interface RenderContext { - hydratables: Map< - string, - { - value: unknown; - encode: Encode | undefined; - } - >; + hydratables: Map; cache: Map; } 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} From e28ced79a412820d9634bdc8bdd692a51a8f22f9 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 6 Nov 2025 08:41:42 -0700 Subject: [PATCH 38/46] tweak hydratable API, add official errors --- .../98-reference/.generated/server-errors.md | 6 ++-- .../98-reference/.generated/shared-errors.md | 8 ++++++ .../svelte/messages/server-errors/errors.md | 6 ++-- .../svelte/messages/shared-errors/errors.md | 6 ++++ packages/svelte/package.json | 4 --- packages/svelte/scripts/generate-types.js | 1 - packages/svelte/src/client/index.js | 4 --- packages/svelte/src/index-server.js | 8 +++--- .../svelte/src/internal/client/hydratable.js | 17 ++++++++--- packages/svelte/src/internal/server/errors.js | 8 +++--- .../svelte/src/internal/server/hydratable.js | 28 +++++++++++++++++-- .../src/internal/server/render-context.js | 17 ++++------- .../svelte/src/internal/server/renderer.js | 8 ++---- packages/svelte/src/internal/shared/errors.js | 18 ++++++++++++ .../svelte/src/internal/shared/types.d.ts | 7 +++++ packages/svelte/src/server/index.d.ts | 2 -- packages/svelte/src/server/index.js | 1 - packages/svelte/types/index.d.ts | 24 ++++++---------- 18 files changed, 107 insertions(+), 66 deletions(-) delete mode 100644 packages/svelte/src/client/index.js diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index c60d43f43070..43f785fe33e7 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -37,10 +37,10 @@ This error occurs when using `hydratable` or `setHydratableValue` multiple times ``` -### lifecycle_function_unavailable +### render_context_unavailable ``` -`%name%(...)` is not available on the server +Failed to retrieve `render` context. %addendum% ``` -Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render. +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 136b3f4957d6..ceb622cb3016 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -42,6 +42,14 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` ``` +### lifecycle_function_unavailable + +``` +`%name%(...)` is not available on the %location% +``` + +Certain methods such as `mount` cannot be invoked while running in a server context, while others, such as `hydratable.set`, cannot be invoked while running in the browser. + ### lifecycle_outside_component ``` diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index 4f2491a25ae7..d8f40a790890 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -29,8 +29,8 @@ This error occurs when using `hydratable` or `setHydratableValue` multiple times ``` -## lifecycle_function_unavailable +## render_context_unavailable -> `%name%(...)` is not available on the server +> Failed to retrieve `render` context. %addendum% -Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render. +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 bf053283e434..1265e553836b 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -34,6 +34,12 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P > A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` +## lifecycle_function_unavailable + +> `%name%(...)` is not available on the %location% + +Certain methods such as `mount` cannot be invoked while running in a server context, while others, such as `hydratable.set`, cannot be invoked while running in the browser. + ## lifecycle_outside_component > `%name%(...)` can only be used during component initialisation diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 0b94e2b7cc87..4e9fe667a4e5 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -95,10 +95,6 @@ "types": "./types/index.d.ts", "default": "./src/server/index.js" }, - "./client": { - "types": "./types/index.d.ts", - "default": "./src/client/index.js" - }, "./store": { "types": "./types/index.d.ts", "worker": "./src/store/index-server.js", diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 10d3caafa3b3..0ee6004d4a2c 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -45,7 +45,6 @@ await createBundle({ [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`, [`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`, [`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`, - [`${pkg.name}/client`]: `${dir}/src/client/index.d.ts`, [`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`, [`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`, [`${pkg.name}/events`]: `${dir}/src/events/public.d.ts`, diff --git a/packages/svelte/src/client/index.js b/packages/svelte/src/client/index.js deleted file mode 100644 index 1cd028d123d6..000000000000 --- a/packages/svelte/src/client/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { - get_hydratable_value as getHydratableValue, - has_hydratable_value as hasHydratableValue -} from '../internal/client/hydratable.js'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 9fb810fd9ebd..a6141851a1d2 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -22,19 +22,19 @@ export function createEventDispatcher() { } export function mount() { - e.lifecycle_function_unavailable('mount'); + e.lifecycle_function_unavailable('mount', 'server'); } export function hydrate() { - e.lifecycle_function_unavailable('hydrate'); + e.lifecycle_function_unavailable('hydrate', 'server'); } export function unmount() { - e.lifecycle_function_unavailable('unmount'); + e.lifecycle_function_unavailable('unmount', 'server'); } export function fork() { - e.lifecycle_function_unavailable('fork'); + e.lifecycle_function_unavailable('fork', 'server'); } export async function tick() {} diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 4045eb94ceda..5ce2efe06504 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -1,4 +1,4 @@ -/** @import { Decode, Transport } from '#shared' */ +/** @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'; @@ -11,7 +11,7 @@ import * as e from './errors.js'; * @param {Transport} [options] * @returns {T} */ -export function hydratable(key, fn, options) { +function isomorphic_hydratable(key, fn, options) { if (!async_mode_flag) { e.experimental_async_required('hydratable'); } @@ -28,13 +28,22 @@ export function hydratable(key, fn, options) { ); } +isomorphic_hydratable['get'] = get_hydratable_value; +isomorphic_hydratable['has'] = has_hydratable_value; +isomorphic_hydratable['set'] = () => e.lifecycle_function_unavailable('hydratable.set', 'browser'); + +/** @type {Hydratable} */ +const hydratable = isomorphic_hydratable; + +export { hydratable }; + /** * @template T * @param {string} key * @param {{ decode?: Decode }} [options] * @returns {T | undefined} */ -export function get_hydratable_value(key, options = {}) { +function get_hydratable_value(key, options = {}) { if (!async_mode_flag) { e.experimental_async_required('getHydratableValue'); } @@ -50,7 +59,7 @@ export function get_hydratable_value(key, options = {}) { * @param {string} key * @returns {boolean} */ -export function has_hydratable_value(key) { +function has_hydratable_value(key) { if (!async_mode_flag) { e.experimental_async_required('hasHydratableValue'); } diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index d229b44ec0fb..f3f7ad1f406f 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -47,12 +47,12 @@ ${stack}\nhttps://svelte.dev/e/hydratable_clobbering`); } /** - * `%name%(...)` is not available on the server - * @param {string} name + * Failed to retrieve `render` context. %addendum% + * @param {string} addendum * @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`); +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 index 1b70353f40ca..9b8d20facc6f 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -1,4 +1,4 @@ -/** @import { Encode, Transport } from '#shared' */ +/** @import { Encode, Hydratable, Transport } from '#shared' */ /** @import { HydratableEntry } from '#server' */ import { async_mode_flag } from '../flags/index.js'; @@ -13,7 +13,7 @@ import { DEV } from 'esm-env'; * @param {Transport} [options] * @returns {T} */ -export function hydratable(key, fn, options) { +function isomorphic_hydratable(key, fn, options) { if (!async_mode_flag) { e.experimental_async_required('hydratable'); } @@ -28,13 +28,23 @@ export function hydratable(key, fn, options) { store.hydratables.set(key, entry); return entry.value; } + +isomorphic_hydratable['get'] = () => e.lifecycle_function_unavailable('hydratable.get', 'server'); +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] */ -export function set_hydratable_value(key, value, options = {}) { +function set_hydratable_value(key, value, options = {}) { if (!async_mode_flag) { e.experimental_async_required('setHydratableValue'); } @@ -48,6 +58,18 @@ export function set_hydratable_value(key, value, options = {}) { 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('hasHydratableValue'); + } + const store = get_render_context(); + return store.hydratables.has(key); +} + /** * @template T * @param {T} value diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js index 66fe3bb5b822..4ec0274693b6 100644 --- a/packages/svelte/src/internal/server/render-context.js +++ b/packages/svelte/src/internal/server/render-context.js @@ -3,6 +3,7 @@ /** @import { RenderContext } from '#server' */ import { deferred } from '../shared/utils.js'; +import * as e from './errors.js'; /** @type {Promise | null} */ let current_render = null; @@ -38,18 +39,9 @@ export function get_render_context() { const store = try_get_render_context(); if (!store) { - // TODO make this a proper e.error - let message = 'Could not get rendering context.'; - - if (als) { - message += ' You may have called `hydratable` or `cache` outside of the render lifecycle.'; - } else { - message += - ' In environments without `AsyncLocalStorage`, `hydratable` must be accessed synchronously, not after an `await`.' + - ' If it was accessed synchronously then this is an internal error or you may have called `hydratable` or `cache` outside of the render lifecycle.'; - } - - throw new Error(message); + e.render_context_unavailable( + `\`AsyncLocalStorage\` is ${als ? 'available' : 'not available'}.` + ); } return store; @@ -93,6 +85,7 @@ export async function init_render_context() { } 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 diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 7162253ce1c3..4fa79eedea1b 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -576,12 +576,12 @@ export class Renderer { async #collect_hydratables() { const map = get_render_context().hydratables; /** @type {(value: unknown) => string} */ - let default_stringify; + const default_encode = new MemoizedUneval().uneval; /** @type {[string, unknown][]} */ let entries = []; for (const [k, v] of map) { - const encode = v.encode ?? (default_stringify ??= new MemoizedUneval().uneval); + 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)]); } @@ -649,9 +649,7 @@ export class Renderer { for (const [k, v] of serialized) { entries.push(`["${k}",${v}]`); } - // TODO csp? - // TODO how can we communicate this error better? Is there a way to not just send it to the console? - // (it is probably very rare so... not too worried) + // TODO csp -- have discussed but not implemented return ` `; } From 551572ef4b5c1f4105184d8f3566b5c413c27731 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 6 Nov 2025 11:20:27 -0700 Subject: [PATCH 42/46] errors --- .../98-reference/.generated/client-errors.md | 6 ++++ .../98-reference/.generated/server-errors.md | 13 +++++++++ .../98-reference/.generated/shared-errors.md | 8 ------ .../svelte/messages/client-errors/errors.md | 4 +++ .../svelte/messages/server-errors/errors.md | 9 ++++++ .../svelte/messages/shared-errors/errors.md | 6 ---- packages/svelte/src/index-server.js | 8 +++--- packages/svelte/src/internal/client/errors.js | 17 +++++++++++ .../svelte/src/internal/client/hydratable.js | 2 +- packages/svelte/src/internal/server/errors.js | 28 +++++++++++++++++++ .../svelte/src/internal/server/hydratable.js | 2 +- packages/svelte/src/internal/shared/errors.js | 18 ------------ 12 files changed, 83 insertions(+), 38 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 789e01238604..4fa8e1fc4382 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -140,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/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index 43f785fe33e7..101dc5db9e34 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -8,6 +8,12 @@ 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 ``` @@ -37,6 +43,13 @@ This error occurs when using `hydratable` or `setHydratableValue` multiple times ``` +### 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 ``` diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index ceb622cb3016..136b3f4957d6 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -42,14 +42,6 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` ``` -### lifecycle_function_unavailable - -``` -`%name%(...)` is not available on the %location% -``` - -Certain methods such as `mount` cannot be invoked while running in a server context, while others, such as `hydratable.set`, cannot be invoked while running in the browser. - ### lifecycle_outside_component ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 20442bd2bc9c..ebaf520153d2 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -108,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 on the client. + ## fork_discarded > Cannot commit a fork that was already discarded diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index d8f40a790890..e55a0e1b39ef 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -4,6 +4,10 @@ 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. @@ -29,6 +33,11 @@ This error occurs when using `hydratable` or `setHydratableValue` multiple times ``` +## 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% diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index 1265e553836b..bf053283e434 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -34,12 +34,6 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P > A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` -## lifecycle_function_unavailable - -> `%name%(...)` is not available on the %location% - -Certain methods such as `mount` cannot be invoked while running in a server context, while others, such as `hydratable.set`, cannot be invoked while running in the browser. - ## lifecycle_outside_component > `%name%(...)` can only be used during component initialisation diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index a6141851a1d2..9fb810fd9ebd 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -22,19 +22,19 @@ export function createEventDispatcher() { } export function mount() { - e.lifecycle_function_unavailable('mount', 'server'); + e.lifecycle_function_unavailable('mount'); } export function hydrate() { - e.lifecycle_function_unavailable('hydrate', 'server'); + e.lifecycle_function_unavailable('hydrate'); } export function unmount() { - e.lifecycle_function_unavailable('unmount', 'server'); + e.lifecycle_function_unavailable('unmount'); } export function fork() { - e.lifecycle_function_unavailable('fork', 'server'); + e.lifecycle_function_unavailable('fork'); } export async function tick() {} diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8d0e47a94d0b..d067ff95a31c 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -245,6 +245,23 @@ export function flush_sync_in_effect() { } } +/** + * `%name%`(...) is unavailable on the client. + * @param {string} name + * @returns {never} + */ +export function fn_unavailable_on_client(name) { + if (DEV) { + 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/fn_unavailable_on_client`); + } +} + /** * Cannot commit a fork that was already discarded * @returns {never} diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js index 5ce2efe06504..2789f54c3064 100644 --- a/packages/svelte/src/internal/client/hydratable.js +++ b/packages/svelte/src/internal/client/hydratable.js @@ -30,7 +30,7 @@ function isomorphic_hydratable(key, fn, options) { isomorphic_hydratable['get'] = get_hydratable_value; isomorphic_hydratable['has'] = has_hydratable_value; -isomorphic_hydratable['set'] = () => e.lifecycle_function_unavailable('hydratable.set', 'browser'); +isomorphic_hydratable['set'] = () => e.fn_unavailable_on_client('hydratable.set'); /** @type {Hydratable} */ const hydratable = isomorphic_hydratable; diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index f3f7ad1f406f..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} @@ -46,6 +59,21 @@ ${stack}\nhttps://svelte.dev/e/hydratable_clobbering`); 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 +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 diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js index 9b8d20facc6f..7a667374324a 100644 --- a/packages/svelte/src/internal/server/hydratable.js +++ b/packages/svelte/src/internal/server/hydratable.js @@ -29,7 +29,7 @@ function isomorphic_hydratable(key, fn, options) { return entry.value; } -isomorphic_hydratable['get'] = () => e.lifecycle_function_unavailable('hydratable.get', 'server'); +isomorphic_hydratable['get'] = () => e.fn_unavailable_on_server('hydratable.get'); isomorphic_hydratable['has'] = has_hydratable_value; isomorphic_hydratable['set'] = set_hydratable_value; diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index beed8b56ad86..b13a65b59865 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -51,24 +51,6 @@ export function invalid_snippet_arguments() { } } -/** - * `%name%(...)` is not available on the %location% - * @param {string} name - * @param {string} location - * @returns {never} - */ -export function lifecycle_function_unavailable(name, location) { - if (DEV) { - const error = new Error(`lifecycle_function_unavailable\n\`${name}(...)\` is not available on the ${location}\nhttps://svelte.dev/e/lifecycle_function_unavailable`); - - error.name = 'Svelte error'; - - throw error; - } else { - throw new Error(`https://svelte.dev/e/lifecycle_function_unavailable`); - } -} - /** * `%name%(...)` can only be used during component initialisation * @param {string} name From f9123f4c22ed6ca04209795c241f9ed4d3edc645 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 6 Nov 2025 11:32:20 -0700 Subject: [PATCH 43/46] Update packages/svelte/messages/client-errors/errors.md Co-authored-by: Rich Harris --- packages/svelte/messages/client-errors/errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index ebaf520153d2..31de4b91a57a 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -110,7 +110,7 @@ This restriction only applies when using the `experimental.async` option, which ## fn_unavailable_on_client -> `%name%`(...) is unavailable on the client. +> `%name%`(...) is unavailable in the browser. ## fork_discarded From d5e4af82a7a3a16664236245a9741644d0981a2a Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 6 Nov 2025 11:45:12 -0700 Subject: [PATCH 44/46] fixes --- packages/svelte/messages/server-errors/errors.md | 2 +- packages/svelte/src/internal/client/hydratable.js | 5 +++-- packages/svelte/src/internal/server/hydratable.js | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/svelte/messages/server-errors/errors.md b/packages/svelte/messages/server-errors/errors.md index e55a0e1b39ef..cff1f5df8789 100644 --- a/packages/svelte/messages/server-errors/errors.md +++ b/packages/svelte/messages/server-errors/errors.md @@ -19,7 +19,7 @@ You (or the framework you're using) called [`render(...)`](svelte-server#render) > 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`. +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