Browse Source

fix(replay): fix some ui jumping between mobile/web (#72825)

this PR fixes some of the UI 'jumping' when we realize a mobile replay
is a mobile replay (and not a web replay).

namely, this adds two loading placeholders which hides the following
discrepancies while we're waiting for our `isVideoReplay` check to
evaluate:
- web "contact us" button vs. mobile "give feedback" button in top right
corner
- no rage/dead clicks in header for mobile replays
- configure replay button in top right goes away for mobile replays

also updated the header placeholder style so they're consistent, and
less jarring white.

before: 


https://github.com/getsentry/sentry/assets/56095982/094bfea3-0d01-46f3-87d2-c93395c25dbb


after (mobile) - testing on `dev-ui` so the 'give feedback' button
doesn't show up but it will on prod:




https://github.com/getsentry/sentry/assets/56095982/1d4e7c70-42f1-4189-bffd-583848d6c43e






after (web):




https://github.com/getsentry/sentry/assets/56095982/50c8184c-fe15-4899-af98-a464e41fb553



it's not perfect and still looks slightly janky (probably because the
dimensions aren't exact - they were chosen by me arbitrarily and can
change; open to suggestions). also because the 'seen by' avatar bit
loads in at a different time 🫠

the logic i'm using to check for the loading state is based on whether
the rrweb events have all loaded in, because for normal replays (both
mobile/web) there are at least 2 rrweb events - otherwise there was some
sort of processing error.

will follow up with changes to fix the rest of the discrepancies; wanted
to get feedback on this new logic/loading state first.

relates to https://github.com/getsentry/sentry/issues/68109
Michelle Zhang 8 months ago
parent
commit
0e2e264a52

+ 2 - 2
static/app/components/replays/header/detailsPageBreadcrumbs.tsx

@@ -2,7 +2,7 @@ import {Fragment} from 'react';
 
 
 import {Breadcrumbs} from 'sentry/components/breadcrumbs';
 import {Breadcrumbs} from 'sentry/components/breadcrumbs';
 import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import ProjectBadge from 'sentry/components/idBadge/projectBadge';
-import HeaderPlaceholder from 'sentry/components/replays/header/headerPlaceholder';
+import Placeholder from 'sentry/components/placeholder';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import EventView from 'sentry/utils/discover/eventView';
 import EventView from 'sentry/utils/discover/eventView';
 import {getShortEventId} from 'sentry/utils/events';
 import {getShortEventId} from 'sentry/utils/events';
