Skip to content

Instantly share code, notes, and snippets.

@AoiYamada
Created September 2, 2024 11:22
Show Gist options
  • Save AoiYamada/e0d3a59c2a69626cf8b188ffd9dc0de0 to your computer and use it in GitHub Desktop.
Save AoiYamada/e0d3a59c2a69626cf8b188ffd9dc0de0 to your computer and use it in GitHub Desktop.
Key based debounce
import keyBasedDebounce from '../keyBasedDebounce'
jest.useFakeTimers().setSystemTime(new Date('2024-09-01'))
describe('keyBasedDebounce', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should debounce the function call', () => {
const mockFn = jest.fn()
const debouncedFn = keyBasedDebounce(mockFn, (...args) => args[0], 1200)
debouncedFn('key1')
debouncedFn('key2')
debouncedFn('key3')
// Only the last call should be executed after the debounce delay
expect(mockFn).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
debouncedFn('key2') // delayed
debouncedFn('key3') // delayed
expect(mockFn).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
debouncedFn('key3') // delayed
expect(mockFn).toHaveBeenCalledTimes(1)
expect(mockFn).toHaveBeenCalledWith('key1')
jest.advanceTimersByTime(1000)
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('key2')
jest.advanceTimersByTime(1000)
expect(mockFn).toHaveBeenCalledTimes(3)
expect(mockFn).toHaveBeenCalledWith('key3')
})
it('should execute the function immediately if leading option is true', () => {
const mockFn = jest.fn()
const debouncedFn = keyBasedDebounce(mockFn, (...args) => args[0], 2000, {
leading: true,
})
debouncedFn('key1', 'value1')
debouncedFn('key2', 'value1')
debouncedFn('key3', 'value1')
// The first call should be executed immediately
expect(mockFn).toHaveBeenCalledTimes(3)
expect(mockFn).toHaveBeenCalledWith('key1', 'value1')
expect(mockFn).toHaveBeenCalledWith('key2', 'value1')
expect(mockFn).toHaveBeenCalledWith('key3', 'value1')
jest.advanceTimersByTime(1000)
// in leading mode, the function should not be called again when the delay is not reached
debouncedFn('key1', 'value2')
debouncedFn('key2', 'value2')
debouncedFn('key3', 'value2')
expect(mockFn).toHaveBeenCalledTimes(3)
jest.advanceTimersByTime(1000)
expect(mockFn).toHaveBeenCalledTimes(3)
// The function should be called again after the delay
debouncedFn('key1', 'value3')
debouncedFn('key2', 'value3')
debouncedFn('key3', 'value3')
expect(mockFn).toHaveBeenCalledTimes(6)
expect(mockFn).toHaveBeenCalledWith('key1', 'value3')
expect(mockFn).toHaveBeenCalledWith('key2', 'value3')
expect(mockFn).toHaveBeenCalledWith('key3', 'value3')
})
it('should execute the function with the latest arguments', () => {
const mockFn = jest.fn()
const debouncedFn = keyBasedDebounce(mockFn, (...args) => args[0], 2000)
debouncedFn('key1', 'value1')
jest.advanceTimersByTime(1000)
debouncedFn('key1', 'value2')
jest.advanceTimersByTime(1000)
debouncedFn('key1', 'value3')
// Only the last call should be executed after the debounce delay
expect(mockFn).not.toHaveBeenCalled()
jest.runAllTimers()
expect(mockFn).toHaveBeenCalledTimes(1)
expect(mockFn).toHaveBeenCalledWith('key1', 'value3')
})
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Fn = (...args: any[]) => any
type DebounceOpts = {
leading?: boolean
}
type Context<T extends Fn> = {
lastCallTime: number
lastArgs: Parameters<T>
result: ReturnType<T> | undefined
timeoutId: number | NodeJS.Timeout | undefined
}
function keyBasedDebounce<T extends Fn>(
fn: T,
keyFn: (...args: Parameters<T>) => string,
delay: number,
options?: DebounceOpts
) {
const { leading = false } = options || {}
const contexts = new Map<string, Context<T>>()
function debounced(...args: Parameters<T>) {
const key = keyFn(...args)
const context = ensureContext(contexts, key, args)
const time = Date.now()
const isCalling = shouldCall(time, delay, context)
context.lastArgs = args
if (leading) {
if (isCalling) {
executeFn<T>(context, time, fn)
}
} else {
if (context.timeoutId) {
clearTimeout(context.timeoutId)
}
//
context.timeoutId = setTimeout(() => {
const time = Date.now()
executeFn<T>(context, time, fn)
}, delay)
}
return context.result
}
return debounced
}
export default keyBasedDebounce
function ensureContext<T extends Fn>(
contexts: Map<string, Context<T>>,
key: string,
args: Parameters<T>
) {
let context = contexts.get(key)
if (!context) {
context = {
lastCallTime: 0,
lastArgs: args,
result: undefined,
timeoutId: undefined,
}
contexts.set(key, context)
}
return context
}
function shouldCall<T extends Fn>(
time: number,
delay: number,
context: Context<T>
) {
return time - context.lastCallTime >= delay
}
function executeFn<T extends Fn>(context: Context<T>, time: number, fn: T) {
context.lastCallTime = time
context.result = fn(...context.lastArgs)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment