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
8 changes: 3 additions & 5 deletions .github/next-minor.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ If you open a PR that introduces a new function, add it to the "New Functions" s

The `####` headline should be short and descriptive of the new functionality. In the body of the section, include a link to the PR. You don't need to include a description of the change itself, as we will extract that from the documentation.

## New Functions

####

## New Features

####
#### dissociate `memo` chache read keys from set keys

https://github.com/radashi-org/radashi/pull/333
29 changes: 29 additions & 0 deletions docs/curry/memo.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,32 @@ const beta = timestamp({ group: 'beta' })
now === later // => true
beta === now // => false
```

You can optionally use a separate key for reading and writing cached values by providing a `setKey` function.
This allows for more flexible cache management and sharing of cached values between different function calls.

```ts
const func = _.memo(
(arg: { id: string; withAdditionalStuff: boolean }) => {
if (arg.withAdditionalStuff) {
// do stuff
}

return arg.id
},
{
key: arg =>
arg.withAdditionalStuff
? `${arg.id}_withAdditionalStuff`
: [`${arg.id}`, `${arg.id}_withAdditionalStuff`], // we also look for the shared key
setKey: arg =>
arg.withAdditionalStuff
? [`${arg.id}`, `${arg.id}_withAdditionalStuff`] // we also set the shared key
: `${arg.id}`,
},
)

func({ id: '1', withAdditionalStuff: true })
func({ id: '1', withAdditionalStuff: false })
// func is executed once
```
36 changes: 27 additions & 9 deletions src/curry/memo.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import type { NoInfer } from 'radashi'
import { type NoInfer, isArray, selectFirst, sift } from 'radashi'

type KeyOrKeys = string | (string | undefined)[]
type Cache<T> = Record<string, { exp: number | null; value: T }>

function memoize<TArgs extends any[], TResult>(
cache: Cache<TResult>,
func: (...args: TArgs) => TResult,
keyFunc: ((...args: TArgs) => string) | null,
getKeyFunc: ((...args: TArgs) => KeyOrKeys) | null,
setKeyFunc: ((...args: TArgs) => KeyOrKeys) | null,
ttl: number | null,
) {
return function callWithMemo(...args: any): TResult {
const key = keyFunc ? keyFunc(...args) : JSON.stringify({ args })
const existing = cache[key]
const keyOrKeys = getKeyFunc
? getKeyFunc(...args)
: JSON.stringify({ args })
const keys = isArray(keyOrKeys) ? sift(keyOrKeys) : [keyOrKeys]

const existing = selectFirst(keys, key => cache[key])
if (existing !== undefined) {
if (!existing.exp) {
return existing.value
Expand All @@ -20,16 +26,22 @@ function memoize<TArgs extends any[], TResult>(
}
}
const result = func(...args)
cache[key] = {
exp: ttl ? new Date().getTime() + ttl : null,
value: result,

const setKeyOrKeys = setKeyFunc ? setKeyFunc(...args) : keys
const setKeys = isArray(setKeyOrKeys) ? sift(setKeyOrKeys) : [setKeyOrKeys]
for (const key of setKeys) {
cache[key] = {
exp: ttl ? new Date().getTime() + ttl : null,
value: result,
}
}
return result
}
}

export interface MemoOptions<TArgs extends any[]> {
key?: (...args: TArgs) => string
key?: (...args: TArgs) => KeyOrKeys
setKey?: (...args: TArgs) => KeyOrKeys
ttl?: number
}

Expand Down Expand Up @@ -61,5 +73,11 @@ export function memo<TArgs extends any[], TResult>(
func: (...args: TArgs) => TResult,
options: MemoOptions<NoInfer<TArgs>> = {},
): (...args: TArgs) => TResult {
return memoize({}, func, options.key ?? null, options.ttl ?? null)
return memoize(
{},
func,
options.key ?? null,
options.setKey ?? null,
options.ttl ?? null,
)
}
34 changes: 34 additions & 0 deletions tests/curry/memo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('memo', () => {
const resultB = func()
expect(resultA).toBe(resultB)
})

test('uses key to identify unique calls', () => {
const func = _.memo(
(arg: { user: { id: string } }) => {
Expand All @@ -23,6 +24,38 @@ describe('memo', () => {
expect(resultA).toBe(resultA2)
expect(resultB).not.toBe(resultA)
})

test('uses multiple keys to identify unique calls', () => {
const rawFn = vi.fn((arg: { id: string; withAdditionalStuff: boolean }) => {
if (arg.withAdditionalStuff) {
// do stuff
}

return arg.id
})

const func = _.memo(rawFn, {
key: arg =>
arg.withAdditionalStuff
? `${arg.id}_withAdditionalStuff`
: [`${arg.id}`, `${arg.id}_withAdditionalStuff`], // we also look for the shared key
setKey: arg =>
arg.withAdditionalStuff
? [`${arg.id}`, `${arg.id}_withAdditionalStuff`] // we also set the shared key
: `${arg.id}`,
})
Comment on lines +37 to +46
Copy link
Member

@aleclarson aleclarson Jan 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this test case reflect how you would use the feature?

I don't understand why you would want to »check for the _withAdditionalStuff key when withAdditionalStuff is falsy« since the arg.id key is set when a memoized call has an arg.withAdditionalStuff value that is truthy.

Is there an actual use case where you would need to have asymmetric getKey/setKey functions, or are you typically only setting one of them?


func({ id: '1', withAdditionalStuff: true })
func({ id: '1', withAdditionalStuff: false })
expect(rawFn).toHaveBeenCalledTimes(1)

rawFn.mockClear()

func({ id: '2', withAdditionalStuff: false })
func({ id: '2', withAdditionalStuff: true })
expect(rawFn).toHaveBeenCalledTimes(2)
})

test('calls function again when first value expires', async () => {
vi.useFakeTimers()
const func = _.memo(() => new Date().getTime(), {
Expand All @@ -33,6 +66,7 @@ describe('memo', () => {
const resultB = func()
expect(resultA).not.toBe(resultB)
})

test('does not call function again when first value has not expired', async () => {
vi.useFakeTimers()
const func = _.memo(() => new Date().getTime(), {
Expand Down
Loading