util: implement debounce · nodejs/node@a3bbb5a · GitHub
Skip to content

Commit a3bbb5a

Browse files
jasnelladuh95
authored andcommitted
util: implement debounce
I found myself using debounce quite a bit recently while testing some recent other additions (quic and dtls testing, perf_hooks improvements, etc). I was using an npm dependency right up until I realized just how generally useful it is to actually have it Just There. So, since it was a holiday and I just felt like it... util.debounce(...) Signed-off-by: James M Snell <jasnell@gmail.com> PR-URL: #65899 Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent b4a6719 commit a3bbb5a

4 files changed

Lines changed: 725 additions & 0 deletions

File tree

‎doc/api/util.md‎

Lines changed: 98 additions & 0 deletions

‎lib/internal/util/debounce.js‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypePush,
5+
ObjectDefineProperties,
6+
PromiseReject,
7+
PromiseWithResolvers,
8+
ReflectApply,
9+
} = primordials;
10+
11+
const {
12+
AbortError,
13+
} = require('internal/errors');
14+
const {
15+
validateAbortSignal,
16+
validateBoolean,
17+
validateFunction,
18+
validateInteger,
19+
validateObject,
20+
} = require('internal/validators');
21+
const { addAbortListener } = require('internal/events/abort_listener');
22+
const { kEmptyObject } = require('internal/util');
23+
const { TIMEOUT_MAX } = require('internal/timers');
24+
const { clearTimeout, setTimeout } = require('timers');
25+
const { markPromiseAsHandled } = internalBinding('util');
26+
27+
/**
28+
* @typedef {object} DebounceOptions
29+
* @property {AbortSignal} [signal] An AbortSignal that cancels pending and future calls.
30+
* @property {boolean} [leading] Whether to invoke on the leading edge.
31+
* @property {boolean} [rejectOnCancel] Whether to reject superseded calls.
32+
*/
33+
34+
/**
35+
* Creates a function that delays calling `fn` until `wait` milliseconds have
36+
* elapsed since its most recent invocation.
37+
* @param {Function} fn
38+
* @param {number} wait
39+
* @param {DebounceOptions} [options]
40+
* @returns {Function}
41+
*/
42+
function debounce(fn, wait, options = kEmptyObject) {
43+
validateFunction(fn, 'fn');
44+
validateInteger(wait, 'wait', 0, TIMEOUT_MAX);
45+
validateObject(options, 'options');
46+
47+
const {
48+
leading = false,
49+
rejectOnCancel = false,
50+
signal,
51+
} = options;
52+
53+
validateBoolean(leading, 'options.leading');
54+
validateBoolean(rejectOnCancel, 'options.rejectOnCancel');
55+
validateAbortSignal(signal, 'options.signal');
56+
57+
if (signal?.aborted) {
58+
throw new AbortError(undefined, { __proto__: null, cause: signal.reason });
59+
}
60+
61+
let args;
62+
let abortError;
63+
let pendingCalls = [];
64+
let refed = true;
65+
let timeout;
66+
67+
function rejectPending(error) {
68+
const calls = pendingCalls;
69+
pendingCalls = [];
70+
for (let i = 0; i < calls.length; i++) {
71+
calls[i].reject(error);
72+
}
73+
}
74+
75+
function cancelWithError(error) {
76+
if (timeout === undefined) return;
77+
clearTimeout(timeout);
78+
timeout = undefined;
79+
args = undefined;
80+
rejectPending(error);
81+
}
82+
83+
function cancel(reason) {
84+
if (timeout === undefined) return;
85+
const error = reason === undefined ?
86+
new AbortError() :
87+
new AbortError(undefined, { __proto__: null, cause: reason });
88+
cancelWithError(error);
89+
}
90+
91+
function abort() {
92+
if (abortError === undefined) {
93+
abortError = signal.reason === undefined ?
94+
new AbortError() :
95+
new AbortError(undefined, { __proto__: null, cause: signal.reason });
96+
}
97+
cancelWithError(abortError);
98+
}
99+
100+
function ref() {
101+
refed = true;
102+
timeout?.ref();
103+
return debounced;
104+
}
105+
106+
function unref() {
107+
refed = false;
108+
timeout?.unref();
109+
return debounced;
110+
}
111+
112+
function invoke(callArgs, calls) {
113+
if (signal?.aborted) {
114+
abort();
115+
for (let i = 0; i < calls.length; i++) {
116+
calls[i].reject(abortError);
117+
}
118+
return;
119+
}
120+
121+
let result;
122+
try {
123+
result = ReflectApply(fn, debounced, callArgs);
124+
} catch (error) {
125+
for (let i = 0; i < calls.length; i++) {
126+
calls[i].reject(error);
127+
}
128+
return;
129+
}
130+
131+
for (let i = 0; i < calls.length; i++) {
132+
calls[i].resolve(result);
133+
}
134+
}
135+
136+
function invokePending() {
137+
const callArgs = args;
138+
args = undefined;
139+
const calls = pendingCalls;
140+
pendingCalls = [];
141+
if (calls.length !== 0) invoke(callArgs, calls);
142+
}
143+
144+
function onTimeout() {
145+
timeout = undefined;
146+
invokePending();
147+
}
148+
149+
function flush() {
150+
if (pendingCalls.length === 0) return;
151+
152+
clearTimeout(timeout);
153+
timeout = undefined;
154+
invokePending();
155+
}
156+
157+
function debounced(...callArgs) {
158+
if (signal?.aborted) abort();
159+
if (abortError !== undefined) return PromiseReject(abortError);
160+
161+
const invokeNow = leading && timeout === undefined;
162+
if (timeout !== undefined) {
163+
if (pendingCalls.length !== 0) {
164+
markPromiseAsHandled(pendingCalls[pendingCalls.length - 1].promise);
165+
if (rejectOnCancel) {
166+
rejectPending(new AbortError('The debounced call was superseded'));
167+
}
168+
}
169+
timeout.refresh();
170+
} else {
171+
timeout = setTimeout(onTimeout, wait);
172+
if (!refed) timeout.unref();
173+
}
174+
175+
if (signal?.aborted) {
176+
abort();
177+
return PromiseReject(abortError);
178+
}
179+
180+
const call = PromiseWithResolvers();
181+
if (invokeNow) {
182+
invoke(callArgs, [call]);
183+
} else {
184+
ArrayPrototypePush(pendingCalls, call);
185+
args = callArgs;
186+
}
187+
return call.promise;
188+
}
189+
190+
function createFn(value) {
191+
return {
192+
__proto__: null,
193+
configurable: true,
194+
enumerable: true,
195+
writable: true,
196+
value,
197+
};
198+
}
199+
200+
ObjectDefineProperties(debounced, {
201+
__proto__: null,
202+
cancel: createFn(cancel),
203+
flush: createFn(flush),
204+
ref: createFn(ref),
205+
unref: createFn(unref),
206+
pending: {
207+
__proto__: null,
208+
enumerable: false,
209+
get() {
210+
return pendingCalls.length === 0 ? null : pendingCalls[pendingCalls.length - 1].promise;
211+
},
212+
},
213+
pendingCount: {
214+
__proto__: null,
215+
enumerable: false,
216+
get() { return pendingCalls.length; },
217+
},
218+
length: {
219+
__proto__: null,
220+
configurable: true,
221+
value: fn.length,
222+
},
223+
name: {
224+
__proto__: null,
225+
configurable: true,
226+
value: fn.name,
227+
},
228+
});
229+
230+
if (signal !== undefined) {
231+
addAbortListener(signal, abort);
232+
}
233+
234+
return debounced;
235+
}
236+
237+
module.exports = debounce;

‎lib/util.js‎

Lines changed: 9 additions & 0 deletions

0 commit comments

Comments
 (0)