Skip to content

Commit b63a73c

Browse files
authored
fix(v10/replay): don't rewrite already-emitted nodes when syncing mirror attributes (#23588)
Backport of: #23426
1 parent d74afa4 commit b63a73c

4 files changed

Lines changed: 116 additions & 12 deletions

File tree

packages/replay-internal/src/coreHandlers/util/getAttributesToRecord.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ const ATTRIBUTES_TO_RECORD = new Set([
1515
'data-sentry-component',
1616
]);
1717

18+
/**
19+
* Attributes that can end up in a click breadcrumb. This is `ATTRIBUTES_TO_RECORD` plus
20+
* `data-sentry-element`, which is not recorded itself but is used as a fallback for
21+
* `data-sentry-component`.
22+
*/
23+
export const BREADCRUMB_RELEVANT_ATTRIBUTES = new Set([...ATTRIBUTES_TO_RECORD, 'data-sentry-element']);
24+
1825
/**
1926
* Inclusion list of attributes that we want to record from the DOM element
2027
*/
2128
export function getAttributesToRecord(attributes: Record<string, unknown>): Record<string, unknown> {
2229
const obj: Record<string, unknown> = {};
23-
if (!attributes['data-sentry-component'] && attributes['data-sentry-element']) {
24-
attributes['data-sentry-component'] = attributes['data-sentry-element'];
25-
}
30+
2631
for (const key in attributes) {
2732
if (ATTRIBUTES_TO_RECORD.has(key)) {
2833
let normalizedKey = key;
@@ -35,5 +40,11 @@ export function getAttributesToRecord(attributes: Record<string, unknown>): Reco
3540
}
3641
}
3742

43+
// `attributes` is the serialized node held by rrweb's mirror, which is the same object that was
44+
// emitted in an earlier `adds` payload, so this fallback must not be written back onto it.
45+
if (!obj['data-sentry-component'] && attributes['data-sentry-element']) {
46+
obj['data-sentry-component'] = attributes['data-sentry-element'];
47+
}
48+
3849
return obj;
3950
}

packages/replay-internal/src/util/handleRecordingEmit.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { EventType, IncrementalSource, record } from '@sentry/rrweb';
22
import { NodeType } from '@sentry/rrweb-snapshot';
33
import { updateClickDetectorForRecordingEvent } from '../coreHandlers/handleClick';
4+
import { BREADCRUMB_RELEVANT_ATTRIBUTES } from '../coreHandlers/util/getAttributesToRecord';
45
import { DEBUG_BUILD } from '../debug-build';
56
import { saveSession } from '../session/saveSession';
67
import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types';
@@ -147,17 +148,35 @@ export function syncMirrorAttributesFromMutationEvent(event: RecordingEvent): vo
147148
const node = record.mirror.getNode(mutation.id);
148149
const meta = node && record.mirror.getMeta(node);
149150

150-
if (meta?.type !== NodeType.Element) {
151+
if (!node || meta?.type !== NodeType.Element) {
151152
continue;
152153
}
153154

155+
const attributes = { ...meta.attributes };
156+
let changed = false;
157+
154158
for (const [attributeName, value] of Object.entries(mutation.attributes)) {
159+
// We only need the handful of attributes that can show up in a click breadcrumb.
160+
// Anything else (notably `style`) is dead weight here.
161+
if (!BREADCRUMB_RELEVANT_ATTRIBUTES.has(attributeName)) {
162+
continue;
163+
}
164+
155165
if (value === null) {
156166
// oxlint-disable-next-line typescript/no-dynamic-delete
157-
delete meta.attributes[attributeName];
167+
delete attributes[attributeName];
158168
} else {
159-
meta.attributes[attributeName] = value;
169+
attributes[attributeName] = value;
160170
}
171+
changed = true;
172+
}
173+
174+
if (changed) {
175+
// rrweb hands out the very same serialized node object that it emitted in an earlier `adds`
176+
// payload, and that event may still be sitting unserialized in the event buffer (this is the
177+
// case whenever compression is disabled). Mutating it in place rewrites already recorded
178+
// history, so swap in a copy instead of touching the original.
179+
record.mirror.add(node, { ...meta, attributes });
161180
}
162181
}
163182
}

