@spotify-confidence/csr-recorder 0.17.18 → 0.17.20

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.20](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.19...csr-recorder-v0.17.20) (2026-09-14)
4
+
5
+
6
+ ### 🐛 Bug Fixes
7
+
8
+ * **csr-recorder:** ignore blocked iframe attachments ([#463](https://github.com/spotify/confidence-sdk-js/issues/463)) ([bf549d7](https://github.com/spotify/confidence-sdk-js/commit/bf549d745f62fd280139dbfd00b9e664838c95a7))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @spotify-confidence/csr-common bumped to 0.18.12
16
+
17
+ ## [0.17.19](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.18...csr-recorder-v0.17.19) (2026-09-11)
18
+
19
+
20
+ ### Dependencies
21
+
22
+ * The following workspace dependencies were updated
23
+ * dependencies
24
+ * @spotify-confidence/csr-common bumped to 0.18.11
25
+
3
26
  ## [0.17.18](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.17...csr-recorder-v0.17.18) (2026-09-09)
4
27
 
5
28
 
package/dist/index.cjs CHANGED
@@ -11127,12 +11127,33 @@ const ALL_CONSOLE_LEVELS = [
11127
11127
  "info"
11128
11128
  ];
11129
11129
  const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
11130
- function labelBlockedElement(node) {
11130
+ function labelBlockedElement(node, tracker, parentId) {
11131
+ if (node.id !== void 0 && parentId !== void 0) {
11132
+ const previousParentId = tracker.parentIdByNode.get(node.id);
11133
+ if (previousParentId !== void 0 && previousParentId !== parentId) tracker.childIdsByParent.get(previousParentId)?.delete(node.id);
11134
+ tracker.parentIdByNode.set(node.id, parentId);
11135
+ const childIds = tracker.childIdsByParent.get(parentId) ?? /* @__PURE__ */ new Set();
11136
+ childIds.add(node.id);
11137
+ tracker.childIdsByParent.set(parentId, childIds);
11138
+ }
11131
11139
  if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
11140
+ if (node.tagName === "iframe" && node.id !== void 0) tracker.blockedIframeIds.add(node.id);
11132
11141
  node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
11133
11142
  node.tagName = "div";
11134
11143
  }
11135
- node.childNodes?.forEach(labelBlockedElement);
11144
+ node.childNodes?.forEach((child) => labelBlockedElement(child, tracker, node.id ?? parentId));
11145
+ }
11146
+ function removeSerializedSubtree(rootId, parentId, tracker) {
11147
+ tracker.childIdsByParent.get(parentId)?.delete(rootId);
11148
+ const pendingIds = [rootId];
11149
+ while (pendingIds.length > 0) {
11150
+ const id = pendingIds.pop();
11151
+ const childIds = tracker.childIdsByParent.get(id);
11152
+ if (childIds) pendingIds.push(...childIds);
11153
+ tracker.blockedIframeIds.delete(id);
11154
+ tracker.childIdsByParent.delete(id);
11155
+ tracker.parentIdByNode.delete(id);
11156
+ }
11136
11157
  }
11137
11158
  /**
11138
11159
  * rrweb strips blocked elements down to their dimensions, but retains their
@@ -11140,13 +11161,26 @@ function labelBlockedElement(node) {
11140
11161
  * metadata so players can render a useful placeholder label.
11141
11162
  */
11142
11163
  function blockedElementLabelsPlugin() {
11164
+ const tracker = {
11165
+ blockedIframeIds: /* @__PURE__ */ new Set(),
11166
+ childIdsByParent: /* @__PURE__ */ new Map(),
11167
+ parentIdByNode: /* @__PURE__ */ new Map()
11168
+ };
11143
11169
  return {
11144
11170
  name: _spotify_confidence_csr_common.RecordingPluginName.BlockedElementLabels,
11145
11171
  options: {},
11146
11172
  observer: () => () => {},
11147
11173
  eventProcessor: (event) => {
11148
- if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
11149
- else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
11174
+ if (event.type === EventType.FullSnapshot) {
11175
+ tracker.blockedIframeIds.clear();
11176
+ tracker.childIdsByParent.clear();
11177
+ tracker.parentIdByNode.clear();
11178
+ labelBlockedElement(event.data.node, tracker);
11179
+ } else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
11180
+ if (event.data.isAttachIframe) event.data.adds = event.data.adds.filter((add) => !tracker.blockedIframeIds.has(add.parentId));
11181
+ event.data.removes.forEach((remove) => removeSerializedSubtree(remove.id, remove.parentId, tracker));
11182
+ event.data.adds.forEach((add) => labelBlockedElement(add.node, tracker, add.parentId));
11183
+ }
11150
11184
  return event;
11151
11185
  }
11152
11186
  };
package/dist/index.js CHANGED
@@ -11126,12 +11126,33 @@ const ALL_CONSOLE_LEVELS = [
11126
11126
  "info"
11127
11127
  ];
11128
11128
  const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
11129
- function labelBlockedElement(node) {
11129
+ function labelBlockedElement(node, tracker, parentId) {
11130
+ if (node.id !== void 0 && parentId !== void 0) {
11131
+ const previousParentId = tracker.parentIdByNode.get(node.id);
11132
+ if (previousParentId !== void 0 && previousParentId !== parentId) tracker.childIdsByParent.get(previousParentId)?.delete(node.id);
11133
+ tracker.parentIdByNode.set(node.id, parentId);
11134
+ const childIds = tracker.childIdsByParent.get(parentId) ?? /* @__PURE__ */ new Set();
11135
+ childIds.add(node.id);
11136
+ tracker.childIdsByParent.set(parentId, childIds);
11137
+ }
11130
11138
  if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
11139
+ if (node.tagName === "iframe" && node.id !== void 0) tracker.blockedIframeIds.add(node.id);
11131
11140
  node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
11132
11141
  node.tagName = "div";
11133
11142
  }
11134
- node.childNodes?.forEach(labelBlockedElement);
11143
+ node.childNodes?.forEach((child) => labelBlockedElement(child, tracker, node.id ?? parentId));
11144
+ }
11145
+ function removeSerializedSubtree(rootId, parentId, tracker) {
11146
+ tracker.childIdsByParent.get(parentId)?.delete(rootId);
11147
+ const pendingIds = [rootId];
11148
+ while (pendingIds.length > 0) {
11149
+ const id = pendingIds.pop();
11150
+ const childIds = tracker.childIdsByParent.get(id);
11151
+ if (childIds) pendingIds.push(...childIds);
11152
+ tracker.blockedIframeIds.delete(id);
11153
+ tracker.childIdsByParent.delete(id);
11154
+ tracker.parentIdByNode.delete(id);
11155
+ }
11135
11156
  }
11136
11157
  /**
11137
11158
  * rrweb strips blocked elements down to their dimensions, but retains their
@@ -11139,13 +11160,26 @@ function labelBlockedElement(node) {
11139
11160
  * metadata so players can render a useful placeholder label.
11140
11161
  */
11141
11162
  function blockedElementLabelsPlugin() {
11163
+ const tracker = {
11164
+ blockedIframeIds: /* @__PURE__ */ new Set(),
11165
+ childIdsByParent: /* @__PURE__ */ new Map(),
11166
+ parentIdByNode: /* @__PURE__ */ new Map()
11167
+ };
11142
11168
  return {
11143
11169
  name: RecordingPluginName.BlockedElementLabels,
11144
11170
  options: {},
11145
11171
  observer: () => () => {},
11146
11172
  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));
11173
+ if (event.type === EventType.FullSnapshot) {
11174
+ tracker.blockedIframeIds.clear();
11175
+ tracker.childIdsByParent.clear();
11176
+ tracker.parentIdByNode.clear();
11177
+ labelBlockedElement(event.data.node, tracker);
11178
+ } else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
11179
+ if (event.data.isAttachIframe) event.data.adds = event.data.adds.filter((add) => !tracker.blockedIframeIds.has(add.parentId));
11180
+ event.data.removes.forEach((remove) => removeSerializedSubtree(remove.id, remove.parentId, tracker));
11181
+ event.data.adds.forEach((add) => labelBlockedElement(add.node, tracker, add.parentId));
11182
+ }
11149
11183
  return event;
11150
11184
  }
