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
80 changes: 80 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,86 @@ The `--throw-deprecation` command-line flag and `process.throwDeprecation`
property take precedence over `--trace-deprecation` and
`process.traceDeprecation`.

## `util.debounce(fn, wait[, options])`

<!-- YAML
added: REPLACEME
-->

* `fn` {Function} The function to debounce.
* `wait` {integer} The number of milliseconds to delay `fn`.
* `options` {Object}
* `rejectOnCancel` {boolean} When `true`, a call superseded by a later call
rejects with an `AbortError`. **Default:** `false`.
* `signal` {AbortSignal} An `AbortSignal` that cancels a pending call when
aborted.
* Returns: {Function} The debounced function.

Creates a function that delays calling `fn` until `wait` milliseconds have
elapsed since the most recent invocation. The debounced function returns a
{Promise} for the value returned by `fn`. If `fn` throws or returns a rejected
promise, the returned promise is rejected with the same reason.

When the debounced function is called more than once before the delay expires,
`fn` receives the arguments from the most recent call. By default, the promises
from all calls resolve or reject with the result of that invocation. If
`options.rejectOnCancel` is `true`, the promises from superseded calls reject
with an `AbortError` instead.

The returned function has the following properties:

* `cancel([reason])` cancels the pending invocation. Its pending promises reject
with an `AbortError`. If provided, `reason` is set as the error's `cause`.
* `flush()` cancels the delay and invokes `fn` immediately. It has no effect if
no invocation is pending.
* `pending` {Promise|null} is the promise returned by the most recent call in
the current debounce window, or `null` if no invocation is pending.
* `pendingCount` {integer} is the number of calls awaiting the invocation in
the current debounce window.
* `ref()` makes the pending and future timeout keep the Node.js event loop
active. Returns the debounced function.
* `unref()` allows the event loop to exit while a timeout is pending. This also
applies to future timeouts. Returns the debounced function.

When invoked, `fn` has the debounced function as its `this` value. Once `fn` is
invoked, a new debounce window can begin even if a promise returned by `fn` is
still pending. The debounced function preserves the `name` and `length` of `fn`.

```mjs
import { setTimeout as wait } from 'node:timers/promises';
import { debounce } from 'node:util';

const fn = debounce(async (value) => {
await wait(100);
return value;
}, 50);

const first = fn(1);
const second = fn(2);

console.log(await first); // 2
console.log(await second); // 2
```

A debounced function can be used to trigger an action after a period of
inactivity. Each call resets the timeout:

```cjs
const { debounce } = require('node:util');

const onInactivity = debounce(() => {
console.log('No activity for 5 seconds');
}, 5_000).unref();

process.stdin.on('data', (data) => {
console.log(`Received ${data.length} bytes`);
onInactivity();
});

// Start the initial inactivity timeout.
onInactivity();
```

## `util.diff(actual, expected)`

<!-- YAML
Expand Down
185 changes: 185 additions & 0 deletions lib/internal/util/debounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
'use strict';

const {
ArrayPrototypePush,
ObjectDefineProperties,
PromiseWithResolvers,
ReflectApply,
} = primordials;

const {
AbortError,
} = require('internal/errors');
const {
validateAbortSignal,
validateBoolean,
validateFunction,
validateInteger,
validateObject,
} = require('internal/validators');
const { addAbortListener } = require('internal/events/abort_listener');
const { kEmptyObject } = require('internal/util');
const { TIMEOUT_MAX } = require('internal/timers');
const { clearTimeout, setTimeout } = require('timers');
const { markPromiseAsHandled } = internalBinding('util');

/**
* @typedef {object} DebounceOptions
* @property {AbortSignal} [signal] An AbortSignal that cancels a pending call.
* @property {boolean} [rejectOnCancel] Whether to reject superseded calls.
*/

/**
* Creates a function that delays calling `fn` until `wait` milliseconds have
* elapsed since its most recent invocation.
* @param {Function} fn
* @param {number} wait
* @param {DebounceOptions} [options]
* @returns {Function}
*/
function debounce(fn, wait, options = kEmptyObject) {
validateFunction(fn, 'fn');
validateInteger(wait, 'wait', 0, TIMEOUT_MAX);
validateObject(options, 'options');

const {
rejectOnCancel = false,
signal,
} = options;

validateBoolean(rejectOnCancel, 'options.rejectOnCancel');
validateAbortSignal(signal, 'options.signal');

if (signal?.aborted) {
throw new AbortError(undefined, { __proto__: null, cause: signal.reason });
}

let args;
let pendingCalls = [];
let refed = true;
let timeout;

function rejectPending(error) {
const calls = pendingCalls;
pendingCalls = [];
for (let i = 0; i < calls.length; i++) {
calls[i].reject(error);
}
}

function cancel(reason) {
if (timeout === undefined) return;
clearTimeout(timeout);
timeout = undefined;
args = undefined;
const error = reason === undefined ?
new AbortError() :
new AbortError(undefined, { __proto__: null, cause: reason });
rejectPending(error);
}

function ref() {
refed = true;
timeout?.ref();
return debounced;
}

function unref() {
refed = false;
timeout?.unref();
return debounced;
}

function flush() {
if (timeout === undefined) return;

clearTimeout(timeout);
timeout = undefined;
const callArgs = args;
args = undefined;
const calls = pendingCalls;
pendingCalls = [];

let result;
try {
result = ReflectApply(fn, debounced, callArgs);
} catch (error) {
for (let i = 0; i < calls.length; i++) {
calls[i].reject(error);
}
return;
}

for (let i = 0; i < calls.length; i++) {
calls[i].resolve(result);
}
}

function debounced(...callArgs) {
if (timeout !== undefined) {
markPromiseAsHandled(pendingCalls[pendingCalls.length - 1].promise);
if (rejectOnCancel) {
rejectPending(new AbortError('The debounced call was superseded'));
}
timeout.refresh();
} else {
timeout = setTimeout(flush, wait);
if (!refed) timeout.unref();
}

const call = PromiseWithResolvers();
ArrayPrototypePush(pendingCalls, call);
args = callArgs;
return call.promise;
}

function createFn(value) {
return {
__proto__: null,
configurable: true,
enumerable: true,
writable: true,
value,
};
}

ObjectDefineProperties(debounced, {
__proto__: null,
cancel: createFn(cancel),
flush: createFn(flush),
ref: createFn(ref),
unref: createFn(unref),
pending: {
__proto__: null,
enumerable: false,
get() {
return timeout === undefined ? null : pendingCalls[pendingCalls.length - 1].promise;
},
},
pendingCount: {
__proto__: null,
enumerable: false,
get() { return pendingCalls.length; },
},
length: {
__proto__: null,
configurable: true,
value: fn.length,
},
name: {
__proto__: null,
configurable: true,
value: fn.name,
},
});

if (signal !== undefined) {
addAbortListener(signal, () => {
cancel(signal.reason);
});
}

return debounced;
}

module.exports = debounce;
9 changes: 9 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,15 @@ defineLazyProperties(
['diff'],
);

ObjectDefineProperties(module.exports, {
debounce: {
__proto__: null,
configurable: true,
enumerable: true,
get() { return require('internal/util/debounce'); },
},
});

defineLazyProperties(
module.exports,
'internal/util/trace_sigint',
Expand Down
Loading
Loading