packages/replay-internal/test/unit/coreHandlers/util/getAttributesToRecord.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,12 @@ it('records data-sentry-element as data-sentry-component when appropriate', func
4949
['data-sentry-component']: 'element',
5050
});
5151
});
52+
53+
it('does not write the data-sentry-component fallback back onto the passed attributes', function () {
54+
// These are rrweb's serialized attributes, shared with events that may not be serialized yet.
55+
const attributes = { ['data-sentry-element']: 'element' };
56+
57+
getAttributesToRecord(attributes);
58+
59+
expect(attributes).toEqual({ ['data-sentry-element']: 'element' });
60+
});

packages/replay-internal/test/unit/util/handleRecordingEmit.test.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,7 @@ describe('Unit | util | handleRecordingEmit', () => {
175175
},
176176
};
177177

178-
vi.spyOn(record.mirror, 'getNode').mockReturnValue(target);
179-
vi.spyOn(record.mirror, 'getMeta').mockReturnValue(meta as serializedElementNodeWithId);
180-
vi.spyOn(record.mirror, 'getId').mockReturnValue(42);
178+
record.mirror.add(target, meta as serializedElementNodeWithId);
181179

182180
syncMirrorAttributesFromMutationEvent({
183181
type: EventType.IncrementalSnapshot,
@@ -237,8 +235,7 @@ describe('Unit | util | handleRecordingEmit', () => {
237235
},
238236
};
239237

240-
vi.spyOn(record.mirror, 'getNode').mockReturnValue(target);
241-
vi.spyOn(record.mirror, 'getMeta').mockReturnValue(meta as serializedElementNodeWithId);
238+
record.mirror.add(target, meta as serializedElementNodeWithId);
242239

243240
syncMirrorAttributesFromMutationEvent({
244241
type: EventType.IncrementalSnapshot,
@@ -259,6 +256,74 @@ describe('Unit | util | handleRecordingEmit', () => {
259256
},
260257
});
261258

262-
expect(meta.attributes['aria-label']).toBe('*********');
259+
expect(record.mirror.getMeta(target)?.attributes['aria-label']).toBe('*********');
260+
});
261+
262+
it('does not rewrite the serialized node that was already emitted in an `adds` payload', function () {
263+
const target = document.createElement('div');
264+
265+
// rrweb stores the very same object in the mirror that it emits in `adds`, so a
266+
// previously emitted event and the mirror share this reference.
267+
const meta = {
268+
id: 42,
269+
type: NodeType.Element,
270+
tagName: 'div',
271+
childNodes: [],
272+
attributes: {
273+
id: 'popover',
274+
style: 'position: fixed; left: 0px; top: 0px; transform: translate(0px, -200%); min-width: max-content;',
275+
},
276+
};
277+
278+
record.mirror.add(target, meta as serializedElementNodeWithId);
279+
280+
const addEvent = {
281+
type: EventType.IncrementalSnapshot,
282+
timestamp: BASE_TIMESTAMP,
283+
data: {
284+
source: IncrementalSource.Mutation,
285+
texts: [],
286+
attributes: [],
287+
removes: [],
288+
adds: [{ parentId: 1, nextId: null, node: meta }],
289+
},
290+
};
291+
292+
// rrweb emits a compact style mutation, where `style` is a partial diff object rather
293+
// than the full style string.
294+
syncMirrorAttributesFromMutationEvent({
295+
type: EventType.IncrementalSnapshot,
296+
timestamp: BASE_TIMESTAMP + 10,
297+
data: {
298+
source: IncrementalSource.Mutation,
299+
texts: [],
300+
attributes: [
301+
{
302+
id: 42,
303+
attributes: {
304+
id: 'popover-open',
305+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
306+
style: { transform: 'translate(631px, 210px)' } as any,
307+
},
308+
},
309+
],
310+
removes: [],
311+
adds: [],
312+
},
313+
});
314+
315+
// The already emitted event still describes the element as it was when it was serialized.
316+
// Buffers that hold events unserialized (i.e. when compression is disabled) would otherwise
317+
// ship this partial style diff in place of the full inline style.
318+
expect(addEvent.data.adds[0]?.node.attributes).toEqual({
319+
id: 'popover',
320+
style: 'position: fixed; left: 0px; top: 0px; transform: translate(0px, -200%); min-width: max-content;',
321+
});
322+
323+
// But the mirror is up to date for the attributes that click breadcrumbs care about.
324+
expect(record.mirror.getMeta(target)?.attributes).toEqual({
325+
id: 'popover-open',
326+
style: 'position: fixed; left: 0px; top: 0px; transform: translate(0px, -200%); min-width: max-content;',
327+
});
263328
});
264329
});

0 commit comments

Comments
 (0)