Skip to content

Commit 9e487b6

Browse files
committed
ref(replay): Replace lodash.debounce with custom implementation
1 parent 2794b68 commit 9e487b6

File tree

6 files changed

+235
-14
lines changed

6 files changed

+235
-14
lines changed

packages/browser/src/client.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import { BaseClient, getEnvelopeEndpointWithUrlEncodedAuth, Scope, SDK_VERSION } from '@sentry/core';
2-
import {
3-
BrowserClientReplayOptions,
4-
ClientOptions,
5-
Event,
6-
EventHint,
7-
Options,
8-
Severity,
9-
SeverityLevel,
10-
} from '@sentry/types';
2+
import type { BrowserClientReplayOptions } from '@sentry/types';
3+
import { ClientOptions, Event, EventHint, Options, Severity, SeverityLevel } from '@sentry/types';
114
import { createClientReportEnvelope, dsnToString, logger, serializeEnvelope } from '@sentry/utils';
125

136
import { eventFromException, eventFromMessage } from './eventbuilder';

packages/replay/src/replay.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
/* eslint-disable max-lines */ // TODO: We might want to split this file up
22
import { addGlobalEventProcessor, captureException, getCurrentHub, setContext } from '@sentry/core';
33
import { Breadcrumb, Event } from '@sentry/types';
4-
import { addInstrumentationHandler, logger } from '@sentry/utils';
5-
import debounce from 'lodash.debounce';
4+
import { addInstrumentationHandler, debounce, logger } from '@sentry/utils';
65
import { EventType, record } from 'rrweb';
76

