Browse Source

fix(replay): New replay foundation for hydration errors (#76927)

Two problems I wanted to fix were:

1. Too many re-renders. With the old system all our replayer state was
in one ginat context, so whenever anything changed (like the currentTime
react would re-calculate everything on the page and see if it needed to
be re-rendered. No longer! This new system splits state into multiple
parts based on stability.
2. The previous <ReplayPlayer> component was doing too much.
Specifically the measuring/scaling code in there was having trouble (the
extra rendering from #1 above didn’t help). This was a root cause for
why hydration errors rendered unreliably. Now we have a new, slimmer
<ReplayPlayerContainment> component to do the measurements and scale the
replay into the webpage. All the other responsibilities will be easily
handled in some future PR.


This PR adds a bunch of react-hooks & providers to split up the state
and make managing the `new Replayer` instances cleaner. This will be
something to continue iterating on over time. For now using it to do
hydration error stuff is a good stepping stone to get the code in and
working in a scenario where the replay doesn't need to play, and doesn't
need to deal with mobile replays.

This rendering fix should solve these:
Fixes https://github.com/getsentry/sentry/issues/76079
Fixes https://github.com/getsentry/sentry/issues/75689
Fixes https://github.com/getsentry/sentry/issues/75073
Ryan Albrecht 6 months ago
parent
commit
2827cdb22d

+ 31 - 26
static/app/components/replays/diff/replaySliderDiff.tsx

@@ -2,13 +2,16 @@ import {Fragment, useCallback, useRef} from 'react';
 import styled from '@emotion/styled';
 
 import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
-import ReplayIFrameRoot from 'sentry/components/replays/player/replayIFrameRoot';
-import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+import ReplayPlayer from 'sentry/components/replays/player/replayPlayer';
+import ReplayPlayerMeasurer from 'sentry/components/replays/player/replayPlayerMeasurer';
 import {Tooltip} from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import toPixels from 'sentry/utils/number/toPixels';
+import {ReplayPlayerEventsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerEventsContext';
+import {ReplayPlayerPluginsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerPluginsContext';
+import {ReplayPlayerStateContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
 import type ReplayReader from 'sentry/utils/replays/replayReader';
 import {useDimensions} from 'sentry/utils/useDimensions';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -99,30 +102,32 @@ function DiffSides({leftOffsetMs, replay, rightOffsetMs, viewDimensions, width})
 
   return (
     <Fragment>
-      <Cover style={{width}}>
-        <Placement style={{width}}>
-          <ReplayContextProvider
-            analyticsContext="replay_comparison_modal_left"
-            initialTimeOffsetMs={{offsetMs: leftOffsetMs}}
-            isFetching={false}
-            replay={replay}
-          >
-            <ReplayIFrameRoot viewDimensions={viewDimensions} />
-          </ReplayContextProvider>
-        </Placement>
-      </Cover>
-      <Cover ref={rightSideElem} style={{width: 0}}>
-        <Placement style={{width}}>
-          <ReplayContextProvider
-            analyticsContext="replay_comparison_modal_right"
-            initialTimeOffsetMs={{offsetMs: rightOffsetMs}}
-            isFetching={false}
-            replay={replay}
-          >
-            <ReplayIFrameRoot viewDimensions={viewDimensions} />
-          </ReplayContextProvider>
-        </Placement>
-      </Cover>
+      <ReplayPlayerPluginsContextProvider>
+        <ReplayPlayerEventsContextProvider replay={replay}>
+          <Cover style={{width}}>
+            <Placement style={{width}}>
+              <ReplayPlayerStateContextProvider>
+                <NegativeSpaceContainer>
+                  <ReplayPlayerMeasurer measure="width">
+                    {style => <ReplayPlayer style={style} offsetMs={leftOffsetMs} />}
+                  </ReplayPlayerMeasurer>
+                </NegativeSpaceContainer>
+              </ReplayPlayerStateContextProvider>
+            </Placement>
+          </Cover>
+          <Cover ref={rightSideElem} style={{width: 0}}>
+            <Placement style={{width}}>
+              <ReplayPlayerStateContextProvider>
+                <NegativeSpaceContainer>
+                  <ReplayPlayerMeasurer measure="width">
+                    {style => <ReplayPlayer style={style} offsetMs={rightOffsetMs} />}
+                  </ReplayPlayerMeasurer>
+                </NegativeSpaceContainer>
+              </ReplayPlayerStateContextProvider>
+            </Placement>
+          </Cover>
+        </ReplayPlayerEventsContextProvider>
+      </ReplayPlayerPluginsContextProvider>
       <Divider ref={dividerElem} onMouseDown={onDividerMouseDownWithAnalytics} />
     </Fragment>
   );

+ 34 - 0
static/app/components/replays/player/__stories__/jumpToOffsetButtonBar.tsx

@@ -0,0 +1,34 @@
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import formatDuration from 'sentry/utils/duration/formatDuration';
+import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds';
+import {useReplayUserAction} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
+
+interface Props {
+  intervals: string[];
+}
+
+export default function JumpToOffsetButtonBar({intervals}: Props) {
+  const userAction = useReplayUserAction();
+
+  return (
+    <ButtonBar merged>
+      {intervals.map(interval => {
+        const intervalMs = intervalToMilliseconds(interval);
+        return (
+          <Button
+            key={interval}
+            onClick={() => userAction({type: 'jumpToOffset', offsetMs: intervalMs})}
+            size="sm"
+          >
+            {formatDuration({
+              duration: [intervalMs, 'ms'],
+              style: 'h:mm:ss.sss',
+              precision: 'ms',
+            })}
+          </Button>
+        );
+      })}
+    </ButtonBar>
+  );
+}

+ 26 - 0
static/app/components/replays/player/__stories__/providers.tsx

@@ -0,0 +1,26 @@
+import type {ReactNode} from 'react';
+
+import {StaticNoSkipReplayPreferences} from 'sentry/components/replays/preferences/replayPreferences';
+import {ReplayPlayerEventsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerEventsContext';
+import {ReplayPlayerPluginsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerPluginsContext';
+import {ReplayPlayerStateContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
+import {ReplayPreferencesContextProvider} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
+import type ReplayReader from 'sentry/utils/replays/replayReader';
+
+export default function Providers({
+  children,
+  replay,
+}: {
+  children: ReactNode;
+  replay: ReplayReader;
+}) {
+  return (
+    <ReplayPreferencesContextProvider prefsStrategy={StaticNoSkipReplayPreferences}>
+      <ReplayPlayerPluginsContextProvider>
+        <ReplayPlayerEventsContextProvider replay={replay}>
+          <ReplayPlayerStateContextProvider>{children}</ReplayPlayerStateContextProvider>
+        </ReplayPlayerEventsContextProvider>
+      </ReplayPlayerPluginsContextProvider>
+    </ReplayPreferencesContextProvider>
+  );
+}

+ 45 - 0
static/app/components/replays/player/__stories__/replaySlugChooser.tsx

@@ -0,0 +1,45 @@
+import {Fragment, type ReactNode} from 'react';
+import {css} from '@emotion/react';
+
+import Providers from 'sentry/components/replays/player/__stories__/providers';
+import useReplayReader from 'sentry/utils/replays/hooks/useReplayReader';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useSessionStorage} from 'sentry/utils/useSessionStorage';
+
+export default function ReplaySlugChooser({children}: {children: ReactNode}) {
+  const [replaySlug, setReplaySlug] = useSessionStorage('stories:replaySlug', '');
+
+  return (
+    <Fragment>
+      <input
+        defaultValue={replaySlug}
+        onChange={event => {
+          setReplaySlug(event.target.value);
+        }}
+        placeholder="Paste a replaySlug"
+        css={css`
+          font-variant-numeric: tabular-nums;
+        `}
+        size={34}
+      />
+      {replaySlug ? <LoadReplay replaySlug={replaySlug}>{children}</LoadReplay> : null}
+    </Fragment>
+  );
+}
+
+function LoadReplay({children, replaySlug}: {children: ReactNode; replaySlug: string}) {
+  const organization = useOrganization();
+  const {fetchError, fetching, replay} = useReplayReader({
+    orgSlug: organization.slug,
+    replaySlug,
+  });
+
+  if (fetchError) {
+    return fetchError.message;
+  }
+  if (!replay || fetching) {
+    return 'Loading...';
+  }
+
+  return <Providers replay={replay}>{children}</Providers>;
+}

+ 55 - 0
static/app/components/replays/player/replayCurrentTime.stories.tsx

@@ -0,0 +1,55 @@
+import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
+import JumpToOffsetButtonBar from 'sentry/components/replays/player/__stories__/jumpToOffsetButtonBar';
+import ReplaySlugChooser from 'sentry/components/replays/player/__stories__/replaySlugChooser';
+import ReplayCurrentTime from 'sentry/components/replays/player/replayCurrentTime';
+import ReplayPlayer from 'sentry/components/replays/player/replayPlayer';
+import ReplayPlayerMeasurer from 'sentry/components/replays/player/replayPlayerMeasurer';
+import ReplayPlayPauseButton from 'sentry/components/replays/player/replayPlayPauseButton';
+import SideBySide from 'sentry/components/stories/sideBySide';
+import storyBook from 'sentry/stories/storyBook';
+
+export default storyBook(ReplayCurrentTime, story => {
+  story('Default', () => {
+    function Example() {
+      return (
+        <SideBySide>
+          <ReplayPlayPauseButton />
+          <ReplayCurrentTime />
+          <NegativeSpaceContainer style={{height: 300}}>
+            <ReplayPlayerMeasurer measure="both">
+              {style => <ReplayPlayer style={style} />}
+            </ReplayPlayerMeasurer>
+          </NegativeSpaceContainer>
+        </SideBySide>
+      );
+    }
+    return (
+      <ReplaySlugChooser>
+        <Example />
+      </ReplaySlugChooser>
+    );
+  });
+
+  story('Jumping to different times', () => {
+    function Example() {
+      return (
+        <SideBySide>
+          <ReplayPlayPauseButton />
+          <ReplayCurrentTime />
+          <JumpToOffsetButtonBar intervals={['0m', '1m', '12m']} />
+
+          <NegativeSpaceContainer style={{height: 300}}>
+            <ReplayPlayerMeasurer measure="both">
+              {style => <ReplayPlayer style={style} />}
+            </ReplayPlayerMeasurer>
+          </NegativeSpaceContainer>
+        </SideBySide>
+      );
+    }
+    return (
+      <ReplaySlugChooser>
+        <Example />
+      </ReplaySlugChooser>
+    );
+  });
+});

+ 12 - 0
static/app/components/replays/player/replayCurrentTime.tsx

@@ -0,0 +1,12 @@
+import {useState} from 'react';
+
+import Duration from 'sentry/components/duration/duration';
+import useReplayCurrentTime from 'sentry/utils/replays/playback/hooks/useReplayCurrentTime';
+
+export default function ReplayCurrentTime() {
+  const [currentTime, setCurrentTime] = useState({timeMs: 0});
+
+  useReplayCurrentTime({callback: setCurrentTime});
+
+  return <Duration duration={[currentTime.timeMs, 'ms']} precision="sec" />;
+}

+ 0 - 37
static/app/components/replays/player/replayIFrameRoot.tsx

@@ -1,37 +0,0 @@
-import {useEffect, useRef} from 'react';
-
-import {useReplayContext} from 'sentry/components/replays/replayContext';
-
-type Dimensions = ReturnType<typeof useReplayContext>['dimensions'];
-interface Props {
-  viewDimensions: Dimensions;
-}
-
-export default function ReplayIFrameRoot({viewDimensions}: Props) {
-  const {dimensions: videoDimensions, isFetching, setRoot} = useReplayContext();
-
-  const viewEl = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    setRoot(isFetching ? null : viewEl.current);
-
-    return () => setRoot(null);
-  }, [setRoot, isFetching]);
-
-  useEffect(() => {
-    if (!viewEl.current) {
-      return;
-    }
-    const scale = Math.min(
-      viewDimensions.width / videoDimensions.width,
-      viewDimensions.height / videoDimensions.height
-    );
-
-    viewEl.current.style['transform-origin'] = 'top left';
-    viewEl.current.style.transform = `scale(${scale})`;
-    viewEl.current.style.width = `${videoDimensions.width * scale}px`;
-    viewEl.current.style.height = `${videoDimensions.height * scale}px`;
-  }, [viewDimensions, videoDimensions]);
-
-  return <div ref={viewEl} />;
-}

+ 69 - 0
static/app/components/replays/player/replayPlayPauseButton.stories.tsx

@@ -0,0 +1,69 @@
+import {Fragment} from 'react';
+
+import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
+import ReplaySlugChooser from 'sentry/components/replays/player/__stories__/replaySlugChooser';
+import ReplayPlayer from 'sentry/components/replays/player/replayPlayer';
+import ReplayPlayerMeasurer from 'sentry/components/replays/player/replayPlayerMeasurer';
+import ReplayPlayPauseButton from 'sentry/components/replays/player/replayPlayPauseButton';
+import JSXNode from 'sentry/components/stories/jsxNode';
+import storyBook from 'sentry/stories/storyBook';
+
+export default storyBook(ReplayPlayer, story => {
+  story('Default', () => {
+    function Example() {
+      return (
+        <Fragment>
+          <p>
+            Include <JSXNode name="ReplayPlayPauseButton" /> inside a{' '}
+            <JSXNode name="ReplayPlayerStateContextProvider" /> to control the play/pause
+            state of the replay.
+          </p>
+
+          <NegativeSpaceContainer style={{height: 400}}>
+            <ReplayPlayerMeasurer measure="both">
+              {style => <ReplayPlayer style={style} />}
+            </ReplayPlayerMeasurer>
+          </NegativeSpaceContainer>
+          <ReplayPlayPauseButton />
+        </Fragment>
+      );
+    }
+    return (
+      <ReplaySlugChooser>
+        <Example />
+      </ReplaySlugChooser>
+    );
+  });
+
+  story('Multiple Players', () => {
+    function Example() {
+      return (
+        <Fragment>
+          <p>
+            All <JSXNode name="ReplayPlayer" /> instances within the{' '}
+            <JSXNode name="ReplayPlayerStateContextProvider" /> will play & pause
+            together.
+          </p>
+
+          <NegativeSpaceContainer style={{height: 200}}>
+            <ReplayPlayerMeasurer measure="both">
+              {style => <ReplayPlayer style={style} />}
+            </ReplayPlayerMeasurer>
+          </NegativeSpaceContainer>
+          <hr />
+          <NegativeSpaceContainer style={{height: 200}}>
+            <ReplayPlayerMeasurer measure="both">
+              {style => <ReplayPlayer style={style} />}
+            </ReplayPlayerMeasurer>
+          </NegativeSpaceContainer>
+          <ReplayPlayPauseButton />
+        </Fragment>
+      );
+    }
+    return (
+      <ReplaySlugChooser>
+        <Example />
+      </ReplaySlugChooser>
+    );
+  });
+});

+ 38 - 0
static/app/components/replays/player/replayPlayPauseButton.tsx

@@ -0,0 +1,38 @@
+import type {BaseButtonProps} from 'sentry/components/button';
+import {Button} from 'sentry/components/button';
+import {IconPause, IconPlay, IconRefresh} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {
+  useReplayPlayerState,
+  useReplayUserAction,
+} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
+
+export default function ReplayPlayPauseButton(props: BaseButtonProps) {
+  const userAction = useReplayUserAction();
+  const {playerState, isFinished} = useReplayPlayerState();
+
+  const isPlaying = playerState === 'playing';
+
+  return isFinished ? (
+    <Button
+      title={t('Restart Replay')}
+      icon={<IconRefresh />}
+      onClick={() => {
+        userAction({type: 'jumpToOffset', offsetMs: 0});
+        userAction({type: 'play'});
+      }}
+      aria-label={t('Restart Replay')}
+      priority="primary"
+      {...props}
+    />
+  ) : (
+    <Button
+      title={isPlaying ? t('Pause') : t('Play')}
+      icon={isPlaying ? <IconPause /> : <IconPlay />}
+      onClick={() => userAction(isPlaying ? {type: 'pause'} : {type: 'play'})}
+      aria-label={isPlaying ? t('Pause') : t('Play')}
+      priority="primary"
+      {...props}
+    />
+  );
+}

+ 50 - 0
static/app/components/replays/player/replayPlayer.stories.tsx

@@ -0,0 +1,50 @@
+import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
+import JumpToOffsetButtonBar from 'sentry/components/replays/player/__stories__/jumpToOffsetButtonBar';
+import ReplaySlugChooser from 'sentry/components/replays/player/__stories__/replaySlugChooser';
+import ReplayCurrentTime from 'sentry/components/replays/player/replayCurrentTime';
+import ReplayPlayer from 'sentry/components/replays/player/replayPlayer';
+import ReplayPlayerMeasurer from 'sentry/components/replays/player/replayPlayerMeasurer';
+import ReplayPlayPauseButton from 'sentry/components/replays/player/replayPlayPauseButton';
+import ReplayPreferenceDropdown from 'sentry/components/replays/preferences/replayPreferenceDropdown';
+import SideBySide from 'sentry/components/stories/sideBySide';
+import {StructuredData} from 'sentry/components/structuredEventData';
+import storyBook from 'sentry/stories/storyBook';
+import {useReplayPlayerState} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
+import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
+
+export default storyBook(ReplayPlayer, story => {
+  story('Default', () => {
+    function Example() {
+      return (
+        <SideBySide>
+          <ReplayPlayPauseButton />
+          <ReplayCurrentTime />
+          <ReplayPreferenceDropdown speedOptions={[0.5, 1, 2, 8]} />
+          <JumpToOffsetButtonBar intervals={['0m', '1ms', '1m', '8m', '12m']} />
+          <DebugReplayPlayerState />
+          <DebugReplayPrefsState />
+          <NegativeSpaceContainer style={{height: 500}}>
+            <ReplayPlayerMeasurer measure="both">
+              {style => <ReplayPlayer style={style} />}
+            </ReplayPlayerMeasurer>
+          </NegativeSpaceContainer>
+        </SideBySide>
+      );
+    }
+    return (
+      <ReplaySlugChooser>
+        <Example />
+      </ReplaySlugChooser>
+    );
+  });
+});
+
+function DebugReplayPlayerState() {
+  const state = useReplayPlayerState();
+  return <StructuredData value={state} maxDefaultDepth={1} withAnnotatedText={false} />;
+}
+
+function DebugReplayPrefsState() {
+  const [prefs] = useReplayPrefs();
+  return <StructuredData value={prefs} maxDefaultDepth={1} withAnnotatedText={false} />;
+}

Some files were not shown because too many files changed in this diff