From 8449ea76d886e57cf0e2d3a02bbe09b9f4015792 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 20 Oct 2025 21:06:38 -0600 Subject: [PATCH] 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 e84a50a409..84a4d7034a 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 0000000000..316dc9844e --- /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 0000000000..15df1e9d0c --- /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 ea6f6a5a18..97ab835664 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 f68b106fff..0000000000 --- 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 f9addbb24b..7ab3e2665b 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 0000000000..ed93539e3f --- /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 0000000000..e542f5e3de --- /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 8840c329bd..cbc0a385a9 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 52456a814d..f092a8c931 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 9b8f4da827..e5be6774bd 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 12550d3743..67ea76f65a 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; - }; -}