diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3b8aa86..441975c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,8 +11,6 @@ jobs: matrix: node-version: - 16 - - 14 - - 12 steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 diff --git a/.gitignore b/.gitignore index 239ecff..c406da7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules yarn.lock +dist diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index c7a489f..0000000 --- a/index.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -type AnyFunction = (...arguments_: readonly any[]) => unknown | void; - -export interface CacheStorage { - has(key: KeyType): boolean; - get(key: KeyType): ValueType | undefined; - set(key: KeyType, value: ValueType): void; - delete(key: KeyType): void; - clear?: () => void; // eslint-disable-line @typescript-eslint/member-ordering -} - -export interface CacheItem { - data: PromiseLike; - maxAge: number; -} - -export type Options< - ArgumentsType extends unknown[], - CacheKeyType, - ReturnType, -> = { - /** - Cache rejected promises. - - @default false - */ - readonly cachePromiseRejection?: boolean; - - /** - The milliseconds until the cache expires. - - @default Infinity - */ - readonly maxAge?: number; - - /** - Determines the cache key for storing the result based on the function arguments. By default, **only the first argument is considered**. - - A `cacheKey` function can return any type supported by `Map` (or whatever structure you use in the `cache` option). - - @default arguments_ => arguments_[0] - @example arguments_ => JSON.stringify(arguments_) - - See the [caching strategy](https://github.com/sindresorhus/mem#caching-strategy) section in the `mem` package for more information. - */ - readonly cacheKey?: (arguments: ArgumentsType) => CacheKeyType; - - /** - Use a different cache storage. Must implement the following methods: `.has(key)`, `.get(key)`, `.set(key, value)`, `.delete(key)`, and optionally `.clear()`. You could for example use a `WeakMap` instead or [`quick-lru`](https://github.com/sindresorhus/quick-lru) for a LRU cache. - - @default new Map() - @example new WeakMap() - - See the [caching strategy](https://github.com/sindresorhus/mem#caching-strategy) section in the `mem` package for more information. - */ - readonly cache?: CacheStorage>; -}; - -/** -[Memoize](https://en.wikipedia.org/wiki/Memoization) promise-returning & async functions. - -@param fn - Promise-returning or async function to be memoized. -@param options - See the [`p-memoize` options](https://github.com/sindresorhus/p-memoize#options). -@returns A memoized version of the `input` function. - -@example -``` -import pMemoize from 'p-memoize'; -import got from 'got'; - -const memoizedGot = pMemoize(got, {maxAge: 1000}); - -memoizedGot('https://sindresorhus.com'); - -// This call is cached -memoizedGot('https://sindresorhus.com'); - -setTimeout(() => { - // This call is not cached as the cache has expired - memoizedGot('https://sindresorhus.com'); -}, 2000); -``` -*/ -export default function pMemoize( - fn: (...arguments: ArgumentsType) => PromiseLike, - options?: Options -): (...arguments: ArgumentsType) => Promise; - -/** -Clear all cached data of a memoized function. - -@param memoized - A function that was previously memoized. It will throw when given a non-memoized function. -*/ -export function pMemoizeClear(memoizedFunction: AnyFunction): void; diff --git a/index.js b/index.js deleted file mode 100644 index f871a46..0000000 --- a/index.js +++ /dev/null @@ -1,65 +0,0 @@ -import mimicFunction from 'mimic-fn'; -import mapAgeCleaner from 'map-age-cleaner'; - -const cacheStore = new WeakMap(); - -export default function pMemoize(fn, { - cachePromiseRejection = false, - maxAge, - cacheKey, - cache = new Map(), -} = {}) { - if (Number.isSafeInteger(maxAge)) { - mapAgeCleaner(cache); - } else if (typeof maxAge !== 'undefined') { - throw new TypeError('maxAge is not a safe integer.'); - } - - const memoized = async function (...arguments_) { - const key = cacheKey ? cacheKey(arguments_) : arguments_[0]; - - const cacheItem = cache.get(key); - if (cacheItem) { - return cacheItem.data; - } - - const promise = fn.apply(this, arguments_); - cache.set(key, { - data: promise, - // We cannot use `Infinity` because of https://github.com/SamVerschueren/map-age-cleaner/issues/8 - maxAge: 2_147_483_647, // This is the largest number `setTimeout` can handle. - }); - - const [{reason}] = await Promise.allSettled([promise]); - if (!cachePromiseRejection && reason) { - cache.delete(key); - } else if (maxAge) { - // Promise fulfilled, so start the timer - cache.set(key, { - data: promise, - maxAge: Date.now() + maxAge, - }); - } - - return promise; - }; - - mimicFunction(memoized, fn); - cacheStore.set(memoized, cache); - - return memoized; -} - -export function pMemoizeClear(memoized) { - if (!cacheStore.has(memoized)) { - throw new Error('Cannot clear a function that was not memoized!'); - } - - const cache = cacheStore.get(memoized); - - if (typeof cache.clear !== 'function') { - throw new TypeError('The cache Map can\'t be cleared!'); - } - - cache.clear(); -} diff --git a/index.test-d.ts b/index.test-d.ts deleted file mode 100644 index 503720c..0000000 --- a/index.test-d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {expectType} from 'tsd'; -import pMemoize, {CacheItem, pMemoizeClear} from './index.js'; - -expectType<(name: string) => Promise>( - pMemoize(async (name: string) => `Hello ${name}!`), -); -expectType<() => Promise>(pMemoize(async () => 1)); - -expectType<() => Promise>(pMemoize(async () => 1, { - cache: new Map>(), -})); - -pMemoize(async () => 1, {maxAge: 1, cachePromiseRejection: true}); - -const memoized = pMemoize(async () => 1); -pMemoizeClear(memoized); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..69809a4 --- /dev/null +++ b/index.ts @@ -0,0 +1,225 @@ +import mimicFn from 'mimic-fn'; +import type {AsyncReturnType} from 'type-fest'; + +// TODO: Use the one in `type-fest` when it's added there. +type AnyAsyncFunction = (...arguments_: readonly any[]) => Promise; + +const cacheStore = new WeakMap>(); +const promiseCacheStore = new WeakMap>(); + +interface CacheStorage { + has: (key: KeyType) => Promise | boolean; + get: (key: KeyType) => Promise | ValueType | undefined; + set: (key: KeyType, value: ValueType) => void; + delete: (key: KeyType) => void; + clear?: () => void; +} + +interface Options< + FunctionToMemoize extends AnyAsyncFunction, + CacheKeyType, +> { + /** + Cache rejected promises. + + @default false + */ + readonly cachePromiseRejection?: boolean; + + /** + Determines the cache key for storing the result based on the function arguments. By default, __only the first argument is considered__ and it only works with [primitives](https://developer.mozilla.org/en-US/docs/Glossary/Primitive). + + A `cacheKey` function can return any type supported by `Map` (or whatever structure you use in the `cache` option). + + You can have it cache **all** the arguments by value with `JSON.stringify`, if they are compatible: + + ``` + import pMemoize from 'p-memoize'; + + pMemoize(function_, {cacheKey: JSON.stringify}); + ``` + + Or you can use a more full-featured serializer like [serialize-javascript](https://github.com/yahoo/serialize-javascript) to add support for `RegExp`, `Date` and so on. + + ``` + import pMemoize from 'p-memoize'; + import serializeJavascript from 'serialize-javascript'; + + pMemoize(function_, {cacheKey: serializeJavascript}); + ``` + + @default arguments_ => arguments_[0] + @example arguments_ => JSON.stringify(arguments_) + */ + readonly cacheKey?: (arguments_: Parameters) => CacheKeyType; + + /** + Use a different cache storage. Must implement the following methods: `.has(key)`, `.get(key)`, `.set(key, value)`, `.delete(key)`, and optionally `.clear()`. You could for example use a `WeakMap` instead or [`quick-lru`](https://github.com/sindresorhus/quick-lru) for a LRU cache. + + @default new Map() + @example new WeakMap() + */ + readonly cache?: CacheStorage>; +} + +/** +[Memoize](https://en.wikipedia.org/wiki/Memoization) functions - An optimization used to speed up consecutive function calls by caching the result of calls with identical input. + +@param fn - Function to be memoized. + +@example +``` +import {setTimeout as delay} from 'node:timer/promises'; +import pMemoize from 'p-memoize'; +import got from 'got'; + +const memoizedGot = pMemoize(got); + +await memoizedGot('https://sindresorhus.com'); + +// This call is cached +await memoizedGot('https://sindresorhus.com'); + +await delay(2000); + +// This call is not cached as the cache has expired +await memoizedGot('https://sindresorhus.com'); +``` +*/ +export default function pMemoize< + FunctionToMemoize extends AnyAsyncFunction, + CacheKeyType, +>( + fn: FunctionToMemoize, + { + cachePromiseRejection = false, + cacheKey, + cache = new Map>(), + }: Options = {}, +): FunctionToMemoize { + // Promise objects can't be serialized so we keep track of them internally and only provide their resolved values to `cache` + // `Promise>` is used instead of `ReturnType` because promise properties are not kept + const promiseCache = new Map>>(); + + const memoized = async function (this: any, ...arguments_: Parameters): Promise> { + const key = cacheKey ? cacheKey(arguments_) : arguments_[0] as CacheKeyType; + + if (promiseCache.has(key)) { + return promiseCache.get(key)!; + } + + if (await cache.has(key)) { + return (await cache.get(key))!; + } + + const promise = fn.apply(this, arguments_) as Promise>; + + promiseCache.set(key, promise); + + try { + const result = await promise; + + cache.set(key, result); + + return result; + } catch (error: unknown) { + if (!cachePromiseRejection) { + promiseCache.delete(key); + } + + throw error as Error; + } + } as FunctionToMemoize; + + mimicFn(memoized, fn, { + ignoreNonConfigurable: true, + }); + + cacheStore.set(memoized, cache); + promiseCacheStore.set(memoized, promiseCache); + + return memoized; +} + +/** +- Only class methods and getters/setters can be memoized, not regular functions (they aren't part of the proposal); +- Only [TypeScript’s decorators](https://www.typescriptlang.org/docs/handbook/decorators.html#parameter-decorators) are supported, not [Babel’s](https://babeljs.io/docs/en/babel-plugin-proposal-decorators), which use a different version of the proposal; +- Being an experimental feature, they need to be enabled with `--experimentalDecorators`; follow TypeScript’s docs. + +@returns A [decorator](https://github.com/tc39/proposal-decorators) to memoize class methods or static class methods. + +@example +``` +import {pMemoizeDecorator} from 'p-memoize'; + +class Example { + index = 0 + + @pMemoizeDecorator() + async counter() { + return ++this.index; + } +} + +class ExampleWithOptions { + index = 0 + + @pMemoizeDecorator() + async counter() { + return ++this.index; + } +} +``` +*/ +export function pMemoizeDecorator< + FunctionToMemoize extends AnyAsyncFunction, + CacheKeyType, +>( + options: Options = {}, +) { + const instanceMap = new WeakMap(); + + return ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ): void => { + const input = target[propertyKey]; // eslint-disable-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + + if (typeof input !== 'function') { + throw new TypeError('The decorated value must be a function'); + } + + delete descriptor.value; + delete descriptor.writable; + + descriptor.get = function () { + if (!instanceMap.has(this)) { + const value = pMemoize(input, options) as FunctionToMemoize; + instanceMap.set(this, value); + return value; + } + + return instanceMap.get(this) as FunctionToMemoize; + }; + }; +} + +/** +Clear all cached data of a memoized function. + +@param fn - Memoized function. +*/ +export function pMemoizeClear(fn: AnyAsyncFunction): void { + const cache = cacheStore.get(fn); + if (!cache) { + throw new TypeError('Can\'t clear a function that was not memoized!'); + } + + if (typeof cache.clear !== 'function') { + throw new TypeError('The cache Map can\'t be cleared!'); + } + + cache.clear(); + promiseCacheStore.get(fn)!.clear(); +} diff --git a/package.json b/package.json index a6b560e..5ce88a0 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,18 @@ "url": "https://sindresorhus.com" }, "type": "module", - "exports": "./index.js", + "exports": "./dist/index.js", + "types": "dist/index.d.ts", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=12.20" }, "scripts": { - "test": "xo && ava && tsd" + "test": "xo && ava && npm run build && tsd", + "build": "del-cli dist && tsc", + "prepack": "npm run build" }, "files": [ - "index.js", - "index.d.ts" + "dist" ], "keywords": [ "promise", @@ -43,14 +45,29 @@ "bluebird" ], "dependencies": { - "map-age-cleaner": "^0.2.0", "mimic-fn": "^4.0.0" }, "devDependencies": { + "@sindresorhus/tsconfig": "^2.0.0", + "@types/serialize-javascript": "^5.0.1", "ava": "^3.15.0", + "del-cli": "^4.0.1", "delay": "^5.0.0", + "serialize-javascript": "^6.0.0", + "ts-node": "^10.2.1", "tsd": "^0.17.0", "typescript": "^4.4.3", "xo": "^0.44.0" + }, + "ava": { + "extensions": { + "ts": "module" + }, + "nonSemVerExperiments": { + "configurableModuleFormat": true + }, + "nodeArguments": [ + "--loader=ts-node/esm" + ] } } diff --git a/readme.md b/readme.md index cffff13..c7dba7b 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ Useful for speeding up consecutive function calls by caching the result of calls By default, **only the memoized function's first argument is considered** via strict equality comparison. If you need to cache multiple arguments or cache `object`s *by value*, have a look at alternative [caching strategies](#caching-strategy) below. -This package is similar to [mem](https://github.com/sindresorhus/mem) but with async-specific enhancements; in particular, it does not cache rejected promises by default (unless the [`cachePromiseRejection`](#cachePromiseRejection) option is set). +This package is similar to [mem](https://github.com/sindresorhus/mem) but with async-specific enhancements; in particular, it allows for asynchronous caches and does not cache rejected promises by default (unless the [`cachePromiseRejection`](#cachePromiseRejection) option is set). ## Install @@ -19,25 +19,30 @@ $ npm install p-memoize ## Usage ```js +import {setTimeout as delay} from 'node:timer/promises'; import pMemoize from 'p-memoize'; import got from 'got'; -const memoizedGot = pMemoize(got, {maxAge: 1000}); +const memoizedGot = pMemoize(got); -memoizedGot('https://sindresorhus.com'); +await memoizedGot('https://sindresorhus.com'); // This call is cached -memoizedGot('https://sindresorhus.com'); +await memoizedGot('https://sindresorhus.com'); -setTimeout(() => { - // This call is not cached as the cache has expired - memoizedGot('https://sindresorhus.com'); -}, 2000); +await delay(2000); + +// This call is not cached as the cache has expired +await memoizedGot('https://sindresorhus.com'); ``` ### Caching strategy -See the [Caching strategy for `mem`](https://github.com/sindresorhus/mem#options). +Similar to the [caching strategy for `mem`](https://github.com/sindresorhus/mem#options) with the following exceptions: + +- Promises returned from a memoized function will be cached internally and take priority over `cache`. The promise cache does not persist outside of the current instance and properties assigned to a returned promise will not be kept. All cached promises can be cleared with [`pMemoizeClear()`](#pmemoizeclearfn). +- `.get()` and `.has()` methods on `cache` can return a promise instead of returning a value immediately. +- Instead of `.set()` being provided an object with the properties `value` and `maxAge`, it will only be provided `value` as the first argument. If you want to implement time-based expiry, consider [doing so in `cache`](#time-based-cache-expiration). ## API @@ -64,13 +69,6 @@ Default: `false` Cache rejected promises. -##### maxAge - -Type: `number`\ -Default: `Infinity` - -The milliseconds until the cache expires. - ##### cacheKey Type: `Function`\ @@ -81,7 +79,7 @@ Determines the cache key for storing the result based on the function arguments. A `cacheKey` function can return any type supported by `Map` (or whatever structure you use in the `cache` option). -See the [caching strategy](https://github.com/sindresorhus/mem#caching-strategy) section in the `mem` package for more information. +See the [caching strategy](#caching-strategy) section for more information. ##### cache @@ -92,12 +90,64 @@ Use a different cache storage. Must implement the following methods: `.has(key)` See the [caching strategy](https://github.com/sindresorhus/mem#caching-strategy) section in the `mem` package for more information. +### pMemoizeDecorator(options) + +Returns a [decorator](https://github.com/tc39/proposal-decorators) to memoize class methods or static class methods. + +Notes: + +- Only class methods and getters/setters can be memoized, not regular functions (they aren't part of the proposal); +- Only [TypeScript’s decorators](https://www.typescriptlang.org/docs/handbook/decorators.html#parameter-decorators) are supported, not [Babel’s](https://babeljs.io/docs/en/babel-plugin-proposal-decorators), which use a different version of the proposal; +- Being an experimental feature, they need to be enabled with `--experimentalDecorators`; follow TypeScript’s docs. + +#### options + +Type: `object` + +Same as options for `pMemoize()`. + +```ts +import {pMemoizeDecorator} from 'p-memoize'; + +class Example { + index = 0 + + @pMemoizeDecorator() + async counter() { + return ++this.index; + } +} + +class ExampleWithOptions { + index = 0 + + @pMemoizeDecorator() + async counter() { + return ++this.index; + } +} +``` + ### pMemoizeClear(memoized) Clear all cached data of a memoized function. It will throw when given a non-memoized function. +## Tips + +### Time-based cache expiration + +```js +import pMemoize from 'p-memoize'; +import ExpiryMap from 'expiry-map'; +import got from 'got'; + +const cache = new ExpiryMap(10000); // Cached values expire after 10 seconds + +const memoizedGot = pMemoize(got, {cache}); +``` + ## Related - [p-debounce](https://github.com/sindresorhus/p-debounce) - Debounce promise-returning & async functions diff --git a/test-d/index.test-d.ts b/test-d/index.test-d.ts new file mode 100644 index 0000000..c732a4d --- /dev/null +++ b/test-d/index.test-d.ts @@ -0,0 +1,71 @@ +import {expectType} from 'tsd'; +import pMemoize, {pMemoizeClear} from '../index.js'; + +const fn = async (text: string) => Boolean(text); + +expectType(pMemoize(fn)); +expectType(pMemoize(fn, {cacheKey: ([firstArgument]: [string]) => firstArgument})); +expectType( + pMemoize(fn, { + // The cacheKey returns an array. This isn't deduplicated by a regular Map, but it's valid. The correct solution would be to use ManyKeysMap to deduplicate it correctly + cacheKey: (arguments_: [string]) => arguments_, + cache: new Map<[string], boolean>(), + }), +); +expectType( + // The `firstArgument` of `fn` is of type `string`, so it's used + pMemoize(fn, {cache: new Map()}), +); + +/* Overloaded function tests */ +async function overloadedFn(parameter: false): Promise; +async function overloadedFn(parameter: true): Promise; +async function overloadedFn(parameter: boolean): Promise { + return parameter; +} + +expectType(pMemoize(overloadedFn)); +expectType(await pMemoize(overloadedFn)(true)); +expectType(await pMemoize(overloadedFn)(false)); + +pMemoizeClear(fn); + +// `cacheKey` tests. +// The argument should match the memoized function’s parameters +pMemoize(async (text: string) => Boolean(text), { + cacheKey: arguments_ => { + expectType<[string]>(arguments_); + }, +}); + +pMemoize(async () => 1, { + cacheKey: arguments_ => { + expectType<[]>(arguments_); // eslint-disable-line @typescript-eslint/ban-types + }, +}); + +// Ensures that the various cache functions infer their arguments type from the return type of `cacheKey` +pMemoize(async (_arguments: {key: string}) => 1, { + cacheKey: (arguments_: [{key: string}]) => { + expectType<[{key: string}]>(arguments_); + return new Date(); + }, + cache: { + async get(key) { + expectType(key); + return 5; + }, + set: (key, data) => { + expectType(key); + expectType(data); + }, + async has(key) { + expectType(key); + return true; + }, + delete: key => { + expectType(key); + }, + clear: () => undefined, + }, +}); diff --git a/test.js b/test.js deleted file mode 100644 index 0cfd14b..0000000 --- a/test.js +++ /dev/null @@ -1,117 +0,0 @@ -import test from 'ava'; -import delay from 'delay'; -import pMemoize, {pMemoizeClear} from './index.js'; - -test('main', async t => { - let index = 0; - const memoized = pMemoize(async () => index++); - t.is(await memoized(), 0); - t.is(await memoized(), 0); - t.is(await memoized(10), 1); -}); - -test('does memoize consecutive calls', async t => { - let index = 0; - const memoized = pMemoize(async () => index++); - const firstCall = memoized(); - const secondCall = memoized(); - - await Promise.all([firstCall, secondCall]); - - t.is(await firstCall, 0); - t.is(await secondCall, 0); -}); - -test('does not memoize rejected promise', async t => { - let index = 0; - - const memoized = pMemoize(async () => { - index++; - - if (index === 2) { - throw new Error('fixture'); - } - - return index; - }); - - t.is(await memoized(), 1); - t.is(await memoized(), 1); - - await t.throwsAsync(memoized(10), {message: 'fixture'}); - await t.notThrowsAsync(memoized(10)); - - t.is(await memoized(10), 3); - t.is(await memoized(10), 3); - t.is(await memoized(100), 4); -}); - -test('can memoize rejected promise', async t => { - let index = 0; - - const memoized = pMemoize(async () => { - index++; - - if (index === 2) { - throw new Error('fixture'); - } - - return index; - }, { - cachePromiseRejection: true, - }); - - t.is(await memoized(), 1); - t.is(await memoized(), 1); - - await t.throwsAsync(memoized(10), {message: 'fixture'}); - await t.throwsAsync(memoized(10), {message: 'fixture'}); - - t.is(await memoized(100), 3); -}); - -test('preserves the original function name', t => { - t.is(pMemoize(async function foo() {}).name, 'foo'); // eslint-disable-line func-names -}); - -test('pMemoizeClear()', async t => { - let index = 0; - const fixture = async () => index++; - const memoized = pMemoize(fixture); - t.is(await memoized(), 0); - t.is(await memoized(), 0); - pMemoizeClear(memoized); - t.is(await memoized(), 1); - t.is(await memoized(), 1); -}); - -test('always returns async function', async t => { - let index = 0; - const fixture = () => index++; - const memoized = pMemoize(fixture); - t.is(await memoized(), 0); - t.is(await memoized(), 0); -}); - -test('pMemoizeClear() throws when called with a plain function', t => { - t.throws(() => { - pMemoizeClear(() => {}); - }, { - message: 'Cannot clear a function that was not memoized!', - }); -}); - -test('maxAge starts on promise settlement', async t => { - let index = 0; - const fixture = async () => { - await delay(40); - return index++; - }; - - const memoized = pMemoize(fixture, {maxAge: 40}); - t.is(await memoized(), 0); - await delay(20); - t.is(await memoized(), 0); - await delay(20); - t.is(await memoized(), 1); -}); diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..f224a5d --- /dev/null +++ b/test.ts @@ -0,0 +1,203 @@ +import test from 'ava'; +import serializeJavascript from 'serialize-javascript'; +import pMemoize, {pMemoizeDecorator, pMemoizeClear} from './index.js'; + +test('memoize', async t => { + let index = 0; + const fixture = async () => index++; + const memoized = pMemoize(fixture); + t.is(await memoized(), 0); + t.is(await memoized(), 0); + t.is(await memoized(), 0); + // @ts-expect-error Argument type does not match + t.is(await memoized(undefined), 0); + // @ts-expect-error Argument type does not match + t.is(await memoized(undefined), 0); + // @ts-expect-error Argument type does not match + t.is(await memoized('foo'), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized('foo'), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized('foo'), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized('foo', 'bar'), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized('foo', 'bar'), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized('foo', 'bar'), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized(1), 2); + // @ts-expect-error Argument type does not match + t.is(await memoized(1), 2); + // @ts-expect-error Argument type does not match + t.is(await memoized(null), 3); + // @ts-expect-error Argument type does not match + t.is(await memoized(null), 3); + // @ts-expect-error Argument type does not match + t.is(await memoized(fixture), 4); + // @ts-expect-error Argument type does not match + t.is(await memoized(fixture), 4); + // @ts-expect-error Argument type does not match + t.is(await memoized(true), 5); + // @ts-expect-error Argument type does not match + t.is(await memoized(true), 5); + + // Ensure that functions are stored by reference and not by "value" (e.g. their `.toString()` representation) + // @ts-expect-error Argument type does not match + t.is(await memoized(() => index++), 6); + // @ts-expect-error Argument type does not match + t.is(await memoized(() => index++), 7); +}); + +test('cacheKey option', async t => { + let index = 0; + const fixture = async (..._arguments: any) => index++; + const memoized = pMemoize(fixture, {cacheKey: ([firstArgument]) => String(firstArgument)}); + t.is(await memoized(1), 0); + t.is(await memoized(1), 0); + t.is(await memoized('1'), 0); + t.is(await memoized('2'), 1); + t.is(await memoized(2), 1); +}); + +test('memoize with multiple non-primitive arguments', async t => { + let index = 0; + const memoized = pMemoize(async () => index++, {cacheKey: JSON.stringify}); + t.is(await memoized(), 0); + t.is(await memoized(), 0); + // @ts-expect-error Argument type does not match + t.is(await memoized({foo: true}, {bar: false}), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized({foo: true}, {bar: false}), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized({foo: true}, {bar: false}, {baz: true}), 2); + // @ts-expect-error Argument type does not match + t.is(await memoized({foo: true}, {bar: false}, {baz: true}), 2); +}); + +test('memoize with regexp arguments', async t => { + let index = 0; + const memoized = pMemoize(async () => index++, {cacheKey: serializeJavascript}); + t.is(await memoized(), 0); + t.is(await memoized(), 0); + // @ts-expect-error Argument type does not match + t.is(await memoized(/Sindre Sorhus/), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized(/Sindre Sorhus/), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized(/Elvin Peng/), 2); + // @ts-expect-error Argument type does not match + t.is(await memoized(/Elvin Peng/), 2); +}); + +test('memoize with Symbol arguments', async t => { + let index = 0; + const argument1 = Symbol('fixture1'); + const argument2 = Symbol('fixture2'); + const memoized = pMemoize(async () => index++); + t.is(await memoized(), 0); + t.is(await memoized(), 0); + // @ts-expect-error Argument type does not match + t.is(await memoized(argument1), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized(argument1), 1); + // @ts-expect-error Argument type does not match + t.is(await memoized(argument2), 2); + // @ts-expect-error Argument type does not match + t.is(await memoized(argument2), 2); +}); + +test('cache option', async t => { + let index = 0; + const fixture = async (..._arguments: any) => index++; + const memoized = pMemoize(fixture, { + cache: new WeakMap(), + cacheKey: ([firstArgument]: [ReturnValue]): ReturnValue => firstArgument, + }); + const foo = {}; + const bar = {}; + t.is(await memoized(foo), 0); + t.is(await memoized(foo), 0); + t.is(await memoized(bar), 1); + t.is(await memoized(bar), 1); +}); + +test('preserves the original function name', t => { + t.is(pMemoize(async function foo() {}).name, 'foo'); // eslint-disable-line func-names, @typescript-eslint/no-empty-function +}); + +test('.pMemoizeClear()', async t => { + let index = 0; + const fixture = async () => index++; + const memoized = pMemoize(fixture); + t.is(await memoized(), 0); + t.is(await memoized(), 0); + pMemoizeClear(memoized); + t.is(await memoized(), 1); + t.is(await memoized(), 1); +}); + +test('prototype support', async t => { + class Unicorn { + index = 0; + async foo() { + return this.index++; + } + } + + Unicorn.prototype.foo = pMemoize(Unicorn.prototype.foo); + + const unicorn = new Unicorn(); + + t.is(await unicorn.foo(), 0); + t.is(await unicorn.foo(), 0); + t.is(await unicorn.foo(), 0); +}); + +test('.pMemoizeDecorator()', async t => { + let returnValue = 1; + const returnValue2 = 101; + + class TestClass { + @pMemoizeDecorator() + async counter() { + return returnValue++; + } + + @pMemoizeDecorator() + async counter2() { + return returnValue2; + } + } + + const alpha = new TestClass(); + t.is(await alpha.counter(), 1); + t.is(await alpha.counter(), 1, 'The method should be memoized'); + t.is(await alpha.counter2(), 101, 'The method should be memoized separately from the other one'); + + const beta = new TestClass(); + t.is(await beta.counter(), 2, 'The method should not be memoized across instances'); +}); + +test('memClear() throws when called with a plain function', t => { + t.throws(() => { + pMemoizeClear(async () => {}); // eslint-disable-line @typescript-eslint/no-empty-function + }, { + message: 'Can\'t clear a function that was not memoized!', + instanceOf: TypeError, + }); +}); + +test('memClear() throws when called on an unclearable cache', t => { + const fixture = async () => 1; + const memoized = pMemoize(fixture, { + cache: new WeakMap(), + }); + + t.throws(() => { + pMemoizeClear(memoized); + }, { + message: 'The cache Map can\'t be cleared!', + instanceOf: TypeError, + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b6ac98e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@sindresorhus/tsconfig", + "compilerOptions": { + "outDir": "dist", + "experimentalDecorators": true + }, + "files": [ + "index.ts" + ] +}