Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 194 additions & 66 deletions packages/react-cache/src/LRU.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
value: T,
onDelete: () => mixed,
previous: Entry<T>,
next: Entry<T>,
previous: Entry<T> | null,
next: Entry<T> | null,
};

type LRU<T> = {
add(value: Object, onDelete: () => mixed): Entry<Object>,
add(value: T, onDelete: () => mixed): Entry<T>,
update(entry: Entry<T>, newValue: T): void,
access(entry: Entry<T>): T,
remove(entry: Entry<T>): void,
setLimit(newLimit: number): void,
getSize(): number,
getLimit(): number,
dump(): Array<T>,
};

export function createLRU<T>(limit: number): LRU<T> {
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<T> | 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);
}
}

Expand All @@ -54,103 +70,215 @@ export function createLRU<T>(limit: number): LRU<T> {
}

function deleteLeastRecentlyUsedEntries(targetSize: number) {
// Delete entries from the cache, starting from the end of the list.
if (first !== null) {
const resolvedFirst: Entry<T> = (first: any);
let last: null | Entry<T> = 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<Object> {
const entry = {
function add(value: T, onDelete: () => mixed): Entry<T> {
const entry: Entry<T> = {
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<T>, 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>): T {
const next = entry.next;
if (next !== null) {
// Entry already cached
const resolvedFirst: Entry<T> = (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<T>): 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<T> {
const out: Array<T> = [];
if (!first) return out;

let current: Entry<T> | 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<T> = (Object.freeze: any)({
add,
update,
access,
remove,
setLimit,
};
getSize,
getLimit,
dump,
});

return api;
}