Skip to content

Commit f577af7

Browse files
authored
feat(hydration error): Create a tree-view that highlights dom mutations directly (#80808)
<img width="1251" alt="SCR-20241115-mtqw" src="https://github.com/user-attachments/assets/a6aab141-87c2-4186-8568-89597e649cb9"> Fixes #74358
1 parent 3361fb9 commit f577af7

File tree

3 files changed

+287
-0
lines changed

3 files changed

+287
-0
lines changed

static/app/components/replays/diff/replayDiffChooser.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import styled from '@emotion/styled';
22

33
import FeatureBadge from 'sentry/components/badge/featureBadge';
4+
import {ReplayMutationTree} from 'sentry/components/replays/diff/replayMutationTree';
45
import {ReplaySideBySideImageDiff} from 'sentry/components/replays/diff/replaySideBySideImageDiff';
56
import {ReplaySliderDiff} from 'sentry/components/replays/diff/replaySliderDiff';
67
import {ReplayTextDiff} from 'sentry/components/replays/diff/replayTextDiff';
@@ -22,6 +23,7 @@ export const enum DiffType {
2223
HTML = 'html',
2324
SLIDER = 'slider',
2425
VISUAL = 'visual',
26+
MUTATIONS = 'mutations',
2527
}
2628

2729
export default function ReplayDiffChooser({
@@ -41,6 +43,9 @@ export default function ReplayDiffChooser({
4143
<TabList>
4244
<TabList.Item key={DiffType.SLIDER}>{t('Slider Diff')}</TabList.Item>
4345
<TabList.Item key={DiffType.VISUAL}>{t('Side By Side Diff')}</TabList.Item>
46+
<TabList.Item key={DiffType.MUTATIONS}>
47+
{t('Mutations')} <FeatureBadge type={'beta'} />
48+
</TabList.Item>
4449
<TabList.Item key={DiffType.HTML}>
4550
{t('HTML Diff')} <FeatureBadge type={'beta'} />
4651
</TabList.Item>
@@ -68,6 +73,13 @@ export default function ReplayDiffChooser({
6873
rightOffsetMs={rightOffsetMs}
6974
/>
7075
</TabPanels.Item>
76+
<TabPanels.Item key={DiffType.MUTATIONS}>
77+
<ReplayMutationTree
78+
leftOffsetMs={leftOffsetMs}
79+
replay={replay}
80+
rightOffsetMs={rightOffsetMs}
81+
/>
82+
</TabPanels.Item>
7183
</StyledTabPanels>
7284
</TabStateProvider>
7385
</Grid>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {css} from '@emotion/react';
2+
import styled from '@emotion/styled';
3+
4+
import StructuredEventData from 'sentry/components/structuredEventData';
5+
import useExtractDiffMutations from 'sentry/utils/replays/hooks/useExtractDiffMutations';
6+
import type ReplayReader from 'sentry/utils/replays/replayReader';
7+
8+
interface Props {
9+
leftOffsetMs: number;
10+
replay: ReplayReader;
11+
rightOffsetMs: number;
12+
}
13+
14+
export function ReplayMutationTree({replay, leftOffsetMs, rightOffsetMs}: Props) {
15+
const {data} = useExtractDiffMutations({
16+
leftOffsetMs,
17+
replay,
18+
rightOffsetMs,
19+
});
20+
21+
const timeIndexedMutations = Array.from(data?.values() ?? []).reduce(
22+
(acc, mutation) => {
23+
for (const timestamp of Object.keys(mutation)) {
24+
acc[timestamp] = mutation[timestamp];
25+
}
26+
return acc;
27+
},
28+
{}
29+
);
30+
31+
return (
32+
<ScrollWrapper>
33+
<StructuredEventData
34+
key={data?.size}
35+
data={timeIndexedMutations}
36+
maxDefaultDepth={4}
37+
css={css`
38+
flex: auto 1 1;
39+
& > pre {
40+
margin: 0;
41+
}
42+
`}
43+
/>
44+
</ScrollWrapper>
45+
);
46+
}
47+
48+
const ScrollWrapper = styled('div')`
49+
overflow: auto;
50+
height: 0;
51+
display: flex;
52+
flex-grow: 1;
53+
`;
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import {useQuery, type UseQueryResult} from 'sentry/utils/queryClient';
2+
import replayerStepper from 'sentry/utils/replays/replayerStepper';
3+
import type ReplayReader from 'sentry/utils/replays/replayReader';
4+
import {
5+
EventType,
6+
IncrementalSource,
7+
type RecordingFrame,
8+
type ReplayFrame,
9+
} from 'sentry/utils/replays/types';
10+
11+
type DiffMutation = Record<
12+
number,
13+
{
14+
adds: Record<string, unknown>;
15+
attributes: Record<string, unknown>;
16+
offset: number;
17+
removes: Record<string, unknown>;
18+
}
19+
>;
20+
21+
type Args = {
22+
rangeEndTimestampMs: number;
23+
rangeStartTimestampMs: number;
24+
replay: ReplayReader;
25+
};
26+
27+
async function extractDiffMutations({
28+
rangeEndTimestampMs,
29+
rangeStartTimestampMs,
30+
replay,
31+
}: Args): Promise<Map<RecordingFrame, DiffMutation>> {
32+
let hasStartedVisiting = false;
33+
let hasFinishedVisiting = false;
34+
let lastFrame: null | RecordingFrame = null;
35+
36+
const startTimestampMs = replay.getReplay().started_at.getTime() ?? 0;
37+
38+
const results = await replayerStepper<RecordingFrame, DiffMutation>({
39+
frames: replay.getRRWebFrames(),
40+
rrwebEvents: replay.getRRWebFrames(),
41+
startTimestampMs,
42+
shouldVisitFrame: (frame, _replayer) => {
43+
const isWithinRange =
44+
rangeStartTimestampMs < frame.timestamp && frame.timestamp <= rangeEndTimestampMs;
45+
46+
// within the range, so we visit.
47+
if (isWithinRange) {
48+
hasStartedVisiting = true;
49+
return (
50+
frame.type === EventType.IncrementalSnapshot &&
51+
'source' in frame.data &&
52+
frame.data.source === IncrementalSource.Mutation
53+
);
54+
}
55+
// if we started, but didn't record that visiting is finished, then this
56+
// is the first frame outside of the range. we'll visit it, so we can
57+
// consume the lastFrame, but afterwards no more visits will happen.
58+
if (hasStartedVisiting && !hasFinishedVisiting) {
59+
hasFinishedVisiting = true;
60+
return true;
61+
}
62+
// we either haven't started, or we already finished, not need to visit.
63+
return false;
64+
},
65+
onVisitFrame: (frame, collection, replayer) => {
66+
const mirror = replayer.getMirror();
67+
68+
if (
69+
lastFrame &&
70+
lastFrame.type === EventType.IncrementalSnapshot &&
71+
'source' in lastFrame.data &&
72+
lastFrame.data.source === IncrementalSource.Mutation
73+
) {
74+
const adds = {};
75+
for (const add of lastFrame.data.adds) {
76+
const node = mirror.getNode(add.node.id) as HTMLElement | null;
77+
if (!node || !node.outerHTML) {
78+
continue;
79+
}
80+
const selector = getSelectorForElem(node);
81+
const rootIsAdded = Object.keys(adds).some(key => selector.startsWith(key));
82+
if (rootIsAdded) {
83+
continue;
84+
}
85+
86+
adds[selector] = {
87+
html: node.outerHTML,
88+
};
89+
}
90+
91+
const attributes = {};
92+
for (const attr of lastFrame.data.attributes) {
93+
const node = mirror.getNode(attr.id) as HTMLElement | null;
94+
if (!node || !node.outerHTML) {
95+
continue;
96+
}
97+
attributes[getSelectorForElem(node)] = {
98+
tag: node.outerHTML.replace(node.innerHTML, '...'),
99+
changed: attr.attributes,
100+
};
101+
}
102+
103+
const item = collection.get(lastFrame);
104+
if (item) {
105+
item[lastFrame.timestamp].adds = adds;
106+
item[lastFrame.timestamp].attributes = attributes;
107+
}
108+
}
109+
110+
if (
111+
frame.type === EventType.IncrementalSnapshot &&
112+
'source' in frame.data &&
113+
frame.data.source === IncrementalSource.Mutation
114+
) {
115+
// fill the cache
116+
lastFrame = frame;
117+
118+
const removes = {};
119+
for (const removal of frame.data.removes) {
120+
const node = mirror.getNode(removal.id) as HTMLElement | null;
121+
if (!node || !node.outerHTML) {
122+
continue;
123+
}
124+
const selector = getSelectorForElem(node);
125+
const rootIsRemoved = Object.keys(removes).some(key =>
126+
selector.startsWith(key)
127+
);
128+
if (rootIsRemoved) {
129+
continue;
130+
}
131+
132+
removes[selector] = {
133+
html: node.outerHTML,
134+
};
135+
}
136+
137+
collection.set(frame, {
138+
[frame.timestamp]: {
139+
adds: {},
140+
attributes: {},
141+
removes,
142+
offset: frame.timestamp - startTimestampMs,
143+
},
144+
});
145+
}
146+
},
147+
});
148+
return results;
149+
}
150+
151+
function getNameForElem(element: HTMLElement) {
152+
if (element.id) {
153+
return `#${element.id}`;
154+
}
155+
const parts = [
156+
element.tagName.toLowerCase(),
157+
element.className && typeof element.className === 'string'
158+
? `.${element.className.split(' ').filter(Boolean).join('.')}`
159+
: '',
160+
];
161+
const attrs = [
162+
'data-sentry-element',
163+
'data-sentry-source-file',
164+
'data-test-id',
165+
'data-testid',
166+
];
167+
for (const attr of attrs) {
168+
const value = element.getAttribute(attr);
169+
if (value) {
170+
parts.push(`[${attr}=${value}]`);
171+
}
172+
}
173+
return parts.join('');
174+
}
175+
176+
// Copy Selector => `#blk_router > div.tsqd-parent-container > div > div`
177+
// Copy JS Path => `document.querySelector("#blk_router > div.tsqd-parent-container > div > div")`
178+
// Copy XPath => `//*[@id="blk_router"]/div[2]/div/div`
179+
// Copy Full XPath => `/html/body/div[1]/div[2]/div/div`
180+
function getSelectorForElem(element: HTMLElement): string {
181+
const parts: string[] = [];
182+
let elem: HTMLElement | null =
183+
element.nodeType !== Node.ELEMENT_NODE ? element.parentElement : element;
184+
185+
while (elem) {
186+
parts.unshift(getNameForElem(elem));
187+
if (elem.id !== '' || (elem.getAttribute('data-sentry-element') ?? '') !== '') {
188+
break;
189+
}
190+
elem = elem.parentElement;
191+
}
192+
return parts.join(' > ');
193+
}
194+
195+
interface Props {
196+
leftOffsetMs: number;
197+
replay: ReplayReader;
198+
rightOffsetMs: number;
199+
}
200+
201+
export default function useExtractDiffMutations({
202+
leftOffsetMs,
203+
replay,
204+
rightOffsetMs,
205+
}: Props): UseQueryResult<Map<ReplayFrame, DiffMutation>> {
206+
const startTimestampMs = replay.getReplay().started_at.getTime();
207+
const rangeStartTimestampMs = startTimestampMs + leftOffsetMs;
208+
const rangeEndTimestampMs = startTimestampMs + rightOffsetMs;
209+
210+
return useQuery({
211+
queryKey: [
212+
'extractDiffMutations',
213+
replay,
214+
rangeStartTimestampMs,
215+
rangeEndTimestampMs,
216+
],
217+
queryFn: () =>
218+
extractDiffMutations({replay, rangeStartTimestampMs, rangeEndTimestampMs}),
219+
enabled: true,
220+
gcTime: Infinity,
221+
});
222+
}

0 commit comments

Comments
 (0)