From 141fd1e955971c0994e7b22031e66788926bbf04 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 7 Nov 2025 18:03:04 -0700 Subject: [PATCH] reactive cache --- .../src/internal/client/reactivity/cache.js | 115 +++++++----------- .../src/internal/client/reactivity/fetcher.js | 10 +- .../src/internal/server/reactivity/cache.js | 62 +++++----- .../src/internal/server/reactivity/fetcher.js | 10 +- .../svelte/src/internal/server/types.d.ts | 4 +- .../src/internal/shared/cache-observer.js | 78 ------------ .../svelte/src/internal/shared/types.d.ts | 2 +- .../svelte/src/reactivity/index-client.js | 2 +- .../svelte/src/reactivity/index-server.js | 2 +- 9 files changed, 99 insertions(+), 186 deletions(-) delete mode 100644 packages/svelte/src/internal/shared/cache-observer.js diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index c1bf1fe4bb..49142f0eda 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,83 +1,60 @@ /** @import { CacheEntry } from '#shared' */ import { async_mode_flag } from '../../flags/index.js'; -import { BaseCacheObserver } from '../../shared/cache-observer.js'; -import { tick } from '../runtime.js'; -import { get_effect_validation_error_code, render_effect } from './effects.js'; +import { active_effect, is_destroying_effect, tick } from '../runtime.js'; +import { render_effect } from './effects.js'; import * as e from '../errors.js'; -/** @typedef {{ count: number, item: any }} Entry */ -/** @type {Map} */ -const client_cache = new Map(); +/** @template T */ +export class ReactiveCache { + /** @type {Map>} */ + #cache = new Map(); -/** - * @template {(...args: any[]) => any} TFn - * @param {string} key - * @param {TFn} fn - * @returns {ReturnType} - */ -export function cache(key, fn) { - if (!async_mode_flag) { - e.experimental_async_required('cache'); - } - - const entry = client_cache.get(key); - const maybe_remove = create_remover(key); - - const tracking = get_effect_validation_error_code() === null; - if (tracking) { - render_effect(() => { - if (entry) entry.count++; - return () => { - const entry = client_cache.get(key); - if (!entry) return; - entry.count--; - maybe_remove(entry); - }; - }); + constructor() { + if (!async_mode_flag) { + e.experimental_async_required('ReactiveCache'); + } } - if (entry !== undefined) { - return entry?.item; - } + /** + * @param {string} key + * @param {() => T} fn + * @returns {T} + */ + register(key, fn) { + let entry = this.#cache.get(key); - const item = fn(); - const new_entry = { - item, - count: tracking ? 1 : 0 - }; - client_cache.set(key, new_entry); + if (!entry) { + entry = { count: 0, item: fn() }; + this.#cache.set(key, entry); + } - Promise.resolve(item).then( - () => maybe_remove(new_entry), - () => maybe_remove(new_entry) - ); - return item; -} + const maybe_remove = () => { + tick().then(() => { + if (entry.count === 0 && this.#cache.get(key) === entry) { + this.#cache.delete(key); + } + }); + }; + + if (active_effect !== null && !is_destroying_effect) { + render_effect(() => { + entry.count++; + + return () => { + entry.count--; + maybe_remove(); + }; + }); + } else { + throw new Error('TODO must be called from within a reactive context'); + } -/** - * @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); - } - }); -} + return entry.item; + } -/** - * @template T - * @extends BaseCacheObserver - */ -export class CacheObserver extends BaseCacheObserver { - constructor(prefix = '') { - if (!async_mode_flag) { - e.experimental_async_required('CacheObserver'); + *[Symbol.iterator]() { + for (const entry of this.#cache.values()) { + yield entry.item; } - super(() => client_cache, prefix); } } diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js index 6d447ef19d..d5621ed24e 100644 --- a/packages/svelte/src/internal/client/reactivity/fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -1,11 +1,13 @@ /** @import { GetRequestInit, Resource } from '#shared' */ -import { cache } from './cache'; +import { ReactiveCache } from './cache'; import { fetch_json } from '../../shared/utils.js'; import { hydratable } from '../hydratable'; import { resource } from './resource'; import { async_mode_flag } from '../../flags'; import * as e from '../errors.js'; +const fetch_cache = new ReactiveCache(); + /** * @template TReturn * @param {string | URL} url @@ -17,6 +19,8 @@ export function fetcher(url, init) { e.experimental_async_required('fetcher'); } - const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; - return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); + const key = `svelte/fetcher/${url}`; + return fetch_cache.register(key, () => + resource(() => hydratable(key, () => fetch_json(url, init))) + ); } diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index 019a10f655..b46e2a252b 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,38 +1,44 @@ import { async_mode_flag } from '../../flags/index.js'; -import { BaseCacheObserver } from '../../shared/cache-observer.js'; -import { get_render_context } from '../render-context.js'; import * as e from '../errors.js'; +import { get_render_context } from '../render-context.js'; + +/** @template T */ +export class ReactiveCache { + #key = Symbol('ReactiveCache'); + + constructor() { + if (!async_mode_flag) { + e.experimental_async_required('ReactiveCache'); + } + } + + /** + * @param {string} key + * @param {() => T} fn + * @returns {T} + */ + register(key, fn) { + const cache = this.#get_cache(); + let entry = cache.get(key); + + if (!entry) { + entry = fn(); + cache.set(key, entry); + } -/** - * @template {(...args: any[]) => any} TFn - * @param {string} key - * @param {TFn} fn - * @returns {ReturnType} - */ -export function cache(key, fn) { - if (!async_mode_flag) { - e.experimental_async_required('cache'); + return entry; } - const cache = get_render_context().cache; - const entry = cache.get(key); - if (entry) { - return /** @type {ReturnType} */ (entry); + [Symbol.iterator]() { + return this.#get_cache().values(); } - const new_entry = fn(); - cache.set(key, new_entry); - return new_entry; -} -/** - * @template T - * @extends BaseCacheObserver - */ -export class CacheObserver extends BaseCacheObserver { - constructor(prefix = '') { - if (!async_mode_flag) { - e.experimental_async_required('CacheObserver'); + #get_cache() { + const store = get_render_context(); + let map = store.cache.get(this.#key); + if (map === undefined) { + store.cache.set(this.#key, (map = new Map())); } - super(() => get_render_context().cache, prefix); + return map; } } diff --git a/packages/svelte/src/internal/server/reactivity/fetcher.js b/packages/svelte/src/internal/server/reactivity/fetcher.js index ee674a8faa..20c6513d9d 100644 --- a/packages/svelte/src/internal/server/reactivity/fetcher.js +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -2,10 +2,12 @@ import { async_mode_flag } from '../../flags/index.js'; import { fetch_json } from '../../shared/utils.js'; import { hydratable } from '../hydratable.js'; -import { cache } from './cache'; +import { ReactiveCache } from './cache'; import { resource } from './resource.js'; import * as e from '../errors.js'; +const fetch_cache = new ReactiveCache(); + /** * @template TReturn * @param {string | URL} url @@ -16,6 +18,8 @@ export function fetcher(url, init) { if (!async_mode_flag) { e.experimental_async_required('fetcher'); } - const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; - return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); + const key = `svelte/fetcher/${url}`; + return fetch_cache.register(key, () => + resource(() => hydratable(key, () => fetch_json(url, init))) + ); } diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 6a27af22a6..87dad63614 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,4 @@ -import type { CacheEntry, Encode } from '#shared'; +import type { Encode } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -23,7 +23,7 @@ export interface HydratableEntry { export interface RenderContext { hydratables: Map; - cache: Map; + cache: Map>; } export interface SyncRenderOutput { diff --git a/packages/svelte/src/internal/shared/cache-observer.js b/packages/svelte/src/internal/shared/cache-observer.js deleted file mode 100644 index 18f50226e6..0000000000 --- a/packages/svelte/src/internal/shared/cache-observer.js +++ /dev/null @@ -1,78 +0,0 @@ -/** @import { CacheEntry } from '#shared' */ - -/** - * @template T - * @implements {ReadonlyMap} */ -export class BaseCacheObserver { - /** - * This is a function so that you can create an ObservableCache instance globally and as long as you don't actually - * use it until you're inside the server render lifecycle you'll be okay - * @type {() => Map} - */ - #get_cache; - - /** @type {string} */ - #prefix; - - /** - * @param {() => Map} get_cache - * @param {string} [prefix] - */ - constructor(get_cache, prefix = '') { - this.#get_cache = get_cache; - this.#prefix = prefix; - } - - /** @param {string} key */ - get(key) { - const entry = this.#get_cache().get(this.#key(key)); - return entry?.item; - } - - /** @param {string} key */ - has(key) { - return this.#get_cache().has(this.#key(key)); - } - - get size() { - return [...this.keys()].length; - } - - /** @param {(item: T, key: string, map: ReadonlyMap) => void} cb */ - forEach(cb) { - for (const [key, entry] of this.entries()) { - cb(entry, key, this); - } - } - - *entries() { - for (const [key, entry] of this.#get_cache().entries()) { - if (!key.startsWith(this.#prefix)) continue; - yield /** @type {[string, T]} */ ([key, entry.item]); - } - return undefined; - } - - *keys() { - for (const [key] of this.entries()) { - yield key; - } - return undefined; - } - - *values() { - for (const [, entry] of this.entries()) { - yield entry; - } - return undefined; - } - - [Symbol.iterator]() { - return this.entries(); - } - - /** @param {string} key */ - #key(key) { - return this.#prefix + key; - } -} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 668ee7f785..4c5eeed601 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -53,4 +53,4 @@ export type Resource = { export type GetRequestInit = Omit & { method?: 'GET' }; -export type CacheEntry = { count: number; item: any }; +export type CacheEntry = { count: number; item: T }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 8b2abd9076..b0418c60ea 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,7 +7,7 @@ export { SvelteURLSearchParams } from './url-search-params.js'; export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; export { resource } from '../internal/client/reactivity/resource.js'; -export { cache, CacheObserver } from '../internal/client/reactivity/cache.js'; +export { ReactiveCache } 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 0dcc459e64..1125cdbd21 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,6 +1,6 @@ /** @import { Resource as ResourceType } from '#shared' */ export { resource } from '../internal/server/reactivity/resource.js'; -export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; +export { ReactiveCache } from '../internal/server/reactivity/cache.js'; export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date;