limit-once
Advanced tools
Comparing version 0.2.0 to 0.3.0
@@ -13,3 +13,3 @@ { | ||
"license": "MIT", | ||
"version": "0.2.0", | ||
"version": "0.3.0", | ||
"private": false, | ||
@@ -26,12 +26,13 @@ "repository": { | ||
"build:clean": "rimraf dist", | ||
"build:dist": "bun build ./src/once.ts --outfile ./dist/limit-once.js --format esm", | ||
"build:types": "tsc ./src/once.ts --outFile ./dist/limit-once.d.ts --declaration --emitDeclarationOnly --skipLibCheck", | ||
"build:dist": "bun build --entry-points ./src/* --outdir ./dist --format esm --external '*'", | ||
"build:types": "tsc ./src/* --outDir ./dist --declaration --emitDeclarationOnly --skipLibCheck", | ||
"check:prettier": "prettier --debug-check src/**/*.ts test/**/*.ts", | ||
"check:typescript": "tsc --noEmit ./src/** ./test/** --skipLibCheck --target es2022" | ||
}, | ||
"module": "./dist/limit-once.js", | ||
"types": "./dist/limit-once.d.ts", | ||
"module": "./dist/once.js", | ||
"types": "./dist/once.d.ts", | ||
"exports": { | ||
".": "./dist/limit-once.js", | ||
"./types": "./dist/limit-once.d.ts" | ||
".": "./dist/once.js", | ||
"./async": "./dist/async-once.js", | ||
"./types": "./dist/once.d.ts" | ||
}, | ||
@@ -45,6 +46,10 @@ "files": [ | ||
}, | ||
"dependencies": { | ||
"bind-event-listener": "^3.0.0" | ||
}, | ||
"devDependencies": { | ||
"@types/bun": "latest", | ||
"prettier": "^3.2.5", | ||
"rimraf": "^5.0.7" | ||
"rimraf": "^5.0.7", | ||
"tiny-invariant": "^1.3.3" | ||
}, | ||
@@ -51,0 +56,0 @@ "peerDependencies": { |
198
README.md
# limit-once | ||
Create a function that caches the result of the first function call. `limit-once` let's you lazily evaluate a value (using a function), and then hold onto the value forever. | ||
Create a `once` function that caches the result of the first function call. `limit-once` let's you lazily evaluate a value (using a function), and then hold onto the value forever. | ||
> [!NOTE] | ||
> This package is still under construction | ||
Features: | ||
- synchronous variant (`0.2 Kb`) | ||
- asynchronous variant for promises (`1Kb`) | ||
- only include the code for the variant(s) you want | ||
- both variants support cache clearing | ||
## Installation | ||
```bash | ||
# yarn | ||
yarn add limit-once | ||
# npm | ||
npm install limit-once | ||
# bun | ||
bun add limit-once | ||
``` | ||
## Synchronous variant | ||
```ts | ||
import { once } from 'limit-once'; | ||
function sayHello(name: string): string { | ||
function getGreeting(name: string): string { | ||
return `Hello ${name}`; | ||
} | ||
const cached = once(sayHello); | ||
const getGreetingOnce = once(getGreeting); | ||
cached('Alex'); | ||
// sayHello called and "Hello Alex" is returned | ||
getGreetingOnce('Alex'); | ||
// getGreeting called and "Hello Alex" is returned | ||
// "Hello Alex" is put into the cache. | ||
// returns "Hello Alex" | ||
cached('Sam'); | ||
// sayHello is not called | ||
getGreetingOnce('Sam'); | ||
// getGreeting is not called | ||
// "Hello Alex" is returned from the cache. | ||
cached('Greg'); | ||
// sayHello is not called | ||
getGreetingOnce('Greg'); | ||
// getGreeting is not called | ||
// "Hello Alex" is returned from the cache. | ||
``` | ||
### Cache clearing (`.clear()`) | ||
You can clear the cache of a onced function by using the `.clear()` function property. | ||
```ts | ||
// is-safari.ts | ||
import { once } from 'limit-once'; | ||
// We are caching the result of our 'isSafari()' function as the result | ||
// of `isSafari()` won't change. | ||
export const isSafari = once(function isSafari(): boolean { | ||
const { userAgent } = navigator; | ||
return userAgent.includes('AppleWebKit') && !userAgent.includes('Chrome'); | ||
}); | ||
function getGreeting(name: string): string { | ||
return `Hello ${name}`; | ||
} | ||
const getGreetingOnce = once(getGreeting); | ||
// app.ts | ||
if (isSafari()) { | ||
applySafariFix(); | ||
getGreetingOnce('Alex'); | ||
// getGreeting called and "Hello Alex" is returned. | ||
// "Hello Alex" is put into the cache | ||
// getGreetingOnce function returns "Hello Alex" | ||
getGreetingOnce('Sam'); | ||
// getGreeting is not called | ||
// "Hello Alex" is returned from cache | ||
getGreetingOnce.clear(); | ||
getGreetingOnce('Greg'); | ||
// getGreeting is called and "Hello Greg" is returned. | ||
// "Hello Greg" is put into the cache | ||
// "Hello Greg" is returned. | ||
``` | ||
## Asynchronous variant | ||
Our async variant allows you to have a `once` functionality for functions that `Promise`. | ||
```ts | ||
import { onceAsync } from 'limit-once/async'; | ||
async function getLoggedInUser() { | ||
await fetch('/user').json(); | ||
} | ||
// We don't want every call to `getLoggedInUser()` to call `fetch` again. | ||
// Ideally we would store the result of the first successful call and return that! | ||
const getLoggedInUserOnce = asyncOnce(getLoggedInUser); | ||
const user1 = await getLoggedInUserOnce(); | ||
// subsequent calls won't call fetch, and will return the previously fulfilled promise value. | ||
const user2 = await getLoggedInUserOnce(); | ||
``` | ||
## Installation | ||
A "rejected" promise call will not be cached and will allow the wrapped function to be called again | ||
```bash | ||
# yarn | ||
yarn add limit-once | ||
```ts | ||
import { onceAsync } from 'limit-once/async'; | ||
# npm | ||
npm install limit-once | ||
let callCount = 0; | ||
async function maybeThrow({ shouldThrow }: { shouldThrow: boolean }): Promise<string> { | ||
callCount++; | ||
# bun | ||
bun add limit-once | ||
if (shouldThrow) { | ||
throw new Error(`Call count: ${callCount}`); | ||
} | ||
return `Call count: ${callCount}`; | ||
} | ||
const maybeThrowOnce = asyncOnce(maybeThrow); | ||
expect(async () => await maybeThrowOnce({ shouldThrow: true })).toThrowError('Call count: 1'); | ||
// failure result was not cached, underlying `maybeThrow` called again | ||
expect(async () => await maybeThrowOnce({ shouldThrow: true })).toThrowError('Call count: 2'); | ||
// our first successful result will be cached | ||
expect(await maybeThrowOnce({ shouldThrow: false })).toBe('Call count: 3'); | ||
expect(await maybeThrowOnce({ shouldThrow: false })).toBe('Call count: 3'); | ||
``` | ||
## Cache clearing (`.clear()`) | ||
If multiple calls are made to the onced function while the original promise is still `"pending"`, then the original promise is re-used. This prevents multiple calls to the underlying function. | ||
You can clear the cache of a memoized function by using a `.clear()` function that is on your cached function. | ||
```ts | ||
import { onceAsync } from 'limit-once/async'; | ||
async function getLoggedInUser() { | ||
await fetch('/user').json(); | ||
} | ||
export const getLoggedInUserOnce = asyncOnce(getLoggedInUser); | ||
const promise1 = getLoggedInUserOnce(); | ||
// This second call to `getLoggedInUserOnce` while the `getLoggedInUser` promise | ||
// is still "pending" will return the same promise that the first call created. | ||
const promise2 = getLoggedInUserOnce(); | ||
console.log(promise1 === promise2); // "true" | ||
``` | ||
### Cache clearing (`.clear()`) | ||
You can clear the cache of a onced async function by using the `.clear()` function property. | ||
```ts | ||
import { once } from 'limit-once'; | ||
import { onceAsync } from 'limit-once/async'; | ||
function sayHello(name: string): string { | ||
return `Hello ${name}`; | ||
let callCount = 0; | ||
async function getCallCount(): Promise<string> { | ||
return `Call count: ${callCount}`; | ||
} | ||
const cached = once(sayHello); | ||
cached('Alex'); | ||
// sayHello called and "Hello Alex" is returned. | ||
// "Hello Alex" is put into the cache | ||
// cached function returns "Hello Alex" | ||
const onced = asyncOnce(getCallCount); | ||
cached('Sam'); | ||
// sayHello is not called | ||
// "Hello Alex" is returned from cache | ||
expect(await onced({ shouldThrow: false })).toBe('Call count: 1'); | ||
expect(await onced({ shouldThrow: false })).toBe('Call count: 1'); | ||
cached.clear(); | ||
onced.clear(); | ||
cached('Greg'); | ||
// sayHello is called and "Hello Greg" is returned. | ||
// "Hello Greg" is put into the cache | ||
// "Hello Greg" is returned. | ||
expect(await onced({ shouldThrow: false })).toBe('Call count: 2'); | ||
expect(await onced({ shouldThrow: false })).toBe('Call count: 2'); | ||
``` | ||
If onced async function is `"pending"` when `.clear()` is called, then the promise will be rejected. | ||
```ts | ||
import { onceAsync } from 'limit-once/async'; | ||
async function getName(): Promise<string> { | ||
return 'Alex'; | ||
} | ||
const getNameOnce = asyncOnce(getName); | ||
const promise1 = getNameOnce().catch(() => { | ||
console.log('rejected'); | ||
}); | ||
// cached cleared while promise was pending | ||
// will cause `promise1` to be rejected | ||
getNameOnce.clear(); | ||
``` |
@@ -0,4 +1,10 @@ | ||
import { bind } from 'bind-event-listener'; | ||
type ResultValue<TFunc extends (this: any, ...args: any[]) => Promise<any>> = Awaited< | ||
ReturnType<TFunc> | ||
>; | ||
export type CachedFn<TFunc extends (this: any, ...args: any[]) => Promise<any>> = { | ||
clear: () => void; | ||
(this: ThisParameterType<TFunc>, ...args: Parameters<TFunc>): Promise<Awaited<ReturnType<TFunc>>>; | ||
(this: ThisParameterType<TFunc>, ...args: Parameters<TFunc>): Promise<ResultValue<TFunc>>; | ||
}; | ||
@@ -9,3 +15,3 @@ | ||
| { type: 'pending'; promise: Promise<T> } | ||
| { type: 'settled'; result: T }; | ||
| { type: 'fulfilled'; result: T }; | ||
@@ -15,3 +21,3 @@ export function asyncOnce<TFunc extends (...args: any[]) => Promise<any>>( | ||
): CachedFn<TFunc> { | ||
type Result = Awaited<ReturnType<CachedFn<TFunc>>>; | ||
type Result = ResultValue<TFunc>; | ||
@@ -28,6 +34,9 @@ let state: State<Result> = { | ||
): ReturnType<CachedFn<TFunc>> { | ||
if (state.type === 'settled') { | ||
// TODO: do we need the .resolve? | ||
if (state.type === 'fulfilled') { | ||
// Doing a Promise.resolve() so that | ||
// this function _always_ returns a promise. | ||
return Promise.resolve(state.result); | ||
} | ||
// while the promise is pending, all folks | ||
if (state.type === 'pending') { | ||
@@ -38,3 +47,3 @@ return state.promise; | ||
const promise: Promise<Result> = new Promise((resolve, reject) => { | ||
controller.signal.addEventListener('abort', () => reject(), { once: true }); | ||
const cleanup = bind(controller.signal, { type: 'abort', listener: () => reject() }); | ||
@@ -44,3 +53,3 @@ fn.call(this, ...args) | ||
state = { | ||
type: 'settled', | ||
type: 'fulfilled', | ||
result, | ||
@@ -54,3 +63,7 @@ }; | ||
reject(...args); | ||
}); | ||
}) | ||
// this isn't needed for functionality, | ||
// but it seems like a good idea to unbind the event listener | ||
// to prevent possible memory leaks | ||
.finally(cleanup); | ||
}); | ||
@@ -68,5 +81,5 @@ | ||
controller.abort(); | ||
// TODO: need this? | ||
// Need to create a new controller | ||
// as the old one has been aborted | ||
controller = new AbortController(); | ||
// nothing to do | ||
state = { | ||
@@ -73,0 +86,0 @@ type: 'initial', |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
13295
9
193
192
2
4
+ Addedbind-event-listener@^3.0.0
+ Addedbind-event-listener@3.0.0(transitive)