Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: rework debounce function #107

Merged
merged 3 commits into from
Jan 26, 2025
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
34 changes: 33 additions & 1 deletion .github/next-major.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,36 @@ The `####` headline should be short and descriptive of the breaking change. In t

## Breaking Changes

####
#### Reworked `debounce` function

- Continue debouncing (in future calls) after `cancel` is called. Previously, `cancel` would disable debouncing, so future calls would be immediate.

```ts
const func = debounce({ delay: 1000 }, mockFunc)

func()
func.cancel()

vi.advanceTimersByTime(1001)
expect(mockFunc).toHaveBeenCalledTimes(0)

func()

vi.advanceTimersByTime(1001)
expect(mockFunc).toHaveBeenCalledTimes(1)
```

- Do not have `flush` call the underlying function if no call is pending.

```ts
const func = debounce({ delay: 1000 }, mockFunc)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(0) // Would previously have called the function
```

- Expose a reference to the underlying function with the new `callee` property.

```ts
const func = debounce({ delay: 1000 }, mockFunc)
expect(func.callee).toBe(mockFunc)
```
92 changes: 43 additions & 49 deletions docs/curry/debounce.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,33 @@ since: 12.1.0

### Usage

The `debounce` function helps manage frequent function calls efficiently. It requires two inputs: a `delay` time (in milliseconds) and a callback. When you use the function returned by `debounce` (a.k.a. the “debounced function”), it doesn't immediately run your callback. Instead, it waits for the specified `delay`.

If called again during this waiting period, it resets the timer. Your source function only runs after the full `delay` passes without interruption. This is useful for handling rapid events like keystrokes, ensuring your code responds only after a pause in activity.
Create a new function that delays invoking a callback until after a specified time has elapsed since the last call.

```ts
import * as _ from 'radashi'

const processData = (data: string) => {
console.log(`Processing data: "${data}"...`)
}
const debouncedProcessData = _.debounce({ delay: 100 }, processData)
// Debounce a search function to avoid making API calls on every keystroke
const searchAPI = _.debounce({ delay: 300 }, async query => {
try {
const response = await fetch(`https://api.example.com/search?q=${query}`)
const results = await response.json()
displayResults(results)
} catch (error) {
console.error('Search failed:', error)
}
})

debouncedProcessData('data1') // Never logs
debouncedProcessData('data2') // Never logs
debouncedProcessData('data3') // Processing data: "data3"...
// Simulate user typing in a search box
document.querySelector('#search-input').addEventListener('input', event => {
const query = event.target.value
searchAPI(query)
})

