@spotify-confidence/csr-recorder 0.17.14 → 0.17.16

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.16](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.15...csr-recorder-v0.17.16) (2026-08-28)
4
+
5
+
6
+ ### 🐛 Bug Fixes
7
+
8
+ * replace blocked videos with labelled placeholders ([#444](https://github.com/spotify/confidence-sdk-js/issues/444)) ([d1385b4](https://github.com/spotify/confidence-sdk-js/commit/d1385b4b19e267ba12e1a51890f085bf4746fb16))
9
+
10
+
11
+ ### ✨ New Features
12
+
13
+ * record clipboard actions ([#445](https://github.com/spotify/confidence-sdk-js/issues/445)) ([7a4aed4](https://github.com/spotify/confidence-sdk-js/commit/7a4aed4bada3b38de513f50aad4224e54488bbbb))
14
+
15
+
16
+ ### Dependencies
17
+
18
+ * The following workspace dependencies were updated
19
+ * dependencies
20
+ * @spotify-confidence/csr-common bumped to 0.18.8
21
+
22
+ ## [0.17.15](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.14...csr-recorder-v0.17.15) (2026-08-20)
23
+
24
+
25
+ ### 🐛 Bug Fixes
26
+
27
+ * **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))
28
+
3
29
  ## [0.17.14](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.13...csr-recorder-v0.17.14) (2026-08-20)
4
30
 
5
31
 
package/dist/index.cjs CHANGED
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _spotify_confidence_csr_common = require("@spotify-confidence/csr-common");
3
3
  //#region src/types.ts
4
4
  const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
5
- const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
5
+ const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]", "video"];
6
6
  let RecorderState = /* @__PURE__ */ function(RecorderState) {
7
7
  RecorderState["Idle"] = "idle";
8
8
  RecorderState["Recording"] = "recording";
@@ -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();
@@ -11114,6 +11125,63 @@ const ALL_CONSOLE_LEVELS = [
11114
11125
  "debug",
11115
11126
  "info"
11116
11127
  ];
11128
+ const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
11129
+ function labelBlockedElement(node) {
11130
+ if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
11131
+ node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
11132
+ node.tagName = "div";
11133
+ }
11134
+ node.childNodes?.forEach(labelBlockedElement);
11135
+ }
11136
+ /**
11137
+ * rrweb strips blocked elements down to their dimensions, but retains their
11138
+ * original tag name. Rebuild them as inert divs and keep the tag name as safe
11139
+ * metadata so players can render a useful placeholder label.
11140
+ */
11141
+ function blockedElementLabelsPlugin() {
11142
+ return {
11143
+ name: "csr/blocked-element-labels@1",
11144
+ options: {},
11145
+ observer: () => () => {},
11146
+ eventProcessor: (event) => {
11147
+ if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
11148
+ else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
11149
+ return event;
11150
+ }
11151
+ };
11152
+ }
11153
+ /**
11154
+ * Record clipboard actions and their DOM target without reading clipboard
11155
+ * contents. The resulting rrweb Plugin events can explain otherwise
11156
+ * surprising input changes during analysis.
11157
+ */
11158
+ function clipboardActionsPlugin() {
11159
+ let getId;
11160
+ return {
11161
+ name: "csr/clipboard@1",
11162
+ options: {},
11163
+ getMirror: ({ nodeMirror }) => {
11164
+ getId = (node) => nodeMirror.getId(node);
11165
+ },
11166
+ observer: (callback, win) => {
11167
+ const handlers = [
11168
+ "copy",
11169
+ "cut",
11170
+ "paste"
11171
+ ].map((action) => {
11172
+ const handler = (event) => {
11173
+ callback({
11174
+ action,
11175
+ targetId: event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1
11176
+ });
11177
+ };
11178
+ win.document.addEventListener(action, handler, true);
11179
+ return () => win.document.removeEventListener(action, handler, true);
11180
+ });
11181
+ return () => handlers.forEach((remove) => remove());
11182
+ }
11183
+ };
11184
+ }
11117
11185
  /**
11118
11186
  * rrweb does not include modifier keys in mouse-interaction events. Capture
11119
11187
  * the native click first, then add its safe, non-text metadata to the rrweb
@@ -11166,7 +11234,11 @@ var RrwebEngine = class {
11166
11234
  start(config, onEvent) {
11167
11235
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
11168
11236
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
11169
- const plugins = [clickModifiersPlugin()];
11237
+ const plugins = [
11238
+ clickModifiersPlugin(),
11239
+ clipboardActionsPlugin(),
11240
+ blockedElementLabelsPlugin()
11241
+ ];
11170
11242
  const { captureConsoleLogs } = config;
11171
11243
  if (captureConsoleLogs) {
11172
11244
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
@@ -11188,6 +11260,9 @@ var RrwebEngine = class {
11188
11260
  slimDOMOptions: "all"
11189
11261
  }) ?? null;
11190
11262
  }
11263
+ takeFullSnapshot() {
11264
+ takeFullSnapshot(true);
11265
+ }
11191
11266
  stop() {
11192
11267
  this.stopFn?.();
11193
11268
  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
@@ -1,7 +1,7 @@
1
1
  import { RecordingEventType } from "@spotify-confidence/csr-common";
2
2
  //#region src/types.ts
3
3
  const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
4
- const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
4
+ const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]", "video"];
5
5
  let RecorderState = /* @__PURE__ */ function(RecorderState) {
6
6
  RecorderState["Idle"] = "idle";
7
7
  RecorderState["Recording"] = "recording";
@@ -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();
@@ -11113,6 +11124,63 @@ const ALL_CONSOLE_LEVELS = [
11113
11124
  "debug",
11114
11125
  "info"
11115
11126
  ];
11127
+ const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
11128
+ function labelBlockedElement(node) {
11129
+ if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
11130
+ node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
11131
+ node.tagName = "div";
11132
+ }
11133
+ node.childNodes?.forEach(labelBlockedElement);
11134
+ }
11135
+ /**
11136
+ * rrweb strips blocked elements down to their dimensions, but retains their
11137
+ * original tag name. Rebuild them as inert divs and keep the tag name as safe
11138
+ * metadata so players can render a useful placeholder label.
11139
+ */
11140
+ function blockedElementLabelsPlugin() {
11141
+ return {
11142
+ name: "csr/blocked-element-labels@1",
11143
+ options: {},
11144
+ observer: () => () => {},
11145
+ eventProcessor: (event) => {
11146
+ if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
11147
+ else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
11148
+ return event;
11149
+ }
11150
+ };
11151
+ }
11152
+ /**
11153
+ * Record clipboard actions and their DOM target without reading clipboard
11154
+ * contents. The resulting rrweb Plugin events can explain otherwise
11155
+ * surprising input changes during analysis.
11156
+ */
11157
+ function clipboardActionsPlugin() {
11158
+ let getId;
11159
+ return {
11160
+ name: "csr/clipboard@1",
11161
+ options: {},
11162
+ getMirror: ({ nodeMirror }) => {
11163
+ getId = (node) => nodeMirror.getId(node);
11164
+ },
11165
+ observer: (callback, win) => {
11166
+ const handlers = [
11167
+ "copy",
11168
+ "cut",
11169
+ "paste"
11170
+ ].map((action) => {
11171
+ const handler = (event) => {
11172
+ callback({
11173
+ action,
11174
+ targetId: event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1
11175
+ });
11176
+ };
11177
+ win.document.addEventListener(action, handler, true);
11178
+ return () => win.document.removeEventListener(action, handler, true);
11179
+ });
11180
+ return () => handlers.forEach((remove) => remove());
11181
+ }
11182
+ };
11183
+ }
11116
11184
  /**
11117
11185
  * rrweb does not include modifier keys in mouse-interaction events. Capture
11118
11186
  * the native click first, then add its safe, non-text metadata to the rrweb
@@ -11165,7 +11233,11 @@ var RrwebEngine = class {
11165
11233
  start(config, onEvent) {
11166
11234
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
11167
11235
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
11168
- const plugins = [clickModifiersPlugin()];
11236
+ const plugins = [
11237
+ clickModifiersPlugin(),
11238
+ clipboardActionsPlugin(),
11239
+ blockedElementLabelsPlugin()
11240
+ ];
11169
11241
  const { captureConsoleLogs } = config;
11170
11242
  if (captureConsoleLogs) {
11171
11243
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
@@ -11187,6 +11259,9 @@ var RrwebEngine = class {
11187
11259
  slimDOMOptions: "all"
11188
11260
  }) ?? null;
11189
11261
  }
11262
+ takeFullSnapshot() {
11263
+ takeFullSnapshot(true);
11264
+ }
11190
11265
  stop() {
11191
11266
  this.stopFn?.();
11192
11267
  this.stopFn = null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spotify-confidence/csr-recorder",
3
3
  "license": "Apache-2.0",
4
- "version": "0.17.14",
4
+ "version": "0.17.16",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/spotify/confidence-sdk-js.git",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@rrweb/rrweb-plugin-console-record": "^2.0.1",
38
- "@spotify-confidence/csr-common": "0.18.7",
38
+ "@spotify-confidence/csr-common": "0.18.8",
39
39
  "rrweb": "^2.0.1"
40
40
  },
41
41
  "module": "./dist/index.js",
@@ -7,5 +7,6 @@ import { RecordingConfig } from '../types';
7
7
  */
8
8
  export interface RecordingEngine {
9
9
  start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
10
+ takeFullSnapshot(): void;
10
11
  stop(): void;
11
12
  }
@@ -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(() => recordSpy.mockClear());
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({}, () => {});
@@ -50,7 +55,56 @@ describe('RrwebEngine', () => {
50
55
 
51
56
  it('applies default blockSelector when blockSelectors is absent', () => {
52
57
  new RrwebEngine().start({}, () => {});
53
- expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block]');
58
+ expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block],video');
59
+ });
60
+
61
+ it('rebuilds blocked elements as labelled inert placeholders', () => {
62
+ new RrwebEngine().start({}, () => {});
63
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(
64
+ ({ name }: { name: string }) => name === 'csr/blocked-element-labels@1',
65
+ );
66
+ const event = {
67
+ type: 2,
68
+ timestamp: 1,
69
+ data: {
70
+ node: {
71
+ type: 0,
72
+ id: 1,
73
+ childNodes: [
74
+ {
75
+ type: 2,
76
+ id: 2,
77
+ tagName: 'video',
78
+ attributes: { rr_width: '640px', rr_height: '360px' },
79
+ childNodes: [],
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ };
85
+
86
+ expect(plugin.eventProcessor(event)).toEqual({
87
+ ...event,
88
+ data: {
89
+ node: {
90
+ type: 0,
91
+ id: 1,
92
+ childNodes: [
93
+ {
94
+ type: 2,
95
+ id: 2,
96
+ tagName: 'div',
97
+ attributes: {
98
+ rr_width: '640px',
99
+ rr_height: '360px',
100
+ 'data-csr-blocked-element': 'video',
101
+ },
102
+ childNodes: [],
103
+ },
104
+ ],
105
+ },
106
+ },
107
+ });
54
108
  });
55
109
 
56
110
  it('throttles mousemove to 100ms and records only last input value', () => {
@@ -64,6 +118,39 @@ describe('RrwebEngine', () => {
64
118
  expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
65
119
  });
66
120
 
121
+ it('records copy, cut, and paste actions without reading clipboard contents', () => {
122
+ new RrwebEngine().start({}, () => {});
123
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(({ name }: { name: string }) => name === 'csr/clipboard@1');
124
+ const getId = vi.fn().mockReturnValue(42);
125
+ plugin.getMirror({ nodeMirror: { getId } });
126
+ const callback = vi.fn();
127
+ const removeObserver = plugin.observer(callback, window);
128
+ const input = document.createElement('input');
129
+ document.body.appendChild(input);
130
+
131
+ for (const action of ['copy', 'cut', 'paste']) {
132
+ const event = new Event(action, { bubbles: true });
133
+ Object.defineProperty(event, 'clipboardData', {
134
+ get: () => {
135
+ throw new Error('clipboard contents must not be read');
136
+ },
137
+ });
138
+ expect(() => input.dispatchEvent(event)).not.toThrow();
139
+ }
140
+
141
+ expect(callback.mock.calls.map(([payload]) => payload)).toEqual([
142
+ { action: 'copy', targetId: 42 },
143
+ { action: 'cut', targetId: 42 },
144
+ { action: 'paste', targetId: 42 },
145
+ ]);
146
+ expect(getId).toHaveBeenCalledTimes(3);
147
+
148
+ removeObserver();
149
+ input.dispatchEvent(new Event('paste', { bubbles: true }));
150
+ expect(callback).toHaveBeenCalledTimes(3);
151
+ input.remove();
152
+ });
153
+
67
154
  it('keeps native click modifiers through a browser microtask checkpoint', async () => {
68
155
  new RrwebEngine().start({}, () => {});
69
156
  const plugin = recordSpy.mock.calls[0][0].plugins.find(
@@ -128,4 +215,12 @@ describe('RrwebEngine', () => {
128
215
 
129
216
  removeObserver();
130
217
  });
218
+
219
+ it('takes a checkout snapshot when requested', () => {
220
+ const engine = new RrwebEngine();
221
+
222
+ engine.takeFullSnapshot();
223
+
224
+ expect(takeFullSnapshotSpy).toHaveBeenCalledWith(true);
225
+ });
131
226
  });
@@ -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'];
@@ -10,6 +10,86 @@ type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number]
10
10
 
11
11
  type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
12
12
 
13
+ const BLOCKED_ELEMENT_ATTRIBUTE = 'data-csr-blocked-element';
14
+
15
+ type SerializedNode = {
16
+ type: number;
17
+ tagName?: string;
18
+ attributes?: Record<string, unknown>;
19
+ childNodes?: SerializedNode[];
20
+ };
21
+
22
+ function labelBlockedElement(node: SerializedNode): void {
23
+ const isBlockedElement =
24
+ node.type === 2 &&
25
+ node.tagName !== undefined &&
26
+ node.attributes !== undefined &&
27
+ typeof node.attributes.rr_width === 'string' &&
28
+ typeof node.attributes.rr_height === 'string';
29
+
30
+ if (isBlockedElement) {
31
+ node.attributes![BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
32
+ node.tagName = 'div';
33
+ }
34
+
35
+ node.childNodes?.forEach(labelBlockedElement);
36
+ }
37
+
38
+ /**
39
+ * rrweb strips blocked elements down to their dimensions, but retains their
40
+ * original tag name. Rebuild them as inert divs and keep the tag name as safe
41
+ * metadata so players can render a useful placeholder label.
42
+ */
43
+ function blockedElementLabelsPlugin(): RrwebPlugin {
44
+ return {
45
+ name: 'csr/blocked-element-labels@1',
46
+ options: {},
47
+ observer: () => () => {},
48
+ eventProcessor: event => {
49
+ if (event.type === EventType.FullSnapshot) {
50
+ labelBlockedElement(event.data.node as SerializedNode);
51
+ } else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
52
+ event.data.adds.forEach(add => labelBlockedElement(add.node as SerializedNode));
53
+ }
54
+
55
+ return event;
56
+ },
57
+ };
58
+ }
59
+
60
+ type ClipboardAction = 'copy' | 'cut' | 'paste';
61
+
62
+ /**
63
+ * Record clipboard actions and their DOM target without reading clipboard
64
+ * contents. The resulting rrweb Plugin events can explain otherwise
65
+ * surprising input changes during analysis.
66
+ */
67
+ function clipboardActionsPlugin(): RrwebPlugin {
68
+ let getId: ((node: Node) => number) | undefined;
69
+
70
+ return {
71
+ name: 'csr/clipboard@1',
72
+ options: {},
73
+ getMirror: ({ nodeMirror }) => {
74
+ getId = node => nodeMirror.getId(node);
75
+ },
76
+ observer: (callback, win) => {
77
+ const actions: ClipboardAction[] = ['copy', 'cut', 'paste'];
78
+ const handlers = actions.map(action => {
79
+ const handler = (event: Event) => {
80
+ const targetId = event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1;
81
+ callback({ action, targetId });
82
+ };
83
+
84
+ win.document.addEventListener(action, handler, true);
85
+ return () => win.document.removeEventListener(action, handler, true);
86
+ });
87
+
88
+ return () => handlers.forEach(remove => remove());
89
+ },
90
+ };
91
+ }
92
+
13
93
  /**
14
94
  * rrweb does not include modifier keys in mouse-interaction events. Capture
15
95
  * the native click first, then add its safe, non-text metadata to the rrweb
@@ -69,7 +149,7 @@ export class RrwebEngine implements RecordingEngine {
69
149
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
70
150
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
71
151
 
72
- const plugins: RrwebPlugin[] = [clickModifiersPlugin()];
152
+ const plugins: RrwebPlugin[] = [clickModifiersPlugin(), clipboardActionsPlugin(), blockedElementLabelsPlugin()];
73
153
  const { captureConsoleLogs } = config;
74
154
  if (captureConsoleLogs) {
75
155
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
@@ -96,6 +176,10 @@ export class RrwebEngine implements RecordingEngine {
96
176
  }) ?? null;
97
177
  }
98
178
 
179
+ takeFullSnapshot(): void {
180
+ takeFullSnapshot(true);
181
+ }
182
+
99
183
  stop(): void {
100
184
  this.stopFn?.();
101
185
  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
+ });
@@ -24,6 +24,8 @@ class MockEngine implements RecordingEngine {
24
24
  this.onEvent = null;
25
25
  }
26
26
 
27
+ takeFullSnapshot(): void {}
28
+
27
29
  emit(event: RecordingEvent): void {
28
30
  this.onEvent?.(event);
29
31
  }
@@ -23,6 +23,8 @@ class MockEngine implements RecordingEngine {
23
23
  this.onEvent = null;
24
24
  }
25
25
 
26
+ takeFullSnapshot(): void {}
27
+
26
28
  emit(event: RecordingEvent): void {
27
29
  this.onEvent?.(event);
28
30
  }
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();
package/src/types.ts CHANGED
@@ -6,7 +6,7 @@ export interface RecorderOptions {
6
6
  }
7
7
 
8
8
  export const DEFAULT_MASK_SELECTORS: string[] = ['[data-csr-mask]'];
9
- export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]'];
9
+ export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]', 'video'];
10
10
 
11
11
  export interface RecordingConfig {
12
12
  /**