/** * memoizedFn — Simple argument memoization without MobX. * * Replaces computedFnDeepCompare for query functions. * With lazy subscribe (SubscribableArray), there's no urgency to clean up * cached entries — unsubscribed entries are just plain arrays in memory. * * refCountedMemoizedFn — Ref-counted memoization for reactive results. * Returns a wrapper that increments a ref count on each call and decrements * on dispose(). When refCount hits 0, the entry is cleaned up (with optional * grace period). */ import { isEqual } from 'lodash-es' import { Thread } from '../thread/basic.ts' /** Deep-compare args, but Thread instances by identity (same as MobX version) */ function compareStructural(argsA: any[], argsB: any[], versionsA?: number[]): boolean { if (argsA.length !== argsB.length) return false for (let i = 0; i < argsA.length; i++) { if (argsA[i] instanceof Thread) { if (argsB[i] !== argsA[i]) return false // Check if thread was mutated since the cache entry was created if (versionsA && versionsA[i] !== undefined && argsB[i]._version !== versionsA[i]) return false } else { if (!isEqual(argsA[i], argsB[i])) return false } } return true } /** Snapshot Thread._version for each Thread arg */ function snapshotVersions(args: any[]): number[] { return args.map(a => a instanceof Thread ? a._version : undefined) } interface MemoizedFnOptions { argsEqual?: (a: any[], b: any[]) => boolean argsDebugName?: (...args: any[]) => string /** Max cache entries. Oldest evicted when exceeded. Default: unlimited. */ maxSize?: number } export function memoizedFn any>( name: string, fn: T, opts?: MemoizedFnOptions, ): T { // TODO: cache grows unboundedly by default — see todo/2026-04-17_ciao-mobx/memoized-cache-cleanup.md for analysis const cache: Array<{ args: any[]; versions: number[]; result: any }> = [] const argsEqual = opts?.argsEqual ?? compareStructural const maxSize = opts?.maxSize ?? Infinity return function (this: any, ...args: any[]) { const existing = cache.find(entry => argsEqual(entry.args, args, entry.versions)) if (existing) return existing.result const result = fn.apply(this, args) if (cache.length >= maxSize) { cache.shift() // evict oldest } cache.push({ args, versions: snapshotVersions(args), result }) return result } as any } // ═══════════════════════════════════════════════════════════════ // refCountedMemoizedFn — ref-counted cache with disposal // ═══════════════════════════════════════════════════════════════ /** A result wrapper returned by refCountedMemoizedFn. Call release() when done. */ export interface RefCounted { readonly value: T /** Decrement ref count. When it reaches 0, the cache entry is cleaned up. */ release(): void } interface RefCountedOptions any> { argsEqual?: (a: any[], b: any[]) => boolean argsDebugName?: (...args: any[]) => string /** * Called when the last reference is released and the entry is evicted. * Use to tear down subscriptions, dispose SubscribableArrays, etc. */ onCleanup?: (result: ReturnType, ...args: Parameters) => void /** * Grace period in ms before actually cleaning up after refCount hits 0. * If someone re-acquires within this window, the entry is reused. * Default: 0 (immediate cleanup). */ gracePeriodMs?: number } interface CacheEntry { args: any[] result: T refCount: number graceTimer: ReturnType | null } /** * Creates a memoized function whose results are ref-counted. * * Currently unused — doesn't fit liveQuery's recursive multi-step structure * (API break, cache loss on pattern change without grace period). * See todo/2026-04-17_ciao-mobx/memoized-cache-cleanup.md for full analysis. * * Each call returns `RefCounted>`. Multiple calls with the same * args return the same cached result with an incremented ref count. * When all holders call `release()`, the cache entry is cleaned up * (subject to optional grace period). * * Usage: * ``` * const getFiltered = refCountedMemoizedFn('rollingFilter', (thread, pattern) => { * // ... expensive setup, returns MappedThread * }, { onCleanup: (result) => result.dispose() }) * * const ref = getFiltered(myThread, myPattern) * // use ref.value (the MappedThread) * ref.release() // decrement; when last holder releases, onCleanup fires * ``` */ export function refCountedMemoizedFn any>( name: string, fn: T, opts?: RefCountedOptions, ): (...args: Parameters) => RefCounted> { const cache: CacheEntry>[] = [] const argsEqual = opts?.argsEqual ?? compareStructural const gracePeriodMs = opts?.gracePeriodMs ?? 0 function evict(entry: CacheEntry>, args: any[]) { const idx = cache.indexOf(entry) if (idx >= 0) cache.splice(idx, 1) opts?.onCleanup?.(entry.result, ...args as any) } return function (this: any, ...args: Parameters): RefCounted> { let entry = cache.find(e => argsEqual(e.args, args)) if (entry) { // Cancel pending grace-period cleanup if (entry.graceTimer !== null) { clearTimeout(entry.graceTimer) entry.graceTimer = null } entry.refCount++ } else { const result = fn.apply(this, args) entry = { args, result, refCount: 1, graceTimer: null } cache.push(entry) } const capturedEntry = entry let released = false return { get value() { return capturedEntry.result }, release() { if (released) return // idempotent released = true capturedEntry.refCount-- if (capturedEntry.refCount <= 0) { if (gracePeriodMs > 0) { capturedEntry.graceTimer = setTimeout(() => { if (capturedEntry.refCount <= 0) { evict(capturedEntry, args) } }, gracePeriodMs) } else { evict(capturedEntry, args) } } }, } } }