@@ -26,7 +26,7 @@ function DetailsPageBreadcrumbs({orgSlug, replayRecord}: Props) {
   const labelTitle = replayRecord ? (
   const labelTitle = replayRecord ? (
     <Fragment>{getShortEventId(replayRecord?.id)}</Fragment>
     <Fragment>{getShortEventId(replayRecord?.id)}</Fragment>
   ) : (
   ) : (
-    <HeaderPlaceholder width="100%" height="16px" />
+    <Placeholder width="100%" height="16px" />
   );
   );
 
 
   return (
   return (

+ 0 - 9
static/app/components/replays/header/headerPlaceholder.tsx

@@ -1,9 +0,0 @@
-import styled from '@emotion/styled';
-
-import Placeholder from 'sentry/components/placeholder';
-
-const HeaderPlaceholder = styled(Placeholder)`
-  background-color: ${p => p.theme.background};
-`;
-
-export default HeaderPlaceholder;

+ 13 - 5
static/app/components/replays/header/replayMetaData.tsx

@@ -2,8 +2,8 @@ import {Fragment} from 'react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 
 
 import Link from 'sentry/components/links/link';
 import Link from 'sentry/components/links/link';
+import Placeholder from 'sentry/components/placeholder';
 import ErrorCounts from 'sentry/components/replays/header/errorCounts';
 import ErrorCounts from 'sentry/components/replays/header/errorCounts';
-import HeaderPlaceholder from 'sentry/components/replays/header/headerPlaceholder';
 import ReplayViewers from 'sentry/components/replays/header/replayViewers';
 import ReplayViewers from 'sentry/components/replays/header/replayViewers';
 import {IconCursorArrow} from 'sentry/icons';
 import {IconCursorArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
@@ -18,10 +18,16 @@ import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
 type Props = {
 type Props = {
   replayErrors: ReplayError[];
   replayErrors: ReplayError[];
   replayRecord: ReplayRecord | undefined;
   replayRecord: ReplayRecord | undefined;
+  isLoading?: boolean;
   showDeadRageClicks?: boolean;
   showDeadRageClicks?: boolean;
 };
 };
 
 
-function ReplayMetaData({replayErrors, replayRecord, showDeadRageClicks = true}: Props) {
+function ReplayMetaData({
+  replayErrors,
+  replayRecord,
+  showDeadRageClicks = true,
+  isLoading,
+}: Props) {
   const location = useLocation();
   const location = useLocation();
   const routes = useRoutes();
   const routes = useRoutes();
   const referrer = getRouteStringFromRoutes(routes);
   const referrer = getRouteStringFromRoutes(routes);
@@ -37,7 +43,9 @@ function ReplayMetaData({replayErrors, replayRecord, showDeadRageClicks = true}:
     },
     },
   };
   };
 
 
-  return (
+  return isLoading ? (
+    <Placeholder height="47px" width="203px" />
+  ) : (
     <KeyMetrics>
     <KeyMetrics>
       {showDeadRageClicks && (
       {showDeadRageClicks && (
         <Fragment>
         <Fragment>
@@ -80,7 +88,7 @@ function ReplayMetaData({replayErrors, replayRecord, showDeadRageClicks = true}:
         {replayRecord ? (
         {replayRecord ? (
           <ErrorCounts replayErrors={replayErrors} replayRecord={replayRecord} />
           <ErrorCounts replayErrors={replayErrors} replayRecord={replayRecord} />
         ) : (
         ) : (
-          <HeaderPlaceholder width="20px" height="16px" />
+          <Placeholder width="20px" height="16px" />
         )}
         )}
       </KeyMetricData>
       </KeyMetricData>
       <KeyMetricLabel>{t('Seen By')}</KeyMetricLabel>
       <KeyMetricLabel>{t('Seen By')}</KeyMetricLabel>
@@ -88,7 +96,7 @@ function ReplayMetaData({replayErrors, replayRecord, showDeadRageClicks = true}:
         {replayRecord ? (
         {replayRecord ? (
           <ReplayViewers projectId={replayRecord.project_id} replayId={replayRecord.id} />
           <ReplayViewers projectId={replayRecord.project_id} replayId={replayRecord.id} />
         ) : (
         ) : (
-          <HeaderPlaceholder width="55px" height="27px" />
+          <Placeholder width="55px" height="27px" />
         )}
         )}
       </KeyMetricData>
       </KeyMetricData>
     </KeyMetrics>
     </KeyMetrics>

+ 2 - 2
static/app/components/replays/header/replayViewers.tsx

@@ -1,5 +1,5 @@
 import AvatarList from 'sentry/components/avatar/avatarList';
 import AvatarList from 'sentry/components/avatar/avatarList';
-import HeaderPlaceholder from 'sentry/components/replays/header/headerPlaceholder';
+import Placeholder from 'sentry/components/placeholder';
 import type {User} from 'sentry/types/user';
 import type {User} from 'sentry/types/user';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -29,7 +29,7 @@ export default function ReplayViewers({projectId, replayId}: Props) {
   });
   });
 
 
   return isLoading || isError ? (
   return isLoading || isError ? (
-    <HeaderPlaceholder width="55px" height="27px" />
+    <Placeholder width="55px" height="27px" />
   ) : (
   ) : (
     <AvatarList avatarSize={25} users={data?.data.viewed_by} />
     <AvatarList avatarSize={25} users={data?.data.viewed_by} />
   );
   );

+ 15 - 5
static/app/views/replays/detail/page.tsx

@@ -1,4 +1,4 @@
-import type {ReactNode} from 'react';
+import {Fragment, type ReactNode} from 'react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 
 
 import type {MenuItemProps} from 'sentry/components/dropdownMenu';
 import type {MenuItemProps} from 'sentry/components/dropdownMenu';
@@ -7,10 +7,10 @@ import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidg
 import UserBadge from 'sentry/components/idBadge/userBadge';
 import UserBadge from 'sentry/components/idBadge/userBadge';
 import FullViewport from 'sentry/components/layouts/fullViewport';
 import FullViewport from 'sentry/components/layouts/fullViewport';
 import * as Layout from 'sentry/components/layouts/thirds';
 import * as Layout from 'sentry/components/layouts/thirds';
+import Placeholder from 'sentry/components/placeholder';
 import ConfigureReplayCard from 'sentry/components/replays/configureReplayCard';
 import ConfigureReplayCard from 'sentry/components/replays/configureReplayCard';
 import DetailsPageBreadcrumbs from 'sentry/components/replays/header/detailsPageBreadcrumbs';
 import DetailsPageBreadcrumbs from 'sentry/components/replays/header/detailsPageBreadcrumbs';
 import FeedbackButton from 'sentry/components/replays/header/feedbackButton';
 import FeedbackButton from 'sentry/components/replays/header/feedbackButton';
-import HeaderPlaceholder from 'sentry/components/replays/header/headerPlaceholder';
 import ReplayMetaData from 'sentry/components/replays/header/replayMetaData';
 import ReplayMetaData from 'sentry/components/replays/header/replayMetaData';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import TimeSince from 'sentry/components/timeSince';
 import TimeSince from 'sentry/components/timeSince';
@@ -28,6 +28,7 @@ type Props = {
   projectSlug: string | null;
   projectSlug: string | null;
   replayErrors: ReplayError[];
   replayErrors: ReplayError[];
   replayRecord: undefined | ReplayRecord;
   replayRecord: undefined | ReplayRecord;
+  isLoading?: boolean;
   isVideoReplay?: boolean;
   isVideoReplay?: boolean;
 };
 };
 
 
@@ -38,6 +39,7 @@ export default function Page({
   projectSlug,
   projectSlug,
   replayErrors,
   replayErrors,
   isVideoReplay,
   isVideoReplay,
+  isLoading,
 }: Props) {
 }: Props) {
   const title = replayRecord
   const title = replayRecord
     ? `${replayRecord.user.display_name ?? t('Anonymous User')} — Session Replay — ${orgSlug}`
     ? `${replayRecord.user.display_name ?? t('Anonymous User')} — Session Replay — ${orgSlug}`
@@ -80,8 +82,15 @@ export default function Page({
       <DetailsPageBreadcrumbs orgSlug={orgSlug} replayRecord={replayRecord} />
       <DetailsPageBreadcrumbs orgSlug={orgSlug} replayRecord={replayRecord} />
 
 
       <ButtonActionsWrapper>
       <ButtonActionsWrapper>
-        {isVideoReplay ? <FeedbackWidgetButton /> : <FeedbackButton />}
-        {isVideoReplay ? null : <ConfigureReplayCard />}
+        {isLoading ? (
+          <Placeholder height="33px" width="203px" />
+        ) : (
+          <Fragment>
+            {isVideoReplay ? <FeedbackWidgetButton /> : <FeedbackButton />}
+            {isVideoReplay ? null : <ConfigureReplayCard />}
+          </Fragment>
+        )}
+
         <DropdownMenu
         <DropdownMenu
           position="bottom-end"
           position="bottom-end"
           triggerProps={{
           triggerProps={{
@@ -121,13 +130,14 @@ export default function Page({
           hideEmail
           hideEmail
         />
         />
       ) : (
       ) : (
-        <HeaderPlaceholder width="30%" height="45px" />
+        <Placeholder width="30%" height="45px" />
       )}
       )}
 
 
       <ReplayMetaData
       <ReplayMetaData
         replayRecord={replayRecord}
         replayRecord={replayRecord}
         replayErrors={replayErrors}
         replayErrors={replayErrors}
         showDeadRageClicks={!isVideoReplay}
         showDeadRageClicks={!isVideoReplay}
+        isLoading={isLoading}
       />
       />
     </Header>
     </Header>
   );
   );

+ 9 - 0
static/app/views/replays/details.tsx

@@ -101,6 +101,14 @@ function ReplayDetails({params: {replaySlug}}: Props) {
     replayStartTimestampMs: replayRecord?.started_at?.getTime(),
     replayStartTimestampMs: replayRecord?.started_at?.getTime(),
   });
   });
 
 
+  const rrwebFrames = replay?.getRRWebFrames();
+  // The replay data takes a while to load in, which causes our `isVideoReplay`
+  // to return an early `false`, which used to cause UI jumping.
+  // One way to check whether it's finished loading is by checking the length
+  // of the rrweb frames, which should always be > 2 for any given replay.
+  // By default, the 2 frames are replay.start and replay.end
+  const isLoading = !rrwebFrames || (rrwebFrames && rrwebFrames.length <= 2);
+
   if (replayRecord?.is_archived) {
   if (replayRecord?.is_archived) {
     return (
     return (
       <Page
       <Page
@@ -189,6 +197,7 @@ function ReplayDetails({params: {replaySlug}}: Props) {
           replayRecord={replayRecord}
           replayRecord={replayRecord}
           projectSlug={projectSlug}
           projectSlug={projectSlug}
           replayErrors={replayErrors}
           replayErrors={replayErrors}
+          isLoading={isLoading}
         >
         >
           <ReplaysLayout isVideoReplay={isVideoReplay} replayRecord={replayRecord} />
           <ReplaysLayout isVideoReplay={isVideoReplay} replayRecord={replayRecord} />
         </Page>
         </Page>