@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 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
- pendingClick = {
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
- queueMicrotask(() => {
11138
- pendingClick = null;
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
- pendingClick = {
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
- queueMicrotask(() => {
11137
- pendingClick = null;
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spotify-confidence/csr-recorder",
3
3
  "license": "Apache-2.0",
4
- "version": "0.17.13",
4
+ "version": "0.17.15",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/spotify/confidence-sdk-js.git",
@@ -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({}, () => {});
@@ -64,7 +69,7 @@ describe('RrwebEngine', () => {
64
69
  expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
65
70
  });
66
71
 
67
- it('adds native click modifier keys to the rrweb click event', () => {
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
- pendingClick = {
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
- queueMicrotask(() => {
35
- pendingClick = null;
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
+ });
@@ -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();