Skip to content

Commit 038458f

Browse files
Debounce async (microsoft#4945)
(for microsoft#4641)
1 parent 5578b78 commit 038458f

File tree

3 files changed

+158
-8
lines changed

3 files changed

+158
-8
lines changed

news/3 Code Health/4641.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support debouncing decorated async methods.

src/client/common/utils/decorators.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,56 @@ import { InMemoryInterpreterSpecificCache } from './cacheUtils';
1111
const _debounce = require('lodash/debounce') as typeof import('lodash/debounce');
1212

1313
type VoidFunction = (...any: any[]) => void;
14+
type AsyncVoidFunction = (...any: any[]) => Promise<void>;
15+
1416
/**
15-
* Debounces a function execution. Function must return either a void or a promise that resolves to a void.
17+
* Combine multiple sequential calls to the decorated function into one.
1618
* @export
17-
* @param {number} [wait] Wait time.
19+
* @param {number} [wait] Wait time (milliseconds).
1820
* @returns void
21+
*
22+
* The point is to ensure that successive calls to the function result
23+
* only in a single actual call. Following the most recent call to
24+
* the debounced function, debouncing resets after the "wait" interval
25+
* has elapsed.
26+
*
27+
* The decorated function must return either a void or a promise that
28+
* resolves to a void.
1929
*/
2030
export function debounce(wait?: number) {
31+
if (isTestExecution()) {
32+
// If running tests, lets not debounce (so tests run fast).
33+
wait = undefined;
34+
// tslint:disable-next-line:no-suspicious-comment
35+
// TODO: We should be able to return a noop decorator instead...
36+
}
37+
return makeDebounceDecorator(wait);
38+
}
39+
40+
export function makeDebounceDecorator(wait?: number) {
2141
// tslint:disable-next-line:no-any no-function-expression
22-
return function (_target: any, _propertyName: string, descriptor: TypedPropertyDescriptor<VoidFunction>) {
42+
return function (_target: any, _propertyName: string, descriptor: TypedPropertyDescriptor<VoidFunction> | TypedPropertyDescriptor<AsyncVoidFunction>) {
43+
// We could also make use of _debounce() options. For instance,
44+
// the following causes the original method to be called
45+
// immediately:
46+
//
47+
// {leading: true, trailing: false}
48+
//
49+
// The default is:
50+
//
51+
// {leading: false, trailing: true}
52+
//
53+
// See https://lodash.com/docs/#debounce.
54+
const options = {};
2355
const originalMethod = descriptor.value!;
24-
// If running tests, lets not debounce (so tests run fast).
25-
wait = wait && isTestExecution() ? undefined : wait;
26-
// tslint:disable-next-line:no-invalid-this no-any
27-
(descriptor as any).value = _debounce(function (this: any) { return originalMethod.apply(this, arguments as any); }, wait);
56+
const debounced = _debounce(
57+
function (this: any) {
58+
return originalMethod.apply(this, arguments as any);
59+
},
60+
wait,
61+
options
62+
);
63+
(descriptor as any).value = debounced;
2864
};
2965
}
3066

src/test/common/utils/decorators.unit.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { expect } from 'chai';
77
import { Uri } from 'vscode';
88
import { Resource } from '../../../client/common/types';
99
import { clearCache } from '../../../client/common/utils/cacheUtils';
10-
import { cacheResourceSpecificInterpreterData } from '../../../client/common/utils/decorators';
10+
import {
11+
cacheResourceSpecificInterpreterData, makeDebounceDecorator
12+
} from '../../../client/common/utils/decorators';
1113
import { sleep } from '../../core';
1214

1315
// tslint:disable:no-any max-func-body-length no-unnecessary-class
@@ -97,6 +99,117 @@ suite('Common Utils - Decorators', () => {
9799

98100
expect(result).to.equal(3);
99101
expect(cls.invoked).to.equal(true, 'Must be invoked');
102+
});
103+
104+
// debounce()
105+
class Base {
106+
public created: number;
107+
public calls: string[];
108+
public timestamps: number[];
109+
constructor() {
110+
this.created = Date.now();
111+
this.calls = [];
112+
this.timestamps = [];
113+
}
114+
protected _addCall(funcname: string, timestamp?: number): void {
115+
if (!timestamp) {
116+
timestamp = Date.now();
117+
}
118+
this.calls.push(funcname);
119+
this.timestamps.push(timestamp);
120+
}
121+
}
122+
async function waitForCalls(timestamps: number[], count: number, delay = 10, timeout = 1000) {
123+
const steps = timeout / delay;
124+
for (let i = 0; i < steps; i += 1) {
125+
if (timestamps.length >= count) {
126+
return;
127+
}
128+
await sleep(delay);
129+
}
130+
if (timestamps.length < count) {
131+
throw Error(`timed out after ${timeout}ms`);
132+
}
133+
}
134+
test('Debounce: one sync call', async () => {
135+
const wait = 100;
136+
// tslint:disable-next-line:max-classes-per-file
137+
class One extends Base {
138+
@makeDebounceDecorator(wait)
139+
public run(): void {
140+
this._addCall('run');
141+
}
142+
}
143+
const one = new One();
144+
145+
const start = Date.now();
146+
one.run();
147+
await waitForCalls(one.timestamps, 1);
148+
const delay = one.timestamps[0] - start;
149+
150+
expect(delay).to.be.at.least(wait);
151+
expect(one.calls).to.deep.equal(['run']);
152+
expect(one.timestamps).to.have.lengthOf(one.calls.length);
153+
});
154+
test('Debounce: one async call', async () => {
155+
const wait = 100;
156+
// tslint:disable-next-line:max-classes-per-file
157+
class One extends Base {
158+
@makeDebounceDecorator(wait)
159+
public async run(): Promise<void> {
160+
this._addCall('run');
161+
}
162+
}
163+
const one = new One();
164+
165+
const start = Date.now();
166+
await one.run();
167+
await waitForCalls(one.timestamps, 1);
168+
const delay = one.timestamps[0] - start;
169+
170+
expect(delay).to.be.at.least(wait);
171+
expect(one.calls).to.deep.equal(['run']);
172+
expect(one.timestamps).to.have.lengthOf(one.calls.length);
173+
});
174+
test('Debounce: multiple calls grouped', async () => {
175+
const wait = 100;
176+
// tslint:disable-next-line:max-classes-per-file
177+
class One extends Base {
178+
@makeDebounceDecorator(wait)
179+
public run(): void {
180+
this._addCall('run');
181+
}
182+
}
183+
const one = new One();
184+
185+
const start = Date.now();
186+
one.run();
187+
one.run();
188+
one.run();
189+
await waitForCalls(one.timestamps, 1);
190+
const delay = one.timestamps[0] - start;
191+
192+
expect(delay).to.be.at.least(wait);
193+
expect(one.calls).to.deep.equal(['run']);
194+
expect(one.timestamps).to.have.lengthOf(one.calls.length);
195+
});
196+
test('Debounce: multiple calls spread', async () => {
197+
const wait = 100;
198+
// tslint:disable-next-line:max-classes-per-file
199+
class One extends Base {
200+
@makeDebounceDecorator(wait)
201+
public run(): void {
202+
this._addCall('run');
203+
}
204+
}
205+
const one = new One();
206+
207+
one.run();
208+
await sleep(wait);
209+
one.run();
210+
await waitForCalls(one.timestamps, 2);
100211

212+
expect(one.calls).to.deep.equal(['run', 'run']);
213+
expect(one.timestamps).to.have.lengthOf(one.calls.length);
101214
});
102215
});

0 commit comments

Comments
 (0)