From 90b85d152f04046bfd922f3ee9f912e220ea1f70 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 30 Oct 2025 12:12:54 -0600 Subject: [PATCH] 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 1595cc72d3..22459f02e8 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 0ee6004d4a..10d3caafa3 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 0000000000..e92140b08e --- /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 3341f81cf5..9d3703950e 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 6fb708c04c..8330eb588c 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 0000000000..f8c095eb03 --- /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 7b38271e66..132b904af8 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 dc3671be18..49cecaf6d6 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 e231a8a862..4e1f5d0ef5 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 780f732f58..f3855f4a7b 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 62c3513b43..5db1db00e7 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 7484fd9f53..9203c983c0 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 29d1ea58c0..675930550b 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 cfe7e92ad2..c45c8a74a8 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 bbe5470869..9c7fc6fd16 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 c02e9d05fb..04d8040088 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 11d349a278..d376e48408 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;