Skip to content

Commit

Permalink
Use custom cache expiration logic
Browse files Browse the repository at this point in the history
Fixes #92
  • Loading branch information
sindresorhus committed Nov 14, 2023
1 parent 3afdfaf commit c704f3b
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 7 deletions.
26 changes: 21 additions & 5 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import mimicFunction from 'mimic-function';
import mapAgeCleaner from 'map-age-cleaner';

type AnyFunction = (...arguments_: readonly any[]) => any;
type AnyFunction = (...arguments_: readonly any[]) => unknown;

const cacheStore = new WeakMap<AnyFunction, CacheStorage<any, any>>();
const cacheTimerStore = new WeakMap<AnyFunction, Set<number>>();

type CacheStorageContent<ValueType> = {
data: ValueType;
Expand Down Expand Up @@ -104,8 +104,8 @@ export default function mem<
maxAge,
}: Options<FunctionToMemoize, CacheKeyType> = {},
): FunctionToMemoize {
if (typeof maxAge === 'number') {
mapAgeCleaner(cache as unknown as Map<CacheKeyType, ReturnType<FunctionToMemoize>>);
if (typeof maxAge === 'number' && maxAge <= 0) {
return fn;
}

const memoized = function (this: any, ...arguments_: Parameters<FunctionToMemoize>): ReturnType<FunctionToMemoize> {
Expand All @@ -123,6 +123,18 @@ export default function mem<
maxAge: maxAge ? Date.now() + maxAge : Number.POSITIVE_INFINITY,
});

if (typeof maxAge === 'number' && maxAge !== Number.POSITIVE_INFINITY) {
const timer = setTimeout(() => {
cache.delete(key);
}, maxAge);

timer.unref?.();

const timers = cacheTimerStore.get(fn) ?? new Set();
timers.add(timer as any); // eslint-disable-line @typescript-eslint/no-unsafe-argument
cacheTimerStore.set(fn, timers);
}

return result;
} as FunctionToMemoize;

Expand Down Expand Up @@ -198,7 +210,7 @@ export function memDecorator<
/**
Clear all cached data of a memoized function.
@param fn - Memoized function.
@param fn - The memoized function.
*/
export function memClear(fn: AnyFunction): void {
const cache = cacheStore.get(fn);
Expand All @@ -211,4 +223,8 @@ export function memClear(fn: AnyFunction): void {
}

cache.clear();

for (const timer of cacheTimerStore.get(fn) ?? []) {
clearTimeout(timer);
}
}
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"promise"
],
"dependencies": {
"map-age-cleaner": "^0.2.0",
"mimic-function": "^5.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Clear all cached data of a memoized function.

Type: `Function`

Memoized function.
The memoized function.

## Tips

Expand Down
76 changes: 76 additions & 0 deletions test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,79 @@ test('memClear() throws when called on an unclearable cache', t => {
instanceOf: TypeError,
});
});

test('maxAge - cache item expires after specified duration', async t => {
let i = 0;
const fixture = () => i++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized(), 0); // Initial call, cached
t.is(memoized(), 0); // Subsequent call, still cached
await delay(150); // Wait for longer than maxAge
t.is(memoized(), 1); // Cache expired, should compute again
});

test('maxAge - cache expiration timing is accurate', async t => {
let i = 0;
const fixture = () => i++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized(), 0);
await delay(90); // Wait for slightly less than maxAge
t.is(memoized(), 0); // Should still be cached
await delay(20); // Total delay now exceeds maxAge
t.is(memoized(), 1); // Should recompute as cache has expired
});

test('maxAge - expired items are not present in cache', async t => {
let i = 0;
const fixture = () => i++;
const cache = new Map();
const memoized = mem(fixture, {maxAge: 100, cache});

memoized(); // Call to cache the result
await delay(150); // Wait for cache to expire
memoized(); // Recompute and recache
t.is(cache.size, 1); // Only one item should be in the cache
});

test('maxAge - complex arguments and cache expiration', async t => {
let i = 0;
const fixture = object => i++;
const memoized = mem(fixture, {maxAge: 100, cacheKey: JSON.stringify});

const arg = {key: 'value'};
t.is(memoized(arg), 0);
await delay(150);
t.is(memoized(arg), 1); // Argument is the same, but should recompute due to expiration
});

test('maxAge - concurrent calls return cached value', async t => {
let i = 0;
const fixture = () => i++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized(), 0);
await delay(50); // Delay less than maxAge
t.is(memoized(), 0); // Should return cached value
});

test('maxAge - different arguments have separate expirations', async t => {
let i = 0;
const fixture = x => i++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized('a'), 0);
await delay(150); // Expire the cache for 'a'
t.is(memoized('b'), 1); // 'b' should be a separate cache entry
t.is(memoized('a'), 2); // 'a' should be recomputed
});

test('maxAge - zero maxAge means no caching', t => {
let i = 0;
const fixture = () => i++;
const memoized = mem(fixture, {maxAge: 0});

t.is(memoized(), 0);
t.is(memoized(), 1); // No caching, should increment
});

0 comments on commit c704f3b

Please sign in to comment.