@spotify-confidence/csr-recorder 0.17.12 → 0.17.13

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.13](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.12...csr-recorder-v0.17.13) (2026-08-18)
4
+
5
+
6
+ ### ✨ New Features
7
+
8
+ * **csr-recorder:** capture click modifier keys ([#435](https://github.com/spotify/confidence-sdk-js/issues/435)) ([a1e7a96](https://github.com/spotify/confidence-sdk-js/commit/a1e7a96996cd2f361f3b44cc924d6b4d5ff0b003))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @spotify-confidence/csr-common bumped to 0.18.7
16
+
3
17
  ## [0.17.12](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.11...csr-recorder-v0.17.12) (2026-08-18)
4
18
 
5
19
 
package/dist/index.cjs CHANGED
@@ -11115,6 +11115,49 @@ const ALL_CONSOLE_LEVELS = [
11115
11115
  "info"
11116
11116
  ];
11117
11117
  /**
11118
+ * rrweb does not include modifier keys in mouse-interaction events. Capture
11119
+ * the native click first, then add its safe, non-text metadata to the rrweb
11120
+ * event emitted during the same browser event dispatch.
11121
+ */
11122
+ function clickModifiersPlugin() {
11123
+ let pendingClick = null;
11124
+ return {
11125
+ name: "csr/click-modifiers@1",
11126
+ options: {},
11127
+ observer: (_callback, win) => {
11128
+ const onClick = (event) => {
11129
+ const click = event;
11130
+ pendingClick = {
11131
+ button: click.button,
11132
+ altKey: click.altKey,
11133
+ ctrlKey: click.ctrlKey,
11134
+ metaKey: click.metaKey,
11135
+ shiftKey: click.shiftKey
11136
+ };
11137
+ queueMicrotask(() => {
11138
+ pendingClick = null;
11139
+ });
11140
+ };
11141
+ win.addEventListener("click", onClick, true);
11142
+ return () => win.removeEventListener("click", onClick, true);
11143
+ },
11144
+ eventProcessor: (event) => {
11145
+ if (pendingClick && event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.MouseInteraction && event.data.type === MouseInteractions.Click) {
11146
+ const click = pendingClick;
11147
+ pendingClick = null;
11148
+ return {
11149
+ ...event,
11150
+ data: {
11151
+ ...event.data,
11152
+ ...click
11153
+ }
11154
+ };
11155
+ }
11156
+ return event;
11157
+ }
11158
+ };
11159
+ }
11160
+ /**
11118
11161
  * rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
11119
11162
  */
11120
11163
  var RrwebEngine = class {
@@ -11122,7 +11165,7 @@ var RrwebEngine = class {
11122
11165
  start(config, onEvent) {
11123
11166
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
11124
11167
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
11125
- const plugins = [];
11168
+ const plugins = [clickModifiersPlugin()];
11126
11169
  const { captureConsoleLogs } = config;
11127
11170
  if (captureConsoleLogs) {
11128
11171
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
package/dist/index.js CHANGED
@@ -11114,6 +11114,49 @@ const ALL_CONSOLE_LEVELS = [
11114
11114
  "info"
11115
11115
  ];
11116
11116
  /**
11117
+ * rrweb does not include modifier keys in mouse-interaction events. Capture
11118
+ * the native click first, then add its safe, non-text metadata to the rrweb
11119
+ * event emitted during the same browser event dispatch.
11120
+ */
11121
+ function clickModifiersPlugin() {
11122
+ let pendingClick = null;
11123
+ return {
11124
+ name: "csr/click-modifiers@1",
11125
+ options: {},
11126
+ observer: (_callback, win) => {
11127
+ const onClick = (event) => {
11128
+ const click = event;
11129
+ pendingClick = {
11130
+ button: click.button,
11131
+ altKey: click.altKey,
11132
+ ctrlKey: click.ctrlKey,
11133
+ metaKey: click.metaKey,
11134
+ shiftKey: click.shiftKey
11135
+ };
11136
+ queueMicrotask(() => {
11137
+ pendingClick = null;
11138
+ });
11139
+ };
11140
+ win.addEventListener("click", onClick, true);
11141
+ return () => win.removeEventListener("click", onClick, true);
11142
+ },
11143
+ eventProcessor: (event) => {
11144
+ if (pendingClick && event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.MouseInteraction && event.data.type === MouseInteractions.Click) {
11145
+ const click = pendingClick;
11146
+ pendingClick = null;
11147
+ return {
11148
+ ...event,
11149
+ data: {
11150
+ ...event.data,
11151
+ ...click
11152
+ }
11153
+ };
11154
+ }
11155
+ return event;
11156
+ }
11157
+ };
11158
+ }
11159
+ /**
11117
11160
  * rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
11118
11161
  */
11119
11162
  var RrwebEngine = class {
@@ -11121,7 +11164,7 @@ var RrwebEngine = class {
11121
11164
  start(config, onEvent) {
11122
11165
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
11123
11166
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
11124
- const plugins = [];
11167
+ const plugins = [clickModifiersPlugin()];
11125
11168
  const { captureConsoleLogs } = config;
11126
11169
  if (captureConsoleLogs) {
11127
11170
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
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.12",
4
+ "version": "0.17.13",
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.6",
38
+ "@spotify-confidence/csr-common": "0.18.7",
39
39
  "rrweb": "^2.0.1"
40
40
  },
41
41
  "module": "./dist/index.js",
@@ -1,9 +1,12 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
4
  import { RrwebEngine } from './rrweb-engine';
3
5
 
4
6
  const recordSpy = vi.fn().mockReturnValue(() => {});
5
7
 
6
- vi.mock('rrweb', () => ({
8
+ vi.mock('rrweb', async importOriginal => ({
9
+ ...(await importOriginal<typeof import('rrweb')>()),
7
10
  record: (opts: unknown) => recordSpy(opts),
8
11
  }));
9
12
 
@@ -60,4 +63,66 @@ describe('RrwebEngine', () => {
60
63
  new RrwebEngine().start({}, () => {});
61
64
  expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
62
65
  });
66
+
67
+ it('adds native click modifier keys to the rrweb click event', () => {
68
+ new RrwebEngine().start({}, () => {});
69
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(
70
+ ({ name }: { name: string }) => name === 'csr/click-modifiers@1',
71
+ );
72
+ const removeObserver = plugin.observer(() => {}, window);
73
+
74
+ document.dispatchEvent(
75
+ new MouseEvent('click', {
76
+ button: 0,
77
+ altKey: true,
78
+ ctrlKey: true,
79
+ metaKey: true,
80
+ shiftKey: true,
81
+ }),
82
+ );
83
+
84
+ expect(
85
+ plugin.eventProcessor({
86
+ type: 3,
87
+ timestamp: 1,
88
+ data: { source: 2, type: 2, id: 7, x: 10, y: 20 },
89
+ }),
90
+ ).toEqual({
91
+ type: 3,
92
+ timestamp: 1,
93
+ data: {
94
+ source: 2,
95
+ type: 2,
96
+ id: 7,
97
+ x: 10,
98
+ y: 20,
99
+ button: 0,
100
+ altKey: true,
101
+ ctrlKey: true,
102
+ metaKey: true,
103
+ shiftKey: true,
104
+ },
105
+ });
106
+
107
+ removeObserver();
108
+ });
109
+
110
+ it('does not add stale modifiers to a later click', async () => {
111
+ new RrwebEngine().start({}, () => {});
112
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(
113
+ ({ name }: { name: string }) => name === 'csr/click-modifiers@1',
114
+ );
115
+ const removeObserver = plugin.observer(() => {}, window);
116
+ document.dispatchEvent(new MouseEvent('click', { metaKey: true }));
117
+ await Promise.resolve();
118
+
119
+ const event = {
120
+ type: 3,
121
+ timestamp: 1,
122
+ data: { source: 2, type: 2, id: 7 },
123
+ };
124
+ expect(plugin.eventProcessor(event)).toBe(event);
125
+
126
+ removeObserver();
127
+ });
63
128
  });
@@ -1,11 +1,63 @@
1
- import { RecordingEvent, type ConsoleLogLevel } from '@spotify-confidence/csr-common';
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 { record } from 'rrweb';
4
+ import { EventType, IncrementalSource, MouseInteractions, record, 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'];
8
8
 
9
+ type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number];
10
+
11
+ type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
12
+
13
+ /**
14
+ * rrweb does not include modifier keys in mouse-interaction events. Capture
15
+ * the native click first, then add its safe, non-text metadata to the rrweb
16
+ * event emitted during the same browser event dispatch.
17
+ */
18
+ function clickModifiersPlugin(): RrwebPlugin {
19
+ let pendingClick: ClickModifiers | null = null;
20
+
21
+ return {
22
+ name: 'csr/click-modifiers@1',
23
+ options: {},
24
+ observer: (_callback, win) => {
25
+ const onClick = (event: Event) => {
26
+ const click = event as MouseEvent;
27
+ pendingClick = {
28
+ button: click.button,
29
+ altKey: click.altKey,
30
+ ctrlKey: click.ctrlKey,
31
+ metaKey: click.metaKey,
32
+ shiftKey: click.shiftKey,
33
+ };
34
+ queueMicrotask(() => {
35
+ pendingClick = null;
36
+ });
37
+ };
38
+
39
+ win.addEventListener('click', onClick, true);
40
+ return () => win.removeEventListener('click', onClick, true);
41
+ },
42
+ eventProcessor: event => {
43
+ if (
44
+ pendingClick &&
45
+ event.type === EventType.IncrementalSnapshot &&
46
+ event.data.source === IncrementalSource.MouseInteraction &&
47
+ event.data.type === MouseInteractions.Click
48
+ ) {
49
+ const click = pendingClick;
50
+ pendingClick = null;
51
+ return {
52
+ ...event,
53
+ data: { ...event.data, ...click },
54
+ };
55
+ }
56
+ return event;
57
+ },
58
+ };
59
+ }
60
+
9
61
  /**
10
62
  * rrweb adapter — bundles rrweb so consumers don't take a peer-dep on it.
11
63
  */
@@ -16,7 +68,7 @@ export class RrwebEngine implements RecordingEngine {
16
68
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
17
69
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
18
70
 
19
- const plugins = [];
71
+ const plugins: RrwebPlugin[] = [clickModifiersPlugin()];
20
72
  const { captureConsoleLogs } = config;
21
73
  if (captureConsoleLogs) {
22
74
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;