Skip to content

Commit

Permalink
v1.1.0 - add multiple debounce modes
Browse files Browse the repository at this point in the history
  • Loading branch information
nruffing committed Jan 5, 2024
1 parent 3789cad commit 5aaa20b
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 24 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function listener(event: Event) {
| `listener` | `EventListenerOrEventListenerObject` | The event handler function to attach. This is the same type as the browser API [`addEventListener.listener` parameter](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#the_event_listener_callback). |
| `options` | `boolean`, `AddEventListenerOptions` or `undefined` | Optional. This is the same type as the browser API [`addEventListener.options` parameter](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options). |
| `debounceMs` | `number` or `undefined` | Optionally specify a debounce timeout. |
| `debounceMode` | [`DebounceMode`](#debounce-mode) | Specify the type of desired debounce behavior. Defaults to `Timeout`. |
| `disabled` | `boolean` or `undefined` | Optionally disable/remove the event handler. |

### Composable
Expand Down Expand Up @@ -123,10 +124,24 @@ onBeforeUnmount(() => {
| `listener` | `EventListenerOrEventListenerObject` | The event handler function to attach. This is the same type as the browser API [`addEventListener.listener` parameter](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#the_event_listener_callback). |
| `options` | `boolean`, `AddEventListenerOptions` or `undefined` | Optional. This is the same type as the browser API [`addEventListener.options` parameter](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options). |
| `debounceMs` | `number` or `undefined` | Optionally specify a debounce timeout. |
| `debounceMode` | [`DebounceMode`](#debounce-mode) | Specify the type of desired debounce behavior. Defaults to `Timeout`. |
| `replaceExisting` | `boolean` or `undefined` | Optionally specify to replace any existing event handler that was attached using `native-event-vue`. Otherwise the new event listener will not be attached. |

### Debounce Mode

The following debounce behavior modes are available via the `DebounceMode` enum. By default the `Timeout` mode is used.

| Mode | Description |
| --- | --- |
| `Timeout` | Debounce using a timeout only. The function will not be called until it has not been called for the specified timeout. |
| `ImmediateAndTimeout` | Debounce using a timeout and immediate execution. The function will be called immediately and then not again until it has not been called for the specified timeout. |
| `MaximumFrequency` | Debounce using a maximum frequency. The function will be called immediately and then at most once every timeout. Debounced calls will always use the latest arguments. The debounce function will be called even if its been called within the timeout. |

## Release Notes

### v1.1.0
* Add `ImmediateAndTimeout` and `MaximumFrequency` debounce modes. The default mode is now called `Timeout` and acts just as the debounce did previously.

### v1.0.1
* Publish initial release again with provenance

Expand Down
65 changes: 56 additions & 9 deletions lib/composables/useDebounce.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { expect, test, describe } from 'vitest'
// import { expectTimestampCloseEnough } from '../../test-util/timestampUtil'
import { useDebounce } from './useDebounce'
import { useDebounce, DebounceMode } from './useDebounce'
import { log } from '../logger'
import { nativeEventVueOptions } from '../NativeEventVue'

interface DebounceTestCall {
delay: number
Expand All @@ -23,7 +24,12 @@ interface DebounceTestExpectedCall {
args: any[]
}

async function executeDebounceTest(calls: DebounceTestCall[], debounceMs: number, expected: DebounceTestExpectedCall[]): Promise<void> {
async function executeDebounceTest(
calls: DebounceTestCall[],
debounceMs: number,
debounceMode: DebounceMode,
expected: DebounceTestExpectedCall[],
): Promise<void> {
const actualCalls = [] as DebounceTestActualCall[]

const debounceTestFunction = (arg1: string, arg2: number) => {
Expand All @@ -32,7 +38,7 @@ async function executeDebounceTest(calls: DebounceTestCall[], debounceMs: number
actualCalls.push(call)
}

const debounced = useDebounce(debounceTestFunction, debounceMs)
const debounced = useDebounce(debounceTestFunction, debounceMs, debounceMode)

const callNext = () => {
if (!calls.length) {
Expand Down Expand Up @@ -81,7 +87,9 @@ async function executeDebounceTest(calls: DebounceTestCall[], debounceMs: number
}

describe('useDebounce', () => {
test('successfully debounces', async () => {
nativeEventVueOptions.debugLog = true

test('successfully debounces - timeout mode', async () => {
const debounceMs = 100

const calls = [
Expand All @@ -95,7 +103,42 @@ describe('useDebounce', () => {

const expected = [{ args: ['b', 2] }, { args: ['c', 3] }] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, expected)
await executeDebounceTest(calls, debounceMs, DebounceMode.Timeout, expected)
})

test('successfully debounces - immediate and timeout mode', async () => {
const debounceMs = 100

const calls = [
{ delay: 0, arg1: 'a', arg2: 1 },
{ delay: 110, arg1: 'a1', arg2: 11 },
{ delay: 10, arg1: 'a', arg2: 1 },
{ delay: 10, arg1: 'a', arg2: 1 },
{ delay: 10, arg1: 'a', arg2: 1 },
{ delay: 50, arg1: 'b', arg2: 2 },
{ delay: 110, arg1: 'c', arg2: 3 },
] as DebounceTestCall[]

const expected = [{ args: ['a', 1] }, { args: ['a1', 11] }, { args: ['b', 2] }, { args: ['c', 3] }] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, DebounceMode.ImmediateAndTimeout, expected)
})

test('successfully debounces - minimum period', async () => {
const debounceMs = 100

const calls = [
{ delay: 0, arg1: 'a', arg2: 1 },
{ delay: 80, arg1: 'a1', arg2: 11 },
{ delay: 25, arg1: 'a', arg2: 1 },
{ delay: 10, arg1: 'a', arg2: 1 },
{ delay: 50, arg1: 'b', arg2: 2 },
{ delay: 110, arg1: 'c', arg2: 3 },
] as DebounceTestCall[]

const expected = [{ args: ['a', 1] }, { args: ['a1', 11] }, { args: ['b', 2] }, { args: ['c', 3] }] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, DebounceMode.MaximumFrequency, expected)
})

test(`clear successfully clears`, async () => {
Expand All @@ -111,7 +154,7 @@ describe('useDebounce', () => {

const expected = [] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, expected)
await executeDebounceTest(calls, debounceMs, DebounceMode.Timeout, expected)
})

test(`clear successfully clears but doesn't destroy`, async () => {
Expand All @@ -127,7 +170,7 @@ describe('useDebounce', () => {

const expected = [{ args: ['c', 3] }] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, expected)
await executeDebounceTest(calls, debounceMs, DebounceMode.Timeout, expected)
})

test(`destroy successfully clears and destroys`, async () => {
Expand All @@ -143,7 +186,7 @@ describe('useDebounce', () => {

const expected = [] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, expected)
await executeDebounceTest(calls, debounceMs, DebounceMode.Timeout, expected)
})

test(`flush successfully flushes. clears, and does not destroy`, async () => {
Expand All @@ -159,7 +202,7 @@ describe('useDebounce', () => {

const expected = [{ args: ['b', 2] }, { args: ['c', 3] }] as DebounceTestExpectedCall[]

await executeDebounceTest(calls, debounceMs, expected)
await executeDebounceTest(calls, debounceMs, DebounceMode.Timeout, expected)
})

test('throws when func is not provided', () => {
Expand All @@ -175,4 +218,8 @@ describe('useDebounce', () => {
test('throws when timeoutMs is negative', () => {
expect(() => useDebounce(() => {}, -1)).toThrow()
})

test('throws when debounce mode is invalid', () => {
expect(() => useDebounce(() => {}, 1, 'some invalid mode' as DebounceMode)).toThrow()
})
})
75 changes: 69 additions & 6 deletions lib/composables/useDebounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,35 @@ export type DebouncedFunction = {
destroy(): void
}

export function useDebounce(func: FunctionToDebounce | EventListenerObject, timeoutMs: number): DebouncedFunction {
export enum DebounceMode {
/**
* Debounce using a timeout only. The function will not be called until it has not been called for the specified timeout.
*/
Timeout = 'timeout',
/**
* Debounce using a timeout and immediate execution. The function will be called immediately and then not again until it has not been called for the specified timeout.
*/
ImmediateAndTimeout = 'immediate-and-timeout',
/**
* Debounce using a maximum frequency. The function will be called immediately and then at most once every timeout. Debounced calls will always use the latest arguments.
* The debounce function will be called even if its been called within the timeout.
*/
MaximumFrequency = 'maximum-frequency',
}

export function useDebounce(
func: FunctionToDebounce | EventListenerObject,
timeoutMs: number,
mode: DebounceMode = DebounceMode.Timeout,
): DebouncedFunction {
const ensure = useEnsure('useDebounce')
ensure.ensureExists(func, 'func')
ensure.ensureNotNegative(timeoutMs, 'timeoutMs')
ensure.ensureValidEnumValue(mode, DebounceMode, 'mode')

const immediate = mode === DebounceMode.ImmediateAndTimeout || mode === DebounceMode.MaximumFrequency

const lastCallTimestamp = ref<number | undefined>(undefined)
const timeoutId = ref<number | undefined>(undefined)
const lastArgs = ref<any[] | undefined>(undefined)
const isDestroyed = ref(false)
Expand All @@ -27,6 +51,7 @@ export function useDebounce(func: FunctionToDebounce | EventListenerObject, time
} else {
func(...lastArgs.value)
}
lastCallTimestamp.value = Date.now()
clear()
}
}
Expand All @@ -41,17 +66,54 @@ export function useDebounce(func: FunctionToDebounce | EventListenerObject, time
return
}

if (immediate) {
/**
* If this is the first call, execute immediately
*/
if (!lastCallTimestamp.value) {
lastCallTimestamp.value = Date.now()
log('useDebounce | first call', { args, lastCallTimestamp: lastCallTimestamp.value })
lastArgs.value = args
return execute()
}

/**
* If this is a subsequent call, check if the timeout has been reached
* and there are no pending calls
*/
const elapsed = Date.now() - lastCallTimestamp.value
if (!timeoutId.value && elapsed > timeoutMs) {
log('useDebounce | subsequent call within timeout', args)
lastArgs.value = args
return execute()
}
}

/**
* If this is a subsequent call within the timeout or immediate execution is not
* enabled, reset the timeout.
* If maximum frequency mode is enabled we only want to update to the latest arguments
* but not reset the timer.
*/
lastArgs.value = args
window.clearTimeout(timeoutId.value)
if (!timeoutId.value || mode === DebounceMode.Timeout || mode === DebounceMode.ImmediateAndTimeout) {
window.clearTimeout(timeoutId.value)
const timeout = lastCallTimestamp.value ? timeoutMs - (Date.now() - lastCallTimestamp.value) : timeoutMs

timeoutId.value = window.setTimeout(() => {
log('useDebounce | timeout reached', lastArgs.value)
execute()
}, timeoutMs)
timeoutId.value = window.setTimeout(() => {
execute()
log('useDebounce | timeout reached', { args: lastArgs.value, lastCallTimestamp: lastCallTimestamp.value })
}, timeout)

log('useDebounce | timeout reset', { args: lastArgs.value, lastCallTimestamp: lastCallTimestamp.value, timeout })
} else {
log('useDebounce | maximum frequency mode skip', { args: lastArgs.value, lastCallTimestamp: lastCallTimestamp.value })
}
}

debounced.clear = () => {
log('useDebounce | clear', {})
lastCallTimestamp.value = undefined
clear()
}

Expand All @@ -62,6 +124,7 @@ export function useDebounce(func: FunctionToDebounce | EventListenerObject, time

debounced.destroy = () => {
log('useDebounce | destroy', {})
lastCallTimestamp.value = undefined
clear()
isDestroyed.value = true
}
Expand Down
13 changes: 12 additions & 1 deletion lib/composables/useEnsure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ export function useEnsure(methodName: string) {
function ensureNotNegative(number: number | null | undefined, parameterName: string) {
ensureExists(number, parameterName)
if (number! < 0) {
const message = 'Expected a positive number'
const message = `Expected a positive number, received ${number}`
logError(message, { number, parameterName, methodName })
throw new NativeEventVueError(message, parameterName, methodName)
}
}

function ensureValidEnumValue(value: any, enumType: any, parameterName: string) {
ensureExists(value, parameterName)
const values = Object.values(enumType)
if (!values.includes(value)) {
const message = `Expected a value from ${JSON.stringify(values)}, received "${value}"`
logError(message, { value, parameterName, methodName })
throw new NativeEventVueError(message, parameterName, methodName)
}
}

return {
ensureExists,
ensureNotNegative,
ensureValidEnumValue,
}
}
17 changes: 11 additions & 6 deletions lib/composables/useNativeEvent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useEnsure } from './useEnsure'
import { useDebounce, type DebouncedFunction } from './useDebounce'
import { useDebounce, type DebouncedFunction, DebounceMode } from './useDebounce'
import { log } from '../logger'
import { resolveEventPropNamePrefix } from '../NativeEventVue'

Expand All @@ -12,6 +12,7 @@ export function useNativeEvent(
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
debounceMs?: number,
debounceMode?: DebounceMode,
replaceExisting?: boolean,
): NativeEvent {
const ensure = useEnsure('useNativeEvent')
Expand All @@ -23,11 +24,15 @@ export function useNativeEvent(
ensure.ensureNotNegative(debounceMs, 'debounceMs')
}

if (debounceMode) {
ensure.ensureValidEnumValue(debounceMode, DebounceMode, 'debounceMode')
}

const eventPropNamePrefix = resolveEventPropNamePrefix(event)
const existing = domEl[eventPropNamePrefix]
if (existing) {
if (replaceExisting) {
log('useNativeEvent | replacing existing listener', { domEl, event, options, debounceMs, replaceExisting })
log('useNativeEvent | replacing existing listener', { domEl, event, options, debounceMs, debounceMode, replaceExisting })
existing.destroy()
} else {
return
Expand All @@ -42,21 +47,21 @@ export function useNativeEvent(
}
domEl[eventPropNamePrefix] = undefined
domEl.removeEventListener(event, listenerRef.value, options)
log('useNativeEvent | event listener removed', { domEl, event, options, debounceMs, replaceExisting })
log('useNativeEvent | event listener removed', { domEl, event, options, debounceMs, debounceMode, replaceExisting })
}

if (debounceMs) {
const debounced = useDebounce(listener, debounceMs)
const debounced = useDebounce(listener, debounceMs, debounceMode)
listenerRef.value = debounced
log('useNativeEvent | event listener debounced', { domEl, event, options, debounceMs, replaceExisting })
log('useNativeEvent | event listener debounced', { domEl, event, options, debounceMs, debounceMode, replaceExisting })
}

domEl.addEventListener(event, listenerRef.value, options)

const result = { destroy: removeListener }
domEl[eventPropNamePrefix] = result

log('useNativeEvent | event listener added', { domEl, event, options, debounceMs, replaceExisting })
log('useNativeEvent | event listener added', { domEl, event, options, debounceMs, debounceMode, replaceExisting })

return result
}
12 changes: 11 additions & 1 deletion lib/directives/nativeEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import type { DirectiveBinding, VNode } from 'vue'
import { log } from '../logger'
import { useNativeEvent } from '../composables/useNativeEvent'
import { resolveEventPropNamePrefix } from '../NativeEventVue'
import type { DebounceMode } from '../composables/useDebounce'

export interface NativeEventOptions {
event: string
listener: EventListenerOrEventListenerObject
options?: boolean | AddEventListenerOptions
debounceMs?: number
debounceMode?: DebounceMode
disabled?: boolean | null | undefined
}

Expand Down Expand Up @@ -49,7 +51,15 @@ export const nativeEventDirective = {
}

function addEventListener(domEl: HTMLElement, binding: DirectiveBinding<NativeEventOptions>, replaceExisting: boolean) {
useNativeEvent(domEl, binding.value.event, binding.value.listener, binding.value.options, binding.value.debounceMs, replaceExisting)
useNativeEvent(
domEl,
binding.value.event,
binding.value.listener,
binding.value.options,
binding.value.debounceMs,
binding.value.debounceMode,
replaceExisting,
)
//log('native-event | event listener added', { domEl, binding: binding.value, replaceExisting })
}

Expand Down
Loading

0 comments on commit 5aaa20b

Please sign in to comment.