From da3260f99a724838193cff284a0c22d05b538978 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 8 Oct 2025 17:38:30 -0600 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 {}; }