setTimeout(() => {
debouncedProcessData('data4') // Processing data: "data4"... (200ms later)
}, 200)
function displayResults(results) {
// Update UI with search results
console.log('Search results:', results)
}
const debouncedProcessData = _.debounce({ delay: 100 }, processData)
```

## Options
Expand All @@ -34,60 +42,46 @@ setTimeout(() => {
When the `leading` option is `true`, your callback is invoked immediately the very first time the debounced function is called. After that, the debounced function works as if `leading` was `false`.

```ts
const myDebouncedFunc = _.debounce({ delay: 100, leading: true }, x => {
const debouncedFunc = _.debounce({ delay: 100, leading: true }, x => {
console.log(x)
})

myDebouncedFunc(0) // Logs "0" immediately
myDebouncedFunc(1) // Never logs
myDebouncedFunc(2) // Logs "2" about 100ms later
debouncedFunc(0) // Logs "0" immediately
debouncedFunc(1) // Never logs
debouncedFunc(2) // Logs "2" about 100ms later
```

## Methods
## Return type

### cancel
The `DebounceFunction` type is used to represent a debounced function in TypeScript. It has the following properties:

The `cancel` method of the debounced function does two things:
- `callee`: The underlying function that is debounced.
- `flush`: A method that forces a debounced call to execute immediately.
- `cancel`: A method that cancels a debounced function.

1. It cancels any pending invocations of the debounced function.
2. It permanently disables the debouncing behavior. All future invocations of the debounced function will immediately invoke your callback.
### Flushing

```ts
const myDebouncedFunc = _.debounce({ delay: 100 }, x => {
console.log(x)
})

myDebouncedFunc(0) // Never logs
myDebouncedFunc(1) // Never logs
myDebouncedFunc.cancel()
myDebouncedFunc(2) // Logs "2" immediately
```

### flush

The `flush` method will immediately invoke your callback, regardless of whether the debounced function is currently pending.
The `flush` method forces a debounced call to execute immediately. If no call was currently scheduled, this method does nothing. So you're effectively saying “execute the function now, but only if it's scheduled to execute.”

```ts
const myDebouncedFunc = _.debounce({ delay: 100 }, x => {
console.log(x)
const debouncedFunc = _.debounce({ delay: 1000 }, () => {
console.log('Flushed')
})

myDebouncedFunc(0) // Logs "0" about 100ms later
myDebouncedFunc.flush(1) // Logs "1" immediately
debouncedFunc.flush()
```

### isPending
The `flush` method is set to [`noop`](/reference/function/noop) when no pending call is scheduled.

The `isPending` method returns `true` if there is any pending invocation of the debounced function.
### Cancelling

The `cancel` method can be used to cancel a debounced function. Future calls still get debounced, but the currently pending call is immediately cancelled.

```ts
const myDebouncedFunc = _.debounce({ delay: 100 }, x => {
console.log(x)
const debouncedFunc = _.debounce({ delay: 1000 }, () => {
console.log('Flushed')
})

myDebouncedFunc(0) // Logs "0" about 100ms later
myDebouncedFunc.isPending() // => true
setTimeout(() => {
myDebouncedFunc.isPending() // => false
}, 100)
debouncedFunc()
debouncedFunc.cancel()
```
65 changes: 34 additions & 31 deletions src/curry/debounce.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { noop } from 'radashi'

declare const setTimeout: (fn: () => void, ms: number) => unknown
declare const clearTimeout: (timer: unknown) => void

export type DebounceFunction<TArgs extends any[]> = {
export interface DebounceFunction<TArgs extends any[] = any> {
(...args: TArgs): void
/**
* When called, future invocations of the debounced function are
* no longer delayed and are instead executed immediately.
*/
cancel(): void
/**
* Returns `true` if the underlying function is scheduled to be
* called once the delay has passed.
* If the debounced function is pending, it will be invoked
* immediately and the result will be returned. Otherwise,
* `undefined` will be returned.
*/
isPending(): boolean
flush(...args: TArgs): void
/**
* Invoke the underlying function immediately.
* The underlying function
*/
flush(...args: TArgs): void
readonly callee: (...args: TArgs) => unknown
}

export interface DebounceOptions {
Expand All @@ -32,11 +35,11 @@ export interface DebounceOptions {
}

/**
* Returns a new function that will only call your callback after
* `delay` milliseconds have passed without any invocations.
* Returns a new function that will only call the source function
* after `delay` milliseconds have passed without any invocations.
*
* The debounced function has a few methods, such as `cancel`,
* `isPending`, and `flush`.
* See the documentation (or the `DebounceFunction` type) for details
* on the methods and properties available on the returned function.
*
* @see https://radashi.js.org/reference/curry/debounce
* @example
Expand All @@ -53,33 +56,33 @@ export interface DebounceOptions {
*/
export function debounce<TArgs extends any[]>(
{ delay, leading }: DebounceOptions,
func: (...args: TArgs) => any,
callee: (...args: TArgs) => unknown,
): DebounceFunction<TArgs> {
let timer: unknown = undefined
let active = true
let timeout: unknown

const debounced: DebounceFunction<TArgs> = (...args: TArgs) => {
if (active) {
clearTimeout(timer)
timer = setTimeout(() => {
active && func(...args)
timer = undefined
}, delay)
if (leading) {
func(...args)
leading = false
}
const debounced = ((...args: TArgs) => {
clearTimeout(timeout)
if (leading) {
leading = false
callee(...args)
} else {
func(...args)
timeout = setTimeout(
(debounced.flush = () => {
debounced.flush = noop
clearTimeout(timeout)
callee(...args)
}),
delay,
)
}
}
debounced.isPending = () => {
return timer !== undefined
}
}) as DebounceFunction<TArgs> & { callee: typeof callee }

debounced.callee = callee
debounced.flush = noop
debounced.cancel = () => {
active = false
debounced.flush = noop
clearTimeout(timeout)
}
debounced.flush = (...args: TArgs) => func(...args)

return debounced
}
68 changes: 28 additions & 40 deletions tests/curry/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as _ from 'radashi'
import type { DebounceFunction } from 'radashi'
import * as _ from 'radashi'

describe('debounce', () => {
let func: DebounceFunction<any>
Expand All @@ -20,60 +20,48 @@ describe('debounce', () => {
vi.clearAllMocks()
})

test('only executes once when called rapidly', async () => {
test('only executes once when called rapidly', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(1)
})

test('does not debounce after cancel is called', () => {
test('cancel prevents the debounced function from being called', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
func.cancel()
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(3)
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(6)
})

test('executes the function immediately when the flush method is called', () => {
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
})
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(0)

test('continues to debounce after flush is called', async () => {
// Verify that new calls after cancel are debounced normally
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
func()
expect(mockFunc).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(2)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(3)
expect(mockFunc).toHaveBeenCalledTimes(1)
})

test('cancels all pending invocations when the cancel method is called', async () => {
const results: boolean[] = []
func()
results.push(func.isPending())
results.push(func.isPending())
vi.advanceTimersByTime(delay + 10)
results.push(func.isPending())
func()
results.push(func.isPending())
vi.advanceTimersByTime(delay + 10)
results.push(func.isPending())
assert.deepEqual(results, [true, true, false, true, false])
})
describe('flush', () => {
test('only calls the function if the debounced function was called', () => {
expect(func.flush).toBe(_.noop)

test('returns if there is any pending invocation when the pending method is called', async () => {
func()
func.cancel()
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(0)
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)

func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
expect(func.flush).toBe(_.noop)
})
test('debouncing resumes after a flush', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)

runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(2)
})
})

test('executes the function immediately on the first invocation when `leading` is `true`', async () => {
Expand Down
Loading