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
10 changes: 6 additions & 4 deletions doc/api/buffer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5345,8 +5345,9 @@ added:
- v19.6.0
- v18.15.0
changes:
- v26.8.0
- v24.21.0
- version:
- v26.8.0
- v24.21.0
pr-url: https://github.com/nodejs/node/pull/64504
description: Detached `ArrayBuffer`s and views backed by them are treated
as empty.
Expand All @@ -5367,8 +5368,9 @@ added:
- v19.4.0
- v18.14.0
changes:
- v26.8.0
- v24.21.0
- version:
- v26.8.0
- v24.21.0
pr-url: https://github.com/nodejs/node/pull/64504
description: Detached `ArrayBuffer`s and views backed by them are treated
as empty.
Expand Down
10 changes: 6 additions & 4 deletions doc/api/fs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2985,8 +2985,9 @@ behavior is similar to `cp dir1/ dir2/`.
<!-- YAML
added: v0.1.31
changes:
- v26.8.0
- v24.21.0
- version:
- v26.8.0
- v24.21.0
pr-url: https://github.com/nodejs/node/pull/63851
description: Add the `windowsHandle` option.
- version: v16.10.0
Expand Down Expand Up @@ -3123,8 +3124,9 @@ If `options` is a string, then it specifies the encoding.
<!-- YAML
added: v0.1.31
changes:
- v26.8.0
- v24.21.0
- version:
- v26.8.0
- v24.21.0
pr-url: https://github.com/nodejs/node/pull/63851
description: Add the `windowsHandle` option.
- version: v22.0.0
Expand Down
98 changes: 98 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,104 @@ 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}
* `leading` {boolean} When `true`, invokes `fn` immediately when a new
debounce window begins. **Default:** `false`.
* `rejectOnCancel` {boolean} When `true`, a call superseded by a later call
rejects with an `AbortError`. **Default:** `false`.
* `signal` {AbortSignal} An `AbortSignal` that cancels pending calls and
prevents future calls 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.

When `options.leading` is `true`, the first call in a debounce window invokes
`fn` immediately. Calls made during that window are delayed until `wait`
milliseconds have elapsed since the most recent call. A trailing invocation
only occurs if the debounced function was called again during the window.
The window begins before `fn` is invoked, so recursive calls and calls made
while an asynchronous `fn` is pending are part of the same window if they occur
before the delay expires. This also applies to calls made after a synchronous
`fn` returns but before the delay expires.

If `options.signal` is aborted, pending and future calls reject with an
`AbortError`, with the signal's reason set as the error's `cause`, and `fn` is
not invoked by those calls. If the signal is already aborted, `debounce()`
throws an `AbortError`.

The returned function has the following properties:

* `cancel([reason])` cancels the current debounce window. 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. After a
trailing invocation, 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
237 changes: 237 additions & 0 deletions lib/internal/util/debounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
'use strict';

const {
ArrayPrototypePush,
ObjectDefineProperties,
PromiseReject,
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 pending and future calls.
* @property {boolean} [leading] Whether to invoke on the leading edge.
* @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 {
leading = false,
rejectOnCancel = false,
signal,
} = options;

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

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

let args;
let abortError;
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 cancelWithError(error) {
if (timeout === undefined) return;
clearTimeout(timeout);
timeout = undefined;
args = undefined;
rejectPending(error);
}

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

function abort() {
if (abortError === undefined) {
abortError = signal.reason === undefined ?
new AbortError() :
new AbortError(undefined, { __proto__: null, cause: signal.reason });
}
cancelWithError(abortError);
}

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

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

function invoke(callArgs, calls) {
if (signal?.aborted) {
abort();
for (let i = 0; i < calls.length; i++) {
calls[i].reject(abortError);
}
return;
}

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 invokePending() {
const callArgs = args;
args = undefined;
const calls = pendingCalls;
pendingCalls = [];
if (calls.length !== 0) invoke(callArgs, calls);
}

function onTimeout() {
timeout = undefined;
invokePending();
}

function flush() {
if (pendingCalls.length === 0) return;

clearTimeout(timeout);
timeout = undefined;
invokePending();
}

function debounced(...callArgs) {
if (signal?.aborted) abort();
if (abortError !== undefined) return PromiseReject(abortError);

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

if (signal?.aborted) {
abort();
return PromiseReject(abortError);
}

const call = PromiseWithResolvers();
if (invokeNow) {
invoke(callArgs, [call]);
} else {
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 pendingCalls.length === 0 ? 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, abort);
}

return debounced;
}

module.exports = debounce;
Loading
Loading