Skip to content
Open
Show file tree
Hide file tree
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
36 changes: 36 additions & 0 deletions src/lru-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// A tiny bounded LRU cache.
//
// measureText() and wrapText() keep module-level caches that previously had no
// eviction, so every distinct string an app ever rendered was retained for the
// lifetime of the process — a streaming/typing indicator, a growing log or a
// clock leaked monotonically until OOM (#986). Capping the caches bounds memory
// while keeping recently-used entries hot.
export class LruCache<K, V> {
private readonly cache = new Map<K, V>();
private readonly maxSize: number;

constructor(maxSize: number) {
this.maxSize = maxSize;
}

get(key: K): V | undefined {
if (!this.cache.has(key)) {
return undefined;
}

const value = this.cache.get(key) as V;
// Re-insert so the entry becomes the most recently used.
this.cache.delete(key);
this.cache.set(key, value);
return value;
}

set(key: K, value: V): void {
if (this.cache.size >= this.maxSize) {
// Map iterates in insertion order; the first key is least recently used.
this.cache.delete(this.cache.keys().next().value as K);
}

this.cache.set(key, value);
}
}
3 changes: 2 additions & 1 deletion src/measure-text.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import widestLine from 'widest-line';
import {LruCache} from './lru-cache.js';

const cache = new Map<string, Output>();
const cache = new LruCache<string, Output>(1000);

type Output = {
width: number;
Expand Down
7 changes: 4 additions & 3 deletions src/wrap-text.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import wrapAnsi from 'wrap-ansi';
import cliTruncate from 'cli-truncate';
import {type Styles} from './styles.js';
import {LruCache} from './lru-cache.js';

const cache: Record<string, string> = {};
const cache = new LruCache<string, string>(1000);

const wrapText = (
text: string,
maxWidth: number,
wrapType: Styles['textWrap'],
): string => {
const cacheKey = text + String(maxWidth) + String(wrapType);
const cachedText = cache[cacheKey];
const cachedText = cache.get(cacheKey);

if (cachedText) {
return cachedText;
Expand Down Expand Up @@ -47,7 +48,7 @@ const wrapText = (
wrappedText = cliTruncate(text, maxWidth, {position});
}

cache[cacheKey] = wrappedText;
cache.set(cacheKey, wrappedText);

return wrappedText;
};
Expand Down
42 changes: 42 additions & 0 deletions test/lru-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import test from 'ava';
import {LruCache} from '../src/lru-cache.js';

test('evicts the least recently used entry when the cap is reached', t => {
const cache = new LruCache<string, number>(2);

cache.set('a', 1);
cache.set('b', 2);
// 'a' is now the least recently used.
cache.set('c', 3);

t.is(cache.get('a'), undefined);
t.is(cache.get('b'), 2);
t.is(cache.get('c'), 3);
});

test('refreshes recency on read so frequently-used entries are retained', t => {
const cache = new LruCache<string, number>(2);

cache.set('a', 1);
cache.set('b', 2);
// Reading 'a' makes it the most recently used, so 'b' becomes the victim.
t.is(cache.get('a'), 1);
cache.set('c', 3);

t.is(cache.get('a'), 1);
t.is(cache.get('b'), undefined);
t.is(cache.get('c'), 3);
});

test('returns undefined for a missing key without affecting recency', t => {
const cache = new LruCache<string, number>(2);

cache.set('a', 1);
cache.set('b', 2);
t.is(cache.get('missing'), undefined);
// 'a' is still the least recently used.
cache.set('c', 3);

t.is(cache.get('a'), undefined);
t.is(cache.get('b'), 2);
});