Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/js-lib/src/decorators/asyncMemo.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { _assert, _assertTypeOf } from '../error/assert.js'
import type { CommonLogger } from '../log/commonLogger.js'
import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js'
import { _objectAssign, MISS } from '../types.js'
import type { MethodDecorator } from './decorator.util.js'
import { _getTargetMethodSignature } from './decorator.util.js'
import type { AsyncMemoCache, MethodDecorator } from './memo.util.js'
import type { AsyncMemoCache } from './memo.util.js'
import { jsonMemoSerializer } from './memo.util.js'

export interface AsyncMemoOptions<FN> {
Expand Down
65 changes: 61 additions & 4 deletions packages/js-lib/src/decorators/debounce.decorator.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { test } from 'vitest'
import { describe, expect, test } from 'vitest'
import { _since } from '../datetime/index.js'
import { pDelay } from '../promise/index.js'
import type { AnyFunction, UnixTimestampMillis } from '../types.js'
import { _Debounce } from './debounce.decorator.js'
import { _AsyncDebounce, _AsyncThrottle, _Debounce } from './debounce.decorator.js'

class C {
// @debounce(200, {leading: true, trailing: true})
Expand All @@ -27,6 +27,63 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro
await pDelay(1000) // extra wait
}

test('@debounce', async () => {
await startTimer(fn, 10, 10)
describe('@_Debounce', () => {
Comment thread
frodi-karlsson marked this conversation as resolved.
test('should debounce decorated method calls', async () => {
await startTimer(fn, 10, 10)
})
})

describe('@_AsyncDebounce', () => {
test('should coalesce calls and resolve every caller with a real result (never undefined)', async () => {
class C {
Comment thread
frodi-karlsson marked this conversation as resolved.
calls = 0

@_AsyncDebounce(20)
async save(n: number): Promise<number> {
this.calls++
return n
}
}

const inst = new C()
const results = await Promise.all([inst.save(1), inst.save(2), inst.save(3)])

expect(results).toEqual([3, 3, 3])
expect(inst.calls).toBe(1)
})
})

describe('@_AsyncThrottle', () => {
test('should invoke on the leading edge immediately', async () => {
class C {
calls = 0

@_AsyncThrottle(50)
async ping(n: number): Promise<number> {
this.calls++
return n
}
}

const inst = new C()
expect(await inst.ping(7)).toBe(7)
expect(inst.calls).toBe(1)
})

test('should resolve in-window calls with undefined when trailing is disabled', async () => {
class C {
calls = 0

@_AsyncThrottle(50, { trailing: false })
async ping(n: number): Promise<number> {
this.calls++
return n
}
}

const inst = new C()
expect(await inst.ping(1)).toBe(1)
expect(await inst.ping(2)).toBeUndefined()
expect(inst.calls).toBe(1)
})
})
67 changes: 59 additions & 8 deletions packages/js-lib/src/decorators/debounce.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,69 @@
import type { DebounceOptions, ThrottleOptions } from './debounce.js'
import { _debounce, _throttle } from './debounce.js'
import type { AnyAsyncFunction, AnyFunction } from '../types.js'
import type {
AsyncDebounceOptions,
AsyncThrottleOptions,
DebounceOptions,
ThrottleOptions,
} from './debounce.js'
import { _asyncDebounce, _asyncThrottle, _debounce, _throttle } from './debounce.js'
import type { MethodDecorator } from './decorator.util.js'

export function _Debounce(wait: number, opt: DebounceOptions = {}): MethodDecorator {
export function _Debounce<T extends AnyFunction>(
wait: number,
opt: DebounceOptions = {},
): MethodDecorator<T> {
return (_target, _key, descriptor) => {
const originalFn = descriptor.value
descriptor.value = _debounce(originalFn as any, wait, opt)
const originalFn = descriptor.value!
descriptor.value = _debounce<T>(originalFn, wait, opt)
return descriptor
}
}

export function _Throttle(wait: number, opt: ThrottleOptions = {}): MethodDecorator {
export function _Throttle<T extends AnyFunction>(
wait: number,
opt: ThrottleOptions = {},
): MethodDecorator<T> {
return (_target, _key, descriptor) => {
const originalFn = descriptor.value
descriptor.value = _throttle(originalFn as any, wait, opt)
const originalFn = descriptor.value!
descriptor.value = _throttle<T>(originalFn, wait, opt)
return descriptor
}
}

/**
* Like `@_Debounce`, but for async methods: every call returns a real Promise resolving with the
* coalesced invocation's result, so `await`-ing never yields a stale value or a non-promise
*
* Be aware that the decorated method may resolve `undefined` if the config yields no invocation,
* which the method's `T` type can't express.
*
* @experimental
*/
export function _AsyncDebounce<T extends AnyAsyncFunction>(
wait: number,
opt: AsyncDebounceOptions = {},
): MethodDecorator<T> {
return (_target, _key, descriptor) => {
const originalFn = descriptor.value!
descriptor.value = _asyncDebounce<T>(originalFn, wait, opt) as any
return descriptor
Comment thread
frodi-karlsson marked this conversation as resolved.
}
}

/**
* Like `@_Throttle`, but for async methods.
*
* @see {@link _AsyncDebounce}
*
* @experimental
*/
export function _AsyncThrottle<T extends AnyAsyncFunction>(
wait: number,
opt: AsyncThrottleOptions = {},
): MethodDecorator<T> {
return (_target, _key, descriptor) => {
const originalFn = descriptor.value!
descriptor.value = _asyncThrottle<T>(originalFn, wait, opt) as any
return descriptor
}
}
110 changes: 93 additions & 17 deletions packages/js-lib/src/decorators/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, test, vi } from 'vitest'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { _since } from '../datetime/index.js'
import { pDelay } from '../promise/index.js'
import type { AnyFunction, UnixTimestampMillis } from '../types.js'
import { _debounce } from './debounce.js'
import { _asyncDebounce, _asyncThrottle, _debounce } from './debounce.js'

afterEach(() => {
vi.useRealTimers()
Expand All @@ -22,22 +22,98 @@ async function startTimer(fn: AnyFunction, interval: number, count: number): Pro
await pDelay(2000) // extra wait
}

test('_debounce', async () => {
vi.useFakeTimers()
describe('_debounce', () => {
Comment thread
frodi-karlsson marked this conversation as resolved.
test('should debounce calls with leading/trailing/maxWait', async () => {
vi.useFakeTimers()

const fn = _debounce(originalFn, 20, { leading: true, trailing: true, maxWait: 300 })
const fn = _debounce(originalFn, 20, { leading: true, trailing: true, maxWait: 300 })

const promise = startTimer(fn, 10, 10)
await vi.runAllTimersAsync()
await promise
const promise = startTimer(fn, 10, 10)
await vi.runAllTimersAsync()
await promise
})

// Test cases:
// _debounce leading=1 trailing=0 (default)
// _debounce leading=1 trailing=1
// _debounce leading=0 trailing=1
// _debounce leading=0 trailing=0
// _throttle leading=1 trailing=1 (default)
// _throttle leading=1 trailing=0
// _throttle leading=0 trailing=1
// _throttle leading=0 trailing=0
})

describe('_asyncDebounce', () => {
test('should return a real promise (never undefined) resolving with the invocation result', async () => {
let calls = 0
Comment thread
frodi-karlsson marked this conversation as resolved.
const fn = _asyncDebounce(async (n: number) => {
calls++
return n * 2
}, 20)

const p = fn(5)
expect(p).toBeInstanceOf(Promise)
expect(await p).toBe(10)
expect(calls).toBe(1)
})

test('should coalesce rapid calls into one invocation and resolve all callers with that result', async () => {
let calls = 0
const fn = _asyncDebounce(async (n: number) => {
calls++
return n
}, 20)

const results = await Promise.all([fn(1), fn(2), fn(3)])
expect(results).toEqual([3, 3, 3])
expect(calls).toBe(1)
})

test('should reject all coalesced callers when the invocation throws', async () => {
const fn = _asyncDebounce(async () => {
throw new Error('boom')
}, 20)

const p1 = fn()
const p2 = fn()

await expect(p1).rejects.toThrow('boom')
await expect(p2).rejects.toThrow('boom')
})

test('should reject pending promises on cancel()', async () => {
const fn = _asyncDebounce(async (n: number) => n, 50)

const p = fn(1)
fn.cancel()

await expect(p).rejects.toThrow('asyncDebounce cancelled')
})

test('should resolve dropped calls with undefined when the config yields no invocation (trailing: false)', async () => {
const fn = _asyncDebounce(async (n: number) => n, 20, { trailing: false })

expect(await fn(1)).toBeUndefined()
})
})

// Test cases:
// _debounce leading=1 trailing=0 (default)
// _debounce leading=1 trailing=1
// _debounce leading=0 trailing=1
// _debounce leading=0 trailing=0
// _throttle leading=1 trailing=1 (default)
// _throttle leading=1 trailing=0
// _throttle leading=0 trailing=1
// _throttle leading=0 trailing=0
describe('_asyncThrottle', () => {
test('should invoke on the leading edge immediately', async () => {
let calls = 0
const fn = _asyncThrottle(async (n: number) => {
calls++
return n
}, 50)

expect(await fn(7)).toBe(7)
expect(calls).toBe(1)
})

test('should resolve the leading caller but resolve in-window callers with undefined when trailing is disabled', async () => {
const fn = _asyncThrottle(async (n: number) => n, 50, { trailing: false })

expect(await fn(1)).toBe(1)
expect(await fn(2)).toBeUndefined()
})
})
Loading