Skip to content

Commit 7dea10a

Browse files
ryan953scttcper
andauthored
feat(replay): Visually compare & diff react hydration errors on the Replay Details page (#61477)
Reads the new `session-replay-show-hydration-errors` feature flag which controls if we show `replay.hydrate-error` crumbs on the Replay Details page. These crumbs have a poor design right now, but they have a button which will open a Modal that shows a visual side-by-side comparison of before & after the hydration error happened. Also, it shows a diff of the html. <img width="1426" alt="SCR-20231208-ninr" src="https://github.com/getsentry/sentry/assets/187460/2b5b172d-024d-4535-98d9-86493405c523"> Depends on #61612 See related SDK change: getsentry/sentry-javascript#9759 fixes #61613 --------- Co-authored-by: Scott Cooper <[email protected]>
1 parent fff4962 commit 7dea10a

17 files changed

+438
-135
lines changed

static/app/components/events/eventReplay/index.spec.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ jest.mock('sentry/utils/replays/hooks/useReplayReader');
1919
jest.mock('sentry/utils/useProjects');
2020

2121
const now = new Date();
22-
const mockReplay = ReplayReader.factory({
23-
replayRecord: ReplayRecordFixture({started_at: now}),
24-
errors: [],
25-
attachments: RRWebInitFrameEvents({timestamp: now}),
26-
});
22+
const mockReplay = ReplayReader.factory(
23+
{
24+
replayRecord: ReplayRecordFixture({started_at: now}),
25+
errors: [],
26+
attachments: RRWebInitFrameEvents({timestamp: now}),
27+
},
28+
{}
29+
);
2730

2831
jest.mocked(useReplayReader).mockImplementation(() => {
2932
return {

static/app/components/events/eventReplay/replayPreview.spec.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,21 @@ jest.mock('screenfull', () => ({
3636
}));
3737

3838
// Get replay data with the mocked replay reader params
39-
const mockReplay = ReplayReader.factory({
40-
replayRecord: ReplayRecordFixture({
41-
browser: {
42-
name: 'Chrome',
43-
version: '110.0.0',
44-
},
45-
}),
46-
errors: [],
47-
attachments: RRWebInitFrameEvents({
48-
timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
49-
}),
50-
});
39+
const mockReplay = ReplayReader.factory(
40+
{
41+
replayRecord: ReplayRecordFixture({
42+
browser: {
43+
name: 'Chrome',
44+
version: '110.0.0',
45+
},
46+
}),
47+
errors: [],
48+
attachments: RRWebInitFrameEvents({
49+
timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
50+
}),
51+
},
52+
{}
53+
);
5154

5255
mockUseReplayReader.mockImplementation(() => {
5356
return {

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {CodeSnippet} from 'sentry/components/codeSnippet';
66
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
77
import ObjectInspector from 'sentry/components/objectInspector';
88
import PanelItem from 'sentry/components/panels/panelItem';
9+
import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
10+
import {useReplayContext} from 'sentry/components/replays/replayContext';
911
import {Tooltip} from 'sentry/components/tooltip';
1012
import {space} from 'sentry/styles/space';
1113
import {Extraction} from 'sentry/utils/replays/extractDomNodes';
@@ -20,6 +22,8 @@ import TimestampButton from 'sentry/views/replays/detail/timestampButton';
2022

2123
type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent<HTMLElement>) => void;
2224

25+
const FRAMES_WITH_BUTTONS = ['replay.hydrate-error'];
26+
2327
interface Props {
2428
extraction: Extraction | undefined;
2529
frame: ReplayFrame;
@@ -63,10 +67,13 @@ function BreadcrumbItem({
6367
}: Props) {
6468
const {color, description, projectSlug, title, icon, timestampMs} =
6569
getCrumbOrFrameData(frame);
70+
const {replay} = useReplayContext();
71+
72+
const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category);
6673

6774
return (
6875
<CrumbItem
69-
as={onClick ? 'button' : 'span'}
76+
as={onClick && !forceSpan ? 'button' : 'span'}
7077
onClick={e => onClick?.(frame, e)}
7178
onMouseEnter={e => onMouseEnter(frame, e)}
7279
onMouseLeave={e => onMouseLeave(frame, e)}
@@ -105,6 +112,19 @@ function BreadcrumbItem({
105112
</InspectorWrapper>
106113
)}
107114

115+
{'data' in frame && frame.data && 'mutations' in frame.data ? (
116+
<div>
117+
<OpenReplayComparisonButton
118+
replay={replay}
119+
leftTimestamp={frame.offsetMs}
120+
rightTimestamp={
121+
(frame.data.mutations.next.timestamp as number) -
122+
(replay?.getReplay().started_at.getTime() ?? 0)
123+
}
124+
/>
125+
</div>
126+
) : null}
127+
108128
{extraction?.html ? (
109129
<CodeContainer>
110130
<CodeSnippet language="html" hideCopyButton>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {Fragment, lazy, Suspense} from 'react';
2+
import {css} from '@emotion/react';
3+
4+
import {openModal} from 'sentry/actionCreators/modal';
5+
import {Button} from 'sentry/components/button';
6+
import LoadingIndicator from 'sentry/components/loadingIndicator';
7+
import {t} from 'sentry/locale';
8+
import ReplayReader from 'sentry/utils/replays/replayReader';
9+
import useOrganization from 'sentry/utils/useOrganization';
10+
11+
const LazyComparisonModal = lazy(
12+
() => import('sentry/components/replays/breadcrumbs/replayComparisonModal')
13+
);
14+
15+
interface Props {
16+
leftTimestamp: number;
17+
replay: null | ReplayReader;
18+
rightTimestamp: number;
19+
}
20+
21+
export function OpenReplayComparisonButton({
22+
leftTimestamp,
23+
replay,
24+
rightTimestamp,
25+
}: Props) {
26+
const organization = useOrganization();
27+
28+
return (
29+
<Button
30+
role="button"
31+
size="xs"
32+
onClick={() => {
33+
openModal(
34+
deps => (
35+
<Suspense
36+
fallback={
37+
<Fragment>
38+
<deps.Header closeButton>
39+
<deps.Header>{t('Hydration Error')}</deps.Header>
40+
</deps.Header>
41+
<deps.Body>
42+
<LoadingIndicator />
43+
</deps.Body>
44+
</Fragment>
45+
}
46+
>
47+
<LazyComparisonModal
48+
replay={replay}
49+
organization={organization}
50+
leftTimestamp={leftTimestamp}
51+
rightTimestamp={rightTimestamp}
52+
{...deps}
53+
/>
54+
</Suspense>
55+
),
56+
{modalCss}
57+
);
58+
}}
59+
>
60+
{t('Open Hydration Diff')}
61+
</Button>
62+
);
63+
}
64+
65+
const modalCss = css`
66+
width: 95vw;
67+
min-height: 80vh;
68+
max-height: 95vh;
69+
`;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {useEffect, useState} from 'react';
2+
import styled from '@emotion/styled';
3+
import {DiffEditor} from '@monaco-editor/react';
4+
import beautify from 'js-beautify';
5+
6+
import {ModalRenderProps} from 'sentry/actionCreators/modal';
7+
import {Flex} from 'sentry/components/profiling/flex';
8+
import {
9+
Provider as ReplayContextProvider,
10+
useReplayContext,
11+
} from 'sentry/components/replays/replayContext';
12+
import ReplayPlayer from 'sentry/components/replays/replayPlayer';
13+
import {TabList} from 'sentry/components/tabs';
14+
import {t} from 'sentry/locale';
15+
import ConfigStore from 'sentry/stores/configStore';
16+
import {useLegacyStore} from 'sentry/stores/useLegacyStore';
17+
import {space} from 'sentry/styles/space';
18+
import {Organization} from 'sentry/types';
19+
import ReplayReader from 'sentry/utils/replays/replayReader';
20+
import {OrganizationContext} from 'sentry/views/organizationContext';
21+
22+
interface Props extends ModalRenderProps {
23+
leftTimestamp: number;
24+
organization: Organization;
25+
replay: null | ReplayReader;
26+
rightTimestamp: number;
27+
}
28+
29+
export default function ReplayComparisonModal({
30+
Body,
31+
Header,
32+
leftTimestamp,
33+
organization,
34+
replay,
35+
rightTimestamp,
36+
}: Props) {
37+
const fetching = false;
38+
39+
const config = useLegacyStore(ConfigStore);
40+
const isDark = config.theme === 'dark';
41+
42+
const [activeTab, setActiveTab] = useState<'visual' | 'html'>('html');
43+
44+
const [leftBody, setLeftBody] = useState(null);
45+
const [rightBody, setRightBody] = useState(null);
46+
47+
return (
48+
<OrganizationContext.Provider value={organization}>
49+
<Header closeButton>
50+
<h4>{t('Hydration Error')}</h4>
51+
</Header>
52+
<Body>
53+
<Flex gap={space(2)} column>
54+
<TabList
55+
hideBorder
56+
selectedKey={activeTab}
57+
onSelectionChange={tab => setActiveTab(tab as 'visual' | 'html')}
58+
>
59+
<TabList.Item key="html">Html Diff</TabList.Item>
60+
<TabList.Item key="visual">Visual Diff</TabList.Item>
61+
</TabList>
62+
<Flex
63+
gap={space(2)}
64+
style={{
65+
// Using css to hide since the splitdiff uses the html from the iframes
66+
// TODO: This causes a bit of a flash when switching tabs
67+
display: activeTab === 'visual' ? undefined : 'none',
68+
}}
69+
>
70+
<ReplayContextProvider
71+
isFetching={fetching}
72+
replay={replay}
73+
initialTimeOffsetMs={{offsetMs: leftTimestamp - 1}}
74+
>
75+
<ComparisonSideWrapper id="leftSide">
76+
<ReplaySide
77+
selector="#leftSide iframe"
78+
expectedTime={leftTimestamp - 1}
79+
onLoad={setLeftBody}
80+
/>
81+
</ComparisonSideWrapper>
82+
</ReplayContextProvider>
83+
<ReplayContextProvider
84+
isFetching={fetching}
85+
replay={replay}
86+
initialTimeOffsetMs={{offsetMs: rightTimestamp + 1}}
87+
>
88+
<ComparisonSideWrapper id="rightSide">
89+
<ReplaySide
90+
selector="#rightSide iframe"
91+
expectedTime={rightTimestamp + 1}
92+
onLoad={setRightBody}
93+
/>
94+
</ComparisonSideWrapper>
95+
</ReplayContextProvider>
96+
</Flex>
97+
{activeTab === 'html' && leftBody && rightBody ? (
98+
<div>
99+
<DiffEditor
100+
height="60vh"
101+
theme={isDark ? 'vs-dark' : 'light'}
102+
language="html"
103+
original={leftBody}
104+
modified={rightBody}
105+
options={{
106+
// Options - https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IDiffEditorConstructionOptions.html
107+
scrollBeyondLastLine: false,
108+
readOnly: true,
109+
}}
110+
/>
111+
</div>
112+
) : null}
113+
</Flex>
114+
</Body>
115+
</OrganizationContext.Provider>
116+
);
117+
}
118+
119+
function ReplaySide({expectedTime, selector, onLoad}) {
120+
const {currentTime} = useReplayContext();
121+
122+
useEffect(() => {
123+
if (currentTime === expectedTime) {
124+
setTimeout(() => {
125+
const iframe = document.querySelector(selector) as HTMLIFrameElement;
126+
const body = iframe.contentWindow?.document.body;
127+
if (body) {
128+
onLoad(
129+
beautify.html(body.innerHTML, {
130+
indent_size: 2,
131+
wrap_line_length: 80,
132+
})
133+
);
134+
}
135+
}, 0);
136+
}
137+
}, [currentTime, expectedTime, selector, onLoad]);
138+
return <ReplayPlayer isPreview />;
139+
}
140+
141+
const ComparisonSideWrapper = styled('div')`
142+
display: contents;
143+
flex-grow: 1;
144+
max-width: 50%;
145+
`;

static/app/utils/replays/getFrameDetails.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,11 @@ const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
151151
title: 'Replay',
152152
icon: <IconWarning size="xs" />,
153153
}),
154-
'replay.hydrate': frame => ({
154+
'replay.hydrate-error': () => ({
155155
color: 'red300',
156-
description: frame.data.mutations,
156+
description: t(
157+
'There was a conflict between the server rendered html and the first client render.'
158+
),
157159
tabKey: TabKey.BREADCRUMBS,
158160
title: 'Hydration Error',
159161
icon: <IconFire size="xs" />,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from 'sentry/utils/replays/getReplayEvent';
88
import hydrateBreadcrumbs from 'sentry/utils/replays/hydrateBreadcrumbs';
99

10-
const mockRRWebFrames = []; // This is only needed for replay.hydrate breadcrumbs.
10+
const mockRRWebFrames = []; // This is only needed for replay.hydrate-error breadcrumbs.
1111

1212
const frames = hydrateBreadcrumbs(
1313
ReplayRecordFixture({

static/app/utils/replays/hooks/useReplayReader.spec.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
22
import {reactHooks} from 'sentry-test/reactTestingLibrary';
33

44
import useReplayReader from 'sentry/utils/replays/hooks/useReplayReader';
5+
import {OrganizationContext} from 'sentry/views/organizationContext';
56

67
jest.mock('sentry/utils/replays/hooks/useReplayData', () => ({
78
__esModule: true,
@@ -10,13 +11,20 @@ jest.mock('sentry/utils/replays/hooks/useReplayData', () => ({
1011

1112
const {organization, project} = initializeOrg();
1213

14+
const wrapper = ({children}: {children?: React.ReactNode}) => (
15+
<OrganizationContext.Provider value={organization}>
16+
{children}
17+
</OrganizationContext.Provider>
18+
);
19+
1320
describe('useReplayReader', () => {
1421
beforeEach(() => {
1522
MockApiClient.clearMockResponses();
1623
});
1724

1825
it('should accept a replaySlug with project and id parts', () => {
1926
const {result} = reactHooks.renderHook(useReplayReader, {
27+
wrapper,
2028
initialProps: {
2129
orgSlug: organization.slug,
2230
replaySlug: `${project.slug}:123`,
@@ -32,6 +40,7 @@ describe('useReplayReader', () => {
3240

3341
it('should accept a replaySlug with only the replay-id', () => {
3442
const {result} = reactHooks.renderHook(useReplayReader, {
43+
wrapper,
3544
initialProps: {
3645
orgSlug: organization.slug,
3746
replaySlug: `123`,

0 commit comments

Comments
 (0)