diff --git a/assets/js/utils/debounced-cache.ts b/assets/js/utils/debounced-cache.ts new file mode 100644 index 00000000..c75d357b --- /dev/null +++ b/assets/js/utils/debounced-cache.ts @@ -0,0 +1,129 @@ +export interface DebouncedCacheParams { + /** + * Time in milliseconds to wait before calling the function. + */ + thresholdMs?: number; +} + +/** + * Wraps a function, caches its results and debounces calls to it. + * + * *Debouncing* means that if the function is called multiple times within + * the `thresholdMs` interval, then every new call resets the timer + * and only the last call to the function will be executed after the timer + * reaches the `thresholdMs` value. Also, in-progress operation + * will be aborted, however, the result will still be cached, only the + * result processing callback will not be called. + * + * See more details about the concept of debouncing here: + * https://lodash.com/docs/4.17.15#debounce. + * + * + * If the function is called with the arguments that were already cached, + * then the cached result will be returned immediately and the previous + * scheduled call will be cancelled. + */ +export class DebouncedCache { + private thresholdMs: number; + private cache = new Map>(); + private func: (params: Params) => Promise; + + private lastSchedule?: { + timeout?: ReturnType; + abortController: AbortController; + }; + + constructor(func: (params: Params) => Promise, params?: DebouncedCacheParams) { + this.thresholdMs = params?.thresholdMs ?? 300; + this.func = func; + } + + /** + * Schedules a call to the wrapped function, that will take place only after + * a `thresholdMs` delay given no new calls to `schedule` are made within that + * time frame. If they are made, than the scheduled call will be canceled. + */ + schedule(params: Params, onResult: (result: R) => void): void { + this.abortLastSchedule(`[DebouncedCache] A new call to '${this.func.name}' was scheduled`); + + const abortController = new AbortController(); + const abortSignal = abortController.signal; + const key = JSON.stringify(params); + + if (this.cache.has(key)) { + this.subscribe(this.cache.get(key)!, abortSignal, onResult); + this.lastSchedule = { abortController }; + return; + } + + const afterTimeout = () => { + // This can't be triggered via the public API of this class, because we cancel + // the setTimeout call when abort is triggered, but it's here just in case + /* v8 ignore start */ + if (this.shouldAbort(abortSignal)) { + return; + } + /* v8 ignore end */ + + // In theory, we could pass the abort signal to the function, but we don't + // do that and let the function run even if it was aborted, and then cache + // its result. This works well under the assumption that the function isn't + // too expensive to run (like a quick GET request), so aborting it in the + // middle wouldn't save too much resources. If needed, we can make this + // behavior configurable in the future. + const promise = this.func.call(null, params); + + // We don't remove an entry from the cache if the promise is rejected. + // We expect that the underlying function will handle the errors and + // do the retries internally if necessary. + this.cache.set(key, promise); + + this.subscribe(promise, abortSignal, onResult); + }; + + this.lastSchedule = { + timeout: setTimeout(afterTimeout, this.thresholdMs), + abortController, + }; + } + + private shouldAbort(abortSignal: AbortSignal) { + if (abortSignal.aborted) { + console.debug(`A call was aborted after the debounce threshold was reached`, abortSignal.reason); + } + return abortSignal.aborted; + } + + private async subscribe(promise: Promise, abortSignal: AbortSignal, onResult: (result: R) => void): Promise { + if (this.shouldAbort(abortSignal)) { + return; + } + + let result; + try { + result = await promise; + } catch (error) { + console.error(`An error occurred while calling '${this.func.name}'.`, error); + return; + } + + if (this.shouldAbort(abortSignal)) { + return; + } + + try { + onResult(result); + } catch (error) { + console.error(`An error occurred while processing the result of '${this.func.name}'.`, error); + } + } + + abortLastSchedule(reason: string): void { + if (!this.lastSchedule) { + return; + } + + clearTimeout(this.lastSchedule.timeout); + this.lastSchedule.abortController.abort(new DOMException(reason, 'AbortError')); + } +}