Skip to content

Commit d840cf1

Browse files
committed
feat: replace circular queue with LRU queue
1 parent f5ea257 commit d840cf1

2 files changed

Lines changed: 142 additions & 56 deletions

File tree

src/react/utils.ts

Lines changed: 136 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,19 @@ export const getKey = (e: ItemElement, i: number): React.Key => {
5151
return key != null ? key : "_" + i;
5252
};
5353

54+
const clamp = (value: number, minValue: number, maxValue: number): number =>
55+
Math.min(maxValue, Math.max(minValue, value));
56+
57+
/**
58+
* A cache implementation that stores values indexed by number, with optional LRU (Least Recently Used) eviction.
59+
*
60+
* - Uses a sparse array for efficient range cleanup and memory usage.
61+
* - Optionally limits the number of cached items using a numerical LRU queue.
62+
* - Provides methods to get, set, and clear cached values by index or range.
63+
*/
5464
export class IndexedCache<T> {
5565
#cache: T[];
56-
#cachedIndicesOrder: CircularUniqueQueue<number> | undefined;
66+
#cachedIndicesOrder: NumericalLruQueue | undefined;
5767

5868
constructor(length: number, cacheLimit: number | undefined | null) {
5969
// Using a sparse array instead of a Map enables faster cleanup of a specific
@@ -62,11 +72,11 @@ export class IndexedCache<T> {
6272
// benefits of both an array and a hash map.
6373
this.#cache = new Array<T>(length);
6474

65-
// Using a circular queue for indices order enables deletion of the oldest
75+
// Using a LRU queue for indices order enables deletion of the oldest
6676
// value when threshold is reached, to not exceed the limit, but yet keep
6777
// the most recent values in the cache.
6878
if (cacheLimit && cacheLimit < length) {
69-
this.#cachedIndicesOrder = new CircularUniqueQueue<number>(cacheLimit);
79+
this.#cachedIndicesOrder = new NumericalLruQueue(length, cacheLimit);
7080
}
7181
}
7282

@@ -77,7 +87,7 @@ export class IndexedCache<T> {
7787
set(i: number, value: T) {
7888
if (this.#cachedIndicesOrder && this.#cache[i] === undefined) {
7989
const cacheIndexToRemove = this.#cachedIndicesOrder.enqueue(i);
80-
if (cacheIndexToRemove !== undefined) {
90+
if (cacheIndexToRemove !== null) {
8191
delete this.#cache[cacheIndexToRemove];
8292
}
8393
}
@@ -87,74 +97,145 @@ export class IndexedCache<T> {
8797

8898
clearRange(
8999
startIndex: number | undefined = 0,
90-
endIndex: number | undefined = this.#cache.length
100+
endIndex: number | undefined = this.#cache.length - 1
91101
) {
92-
const deletedIndices = this.#cachedIndicesOrder && new Set<number>();
93-
94-
for (const indexStr in this.#cache) {
95-
const index = Number(indexStr);
96-
if (index >= startIndex || index < endIndex) {
97-
delete this.#cache[index];
102+
startIndex = clamp(startIndex, 0, this.#cache.length - 1);
103+
endIndex = clamp(endIndex, 0, this.#cache.length - 1);
98104

99-
if (deletedIndices) {
100-
deletedIndices.add(index);
101-
}
102-
}
103-
104-
if (index >= endIndex) {
105-
break;
106-
}
105+
for (let i = startIndex; i <= endIndex; i++) {
106+
delete this.#cache[i];
107107
}
108108

109-
if (deletedIndices && this.#cachedIndicesOrder) {
110-
this.#cachedIndicesOrder.filter((i) => deletedIndices.has(i));
111-
}
109+
this.#cachedIndicesOrder?.clearRange(startIndex, endIndex);
112110
}
113111
}
114112

115-
class CircularUniqueQueue<T> {
116-
#data: (T | undefined)[];
117-
#head = 0;
118-
#tail = 0;
119-
#size = 0;
120-
#capacity: number;
121-
#set: Set<T>;
113+
/**
114+
* A numerical Least Recently Used (LRU) queue optimized for integer keys in the range [0, maxValue).
115+
*
116+
* This class maintains a fixed-capacity LRU queue using efficient array-based doubly-linked lists,
117+
* allowing for fast O(1) operations for insertion, access, and removal. It is designed for scenarios
118+
* where the set of possible keys is known and bounded, such as managing resources or cache entries
119+
* indexed by small integers.
120+
*
121+
* @example
122+
* ```typescript
123+
* const lru = new NumericalLruQueue(100, 10); // maxValue = 100, capacity = 10
124+
* lru.enqueue(7); // Access or insert 7, evict if over capacity
125+
* lru.dequeue(); // Remove and return the least recently used element
126+
* lru.clearRange(0, 9); // Remove all elements in the range [0, 9]
127+
* ```
128+
*
129+
* @remarks
130+
* - Keys must be integers in the range [0, maxValue).
131+
* - Not thread-safe.
132+
*
133+
* @public
134+
*/
135+
class NumericalLruQueue {
136+
readonly #capacity: number;
137+
readonly #maxValue: number;
138+
139+
// Doubly-linked list arrays (store indices into 0..maxValue-1)
140+
#next: Int32Array; // length = maxValue
141+
#prev: Int32Array; // length = maxValue
142+
143+
// Tracks whether element is in the LRU (and if so, points to itself)
144+
// -1 means "not present"
145+
#position: Int32Array; // length = maxValue
146+
147+
#head: number; // LRU oldest
148+
#tail: number; // LRU newest
149+
#size: number;
122150

123-
constructor(capacity: number) {
124-
this.#data = new Array<T>(capacity);
151+
constructor(maxValue: number, capacity: number) {
152+
this.#maxValue = maxValue;
125153
this.#capacity = capacity;
126-
this.#set = new Set<T>();
127-
}
128154

129-
enqueue(item: T) {
130-
if (this.#set.has(item)) return undefined;
131-
const dequeuedItem =
132-
this.#size === this.#capacity ? this.dequeue() : undefined;
155+
this.#next = new Int32Array(maxValue);
156+
this.#prev = new Int32Array(maxValue);
157+
this.#position = new Int32Array(maxValue);
133158

134-
this.#data[this.#tail] = item;
135-
this.#set.add(item);
136-
this.#tail = (this.#tail + 1) % this.#capacity;
137-
this.#size++;
138-
return dequeuedItem;
159+
this.#position.fill(-1);
160+
161+
this.#head = -1;
162+
this.#tail = -1;
163+
this.#size = 0;
139164
}
140165

141-
dequeue() {
142-
if (this.#size === 0) return undefined;
143-
const item = this.#data[this.#head];
144-
if (item !== undefined) this.#set.delete(item);
166+
/**
167+
* Adds the item at the specified index to the queue and manages the queue size.
168+
* If adding the item causes the queue to exceed its capacity, the oldest item is dequeued and its value is returned.
169+
* Otherwise, returns `null`.
170+
*/
171+
enqueue(i: number): number | null {
172+
if (i < 0 || i >= this.#maxValue) return null;
173+
174+
this.#access(i);
175+
if (this.#size > this.#capacity) {
176+
return this.dequeue();
177+
}
178+
return null;
179+
}
145180

146-
this.#data[this.#head] = undefined;
147-
this.#head = (this.#head + 1) % this.#capacity;
148-
this.#size--;
149-
return item;
181+
/** Evicts the least used item. If the queue is empty, returns null. */
182+
dequeue(): number | null {
183+
if (this.#head === -1) return null;
184+
const victim = this.#head;
185+
this.#detach(victim);
186+
return victim;
150187
}
151188

152-
filter(predicate: (item: T) => boolean) {
153-
for (let i = 0; i < this.#size; i++) {
154-
const item = this.dequeue();
155-
if (item !== undefined && predicate(item)) {
156-
this.enqueue(item);
189+
/** Efficiently finds and detaches all items within the desired range. */
190+
clearRange(startIndex: number, endIndex: number): void {
191+
startIndex = clamp(startIndex, 0, this.#maxValue - 1);
192+
endIndex = clamp(endIndex, 0, this.#maxValue - 1);
193+
194+
for (let i = startIndex; i <= endIndex; i++) {
195+
if (this.#position[i] !== -1) {
196+
this.#detach(i);
157197
}
158198
}
159199
}
200+
201+
#access(i: number): void {
202+
if (this.#position[i] === -1) {
203+
this.#insertAtTail(i);
204+
} else {
205+
this.#detach(i);
206+
this.#append(i);
207+
}
208+
}
209+
210+
#insertAtTail(i: number): void {
211+
this.#append(i);
212+
this.#size++;
213+
this.#position[i] = i;
214+
}
215+
216+
#append(i: number): void {
217+
this.#prev[i] = this.#tail;
218+
this.#next[i] = -1;
219+
if (this.#tail !== -1) this.#next[this.#tail] = i;
220+
this.#tail = i;
221+
if (this.#head === -1) this.#head = i;
222+
this.#position[i] = i;
223+
}
224+
225+
#detach(i: number): void {
226+
const p = this.#prev[i]!;
227+
const n = this.#next[i]!;
228+
229+
if (p !== -1) this.#next[p] = n;
230+
if (n !== -1) this.#prev[n] = p;
231+
232+
if (this.#head === i) this.#head = n;
233+
if (this.#tail === i) this.#tail = p;
234+
235+
this.#prev[i] = -1;
236+
this.#next[i] = -1;
237+
this.#position[i] = -1;
238+
239+
this.#size--;
240+
}
160241
}

stories/react/basics/VList.stories.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,12 @@ export const RenderPropCache: StoryObj = {
400400
append more
401401
</button>
402402
</div>
403-
<VList style={{ flex: 1 }} data={items} enableRenderCache>
403+
<VList
404+
style={{ flex: 1 }}
405+
data={items}
406+
enableRenderCache
407+
maxCacheSize={500}
408+
>
404409
{(item, i) => {
405410
return (
406411
<div

0 commit comments

Comments
 (0)