11151
11185
  };
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.18",
4
+ "version": "0.17.20",
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.10",
38
+ "@spotify-confidence/csr-common": "0.18.12",
39
39
  "rrweb": "^2.0.1"
40
40
  },
41
41
  "module": "./dist/index.js",
@@ -13,6 +13,52 @@ vi.mock('rrweb', async importOriginal => ({
13
13
  takeFullSnapshot: (isCheckout: boolean) => takeFullSnapshotSpy(isCheckout),
14
14
  }));
15
15
 
16
+ function blockedElementLabelsPlugin() {
17
+ new RrwebEngine().start({}, () => {});
18
+ return recordSpy.mock.calls[0][0].plugins.find(
19
+ ({ name }: { name: string }) => name === RecordingPluginName.BlockedElementLabels,
20
+ );
21
+ }
22
+
23
+ function fullSnapshotWithNode(node: unknown) {
24
+ return {
25
+ type: 2,
26
+ timestamp: 1,
27
+ data: {
28
+ node: {
29
+ type: 0,
30
+ id: 1,
31
+ childNodes: [node],
32
+ },
33
+ },
34
+ };
35
+ }
36
+
37
+ function fullSnapshotWithIframe(id: number, attributes: Record<string, string>) {
38
+ return fullSnapshotWithNode({ type: 2, id, tagName: 'iframe', attributes, childNodes: [] });
39
+ }
40
+
41
+ function attachIframeDocument(parentId: number, childNodes: unknown[] = []) {
42
+ return {
43
+ type: 3,
44
+ timestamp: 2,
45
+ data: {
46
+ source: 0,
47
+ adds: [
48
+ {
49
+ parentId,
50
+ nextId: null,
51
+ node: { type: 0, id: parentId + 1, childNodes },
52
+ },
53
+ ],
54
+ removes: [],
55
+ texts: [],
56
+ attributes: [],
57
+ isAttachIframe: true,
58
+ },
59
+ };
60
+ }
61
+
16
62
  describe('RrwebEngine', () => {
17
63
  beforeEach(() => {
18
64
  recordSpy.mockClear();
@@ -108,6 +154,97 @@ describe('RrwebEngine', () => {
108
154
  });
109
155
  });
110
156
 
157
+ it('drops iframe document attachments for blocked iframe placeholders', () => {
158
+ const plugin = blockedElementLabelsPlugin();
159
+ plugin.eventProcessor(fullSnapshotWithIframe(2, { rr_width: '640px', rr_height: '360px' }));
160
+ const attachIframeEvent = attachIframeDocument(2);
161
+
162
+ expect(plugin.eventProcessor(attachIframeEvent)).toEqual({
163
+ ...attachIframeEvent,
164
+ data: { ...attachIframeEvent.data, adds: [] },
165
+ });
166
+ });
167
+
168
+ it('keeps iframe document attachments for unblocked iframes', () => {
169
+ const plugin = blockedElementLabelsPlugin();
170
+ plugin.eventProcessor(fullSnapshotWithIframe(2, {}));
171
+ const attachIframeEvent = attachIframeDocument(2);
172
+ const result = plugin.eventProcessor(attachIframeEvent);
173
+
174
+ expect(result.data.adds).toHaveLength(1);
175
+ expect(result.data.adds[0].parentId).toBe(2);
176
+ });
177
+
178
+ it('stops filtering attachments after a blocked iframe is removed', () => {
179
+ const plugin = blockedElementLabelsPlugin();
180
+ plugin.eventProcessor(fullSnapshotWithIframe(2, { rr_width: '640px', rr_height: '360px' }));
181
+ plugin.eventProcessor({
182
+ type: 3,
183
+ timestamp: 2,
184
+ data: {
185
+ source: 0,
186
+ adds: [],
187
+ removes: [{ parentId: 1, id: 2 }],
188
+ texts: [],
189
+ attributes: [],
190
+ },
191
+ });
192
+
193
+ expect(plugin.eventProcessor(attachIframeDocument(2)).data.adds).toHaveLength(1);
194
+ });
195
+
196
+ it('stops filtering attachments after an ancestor of a blocked iframe is removed', () => {
197
+ const plugin = blockedElementLabelsPlugin();
198
+ plugin.eventProcessor(
199
+ fullSnapshotWithNode({
200
+ type: 2,
201
+ id: 2,
202
+ tagName: 'div',
203
+ attributes: {},
204
+ childNodes: [
205
+ {
206
+ type: 2,
207
+ id: 3,
208
+ tagName: 'iframe',
209
+ attributes: { rr_width: '640px', rr_height: '360px' },
210
+ childNodes: [],
211
+ },
212
+ ],
213
+ }),
214
+ );
215
+ plugin.eventProcessor({
216
+ type: 3,
217
+ timestamp: 2,
218
+ data: {
219
+ source: 0,
220
+ adds: [],
221
+ removes: [{ parentId: 1, id: 2 }],
222
+ texts: [],
223
+ attributes: [],
224
+ },
225
+ });
226
+
227
+ expect(plugin.eventProcessor(attachIframeDocument(3)).data.adds).toHaveLength(1);
228
+ });
229
+
230
+ it('does not track blocked iframes inside discarded iframe documents', () => {
231
+ const plugin = blockedElementLabelsPlugin();
232
+ plugin.eventProcessor(fullSnapshotWithIframe(2, { rr_width: '640px', rr_height: '360px' }));
233
+ plugin.eventProcessor(
234
+ attachIframeDocument(2, [
235
+ {
236
+ type: 2,
237
+ id: 4,
238
+ tagName: 'iframe',
239
+ attributes: { rr_width: '640px', rr_height: '360px' },
240
+ childNodes: [],
241
+ },
242
+ ]),
243
+ );
244
+
245
+ expect(plugin.eventProcessor(attachIframeDocument(4)).data.adds).toHaveLength(1);
246
+ });
247
+
111
248
  it('throttles mousemove to 100ms and records only last input value', () => {
112
249
  new RrwebEngine().start({}, () => {});
113
250
  const opts = recordSpy.mock.calls[0][0];
@@ -13,13 +13,31 @@ type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKe
13
13
  const BLOCKED_ELEMENT_ATTRIBUTE = 'data-csr-blocked-element';
14
14
 
15
15
  type SerializedNode = {
16
+ id?: number;
16
17
  type: number;
17
18
  tagName?: string;
18
19
  attributes?: Record<string, unknown>;
19
20
  childNodes?: SerializedNode[];
20
21
  };
21
22
 
22
- function labelBlockedElement(node: SerializedNode): void {
23
+ type SerializedNodeTracker = {
24
+ blockedIframeIds: Set<number>;
25
+ childIdsByParent: Map<number, Set<number>>;
26
+ parentIdByNode: Map<number, number>;
27
+ };
28
+
29
+ function labelBlockedElement(node: SerializedNode, tracker: SerializedNodeTracker, parentId?: number): void {
30
+ if (node.id !== undefined && parentId !== undefined) {
31
+ const previousParentId = tracker.parentIdByNode.get(node.id);
32
+ if (previousParentId !== undefined && previousParentId !== parentId) {
33
+ tracker.childIdsByParent.get(previousParentId)?.delete(node.id);
34
+ }
35
+ tracker.parentIdByNode.set(node.id, parentId);
36
+ const childIds = tracker.childIdsByParent.get(parentId) ?? new Set<number>();
37
+ childIds.add(node.id);
38
+ tracker.childIdsByParent.set(parentId, childIds);
39
+ }
40
+
23
41
  const isBlockedElement =
24
42
  node.type === 2 &&
25
43
  node.tagName !== undefined &&
@@ -28,11 +46,28 @@ function labelBlockedElement(node: SerializedNode): void {
28
46
  typeof node.attributes.rr_height === 'string';
29
47
 
30
48
  if (isBlockedElement) {
49
+ if (node.tagName === 'iframe' && node.id !== undefined) {
50
+ tracker.blockedIframeIds.add(node.id);
51
+ }
31
52
  node.attributes![BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
32
53
  node.tagName = 'div';
33
54
  }
34
55
 
35
- node.childNodes?.forEach(labelBlockedElement);
56
+ node.childNodes?.forEach(child => labelBlockedElement(child, tracker, node.id ?? parentId));
57
+ }
58
+
59
+ function removeSerializedSubtree(rootId: number, parentId: number, tracker: SerializedNodeTracker): void {
60
+ tracker.childIdsByParent.get(parentId)?.delete(rootId);
61
+ const pendingIds = [rootId];
62
+
63
+ while (pendingIds.length > 0) {
64
+ const id = pendingIds.pop()!;
65
+ const childIds = tracker.childIdsByParent.get(id);
66
+ if (childIds) pendingIds.push(...childIds);
67
+ tracker.blockedIframeIds.delete(id);
68
+ tracker.childIdsByParent.delete(id);
69
+ tracker.parentIdByNode.delete(id);
70
+ }
36
71
  }
37
72
 
38
73
  /**
@@ -41,15 +76,28 @@ function labelBlockedElement(node: SerializedNode): void {
41
76
  * metadata so players can render a useful placeholder label.
42
77
  */
43
78
  function blockedElementLabelsPlugin(): RrwebPlugin {
79
+ const tracker: SerializedNodeTracker = {
80
+ blockedIframeIds: new Set<number>(),
81
+ childIdsByParent: new Map<number, Set<number>>(),
82
+ parentIdByNode: new Map<number, number>(),
83
+ };
84
+
44
85
  return {
45
86
  name: RecordingPluginName.BlockedElementLabels,
46
87
  options: {},
47
88
  observer: () => () => {},
48
89
  eventProcessor: event => {
49
90
  if (event.type === EventType.FullSnapshot) {
50
- labelBlockedElement(event.data.node as SerializedNode);
91
+ tracker.blockedIframeIds.clear();
92
+ tracker.childIdsByParent.clear();
93
+ tracker.parentIdByNode.clear();
94
+ labelBlockedElement(event.data.node as SerializedNode, tracker);
51
95
  } else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
52
- event.data.adds.forEach(add => labelBlockedElement(add.node as SerializedNode));
96
+ if (event.data.isAttachIframe) {
97
+ event.data.adds = event.data.adds.filter(add => !tracker.blockedIframeIds.has(add.parentId));
98
+ }
99
+ event.data.removes.forEach(remove => removeSerializedSubtree(remove.id, remove.parentId, tracker));
100
+ event.data.adds.forEach(add => labelBlockedElement(add.node as SerializedNode, tracker, add.parentId));
53
101
  }
54
102
 
55
103
  return event;