Repository: sindresorhus/mem (ts)

Issue:
Replace/inline dead `map-age-cleaner` package
The package is outdated and it has an old [p-defer](https://github.com/sindresorhus/p-defer/releases) dependency:

- https://github.com/SamVerschueren/map-age-cleaner/issues/9
- https://github.com/SamVerschueren/map-age-cleaner/blob/0f9f8631510815dd7f567372461fa923c25f2c46/package.json#L38

Related:

- https://github.com/sindresorhus/mem/pull/91

Pull request:
Use custom cache expiration logic
Fixes #92

Patch:
diff --git a/index.ts b/index.ts
index b945855..35bd595 100644
--- a/index.ts
+++ b/index.ts
@@ -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;
@@ -104,8 +104,19 @@ export default function mem<
 		maxAge,
 	}: Options<FunctionToMemoize, CacheKeyType> = {},
 ): FunctionToMemoize {
+	if (maxAge === 0) {
+		return fn;
+	}
+
 	if (typeof maxAge === 'number') {
-		mapAgeCleaner(cache as unknown as Map<CacheKeyType, ReturnType<FunctionToMemoize>>);
+		const maxSetIntervalValue = 2_147_483_647;
+		if (maxAge > maxSetIntervalValue) {
+			throw new TypeError(`The \`maxAge\` option cannot exceed ${maxSetIntervalValue}.`);
+		}
+
+		if (maxAge < 0) {
+			throw new TypeError('The `maxAge` option should not be a negative number.');
+		}
 	}
 
 	const memoized = function (this: any, ...arguments_: Parameters<FunctionToMemoize>): ReturnType<FunctionToMemoize> {
@@ -123,6 +134,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<number>();
+			timers.add(timer as unknown as number);
+			cacheTimerStore.set(fn, timers);
+		}
+
 		return result;
 	} as FunctionToMemoize;
 
@@ -198,7 +221,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);
@@ -210,5 +233,9 @@ export function memClear(fn: AnyFunction): void {
 		throw new TypeError('The cache Map can\'t be cleared!');
 	}
 
-	cache.clear();
+	cache.clear?.();
+
+	for (const timer of cacheTimerStore.get(fn) ?? []) {
+		clearTimeout(timer);
+	}
 }