87
import {
@@ -892,8 +891,8 @@ export class ReplayContainer implements ReplayContainerInterface {
892891
*/
893892
flushImmediate(): Promise<void> {
894893
this._debouncedFlush();
895-
// `.flush` is provided by lodash.debounce
896-
return this._debouncedFlush.flush();
894+
// `.flush` is provided by the debounced function, analogously to lodash.debounce
895+
return this._debouncedFlush.flush() as Promise<void>;
897896
}
898897

899898
/**

packages/replay/test/unit/index-errorSampleRate.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ describe('Replay (errorSampleRate)', () => {
257257

258258
it('does not upload if user has been idle for more than 15 minutes and comes back to move their mouse', async () => {
259259
// Idle for 15 minutes
260-
jest.advanceTimersByTime(15 * 60000);
260+
jest.advanceTimersByTime(15 * 60_000);
261261

262262
// TBD: We are currently deciding that this event will get dropped, but
263263
// this could/should change in the future.

packages/utils/src/debounce.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
type DebouncedCallback = {
2+
flush: () => void | unknown;
3+
cancel: () => void;
4+
(): void | unknown;
5+
};
6+
type CallbackFunction = () => unknown;
7+
type DebounceOptions = { maxWait?: number };
8+
9+
/**
10+
* Heavily simplified debounce function based on lodash.debounce.
11+
*
12+
* This function takes a callback function (@param fun) and delays its invocation
13+
* by @param wait milliseconds. Optionally, a maxWait can be specified in @param options,
14+
* which ensures that the callback is invoked at least once after the specified max. wait time.
15+
*
16+
* @param func the function whose invocation is to be debounced
17+
* @param wait the minimum time until the function is invoked after it was called once
18+
* @param options the options object, which can contain the `maxWait` property
19+
*
20+
* @returns the debounced version of the function, which needs to be called at least once to start the
21+
* debouncing process. Subsequent calls will reset the debouncing timer and, in case @paramfunc
22+
* was already invoked in the meantime, return @param func's return value.
23+
* The debounced function has two additional properties:
24+
* - `flush`: Invokes the debounced function immediately and returns its return value
25+
* - `cancel`: Cancels the debouncing process and resets the debouncing timer
26+
*/
27+
export function debounce(func: CallbackFunction, wait: number, options?: DebounceOptions): DebouncedCallback {
28+
let callbackReturnValue: unknown;
29+
let timerId: ReturnType<typeof setTimeout> | undefined;
30+
let lastCallTime: number | undefined;
31+
let lastInvokeTime = 0;
32+
33+
const maxWait = options && options.maxWait ? Math.max(options.maxWait || 0, wait) : 0;
34+
35+
function invokeFunc(time: number): unknown {
36+
timerId = undefined;
37+
38+
// Only invoke if we have `lastCallTime` which means `func` has been
39+
// debounced at least once.
40+
if (lastCallTime !== undefined) {
41+
lastInvokeTime = time;
42+
callbackReturnValue = func();
43+
}
44+
45+
return callbackReturnValue;
46+
}
47+
48+
function calcRemainingWait(time: number): number {
49+
const timeSinceLastCall = time - (lastCallTime || 0);
50+
const timeSinceLastInvoke = time - lastInvokeTime;
51+
const remainingWait = wait - timeSinceLastCall;
52+
53+
return maxWait ? Math.min(remainingWait, maxWait - timeSinceLastInvoke) : remainingWait;
54+
}
55+
56+
function shouldInvoke(time: number): boolean {
57+
const timeSinceLastCall = time - (lastCallTime || 0);
58+
const timeSinceLastInvoke = time - lastInvokeTime;
59+
// console.log({ timeSinceLastCall, timeSinceLastInvoke, wait });
60+
61+
return timeSinceLastCall >= wait || (Boolean(maxWait) && timeSinceLastInvoke >= maxWait);
62+
}
63+
64+
function timerExpired(): void {
65+
const time = Date.now();
66+
// console.log('timerexpired', time);
67+
if (shouldInvoke(time)) {
68+
// console.log('invoking', time);
69+
70+
return void invokeFunc(time);
71+
}
72+
73+
// Restart the timer.
74+
timerId = setTimeout(timerExpired, calcRemainingWait(time));
75+
}
76+
77+
function cancel(): void {
78+
if (timerId !== undefined) {
79+
clearTimeout(timerId);
80+
}
81+
lastInvokeTime = 0;
82+
lastCallTime = timerId = undefined;
83+
}
84+
85+
function flush(): unknown {
86+
return timerId === undefined ? callbackReturnValue : invokeFunc(Date.now());
87+
}
88+
89+
function debounced(): unknown {
90+
lastCallTime = Date.now();
91+
if (timerId === undefined) {
92+
lastInvokeTime = lastCallTime;
93+
timerId = setTimeout(timerExpired, wait);
94+
}
95+
return callbackReturnValue;
96+
}
97+
98+
debounced.cancel = cancel;
99+
debounced.flush = flush;
100+
return debounced;
101+
}

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export * from './clientreport';
2727
export * from './ratelimit';
2828
export * from './baggage';
2929
export * from './url';
30+
export * from './debounce';

packages/utils/test/debounce.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { debounce } from '../src/debounce';
2+
3+
describe('debounce', () => {
4+
jest.useFakeTimers();
5+
it('delay the execution of the passed callback function by the passed minDelay', () => {
6+
const callback = jest.fn();
7+
const debouncedCallback = debounce(callback, 100);
8+
debouncedCallback();
9+
expect(callback).not.toHaveBeenCalled();
10+
11+
jest.advanceTimersByTime(99);
12+
expect(callback).not.toHaveBeenCalled();
13+
14+
jest.advanceTimersByTime(1);
15+
expect(callback).toHaveBeenCalled();
16+
});
17+
18+
it('should invoke the callback at latest by maxWait, if the option is specified', () => {
19+
const callback = jest.fn();
20+
const debouncedCallback = debounce(callback, 100, { maxWait: 150 });
21+
debouncedCallback();
22+
expect(callback).not.toHaveBeenCalled();
23+
24+
jest.advanceTimersByTime(98);
25+
expect(callback).not.toHaveBeenCalled();
26+
27+
debouncedCallback();
28+
29+
jest.advanceTimersByTime(1);
30+
expect(callback).not.toHaveBeenCalled();
31+
32+
jest.advanceTimersByTime(49);
33+
// at this time, the callback shouldn't be invoked and with a new call, it should be devounced further.
34+
debouncedCallback();
35+
expect(callback).not.toHaveBeenCalled();
36+
37+
// But because the maxWait is reached, the callback should nevertheless be invoked.
38+
jest.advanceTimersByTime(10);
39+
expect(callback).toHaveBeenCalled();
40+
});
41+
42+
it('should not invoke the callback as long as it is debounced and no maxWait option is specified', () => {
43+
const callback = jest.fn();
44+
const debouncedCallback = debounce(callback, 100);
45+
debouncedCallback();
46+
expect(callback).not.toHaveBeenCalled();
47+
48+
jest.advanceTimersByTime(99);
49+
expect(callback).not.toHaveBeenCalled();
50+
51+
debouncedCallback();
52+
53+
jest.advanceTimersByTime(1);
54+
expect(callback).not.toHaveBeenCalled();
55+
56+
jest.advanceTimersByTime(98);
57+
debouncedCallback();
58+
expect(callback).not.toHaveBeenCalled();
59+
60+
jest.advanceTimersByTime(99);
61+
expect(callback).not.toHaveBeenCalled();
62+
});
63+
64+
it('should invoke the callback as soon as callback.flush() is called', () => {
65+
const callback = jest.fn();
66+
const debouncedCallback = debounce(callback, 100, { maxWait: 200 });
67+
debouncedCallback();
68+
expect(callback).not.toHaveBeenCalled();
69+
70+
jest.advanceTimersByTime(10);
71+
expect(callback).not.toHaveBeenCalled();
72+
73+
debouncedCallback.flush();
74+
expect(callback).toHaveBeenCalled();
75+
});
76+
77+
it('should not invoke the callback, if callback.cancel() is called', () => {
78+
const callback = jest.fn();
79+
const debouncedCallback = debounce(callback, 100, { maxWait: 200 });
80+
debouncedCallback();
81+
expect(callback).not.toHaveBeenCalled();
82+
83+
jest.advanceTimersByTime(99);
84+
expect(callback).not.toHaveBeenCalled();
85+
86+
// If the callback is canceled, it should not be invoked after the minwait
87+
debouncedCallback.cancel();
88+
jest.advanceTimersByTime(1);
89+
expect(callback).not.toHaveBeenCalled();
90+
91+
// And it should also not be invoked after the maxWait
92+
jest.advanceTimersByTime(500);
93+
expect(callback).not.toHaveBeenCalled();
94+
});
95+
96+
it("should return the callback's return value when calling callback.flush()", () => {
97+
const callback = jest.fn().mockReturnValue('foo');
98+
const debouncedCallback = debounce(callback, 100);
99+
100+
debouncedCallback();
101+
102+
const returnValue = debouncedCallback.flush();
103+
expect(returnValue).toBe('foo');
104+
});
105+
106+
it('should return the callbacks return value on subsequent calls of the debounced function', () => {
107+
const callback = jest.fn().mockReturnValue('foo');
108+
const debouncedCallback = debounce(callback, 100);
109+
110+
const returnValue1 = debouncedCallback();
111+
expect(returnValue1).toBe(undefined);
112+
expect(callback).not.toHaveBeenCalled();
113+
114+
// now we expect the callback to have been invoked
115+
jest.advanceTimersByTime(200);
116+
expect(callback).toHaveBeenCalledTimes(1);
117+
118+
// calling the debounced function now should return the return value of the callback execution
119+
const returnValue2 = debouncedCallback();
120+
expect(returnValue2).toBe('foo');
121+
expect(callback).toHaveBeenCalledTimes(1);
122+
123+
// and the callback should also be invoked again
124+
jest.advanceTimersByTime(200);
125+
expect(callback).toHaveBeenCalledTimes(2);
126+
});
127+
});

0 commit comments

Comments
 (0)