From 2e292b1a6d0e19512ed54ef0d9671a5d41eb5e36 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 29 Oct 2025 15:52:20 -0600 Subject: [PATCH] cache observer --- .../src/internal/client/reactivity/cache.js | 19 ++-- .../src/internal/server/reactivity/cache.js | 20 ++-- .../src/internal/server/render-context.js | 3 +- .../svelte/src/internal/server/types.d.ts | 3 +- .../src/internal/shared/cache-observer.js | 100 ++++++++++++++---- .../src/internal/shared/observable-cache.js | 88 +++++++++++++++ .../svelte/src/internal/shared/types.d.ts | 2 + .../svelte/src/reactivity/index-client.js | 2 +- .../svelte/src/reactivity/index-server.js | 2 +- packages/svelte/types/index.d.ts | 60 ++++++++++- 10 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 packages/svelte/src/internal/shared/observable-cache.js diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index e61d13b5e2..7b38271e66 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,11 +1,12 @@ import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { ObservableCache } from '../../shared/observable-cache.js'; import { set_hydratable_key } from '../context.js'; import { tick } from '../runtime.js'; import { render_effect } from './effects.js'; /** @typedef {{ count: number, item: any }} Entry */ -/** @type {Map} */ -const client_cache = new Map(); +/** @type {ObservableCache} */ +const client_cache = new ObservableCache(); /** * @template {(...args: any[]) => any} TFn @@ -68,8 +69,12 @@ function create_remover(key) { }); } -// export class CacheObserver extends BaseCacheObserver { -// constructor() { -// super(client_cache); -// } -// } +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + super(() => client_cache, prefix); + } +} diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index 3e057cf92f..d38be324ec 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,5 +1,6 @@ -import { set_hydratable_key } from '../hydratable'; -import { get_render_context } from '../render-context'; +import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { set_hydratable_key } from '../hydratable.js'; +import { get_render_context } from '../render-context.js'; /** * @template {(...args: any[]) => any} TFn @@ -20,9 +21,12 @@ export function cache(key, fn) { return new_entry; } -// TODO, has to be async -// export class CacheObserver extends BaseCacheObserver { -// constructor() { -// super(get_render_store().cache); -// } -// } +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + super(() => get_render_context().cache, prefix); + } +} diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js index e86ee813c7..83d3b3b3f3 100644 --- a/packages/svelte/src/internal/server/render-context.js +++ b/packages/svelte/src/internal/server/render-context.js @@ -1,6 +1,7 @@ /** @import { AsyncLocalStorage } from 'node:async_hooks' */ /** @import { RenderContext } from '#server' */ +import { ObservableCache } from '../shared/observable-cache'; import { deferred } from '../shared/utils'; /** @type {Promise | null} */ @@ -63,7 +64,7 @@ export async function with_render_context(fn) { try { sync_context = { hydratables: new Map(), - cache: new Map() + cache: new ObservableCache() }; if (in_webcontainer()) { const { promise, resolve } = deferred(); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 6563fdf6f9..cfe7e92ad2 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,4 +1,5 @@ import type { Transport } from '#shared'; +import type { ObservableCache } from '../shared/observable-cache'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -23,7 +24,7 @@ export interface RenderContext { transport: Transport | undefined; } >; - cache: Map; + cache: ObservableCache; } export interface SyncRenderOutput { diff --git a/packages/svelte/src/internal/shared/cache-observer.js b/packages/svelte/src/internal/shared/cache-observer.js index 1dd72b2b34..b51aa2e1a0 100644 --- a/packages/svelte/src/internal/shared/cache-observer.js +++ b/packages/svelte/src/internal/shared/cache-observer.js @@ -1,56 +1,112 @@ -/** @implements {ReadonlyMap} */ +/** @import { ObservableCache } from './observable-cache.js' */ + +/** + * @template T + * @implements {ReadonlyMap} */ export class BaseCacheObserver { - /** @type {ReadonlyMap} */ - #cache; + /** + * This is a function so that you can create an ObservableCache instance globally and as long as you don't actually + * use it until you're inside the server render lifecycle you'll be okay + * @type {() => ObservableCache} + */ + #get_cache; + + /** @type {string} */ + #prefix; + + /** + * @param {() => ObservableCache} get_cache + * @param {string} [prefix] + */ + constructor(get_cache, prefix = '') { + this.#get_cache = get_cache; + this.#prefix = prefix; + } + + /** + * Register a callback to be called when a new key is inserted + * @param {(key: string, value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onInsert(callback) { + return this.#get_cache().on_insert((key, value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, value.item); + }); + } + + /** + * Register a callback to be called when an existing key is updated + * @param {(key: string, value: T, old_value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onUpdate(callback) { + return this.#get_cache().on_update((key, value, old_value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, value.item, old_value.item); + }); + } - /** @param {Map} cache */ - constructor(cache) { - this.#cache = cache; + /** + * Register a callback to be called when a key is deleted + * @param {(key: string, old_value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onDelete(callback) { + return this.#get_cache().on_delete((key, old_value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, old_value.item); + }); } - /** @type {ReadonlyMap['get']} */ + /** @param {string} key */ get(key) { - const entry = this.#cache.get(key); + const entry = this.#get_cache().get(this.#key(key)); return entry?.item; } - /** @type {ReadonlyMap['has']} */ + /** @param {string} key */ has(key) { - return this.#cache.has(key); + return this.#get_cache().has(this.#key(key)); } - /** @type {ReadonlyMap['size']} */ get size() { - return this.#cache.size; + return [...this.keys()].length; } - /** @type {ReadonlyMap['forEach']} */ + /** @param {(item: T, key: string, map: ReadonlyMap) => void} cb */ forEach(cb) { - this.#cache.forEach((entry, key) => cb(entry.item, key, this)); + this.entries().forEach(([key, entry]) => cb(entry, key, this)); } - /** @type {ReadonlyMap['entries']} */ *entries() { - for (const [key, entry] of this.#cache.entries()) { - yield [key, entry.item]; + for (const [key, entry] of this.#get_cache().entries()) { + if (!key.startsWith(this.#prefix)) continue; + yield /** @type {[string, T]} */ ([key, entry.item]); } + return undefined; } - /** @type {ReadonlyMap['keys']} */ *keys() { - for (const key of this.#cache.keys()) { + for (const [key] of this.entries()) { yield key; } + return undefined; } - /** @type {ReadonlyMap['values']} */ *values() { - for (const entry of this.#cache.values()) { - yield entry.item; + for (const [, entry] of this.entries()) { + yield entry; } + return undefined; } [Symbol.iterator]() { return this.entries(); } + + /** @param {string} key */ + #key(key) { + return this.#prefix + key; + } } diff --git a/packages/svelte/src/internal/shared/observable-cache.js b/packages/svelte/src/internal/shared/observable-cache.js new file mode 100644 index 0000000000..30a68a4c67 --- /dev/null +++ b/packages/svelte/src/internal/shared/observable-cache.js @@ -0,0 +1,88 @@ +/** @import { CacheEntry } from '#shared' */ + +/** + * @extends {Map} + */ +export class ObservableCache extends Map { + /** @type {Set<(key: string, value: CacheEntry) => void>} */ + #insert_callbacks = new Set(); + + /** @type {Set<(key: string, value: CacheEntry, old_value: CacheEntry) => void>} */ + #update_callbacks = new Set(); + + /** @type {Set<(key: string, old_value: CacheEntry) => void>} */ + #delete_callbacks = new Set(); + + /** + * @param {(key: string, value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_insert(callback) { + this.#insert_callbacks.add(callback); + return () => this.#insert_callbacks.delete(callback); + } + + /** + * @param {(key: string, value: CacheEntry, old_value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_update(callback) { + this.#update_callbacks.add(callback); + return () => this.#update_callbacks.delete(callback); + } + + /** + * @param {(key: string, old_value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_delete(callback) { + this.#delete_callbacks.add(callback); + return () => this.#delete_callbacks.delete(callback); + } + + /** + * @param {string} key + * @param {CacheEntry} value + * @returns {this} + */ + set(key, value) { + const had = this.has(key); + if (had) { + const old_value = /** @type {CacheEntry} */ (super.get(key)); + super.set(key, value); + for (const callback of this.#update_callbacks) { + callback(key, value, old_value); + } + } else { + super.set(key, value); + for (const callback of this.#insert_callbacks) { + callback(key, value); + } + } + return this; + } + + /** + * @param {string} key + * @returns {boolean} + */ + delete(key) { + const old_value = super.get(key); + const deleted = super.delete(key); + if (deleted) { + for (const callback of this.#delete_callbacks) { + callback(key, /** @type {CacheEntry} */ (old_value)); + } + } + return deleted; + } + + clear() { + for (const [key, value] of this) { + for (const callback of this.#delete_callbacks) { + callback(key, value); + } + } + super.clear(); + } +} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 781e6e1e5e..bbe5470869 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -41,3 +41,5 @@ export type Resource = { ); export type GetRequestInit = Omit & { method?: 'GET' }; + +export type CacheEntry = { count: number; item: any }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index d1c16b1f78..8b2abd9076 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,7 +7,7 @@ export { SvelteURLSearchParams } from './url-search-params.js'; export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; export { resource } from '../internal/client/reactivity/resource.js'; -export { cache } from '../internal/client/reactivity/cache.js'; +export { cache, CacheObserver } from '../internal/client/reactivity/cache.js'; export { fetcher } from '../internal/client/reactivity/fetcher.js'; /** diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 99bb469ae2..0dcc459e64 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,6 +1,6 @@ /** @import { Resource as ResourceType } from '#shared' */ export { resource } from '../internal/server/reactivity/resource.js'; -export { cache } from '../internal/server/reactivity/cache.js'; +export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index b9db2f9861..11d349a278 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2422,7 +2422,6 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export function resource(fn: () => Promise): Resource_1; - export function cache any>(key: string, fn: TFn): ReturnType; export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; type Resource_1 = { then: Promise['then']; @@ -2444,12 +2443,71 @@ declare module 'svelte/reactivity' { ); type GetRequestInit = Omit & { method?: 'GET' }; + + type CacheEntry = { count: number; item: any }; + export function cache any>(key: string, fn: TFn): ReturnType; + + export class CacheObserver extends BaseCacheObserver { + constructor(prefix?: string); + } class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; } + class BaseCacheObserver implements ReadonlyMap { + + constructor(get_cache: () => ObservableCache, prefix?: string | undefined); + /** + * Register a callback to be called when a new key is inserted + * @returns Function to unregister the callback + */ + onInsert(callback: (key: string, value: T) => void): () => void; + /** + * Register a callback to be called when an existing key is updated + * @returns Function to unregister the callback + */ + onUpdate(callback: (key: string, value: T, old_value: T) => void): () => void; + /** + * Register a callback to be called when a key is deleted + * @returns Function to unregister the callback + */ + onDelete(callback: (key: string, old_value: T) => void): () => void; + + get(key: string): any; + + has(key: string): boolean; + get size(): number; + + forEach(cb: (item: T, key: string, map: ReadonlyMap) => void): void; + entries(): Generator<[string, T], undefined, unknown>; + keys(): Generator; + values(): Generator; + [Symbol.iterator](): Generator<[string, T], undefined, unknown>; + #private; + } + class ObservableCache extends Map { + constructor(); + constructor(entries?: readonly (readonly [string, CacheEntry])[] | null | undefined); + constructor(); + constructor(iterable?: Iterable | null | undefined); + /** + * @returns Function to unregister the callback + */ + on_insert(callback: (key: string, value: CacheEntry) => void): () => void; + /** + * @returns Function to unregister the callback + */ + on_update(callback: (key: string, value: CacheEntry, old_value: CacheEntry) => void): () => void; + /** + * @returns Function to unregister the callback + */ + on_delete(callback: (key: string, old_value: CacheEntry) => void): () => void; + + set(key: string, value: CacheEntry): this; + #private; + } export {}; }