diff --git a/package.json b/package.json
index 5c694f7..096ed43 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,6 @@
 		"promise"
 	],
 	"dependencies": {
-		"map-age-cleaner": "^0.2.0",
 		"mimic-function": "^5.0.0"
 	},
 	"devDependencies": {
diff --git a/readme.md b/readme.md
index b4ffe68..3f7bf5a 100644
--- a/readme.md
+++ b/readme.md
@@ -244,7 +244,7 @@ Clear all cached data of a memoized function.
 
 Type: `Function`
 
-Memoized function.
+The memoized function.
 
 ## Tips
 


Test patch:
diff --git a/test.ts b/test.ts
index 41aa6c0..082107e 100644
--- a/test.ts
+++ b/test.ts
@@ -4,8 +4,8 @@ import serializeJavascript from 'serialize-javascript';
 import mem, {memDecorator, memClear} from './index.js';
 
 test('memoize', t => {
-	let i = 0;
-	const fixture = (a?: unknown, b?: unknown) => i++;
+	let index = 0;
+	const fixture = (a?: unknown, b?: unknown) => index++;
 	const memoized = mem(fixture);
 	t.is(memoized(), 0);
 	t.is(memoized(), 0);
@@ -28,13 +28,13 @@ test('memoize', t => {
 	t.is(memoized(true), 5);
 
 	// Ensure that functions are stored by reference and not by "value" (e.g. their `.toString()` representation)
-	t.is(memoized(() => i++), 6);
-	t.is(memoized(() => i++), 7);
+	t.is(memoized(() => index++), 6);
+	t.is(memoized(() => index++), 7);
 });
 
 test('cacheKey option', t => {
-	let i = 0;
-	const fixture = (..._arguments: any) => i++;
+	let index = 0;
+	const fixture = (..._arguments: any) => index++;
 	const memoized = mem(fixture, {cacheKey: ([firstArgument]) => String(firstArgument)});
 	t.is(memoized(1), 0);
 	t.is(memoized(1), 0);
@@ -44,8 +44,8 @@ test('cacheKey option', t => {
 });
 
 test('memoize with multiple non-primitive arguments', t => {
-	let i = 0;
-	const memoized = mem((a?: unknown, b?: unknown, c?: unknown) => i++, {cacheKey: JSON.stringify});
+	let index = 0;
+	const memoized = mem((a?: unknown, b?: unknown, c?: unknown) => index++, {cacheKey: JSON.stringify});
 	t.is(memoized(), 0);
 	t.is(memoized(), 0);
 	t.is(memoized({foo: true}, {bar: false}), 1);
@@ -55,8 +55,8 @@ test('memoize with multiple non-primitive arguments', t => {
 });
 
 test('memoize with regexp arguments', t => {
-	let i = 0;
-	const memoized = mem((a?: unknown) => i++, {cacheKey: serializeJavascript});
+	let index = 0;
+	const memoized = mem((a?: unknown) => index++, {cacheKey: serializeJavascript});
 	t.is(memoized(), 0);
 	t.is(memoized(), 0);
 	t.is(memoized(/Sindre Sorhus/), 1);
@@ -66,10 +66,10 @@ test('memoize with regexp arguments', t => {
 });
 
 test('memoize with Symbol arguments', t => {
-	let i = 0;
+	let index = 0;
 	const argument1 = Symbol('fixture1');
 	const argument2 = Symbol('fixture2');
-	const memoized = mem((a?: unknown) => i++);
+	const memoized = mem((a?: unknown) => index++);
 	t.is(memoized(), 0);
 	t.is(memoized(), 0);
 	t.is(memoized(argument1), 1);
@@ -79,8 +79,8 @@ test('memoize with Symbol arguments', t => {
 });
 
 test('maxAge option', async t => {
-	let i = 0;
-	const fixture = (a?: unknown) => i++;
+	let index = 0;
+	const fixture = (a?: unknown) => index++;
 	const memoized = mem(fixture, {maxAge: 100});
 	t.is(memoized(1), 0);
 	t.is(memoized(1), 0);
@@ -91,8 +91,8 @@ test('maxAge option', async t => {
 });
 
 test('maxAge option deletes old items', async t => {
-	let i = 0;
-	const fixture = (a?: unknown) => i++;
+	let index = 0;
+	const fixture = (a?: unknown) => index++;
 	const cache = new Map<number, number>();
 	const deleted: number[] = [];
 	const _delete = cache.delete.bind(cache);
@@ -115,13 +115,13 @@ test('maxAge option deletes old items', async t => {
 });
 
 test('maxAge items are deleted even if function throws', async t => {
-	let i = 0;
+	let index = 0;
 	const fixture = (a?: unknown) => {
-		if (i === 1) {
+		if (index === 1) {
 			throw new Error('failure');
 		}
 
-		return i++;
+		return index++;
 	};
 
 	const cache = new Map();
@@ -139,8 +139,8 @@ test('maxAge items are deleted even if function throws', async t => {
 });
 
 test('cache option', t => {
-	let i = 0;
-	const fixture = (..._arguments: any) => i++;
+	let index = 0;
+	const fixture = (..._arguments: any) => index++;
 	const memoized = mem(fixture, {
 		cache: new WeakMap(),
 		cacheKey: <ReturnValue>([firstArgument]: [ReturnValue]): ReturnValue => firstArgument,
@@ -154,8 +154,8 @@ test('cache option', t => {
 });
 
 test('promise support', async t => {
-	let i = 0;
-	const memoized = mem(async (a?: unknown) => i++);
+	let index = 0;
+	const memoized = mem(async (a?: unknown) => index++);
 	t.is(await memoized(), 0);
 	t.is(await memoized(), 0);
 	t.is(await memoized(10), 1);
@@ -166,8 +166,8 @@ test('preserves the original function name', t => {
 });
 
 test('.clear()', t => {
-	let i = 0;
-	const fixture = () => i++;
+	let index = 0;
+	const fixture = () => index++;
 	const memoized = mem(fixture);
 	t.is(memoized(), 0);
 	t.is(memoized(), 0);
@@ -240,3 +240,102 @@ test('memClear() throws when called on an unclearable cache', t => {
 		instanceOf: TypeError,
 	});
 });
+
+test('maxAge - cache item expires after specified duration', async t => {
+	let index = 0;
+	const fixture = () => index++;
+	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 index = 0;
+	const fixture = () => index++;
+	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 index = 0;
+	const fixture = () => index++;
+	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 index = 0;
+	const fixture = object => index++;
+	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 index = 0;
+	const fixture = () => index++;
+	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 index = 0;
+	const fixture = x => index++;
+	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 index = 0;
+	const fixture = () => index++;
+	const memoized = mem(fixture, {maxAge: 0});
+
+	t.is(memoized(), 0);
+	t.is(memoized(), 1); // No caching, should increment
+});
+
+test('maxAge - immediate expiration', async t => {
+	let index = 0;
+	const fixture = () => index++;
+	const memoized = mem(fixture, {maxAge: 1});
+	t.is(memoized(), 0);
+	await delay(10);
+	t.is(memoized(), 1); // Cache should expire immediately
+});
+
+test('maxAge - high concurrency', async t => {
+	let index = 0;
+	const fixture = () => index++;
+	const memoized = mem(fixture, {maxAge: 50});
+
+	// Simulate concurrent calls
+	for (let job = 0; job < 10_000; job++) {
+		memoized();
+	}
+
+	await delay(100);
+	t.is(memoized(), 1);
+});