Skip to content

Commit 23a7d0b

Browse files
authored
ref(replay): Replace lodash.debounce with custom debounce implementation (#6593)
Replace lodash's `debounce` function with a custom, minimal implementation that - Delays the function invocation by a `wait` time and gate it with a `maxWait` value - Provides the return value of the invocation for subsequent debounced function calls - Provides a `flush` and `cancel` function on the debounced function (analogously to lodash). - Invokes the function _after_ the wait/maxwait time triggered (i.e. on the trailing edge). Lodash allows to choose between the leading and trailing edge, which makes the implementation much more complicated than for us necessary - Works for functions _without_ parameters. By not supporting args, we can further cut down bundle size. (We might want to revisit this in the future but for now, this should do). With this change, we can also get rid of the package patch we introduced in #6551 and of the `commonjs()` plugin in our build process.
1 parent 371d42d commit 23a7d0b

File tree

10 files changed

+347
-58
lines changed

10 files changed

+347
-58
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/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,8 @@
4646
"homepage": "https://docs.sentry.io/platforms/javascript/session-replay/",
4747
"devDependencies": {
4848
"@babel/core": "^7.17.5",
49-
"@types/lodash.debounce": "4.0.7",
5049
"@types/pako": "^2.0.0",
5150
"jsdom-worker": "^0.2.1",
52-
"lodash.debounce": "4.0.8",
5351
"pako": "^2.0.4",
5452
"rrweb": "1.1.3",
5553
"tslib": "^1.9.3"

packages/replay/rollup.bundle.config.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import commonjs from '@rollup/plugin-commonjs';
21
import replace from '@rollup/plugin-replace';
32

43
import { makeBaseBundleConfig, makeBundleConfigVariants } from '../../rollup/index.js';
@@ -19,8 +18,6 @@ const baseBundleConfig = makeBaseBundleConfig({
1918
__SENTRY_REPLAY_VERSION__: JSON.stringify(pkg.version),
2019
},
2120
}),
22-
// lodash.debounce is a CJS module, so we need to convert it to ESM first
23-
commonjs(),
2421
],
2522
},
2623
});

packages/replay/rollup.npm.config.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import path from 'path';
22

3-
import commonjs from '@rollup/plugin-commonjs';
43
import replace from '@rollup/plugin-replace';
54

65
import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index';
@@ -19,8 +18,6 @@ export default makeNPMConfigVariants(
1918
__SENTRY_REPLAY_VERSION__: JSON.stringify(pkg.version),
2019
},
2120
}),
22-
// lodash.debounce is a CJS module, so we need to convert it to ESM first
23-
commonjs(),
2421
],
2522
output: {
2623
// set exports to 'named' or 'auto' so that rollup doesn't warn about

packages/replay/src/replay.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { addGlobalEventProcessor, captureException, getCurrentHub, setContext } from '@sentry/core';
33
import { Breadcrumb, ReplayEvent } from '@sentry/types';
44
import { addInstrumentationHandler, logger } from '@sentry/utils';
5-
import debounce from 'lodash.debounce';
65
import { EventType, record } from 'rrweb';
76

87
import {
@@ -45,6 +44,7 @@ import { createBreadcrumb } from './util/createBreadcrumb';
4544
import { createPayload } from './util/createPayload';
4645
import { createPerformanceSpans } from './util/createPerformanceSpans';
4746
import { createReplayEnvelope } from './util/createReplayEnvelope';
47+
import { debounce } from './util/debounce';
4848
import { getReplayEvent } from './util/getReplayEvent';
4949
import { isExpired } from './util/isExpired';
5050
import { isSessionExpired } from './util/isSessionExpired';
@@ -859,7 +859,6 @@ export class ReplayContainer implements ReplayContainerInterface {
859859
// A flush is about to happen, cancel any queued flushes
860860
this._debouncedFlush?.cancel();
861861

862-
// No existing flush in progress, proceed with flushing.
863862
// this._flushLock acts as a lock so that future calls to `flush()`
864863
// will be blocked until this promise resolves
865864
if (!this._flushLock) {
@@ -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/src/util/debounce.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
30+
let timerId: ReturnType<typeof setTimeout> | undefined;
31+
let maxTimerId: ReturnType<typeof setTimeout> | undefined;
32+
33+
const maxWait = options && options.maxWait ? Math.max(options.maxWait, wait) : 0;
34+
35+
function invokeFunc(): unknown {
36+
cancelTimers();
37+
callbackReturnValue = func();
38+
return callbackReturnValue;
39+
}
40+
41+
function cancelTimers(): void {
42+
timerId !== undefined && clearTimeout(timerId);
43+
maxTimerId !== undefined && clearTimeout(maxTimerId);
44+
timerId = maxTimerId = undefined;
45+
}
46+
47+
function flush(): unknown {
48+
if (timerId !== undefined || maxTimerId !== undefined) {
49+
return invokeFunc();
50+
}
51+
return callbackReturnValue;
52+
}
53+
54+
function debounced(): unknown {
55+
if (timerId) {
56+
clearTimeout(timerId);
57+
}
58+
timerId = setTimeout(invokeFunc, wait);
59+
60+
if (maxWait && maxTimerId === undefined) {
61+
maxTimerId = setTimeout(invokeFunc, maxWait);
62+
}
63+
64+
return callbackReturnValue;
65+
}
66+
67+
debounced.cancel = cancelTimers;
68+
debounced.flush = flush;
69+
return debounced;
70+
}

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('Replay (errorSampleRate)', () => {
5454
expect(replay).not.toHaveLastSentReplay();
5555

5656
captureException(new Error('testing'));
57-
jest.runAllTimers();
57+
jest.advanceTimersByTime(5000);
5858
await new Promise(process.nextTick);
5959

6060
expect(replay).toHaveSentReplay({
@@ -99,8 +99,7 @@ describe('Replay (errorSampleRate)', () => {
9999
events: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5020, type: 2 }]),
100100
});
101101

102-
jest.runAllTimers();
103-
await new Promise(process.nextTick);
102+
jest.advanceTimersByTime(5000);
104103

105104
// New checkout when we call `startRecording` again after uploading segment
106105
// after an error occurs
@@ -118,8 +117,10 @@ describe('Replay (errorSampleRate)', () => {
118117
domHandler({
119118
name: 'click',
120119
});
121-
jest.runAllTimers();
120+
121+
jest.advanceTimersByTime(5000);
122122
await new Promise(process.nextTick);
123+
123124
expect(replay).toHaveLastSentReplay({
124125
events: JSON.stringify([
125126
{
@@ -297,7 +298,7 @@ describe('Replay (errorSampleRate)', () => {
297298

298299
captureException(new Error('testing'));
299300

300-
jest.runAllTimers();
301+
jest.advanceTimersByTime(5000);
301302
await new Promise(process.nextTick);
302303

303304
expect(replay).toHaveSentReplay({
@@ -398,7 +399,8 @@ it('sends a replay after loading the session multiple times', async () => {
398399
expect(replay).not.toHaveLastSentReplay();
399400

400401
captureException(new Error('testing'));
401-
jest.runAllTimers();
402+
403+
jest.advanceTimersByTime(5000);
402404
await new Promise(process.nextTick);
403405

404406
expect(replay).toHaveSentReplay({

0 commit comments

Comments
 (0)