Skip to content

Commit 21f1e4c

Browse files
billyvgandrewshie-sentry
authored andcommitted
feat(replay): Change breadcrumbs to require click to view DOM nodes (#92229)
In replay details, change breadcrumbs to require a click to view the extracted HTML. This is needed because the `replayerStepper` can still cause perf issues because it is synchronous. I also think the HTML can be a bit distracting when there are many breadcrumbs and some of the info is duplicated. Breadcrumbs list: ![image](https://github.com/user-attachments/assets/cac31990-51db-4c61-9f46-a056c3ff1716) Once "View HTML" is clicked, the HTML will always be displayed (works w/ virtualization): ![image](https://github.com/user-attachments/assets/78ff2d69-bdb0-4a5f-ac72-7699e8b44250) Timeline breadcrumbs are unaffected: ![image](https://github.com/user-attachments/assets/23642548-8452-4f12-9d1d-bb0d12c77ff7) Closes #91331
1 parent f297da3 commit 21f1e4c

File tree

11 files changed

+263
-126
lines changed

11 files changed

+263
-126
lines changed

static/app/components/replays/breadcrumbs/breadcrumbItem.spec.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ describe('BreadcrumbItem', function () {
2828
onClick={mockClick}
2929
onInspectorExpanded={() => {}}
3030
startTimestampMs={MOCK_FRAME!.timestampMs}
31+
allowShowSnippet={false}
32+
onShowSnippet={() => {}}
33+
showSnippet={false}
3134
/>,
3235
{organization}
3336
);

static/app/components/replays/breadcrumbs/breadcrumbItem.tsx

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {CSSProperties, ReactNode} from 'react';
2-
import {isValidElement, useCallback} from 'react';
2+
import {isValidElement, useCallback, useEffect, useRef} from 'react';
33
import styled from '@emotion/styled';
44
import beautify from 'js-beautify';
55

@@ -9,16 +9,19 @@ import {Button} from 'sentry/components/core/button';
99
import {Tooltip} from 'sentry/components/core/tooltip';
1010
import ErrorBoundary from 'sentry/components/errorBoundary';
1111
import Link from 'sentry/components/links/link';
12+
import Placeholder from 'sentry/components/placeholder';
1213
import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
1314
import {useReplayContext} from 'sentry/components/replays/replayContext';
1415
import {useReplayGroupContext} from 'sentry/components/replays/replayGroupContext';
1516
import StructuredEventData from 'sentry/components/structuredEventData';
1617
import {Timeline} from 'sentry/components/timeline';
1718
import {t} from 'sentry/locale';
1819
import {space} from 'sentry/styles/space';
20+
import {trackAnalytics} from 'sentry/utils/analytics';
1921
import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
2022
import {getReplayDiffOffsetsFromFrame} from 'sentry/utils/replays/getDiffTimestamps';
2123
import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
24+
import useExtractDomNodes from 'sentry/utils/replays/hooks/useExtractDomNodes';
2225
import type ReplayReader from 'sentry/utils/replays/replayReader';
2326
import type {
2427
ErrorFrame,
@@ -45,41 +48,91 @@ import {makeFeedbackPathname} from 'sentry/views/userFeedback/pathnames';
4548
type MouseCallback = (frame: ReplayFrame, nodeId?: number) => void;
4649

4750
interface Props {
51+
allowShowSnippet: boolean;
4852
frame: ReplayFrame;
4953
onClick: null | MouseCallback;
5054
onInspectorExpanded: OnExpandCallback;
5155
onMouseEnter: MouseCallback;
5256
onMouseLeave: MouseCallback;
57+
onShowSnippet: () => void;
58+
showSnippet: boolean;
5359
startTimestampMs: number;
5460
className?: string;
5561
expandPaths?: string[];
5662
extraction?: Extraction;
5763
ref?: React.Ref<HTMLDivElement>;
5864
style?: CSSProperties;
65+
updateDimensions?: () => void;
5966
}
6067

6168
function BreadcrumbItem({
6269
className,
63-
extraction,
6470
frame,
6571
expandPaths,
6672
onClick,
6773
onInspectorExpanded,
6874
onMouseEnter,
6975
onMouseLeave,
76+
showSnippet,
7077
startTimestampMs,
7178
style,
7279
ref,
80+
onShowSnippet,
81+
updateDimensions,
82+
allowShowSnippet,
7383
}: Props) {
7484
const {color, description, title, icon} = getFrameDetails(frame);
7585
const {replay} = useReplayContext();
86+
const organization = useOrganization();
87+
const {data: extraction, isPending} = useExtractDomNodes({
88+
replay,
89+
frame,
90+
enabled: showSnippet,
91+
});
92+
93+
const prevExtractState = useRef(isPending);
94+
95+
useEffect(() => {
96+
if (!updateDimensions) {
97+
return;
98+
}
99+
100+
if (isPending !== prevExtractState.current || showSnippet) {
101+
prevExtractState.current = isPending;
102+
updateDimensions();
103+
}
104+
}, [isPending, updateDimensions, showSnippet]);
105+
106+
const handleViewHtml = useCallback(
107+
(e: React.MouseEvent<HTMLButtonElement>) => {
108+
onShowSnippet();
109+
e.preventDefault();
110+
e.stopPropagation();
111+
trackAnalytics('replay.view_html', {
112+
organization,
113+
breadcrumb_type: 'category' in frame ? frame.category : 'unknown',
114+
});
115+
},
116+
[onShowSnippet, organization, frame]
117+
);
76118

77119
const renderDescription = useCallback(() => {
78120
return typeof description === 'string' ||
79121
(description !== undefined && isValidElement(description)) ? (
80-
<Description title={description} showOnlyOnOverflow isHoverable>
81-
{description}
82-
</Description>
122+
<DescriptionWrapper>
123+
<Description title={description} showOnlyOnOverflow isHoverable>
124+
{description}
125+
</Description>
126+
127+
{allowShowSnippet &&
128+
!showSnippet &&
129+
frame.data?.nodeId !== undefined &&
130+
(!isSpanFrame(frame) || !isWebVitalFrame(frame)) && (
131+
<ViewHtmlButton priority="link" onClick={handleViewHtml} size="xs">
132+
{t('View HTML')}
133+
</ViewHtmlButton>
134+
)}
135+
</DescriptionWrapper>
83136
) : (
84137
<Wrapper>
85138
<StructuredEventData
@@ -95,7 +148,15 @@ function BreadcrumbItem({
95148
/>
96149
</Wrapper>
97150
);
98-
}, [description, expandPaths, onInspectorExpanded]);
151+
}, [
152+
description,
153+
expandPaths,
154+
frame,
155+
onInspectorExpanded,
156+
showSnippet,
157+
allowShowSnippet,
158+
handleViewHtml,
159+
]);
99160

100161
const renderComparisonButton = useCallback(() => {
101162
return isBreadcrumbFrame(frame) && isHydrationErrorFrame(frame) && replay ? (
@@ -124,8 +185,14 @@ function BreadcrumbItem({
124185
]);
125186

126187
const renderCodeSnippet = useCallback(() => {
188+
if (showSnippet && isPending) {
189+
return <Placeholder height="34px" />;
190+
}
191+
127192
return (
128193
(!isSpanFrame(frame) || !isWebVitalFrame(frame)) &&
194+
!isPending &&
195+
showSnippet &&
129196
extraction?.html?.map(html => (
130197
<CodeContainer key={html}>
131198
<CodeSnippet language="html" hideCopyButton>
@@ -134,7 +201,7 @@ function BreadcrumbItem({
134201
</CodeContainer>
135202
))
136203
);
137-
}, [extraction?.html, frame]);
204+
}, [frame, isPending, extraction?.html, showSnippet]);
138205

139206
const renderIssueLink = useCallback(() => {
140207
return isErrorFrame(frame) || isFeedbackFrame(frame) ? (
@@ -350,6 +417,16 @@ const Description = styled(Tooltip)`
350417
color: ${p => p.theme.subText};
351418
`;
352419

420+
const DescriptionWrapper = styled('div')`
421+
display: flex;
422+
gap: ${space(1)};
423+
justify-content: space-between;
424+
`;
425+
426+
const ViewHtmlButton = styled(Button)`
427+
white-space: nowrap;
428+
`;
429+
353430
const StyledTimelineItem = styled(Timeline.Item)`
354431
width: 100%;
355432
position: relative;

static/app/components/replays/breadcrumbs/replayTimelineEvents.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ function Event({
7676

7777
const buttons = frames.map((frame, i) => (
7878
<BreadcrumbItem
79+
allowShowSnippet={false}
7980
frame={frame}
80-
extraction={undefined}
81+
showSnippet={false}
8182
key={i}
8283
onClick={() => {
8384
onClickTimestamp(frame);
@@ -87,6 +88,7 @@ function Event({
8788
onMouseLeave={onMouseLeave}
8889
startTimestampMs={startTimestampMs}
8990
onInspectorExpanded={() => {}}
91+
onShowSnippet={() => {}}
9092
/>
9193
));
9294
const title = <TooltipWrapper>{buttons}</TooltipWrapper>;

static/app/components/replays/replayContext.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
22
import {useTheme} from '@emotion/react';
33
import {Replayer, ReplayerEvents} from '@sentry-internal/rrweb';
4+
import type {Mirror} from '@sentry-internal/rrweb-snapshot';
45

56
import useReplayHighlighting from 'sentry/components/replays/useReplayHighlighting';
67
import {VideoReplayerWithInteractions} from 'sentry/components/replays/videoReplayerWithInteractions';
@@ -51,6 +52,11 @@ interface ReplayPlayerContextProps extends HighlightCallbacks {
5152
*/
5253
fastForwardSpeed: number;
5354

55+
/**
56+
* Returns the replay DOM mirror
57+
*/
58+
getMirror: () => Mirror | null;
59+
5460
/**
5561
* Set to true while the library is reconstructing the DOM
5662
*/
@@ -132,6 +138,7 @@ const ReplayPlayerContext = createContext<ReplayPlayerContextProps>({
132138
setCurrentTime: () => {},
133139
setRoot: () => {},
134140
togglePlayPause: () => {},
141+
getMirror: () => null,
135142
});
136143

137144
type Props = {
@@ -611,6 +618,7 @@ export function Provider({
611618
restart,
612619
setCurrentTime,
613620
togglePlayPause,
621+
getMirror: () => replayerRef.current?.getMirror() ?? null,
614622
...value,
615623
}}
616624
>

static/app/utils/analytics/replayAnalyticsEvents.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export type ReplayEventParameters = {
118118
fullscreen: boolean;
119119
user_email: string;
120120
};
121+
'replay.view-html': {
122+
breadcrumb_type: string;
123+
};
121124
};
122125

123126
type ReplayEventKey = keyof ReplayEventParameters;
@@ -151,4 +154,5 @@ export const replayEventMap: Record<ReplayEventKey, string | null> = {
151154
'replay.render-missing-replay-alert': 'Render Missing Replay Alert',
152155
'replay.search': 'Searched Replay',
153156
'replay.toggle-fullscreen': 'Toggled Replay Fullscreen',
157+
'replay.view-html': 'Clicked "View HTML" in Replay Breadcrumb',
154158
};
Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
import {useQuery, type UseQueryResult} from 'sentry/utils/queryClient';
1+
import {useQuery} from 'sentry/utils/queryClient';
22
import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
33
import type ReplayReader from 'sentry/utils/replays/replayReader';
44
import type {ReplayFrame} from 'sentry/utils/replays/types';
55

6-
export default function useExtractDomNodes({
7-
replay,
8-
}: {
6+
interface Params {
7+
enabled: boolean;
8+
frame: ReplayFrame;
99
replay: null | ReplayReader;
10-
}): UseQueryResult<Map<ReplayFrame, Extraction>> {
11-
return useQuery({
12-
queryKey: ['getDomNodes', replay],
10+
}
11+
12+
export default function useExtractDomNodes({replay, frame, enabled = true}: Params) {
13+
return useQuery<Extraction | null>({
14+
queryKey: ['getDomNodes', frame, replay],
1315
// Note: we filter out `style` mutations due to perf issues.
1416
// We can do this as long as we only need the HTML and not need to
1517
// visualize the rendered elements
16-
queryFn: () => replay?.getExtractDomNodes({withoutStyles: true}),
17-
enabled: Boolean(replay),
18+
queryFn: () => replay?.getDomNodesForFrame({frame}) ?? null,
19+
enabled: Boolean(!replay?.isFetching() && enabled),
1820
gcTime: Infinity,
21+
staleTime: Infinity,
22+
retry: false,
1923
});
2024
}

static/app/utils/replays/replayReader.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ describe('ReplayReader', () => {
567567
throw new Error('Failed to create ReplayReader instance');
568568
}
569569

570-
const result = reader.getRRWebFramesWithoutStyles();
570+
const result = reader.getRRWebFramesForDomExtraction();
571571

572572
expect(result).toEqual([
573573
{data: {node: {childNodes: [], tagName: 'html', type: 1}}, timestamp: 0, type: 2},

0 commit comments

Comments
 (0)