diff --git a/packages/react-cache/src/LRU.js b/packages/react-cache/src/LRU.js index bf4e4d4c6de..6d28008707d 100644 --- a/packages/react-cache/src/LRU.js +++ b/packages/react-cache/src/LRU.js @@ -4,47 +4,63 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * Rewritten LRU with safer flows, explicit APIs, and improved cleanup handling. * @flow */ import * as Scheduler from 'scheduler'; -// Intentionally not named imports because Rollup would -// use dynamic dispatch for CommonJS interop named imports. -const { - unstable_scheduleCallback: scheduleCallback, - unstable_IdlePriority: IdlePriority, -} = Scheduler; +// Use named identifiers from Scheduler but allow a fallback for non-React envs. +const scheduleCallback = + // $FlowFixMe[prop-missing] + (Scheduler && Scheduler.unstable_scheduleCallback) || null; +const IdlePriority = (Scheduler && Scheduler.unstable_IdlePriority) || 5; type Entry = { value: T, onDelete: () => mixed, - previous: Entry, - next: Entry, + previous: Entry | null, + next: Entry | null, }; type LRU = { - add(value: Object, onDelete: () => mixed): Entry, + add(value: T, onDelete: () => mixed): Entry, update(entry: Entry, newValue: T): void, access(entry: Entry): T, + remove(entry: Entry): void, setLimit(newLimit: number): void, + getSize(): number, + getLimit(): number, + dump(): Array, }; export function createLRU(limit: number): LRU { - let LIMIT = limit; + let LIMIT: number = Math.max(0, limit); - // Circular, doubly-linked list + // Circular, doubly-linked list head (most-recently used) let first: Entry | null = null; let size: number = 0; let cleanUpIsScheduled: boolean = false; + function scheduleWithFallback(fn: () => void) { + if (scheduleCallback) { + try { + scheduleCallback(IdlePriority, fn); + } catch (e) { + // if scheduler exists but fails for some reason, fallback + setTimeout(fn, 0); + } + } else { + // No scheduler available (e.g. non-React environment) + setTimeout(fn, 0); + } + } + function scheduleCleanUp() { - if (cleanUpIsScheduled === false && size > LIMIT) { - // The cache size exceeds the limit. Schedule a callback to delete the - // least recently used entries. + if (!cleanUpIsScheduled && size > LIMIT) { cleanUpIsScheduled = true; - scheduleCallback(IdlePriority, cleanUp); + scheduleWithFallback(cleanUp); } } @@ -54,103 +70,215 @@ export function createLRU(limit: number): LRU { } function deleteLeastRecentlyUsedEntries(targetSize: number) { - // Delete entries from the cache, starting from the end of the list. - if (first !== null) { - const resolvedFirst: Entry = (first: any); - let last: null | Entry = resolvedFirst.previous; - while (size > targetSize && last !== null) { + // Remove entries from the tail (least-recently used) + while (size > targetSize && first !== null) { + // Tail is previous of head + const last = first.previous; + if (!last) break; // defensive, shouldn't happen with circular list + + // If there's only one entry + if (last === first) { + // Capture onDelete then clear the list const onDelete = last.onDelete; - const previous = last.previous; - last.onDelete = (null: any); - - // Remove from the list - last.previous = last.next = (null: any); - if (last === first) { - // Reached the head of the list. - first = last = null; - } else { - (first: any).previous = previous; - previous.next = (first: any); - last = previous; + first = null; + size = 0; + + // Execute destructor safely + try { + onDelete(); + } catch (err) { + // Do not stop cleanup completely — log and continue + // eslint-disable-next-line no-console + console.error('LRU onDelete threw:', err); } + break; + } - size -= 1; + // More than one entry: remove 'last' node + const previous = last.previous; + const onDelete = last.onDelete; + + // Patch the circular links to remove `last` + if (previous && first) { + previous.next = first; + first.previous = previous; + } - // Call the destroy method after removing the entry from the list. If it - // throws, the rest of cache will not be deleted, but it will be in a - // valid state. + // Break links from removed node to help GC and mark as deleted + last.next = last.previous = null; + // Replace onDelete with noop to avoid double delete + last.onDelete = () => {}; + + size -= 1; + + try { onDelete(); + } catch (err) { + // eslint-disable-next-line no-console + console.error('LRU onDelete threw:', err); } } } - function add(value: Object, onDelete: () => mixed): Entry { - const entry = { + function add(value: T, onDelete: () => mixed): Entry { + const entry: Entry = { value, onDelete, - next: (null: any), - previous: (null: any), + next: null, + previous: null, }; + if (first === null) { + // first entry in list entry.previous = entry.next = entry; first = entry; } else { - // Append to head + // insert at head (most-recently used) const last = first.previous; - last.next = entry; - entry.previous = last; + if (!last) { + // Defensive: if circular invariants broken, reinitialize + entry.previous = entry.next = entry; + first = entry; + } else { + last.next = entry; + entry.previous = last; - first.previous = entry; - entry.next = first; + entry.next = first; + first.previous = entry; - first = entry; + first = entry; + } } + size += 1; + + // Schedule cleanup immediately so we don't grow unbounded + scheduleCleanUp(); + return entry; } function update(entry: Entry, newValue: T): void { + if (!entry || entry.next === null) { + // Attempt to update a deleted entry + // eslint-disable-next-line no-console + console.warn('LRU.update called on deleted or invalid entry'); + return; + } entry.value = newValue; } function access(entry: Entry): T { - const next = entry.next; - if (next !== null) { - // Entry already cached - const resolvedFirst: Entry = (first: any); - if (first !== entry) { - // Remove from current position - const previous = entry.previous; - previous.next = next; - next.previous = previous; - - // Append to head - const last = resolvedFirst.previous; + if (!entry || entry.next === null) { + throw new Error('LRU: access() called on a removed entry'); + } + + if (first !== entry) { + // Remove entry from its current position + const prev = entry.previous; + const next = entry.next; + if (!prev || !next || !first) { + // Defensive: list invariants broken + throw new Error('LRU: internal list corrupted during access'); + } + + prev.next = next; + next.previous = prev; + + // Insert at head + const last = first.previous; + if (!last) { + // Defensive re-link + entry.previous = entry.next = entry; + first = entry; + } else { last.next = entry; entry.previous = last; - resolvedFirst.previous = entry; - entry.next = resolvedFirst; + entry.next = first; + first.previous = entry; first = entry; } - } else { - // Cannot access a deleted entry - // TODO: Error? Warning? } + scheduleCleanUp(); return entry.value; } + function remove(entry: Entry): void { + if (!entry || entry.next === null) { + // Already removed or invalid + return; + } + + // Single node + if (entry === first && entry.next === entry) { + first = null; + } else { + const prev = entry.previous; + const next = entry.next; + if (prev) prev.next = next; + if (next) next.previous = prev; + + if (entry === first) { + first = next; + } + } + + size = Math.max(0, size - 1); + + const onDelete = entry.onDelete; + + // Break links and replace destructor to prevent double-calls + entry.next = entry.previous = null; + entry.onDelete = () => {}; + + try { + onDelete(); + } catch (err) { + // eslint-disable-next-line no-console + console.error('LRU onDelete threw:', err); + } + } + function setLimit(newLimit: number): void { - LIMIT = newLimit; + LIMIT = Math.max(0, newLimit); scheduleCleanUp(); } - return { + function getSize(): number { + return size; + } + + function getLimit(): number { + return LIMIT; + } + + function dump(): Array { + const out: Array = []; + if (!first) return out; + + let current: Entry | null = first; + do { + if (current) out.push(current.value); + current = current && current.next ? current.next : null; + } while (current && current !== first); + + return out; + } + + // Freeze the public API so external code cannot mutate methods + const api: LRU = (Object.freeze: any)({ add, update, access, + remove, setLimit, - }; + getSize, + getLimit, + dump, + }); + + return api; }