@spotify-confidence/csr-recorder 0.17.13 → 0.17.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/index.cjs +19 -4
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -4
- package/package.json +1 -1
- package/src/engine/index.ts +1 -0
- package/src/engine/rrweb-engine.test.ts +19 -3
- package/src/engine/rrweb-engine.ts +10 -5
- package/src/recorder-bfcache.test.ts +54 -0
- package/src/recorder-routing.test.ts +2 -0
- package/src/recorder.test.ts +2 -0
- package/src/recorder.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.15](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.14...csr-recorder-v0.17.15) (2026-08-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### 🐛 Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **csr:** resnapshot pages restored from BFCache ([#439](https://github.com/spotify/confidence-sdk-js/issues/439)) ([5594d02](https://github.com/spotify/confidence-sdk-js/commit/5594d02b3b3141010f068e41ff0fff328115af4b))
|
|
9
|
+
|
|
10
|
+
## [0.17.14](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.13...csr-recorder-v0.17.14) (2026-08-20)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### 🐛 Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **csr-recorder:** preserve native click modifiers ([#440](https://github.com/spotify/confidence-sdk-js/issues/440)) ([9575f1a](https://github.com/spotify/confidence-sdk-js/commit/9575f1a915a1a71ae16b1bd59053085d2456b0da))
|
|
16
|
+
|
|
3
17
|
## [0.17.13](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.12...csr-recorder-v0.17.13) (2026-08-18)
|
|
4
18
|
|
|
5
19
|
|
package/dist/index.cjs
CHANGED
|
@@ -70,6 +70,7 @@ var Recorder = class Recorder {
|
|
|
70
70
|
onEvent;
|
|
71
71
|
state = "idle";
|
|
72
72
|
visibilityHandler = null;
|
|
73
|
+
pageShowHandler = null;
|
|
73
74
|
originalFetch = null;
|
|
74
75
|
originalXhrOpen = null;
|
|
75
76
|
originalXhrSend = null;
|
|
@@ -115,6 +116,12 @@ var Recorder = class Recorder {
|
|
|
115
116
|
};
|
|
116
117
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
117
118
|
}
|
|
119
|
+
if (typeof window !== "undefined") {
|
|
120
|
+
this.pageShowHandler = (event) => {
|
|
121
|
+
if (event.persisted) this.engine.takeFullSnapshot();
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener("pageshow", this.pageShowHandler);
|
|
124
|
+
}
|
|
118
125
|
if (config?.captureNetworkRequests) this.patchNetwork();
|
|
119
126
|
if (config?.captureRouteChanges !== false) this.patchRouting();
|
|
120
127
|
}
|
|
@@ -301,6 +308,10 @@ var Recorder = class Recorder {
|
|
|
301
308
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
302
309
|
this.visibilityHandler = null;
|
|
303
310
|
}
|
|
311
|
+
if (this.pageShowHandler) {
|
|
312
|
+
window.removeEventListener("pageshow", this.pageShowHandler);
|
|
313
|
+
this.pageShowHandler = null;
|
|
314
|
+
}
|
|
304
315
|
this.restoreNetwork();
|
|
305
316
|
this.restoreRouting();
|
|
306
317
|
this.engine.stop();
|
|
@@ -11127,16 +11138,17 @@ function clickModifiersPlugin() {
|
|
|
11127
11138
|
observer: (_callback, win) => {
|
|
11128
11139
|
const onClick = (event) => {
|
|
11129
11140
|
const click = event;
|
|
11130
|
-
|
|
11141
|
+
const modifiers = {
|
|
11131
11142
|
button: click.button,
|
|
11132
11143
|
altKey: click.altKey,
|
|
11133
11144
|
ctrlKey: click.ctrlKey,
|
|
11134
11145
|
metaKey: click.metaKey,
|
|
11135
11146
|
shiftKey: click.shiftKey
|
|
11136
11147
|
};
|
|
11137
|
-
|
|
11138
|
-
|
|
11139
|
-
|
|
11148
|
+
pendingClick = modifiers;
|
|
11149
|
+
win.setTimeout(() => {
|
|
11150
|
+
if (pendingClick === modifiers) pendingClick = null;
|
|
11151
|
+
}, 0);
|
|
11140
11152
|
};
|
|
11141
11153
|
win.addEventListener("click", onClick, true);
|
|
11142
11154
|
return () => win.removeEventListener("click", onClick, true);
|
|
@@ -11187,6 +11199,9 @@ var RrwebEngine = class {
|
|
|
11187
11199
|
slimDOMOptions: "all"
|
|
11188
11200
|
}) ?? null;
|
|
11189
11201
|
}
|
|
11202
|
+
takeFullSnapshot() {
|
|
11203
|
+
takeFullSnapshot(true);
|
|
11204
|
+
}
|
|
11190
11205
|
stop() {
|
|
11191
11206
|
this.stopFn?.();
|
|
11192
11207
|
this.stopFn = null;
|
package/dist/index.d.cts
CHANGED
|
@@ -7,6 +7,7 @@ import { RecordingEvent } from "@spotify-confidence/csr-common";
|
|
|
7
7
|
*/
|
|
8
8
|
interface RecordingEngine {
|
|
9
9
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
10
|
+
takeFullSnapshot(): void;
|
|
10
11
|
stop(): void;
|
|
11
12
|
}
|
|
12
13
|
//#endregion
|
|
@@ -102,6 +103,7 @@ declare class Recorder {
|
|
|
102
103
|
private readonly onEvent;
|
|
103
104
|
private state;
|
|
104
105
|
private visibilityHandler;
|
|
106
|
+
private pageShowHandler;
|
|
105
107
|
private originalFetch;
|
|
106
108
|
private originalXhrOpen;
|
|
107
109
|
private originalXhrSend;
|
|
@@ -132,6 +134,7 @@ declare class Recorder {
|
|
|
132
134
|
declare class RrwebEngine implements RecordingEngine {
|
|
133
135
|
private stopFn;
|
|
134
136
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
137
|
+
takeFullSnapshot(): void;
|
|
135
138
|
stop(): void;
|
|
136
139
|
}
|
|
137
140
|
//#endregion
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { RecordingEvent } from "@spotify-confidence/csr-common";
|
|
|
7
7
|
*/
|
|
8
8
|
interface RecordingEngine {
|
|
9
9
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
10
|
+
takeFullSnapshot(): void;
|
|
10
11
|
stop(): void;
|
|
11
12
|
}
|
|
12
13
|
//#endregion
|
|
@@ -102,6 +103,7 @@ declare class Recorder {
|
|
|
102
103
|
private readonly onEvent;
|
|
103
104
|
private state;
|
|
104
105
|
private visibilityHandler;
|
|
106
|
+
private pageShowHandler;
|
|
105
107
|
private originalFetch;
|
|
106
108
|
private originalXhrOpen;
|
|
107
109
|
private originalXhrSend;
|
|
@@ -132,6 +134,7 @@ declare class Recorder {
|
|
|
132
134
|
declare class RrwebEngine implements RecordingEngine {
|
|
133
135
|
private stopFn;
|
|
134
136
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
137
|
+
takeFullSnapshot(): void;
|
|
135
138
|
stop(): void;
|
|
136
139
|
}
|
|
137
140
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,7 @@ var Recorder = class Recorder {
|
|
|
69
69
|
onEvent;
|
|
70
70
|
state = "idle";
|
|
71
71
|
visibilityHandler = null;
|
|
72
|
+
pageShowHandler = null;
|
|
72
73
|
originalFetch = null;
|
|
73
74
|
originalXhrOpen = null;
|
|
74
75
|
originalXhrSend = null;
|
|
@@ -114,6 +115,12 @@ var Recorder = class Recorder {
|
|
|
114
115
|
};
|
|
115
116
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
116
117
|
}
|
|
118
|
+
if (typeof window !== "undefined") {
|
|
119
|
+
this.pageShowHandler = (event) => {
|
|
120
|
+
if (event.persisted) this.engine.takeFullSnapshot();
|
|
121
|
+
};
|
|
122
|
+
window.addEventListener("pageshow", this.pageShowHandler);
|
|
123
|
+
}
|
|
117
124
|
if (config?.captureNetworkRequests) this.patchNetwork();
|
|
118
125
|
if (config?.captureRouteChanges !== false) this.patchRouting();
|
|
119
126
|
}
|
|
@@ -300,6 +307,10 @@ var Recorder = class Recorder {
|
|
|
300
307
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
301
308
|
this.visibilityHandler = null;
|
|
302
309
|
}
|
|
310
|
+
if (this.pageShowHandler) {
|
|
311
|
+
window.removeEventListener("pageshow", this.pageShowHandler);
|
|
312
|
+
this.pageShowHandler = null;
|
|
313
|
+
}
|
|
303
314
|
this.restoreNetwork();
|
|
304
315
|
this.restoreRouting();
|
|
305
316
|
this.engine.stop();
|
|
@@ -11126,16 +11137,17 @@ function clickModifiersPlugin() {
|
|
|
11126
11137
|
observer: (_callback, win) => {
|
|
11127
11138
|
const onClick = (event) => {
|
|
11128
11139
|
const click = event;
|
|
11129
|
-
|
|
11140
|
+
const modifiers = {
|
|
11130
11141
|
button: click.button,
|
|
11131
11142
|
altKey: click.altKey,
|
|
11132
11143
|
ctrlKey: click.ctrlKey,
|
|
11133
11144
|
metaKey: click.metaKey,
|
|
11134
11145
|
shiftKey: click.shiftKey
|
|
11135
11146
|
};
|
|
11136
|
-
|
|
11137
|
-
|
|
11138
|
-
|
|
11147
|
+
pendingClick = modifiers;
|
|
11148
|
+
win.setTimeout(() => {
|
|
11149
|
+
if (pendingClick === modifiers) pendingClick = null;
|
|
11150
|
+
}, 0);
|
|
11139
11151
|
};
|
|
11140
11152
|
win.addEventListener("click", onClick, true);
|
|
11141
11153
|
return () => win.removeEventListener("click", onClick, true);
|
|
@@ -11186,6 +11198,9 @@ var RrwebEngine = class {
|
|
|
11186
11198
|
slimDOMOptions: "all"
|
|
11187
11199
|
}) ?? null;
|
|
11188
11200
|
}
|
|
11201
|
+
takeFullSnapshot() {
|
|
11202
|
+
takeFullSnapshot(true);
|
|
11203
|
+
}
|
|
11189
11204
|
stop() {
|
|
11190
11205
|
this.stopFn?.();
|
|
11191
11206
|
this.stopFn = null;
|
package/package.json
CHANGED
package/src/engine/index.ts
CHANGED
|
@@ -4,14 +4,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
4
4
|
import { RrwebEngine } from './rrweb-engine';
|
|
5
5
|
|
|
6
6
|
const recordSpy = vi.fn().mockReturnValue(() => {});
|
|
7
|
+
const takeFullSnapshotSpy = vi.fn();
|
|
7
8
|
|
|
8
9
|
vi.mock('rrweb', async importOriginal => ({
|
|
9
10
|
...(await importOriginal<typeof import('rrweb')>()),
|
|
10
11
|
record: (opts: unknown) => recordSpy(opts),
|
|
12
|
+
takeFullSnapshot: (isCheckout: boolean) => takeFullSnapshotSpy(isCheckout),
|
|
11
13
|
}));
|
|
12
14
|
|
|
13
15
|
describe('RrwebEngine', () => {
|
|
14
|
-
beforeEach(() =>
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
recordSpy.mockClear();
|
|
18
|
+
takeFullSnapshotSpy.mockClear();
|
|
19
|
+
});
|
|
15
20
|
|
|
16
21
|
it('defaults maskAllInputs=true when maskInputs is omitted', () => {
|
|
17
22
|
new RrwebEngine().start({}, () => {});
|
|
@@ -64,7 +69,7 @@ describe('RrwebEngine', () => {
|
|
|
64
69
|
expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
|
|
65
70
|
});
|
|
66
71
|
|
|
67
|
-
it('
|
|
72
|
+
it('keeps native click modifiers through a browser microtask checkpoint', async () => {
|
|
68
73
|
new RrwebEngine().start({}, () => {});
|
|
69
74
|
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
70
75
|
({ name }: { name: string }) => name === 'csr/click-modifiers@1',
|
|
@@ -80,6 +85,9 @@ describe('RrwebEngine', () => {
|
|
|
80
85
|
shiftKey: true,
|
|
81
86
|
}),
|
|
82
87
|
);
|
|
88
|
+
// Browsers can run a microtask checkpoint between the window capture
|
|
89
|
+
// listener above and rrweb's document listener for a trusted click.
|
|
90
|
+
await Promise.resolve();
|
|
83
91
|
|
|
84
92
|
expect(
|
|
85
93
|
plugin.eventProcessor({
|
|
@@ -114,7 +122,7 @@ describe('RrwebEngine', () => {
|
|
|
114
122
|
);
|
|
115
123
|
const removeObserver = plugin.observer(() => {}, window);
|
|
116
124
|
document.dispatchEvent(new MouseEvent('click', { metaKey: true }));
|
|
117
|
-
await Promise.resolve
|
|
125
|
+
await new Promise(resolve => window.setTimeout(resolve, 0));
|
|
118
126
|
|
|
119
127
|
const event = {
|
|
120
128
|
type: 3,
|
|
@@ -125,4 +133,12 @@ describe('RrwebEngine', () => {
|
|
|
125
133
|
|
|
126
134
|
removeObserver();
|
|
127
135
|
});
|
|
136
|
+
|
|
137
|
+
it('takes a checkout snapshot when requested', () => {
|
|
138
|
+
const engine = new RrwebEngine();
|
|
139
|
+
|
|
140
|
+
engine.takeFullSnapshot();
|
|
141
|
+
|
|
142
|
+
expect(takeFullSnapshotSpy).toHaveBeenCalledWith(true);
|
|
143
|
+
});
|
|
128
144
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ConsoleLogLevel, type RecordingEvent } from '@spotify-confidence/csr-common';
|
|
2
2
|
import { RecordingConfig, DEFAULT_MASK_SELECTORS, DEFAULT_BLOCK_SELECTORS } from '../types';
|
|
3
3
|
import { RecordingEngine } from './index';
|
|
4
|
-
import { EventType, IncrementalSource, MouseInteractions, record, type recordOptions } from 'rrweb';
|
|
4
|
+
import { EventType, IncrementalSource, MouseInteractions, record, takeFullSnapshot, type recordOptions } from 'rrweb';
|
|
5
5
|
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
|
|
6
6
|
|
|
7
7
|
const ALL_CONSOLE_LEVELS: ConsoleLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
|
|
@@ -24,16 +24,17 @@ function clickModifiersPlugin(): RrwebPlugin {
|
|
|
24
24
|
observer: (_callback, win) => {
|
|
25
25
|
const onClick = (event: Event) => {
|
|
26
26
|
const click = event as MouseEvent;
|
|
27
|
-
|
|
27
|
+
const modifiers = {
|
|
28
28
|
button: click.button,
|
|
29
29
|
altKey: click.altKey,
|
|
30
30
|
ctrlKey: click.ctrlKey,
|
|
31
31
|
metaKey: click.metaKey,
|
|
32
32
|
shiftKey: click.shiftKey,
|
|
33
33
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
pendingClick = modifiers;
|
|
35
|
+
win.setTimeout(() => {
|
|
36
|
+
if (pendingClick === modifiers) pendingClick = null;
|
|
37
|
+
}, 0);
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
win.addEventListener('click', onClick, true);
|
|
@@ -95,6 +96,10 @@ export class RrwebEngine implements RecordingEngine {
|
|
|
95
96
|
}) ?? null;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
takeFullSnapshot(): void {
|
|
100
|
+
takeFullSnapshot(true);
|
|
101
|
+
}
|
|
102
|
+
|
|
98
103
|
stop(): void {
|
|
99
104
|
this.stopFn?.();
|
|
100
105
|
this.stopFn = null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { RecordingEvent } from '@spotify-confidence/csr-common';
|
|
4
|
+
import type { RecordingEngine } from './engine';
|
|
5
|
+
import { Recorder } from './recorder';
|
|
6
|
+
|
|
7
|
+
class MockEngine implements RecordingEngine {
|
|
8
|
+
takeFullSnapshot = vi.fn();
|
|
9
|
+
|
|
10
|
+
start(_config: unknown, _onEvent: (event: RecordingEvent) => void): void {}
|
|
11
|
+
|
|
12
|
+
stop(): void {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dispatchPageShow(persisted: boolean): void {
|
|
16
|
+
const event = new Event('pageshow');
|
|
17
|
+
Object.defineProperty(event, 'persisted', { value: persisted });
|
|
18
|
+
window.dispatchEvent(event);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Recorder BFCache restoration', () => {
|
|
22
|
+
it('takes a full snapshot when the page is restored from BFCache', () => {
|
|
23
|
+
const engine = new MockEngine();
|
|
24
|
+
const recorder = new Recorder({ engine, onEvent: vi.fn() });
|
|
25
|
+
recorder.start();
|
|
26
|
+
|
|
27
|
+
dispatchPageShow(true);
|
|
28
|
+
|
|
29
|
+
expect(engine.takeFullSnapshot).toHaveBeenCalledOnce();
|
|
30
|
+
recorder.stop();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not take another snapshot for a normal pageshow event', () => {
|
|
34
|
+
const engine = new MockEngine();
|
|
35
|
+
const recorder = new Recorder({ engine, onEvent: vi.fn() });
|
|
36
|
+
recorder.start();
|
|
37
|
+
|
|
38
|
+
dispatchPageShow(false);
|
|
39
|
+
|
|
40
|
+
expect(engine.takeFullSnapshot).not.toHaveBeenCalled();
|
|
41
|
+
recorder.stop();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('stops responding to pageshow events after recording stops', () => {
|
|
45
|
+
const engine = new MockEngine();
|
|
46
|
+
const recorder = new Recorder({ engine, onEvent: vi.fn() });
|
|
47
|
+
recorder.start();
|
|
48
|
+
recorder.stop();
|
|
49
|
+
|
|
50
|
+
dispatchPageShow(true);
|
|
51
|
+
|
|
52
|
+
expect(engine.takeFullSnapshot).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/recorder.test.ts
CHANGED
package/src/recorder.ts
CHANGED
|
@@ -16,6 +16,7 @@ export class Recorder {
|
|
|
16
16
|
private readonly onEvent: (event: RecordingEvent) => void;
|
|
17
17
|
private state: RecorderState = RecorderState.Idle;
|
|
18
18
|
private visibilityHandler: (() => void) | null = null;
|
|
19
|
+
private pageShowHandler: ((event: PageTransitionEvent) => void) | null = null;
|
|
19
20
|
private originalFetch: typeof globalThis.fetch | null = null;
|
|
20
21
|
private originalXhrOpen: typeof XMLHttpRequest.prototype.open | null = null;
|
|
21
22
|
private originalXhrSend: typeof XMLHttpRequest.prototype.send | null = null;
|
|
@@ -65,6 +66,15 @@ export class Recorder {
|
|
|
65
66
|
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (typeof window !== 'undefined') {
|
|
70
|
+
this.pageShowHandler = event => {
|
|
71
|
+
if (event.persisted) {
|
|
72
|
+
this.engine.takeFullSnapshot();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
window.addEventListener('pageshow', this.pageShowHandler);
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
if (config?.captureNetworkRequests) {
|
|
69
79
|
this.patchNetwork();
|
|
70
80
|
}
|
|
@@ -287,6 +297,10 @@ export class Recorder {
|
|
|
287
297
|
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
288
298
|
this.visibilityHandler = null;
|
|
289
299
|
}
|
|
300
|
+
if (this.pageShowHandler) {
|
|
301
|
+
window.removeEventListener('pageshow', this.pageShowHandler);
|
|
302
|
+
this.pageShowHandler = null;
|
|
303
|
+
}
|
|
290
304
|
this.restoreNetwork();
|
|
291
305
|
this.restoreRouting();
|
|
292
306
|
this.engine.stop();
|