Skip to content

Commit

Permalink
feat(replay): Visually compare & diff react hydration errors on the R…
Browse files Browse the repository at this point in the history
…eplay 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 <scttcper@gmail.com>
  • Loading branch information
ryan953 and scttcper committed Dec 13, 2023
1 parent fff4962 commit 7dea10a
Show file tree
Hide file tree
Showing 17 changed files with 438 additions and 135 deletions.
13 changes: 8 additions & 5 deletions static/app/components/events/eventReplay/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ jest.mock('sentry/utils/replays/hooks/useReplayReader');
jest.mock('sentry/utils/useProjects');

const now = new Date();
const mockReplay = ReplayReader.factory({
replayRecord: ReplayRecordFixture({started_at: now}),
errors: [],
attachments: RRWebInitFrameEvents({timestamp: now}),
});
const mockReplay = ReplayReader.factory(
{
replayRecord: ReplayRecordFixture({started_at: now}),
errors: [],
attachments: RRWebInitFrameEvents({timestamp: now}),
},
{}
);

jest.mocked(useReplayReader).mockImplementation(() => {
return {
Expand Down
27 changes: 15 additions & 12 deletions static/app/components/events/eventReplay/replayPreview.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,21 @@ jest.mock('screenfull', () => ({
}));

// Get replay data with the mocked replay reader params
const mockReplay = ReplayReader.factory({
replayRecord: ReplayRecordFixture({
browser: {
name: 'Chrome',
version: '110.0.0',
},
}),
errors: [],
attachments: RRWebInitFrameEvents({
timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
}),
});
const mockReplay = ReplayReader.factory(
{
replayRecord: ReplayRecordFixture({
browser: {
name: 'Chrome',
version: '110.0.0',
},
}),
errors: [],
attachments: RRWebInitFrameEvents({
timestamp: new Date('Sep 22, 2022 4:58:39 PM UTC'),
}),
},
{}
);

mockUseReplayReader.mockImplementation(() => {
return {
Expand Down
22 changes: 21 additions & 1 deletion static/app/components/replays/breadcrumbs/breadcrumbItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {CodeSnippet} from 'sentry/components/codeSnippet';
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
import ObjectInspector from 'sentry/components/objectInspector';
import PanelItem from 'sentry/components/panels/panelItem';
import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
import {useReplayContext} from 'sentry/components/replays/replayContext';
import {Tooltip} from 'sentry/components/tooltip';
import {space} from 'sentry/styles/space';
import {Extraction} from 'sentry/utils/replays/extractDomNodes';
Expand All @@ -20,6 +22,8 @@ import TimestampButton from 'sentry/views/replays/detail/timestampButton';

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

const FRAMES_WITH_BUTTONS = ['replay.hydrate-error'];

interface Props {
extraction: Extraction | undefined;
frame: ReplayFrame;
Expand Down Expand Up @@ -63,10 +67,13 @@ function BreadcrumbItem({
}: Props) {
const {color, description, projectSlug, title, icon, timestampMs} =
getCrumbOrFrameData(frame);
const {replay} = useReplayContext();

const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category);

return (
<CrumbItem
as={onClick ? 'button' : 'span'}
as={onClick && !forceSpan ? 'button' : 'span'}
onClick={e => onClick?.(frame, e)}
onMouseEnter={e => onMouseEnter(frame, e)}
onMouseLeave={e => onMouseLeave(frame, e)}
Expand Down Expand Up @@ -105,6 +112,19 @@ function BreadcrumbItem({
</InspectorWrapper>
)}

{'data' in frame && frame.data && 'mutations' in frame.data ? (
<div>
<OpenReplayComparisonButton
replay={replay}
leftTimestamp={frame.offsetMs}
rightTimestamp={
(frame.data.mutations.next.timestamp as number) -
(replay?.getReplay().started_at.getTime() ?? 0)
}
/>
</div>
) : null}

{extraction?.html ? (
<CodeContainer>
<CodeSnippet language="html" hideCopyButton>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {Fragment, lazy, Suspense} from 'react';
import {css} from '@emotion/react';

import {openModal} from 'sentry/actionCreators/modal';
import {Button} from 'sentry/components/button';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {t} from 'sentry/locale';
import ReplayReader from 'sentry/utils/replays/replayReader';
import useOrganization from 'sentry/utils/useOrganization';

const LazyComparisonModal = lazy(
() => import('sentry/components/replays/breadcrumbs/replayComparisonModal')
);

interface Props {
leftTimestamp: number;
replay: null | ReplayReader;
rightTimestamp: number;
}

export function OpenReplayComparisonButton({
leftTimestamp,
replay,
rightTimestamp,
}: Props) {
const organization = useOrganization();

return (
<Button
role="button"
size="xs"
onClick={() => {
openModal(
deps => (
<Suspense
fallback={
<Fragment>
<deps.Header closeButton>
<deps.Header>{t('Hydration Error')}</deps.Header>
</deps.Header>
<deps.Body>
<LoadingIndicator />
</deps.Body>
</Fragment>
}
>
<LazyComparisonModal
replay={replay}
organization={organization}
leftTimestamp={leftTimestamp}
rightTimestamp={rightTimestamp}
{...deps}
/>
</Suspense>
),
{modalCss}
);
}}
>
{t('Open Hydration Diff')}
</Button>
);
}

const modalCss = css`
width: 95vw;
min-height: 80vh;
max-height: 95vh;
`;
145 changes: 145 additions & 0 deletions static/app/components/replays/breadcrumbs/replayComparisonModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {useEffect, useState} from 'react';
import styled from '@emotion/styled';
import {DiffEditor} from '@monaco-editor/react';
import beautify from 'js-beautify';

import {ModalRenderProps} from 'sentry/actionCreators/modal';
import {Flex} from 'sentry/components/profiling/flex';
import {
Provider as ReplayContextProvider,
useReplayContext,
} from 'sentry/components/replays/replayContext';
import ReplayPlayer from 'sentry/components/replays/replayPlayer';
import {TabList} from 'sentry/components/tabs';
import {t} from 'sentry/locale';
import ConfigStore from 'sentry/stores/configStore';
import {useLegacyStore} from 'sentry/stores/useLegacyStore';
import {space} from 'sentry/styles/space';
import {Organization} from 'sentry/types';
import ReplayReader from 'sentry/utils/replays/replayReader';
import {OrganizationContext} from 'sentry/views/organizationContext';

interface Props extends ModalRenderProps {
leftTimestamp: number;
organization: Organization;
replay: null | ReplayReader;
rightTimestamp: number;
}

export default function ReplayComparisonModal({
Body,
Header,
leftTimestamp,
organization,
replay,
rightTimestamp,
}: Props) {
const fetching = false;

const config = useLegacyStore(ConfigStore);
const isDark = config.theme === 'dark';

const [activeTab, setActiveTab] = useState<'visual' | 'html'>('html');

const [leftBody, setLeftBody] = useState(null);
const [rightBody, setRightBody] = useState(null);

return (
<OrganizationContext.Provider value={organization}>
<Header closeButton>
<h4>{t('Hydration Error')}</h4>
</Header>
<Body>
<Flex gap={space(2)} column>
<TabList
hideBorder
selectedKey={activeTab}
onSelectionChange={tab => setActiveTab(tab as 'visual' | 'html')}
>
<TabList.Item key="html">Html Diff</TabList.Item>
<TabList.Item key="visual">Visual Diff</TabList.Item>
</TabList>
<Flex
gap={space(2)}
style={{
// Using css to hide since the splitdiff uses the html from the iframes
// TODO: This causes a bit of a flash when switching tabs
display: activeTab === 'visual' ? undefined : 'none',
}}
>
<ReplayContextProvider
isFetching={fetching}
replay={replay}
initialTimeOffsetMs={{offsetMs: leftTimestamp - 1}}
>
<ComparisonSideWrapper id="leftSide">
<ReplaySide
selector="#leftSide iframe"
expectedTime={leftTimestamp - 1}
onLoad={setLeftBody}
/>
</ComparisonSideWrapper>
</ReplayContextProvider>
<ReplayContextProvider
isFetching={fetching}
replay={replay}
initialTimeOffsetMs={{offsetMs: rightTimestamp + 1}}
>
<ComparisonSideWrapper id="rightSide">
<ReplaySide
selector="#rightSide iframe"
expectedTime={rightTimestamp + 1}
onLoad={setRightBody}
/>
</ComparisonSideWrapper>
</ReplayContextProvider>
</Flex>
{activeTab === 'html' && leftBody && rightBody ? (
<div>
<DiffEditor
height="60vh"
theme={isDark ? 'vs-dark' : 'light'}
language="html"
original={leftBody}
modified={rightBody}
options={{
// Options - https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IDiffEditorConstructionOptions.html
scrollBeyondLastLine: false,
readOnly: true,
}}
/>
</div>
) : null}
</Flex>
</Body>
</OrganizationContext.Provider>
);
}

function ReplaySide({expectedTime, selector, onLoad}) {
const {currentTime} = useReplayContext();

useEffect(() => {
if (currentTime === expectedTime) {
setTimeout(() => {
const iframe = document.querySelector(selector) as HTMLIFrameElement;
const body = iframe.contentWindow?.document.body;
if (body) {
onLoad(
beautify.html(body.innerHTML, {
indent_size: 2,
wrap_line_length: 80,
})
);
}
}, 0);
}
}, [currentTime, expectedTime, selector, onLoad]);
return <ReplayPlayer isPreview />;
}

const ComparisonSideWrapper = styled('div')`
display: contents;
flex-grow: 1;
max-width: 50%;
`;
6 changes: 4 additions & 2 deletions static/app/utils/replays/getFrameDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
title: 'Replay',
icon: <IconWarning size="xs" />,
}),
'replay.hydrate': frame => ({
'replay.hydrate-error': () => ({
color: 'red300',
description: frame.data.mutations,
description: t(
'There was a conflict between the server rendered html and the first client render.'
),
tabKey: TabKey.BREADCRUMBS,
title: 'Hydration Error',
icon: <IconFire size="xs" />,
Expand Down
2 changes: 1 addition & 1 deletion static/app/utils/replays/getReplayEvent.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from 'sentry/utils/replays/getReplayEvent';
import hydrateBreadcrumbs from 'sentry/utils/replays/hydrateBreadcrumbs';

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

const frames = hydrateBreadcrumbs(
ReplayRecordFixture({
Expand Down
9 changes: 9 additions & 0 deletions static/app/utils/replays/hooks/useReplayReader.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
import {reactHooks} from 'sentry-test/reactTestingLibrary';

import useReplayReader from 'sentry/utils/replays/hooks/useReplayReader';
import {OrganizationContext} from 'sentry/views/organizationContext';

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

const {organization, project} = initializeOrg();

const wrapper = ({children}: {children?: React.ReactNode}) => (
<OrganizationContext.Provider value={organization}>
{children}
</OrganizationContext.Provider>
);

describe('useReplayReader', () => {
beforeEach(() => {
MockApiClient.clearMockResponses();
});

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

it('should accept a replaySlug with only the replay-id', () => {
const {result} = reactHooks.renderHook(useReplayReader, {
wrapper,
initialProps: {
orgSlug: organization.slug,
replaySlug: `123`,
Expand Down

0 comments on commit 7dea10a

Please sign in to comment.