Browse Source

feat(replay): Wrap rrweb library in react context (#33464)

This change implements a react-centric replacement for the `<BaseRRWebReplayer>` component, and the `rrweb-player` npm package. That package is based on svelte and doesn't really do things in a react way. 

So what we've got instead are the core features of a react-context based system.
1. There's the ReplayPlayer, which wraps `rrweb` and hosts the iframe where replays will be injected. 
2. We've also got the ReplayController, which is where the play/pause buttons are hosted, as well as the video scrobber and so on. 

Using context to manage the replay player is important because it allows us to move child components around inside the render tree and still give them access to the replay data.

<img width="1407" alt="Screen Shot 2022-04-11 at 10 02 26 AM" src="https://user-images.githubusercontent.com/187460/162792601-46986178-6fe4-4831-be10-575f963de8d4.png">

Fixes: #33519 #33513
Ryan Albrecht 2 years ago
parent
commit
e72d16c840

File diff suppressed because it is too large
+ 113 - 0
docs-ui/stories/components/replays/example_rrweb_events_1.json


File diff suppressed because it is too large
+ 100 - 0
docs-ui/stories/components/replays/example_rrweb_events_2.json


+ 19 - 0
docs-ui/stories/components/replays/replayController.stories.js

@@ -0,0 +1,19 @@
+import ReplayController from 'sentry/components/replays/replayController';
+
+export default {
+  title: 'Components/Replays/ReplayController',
+  component: ReplayController,
+};
+
+const Template = ({...args}) => <ReplayController {...args} />;
+
+export const _ReplayController = Template.bind({});
+_ReplayController.args = {};
+_ReplayController.parameters = {
+  docs: {
+    description: {
+      story:
+        'ReplayController is a component that contains play/pause buttons for the replay timeline',
+    },
+  },
+};

+ 53 - 0
docs-ui/stories/components/replays/replayPage.stories.js

@@ -0,0 +1,53 @@
+import {useState} from 'react';
+
+import SelectControl from 'sentry/components/forms/selectControl';
+import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+import ReplayController from 'sentry/components/replays/replayController';
+import ReplayPlayer from 'sentry/components/replays/replayPlayer';
+
+import rrwebEvents1 from './example_rrweb_events_1.json';
+import rrwebEvents2 from './example_rrweb_events_2.json';
+
+export default {
+  title: 'Components/Replays/Replay Page',
+  component: ReplayPlayer,
+};
+
+export const PlayerWithController = () => (
+  <ReplayContextProvider events={rrwebEvents1}>
+    <ReplayPlayer />
+    <ReplayController speedOptions={[0.5, 1, 2, 8]} />
+  </ReplayContextProvider>
+);
+
+export const CustomSpeedOptions = () => (
+  <ReplayContextProvider events={rrwebEvents1}>
+    <ReplayPlayer />
+    <ReplayController speedOptions={[1, 8, 16]} />
+  </ReplayContextProvider>
+);
+
+export const ChangeEventsInput = () => {
+  const [selected, setSelected] = useState('example_1');
+
+  const events = {
+    example_1: rrwebEvents1,
+    example_2: rrwebEvents2,
+  };
+
+  return (
+    <ReplayContextProvider events={events[selected]}>
+      <SelectControl
+        label="Input"
+        value={selected}
+        onChange={opt => setSelected(opt.value)}
+        choices={[
+          ['example_1', 'Example 1'],
+          ['example_2', 'Example 2'],
+        ]}
+      />
+      <ReplayPlayer />
+      <ReplayController speedOptions={[1, 8, 16]} />
+    </ReplayContextProvider>
+  );
+};

+ 15 - 0
docs-ui/stories/components/replays/replayPlayer.stories.js

@@ -0,0 +1,15 @@
+import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+import ReplayPlayer from 'sentry/components/replays/replayPlayer';
+
+import events from './example_rrweb_events_1.json';
+
+export default {
+  title: 'Components/Replays/ReplayPlayer',
+  component: ReplayPlayer,
+};
+
+export const ScaledReplayPlayer = () => (
+  <ReplayContextProvider events={events}>
+    <ReplayPlayer />
+  </ReplayContextProvider>
+);

+ 1 - 0
package.json

@@ -130,6 +130,7 @@
     "react-virtualized": "^9.22.3",
     "reflux": "0.4.1",
     "regenerator-runtime": "^0.13.3",
+    "rrweb": "^1.1.3",
     "rrweb-player": "^0.7.13",
     "scroll-to-element": "^2.0.0",
     "sprintf-js": "1.0.3",

+ 232 - 0
static/app/components/replays/replayContext.tsx

@@ -0,0 +1,232 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {useTheme} from '@emotion/react';
+import {Replayer, ReplayerEvents} from 'rrweb';
+import type {eventWithTime} from 'rrweb/typings/types';
+
+import usePrevious from 'sentry/utils/usePrevious';
+
+import useRAF from './useRAF';
+
+type Dimensions = {height: number; width: number};
+type RootElem = null | HTMLDivElement;
+
+// Important: Don't allow context Consumers to access `Replayer` directly.
+// It has state that, when changed, will not trigger a react render.
+// Instead only expose methods that wrap `Replayer` and manage state.
+type ReplayPlayerContextProps = {
+  currentTime: number;
+  dimensions: Dimensions;
+  duration: undefined | number;
+  events: eventWithTime[];
+  initRoot: (root: RootElem) => void;
+  isPlaying: boolean;
+  setCurrentTime: (time: number) => void;
+  setSpeed: (speed: number) => void;
+  skipInactive: boolean;
+  speed: number;
+  togglePlayPause: (play: boolean) => void;
+  toggleSkipInactive: (skip: boolean) => void;
+};
+
+const ReplayPlayerContext = React.createContext<ReplayPlayerContextProps>({
+  currentTime: 0,
+  dimensions: {height: 0, width: 0},
+  duration: undefined,
+  events: [],
+  initRoot: _root => {},
+  isPlaying: false,
+  setCurrentTime: () => {},
+  setSpeed: () => {},
+  skipInactive: false,
+  speed: 1,
+  togglePlayPause: () => {},
+  toggleSkipInactive: () => {},
+});
+
+type Props = {
+  events: eventWithTime[];
+};
+
+function useCurrentTime(callback: () => number) {
+  const [currentTime, setCurrentTime] = useState(0);
+  useRAF(() => setCurrentTime(callback));
+  return currentTime;
+}
+
+export function Provider({children, events}: React.PropsWithChildren<Props>) {
+  const theme = useTheme();
+  const oldEvents = usePrevious(events);
+  const replayerRef = useRef<Replayer>(null);
+  const [dimensions, setDimensions] = useState<Dimensions>({height: 0, width: 0});
+  const [isPlaying, setIsPlaying] = useState(false);
+  const [skipInactive, setSkipInactive] = useState(false);
+  const [speed, setSpeedState] = useState(1);
+
+  const forceDimensions = dimension => {
+    setDimensions(dimension as Dimensions);
+  };
+  const setPlayingFalse = () => {
+    setIsPlaying(false);
+  };
+
+  const cleanupListeners = () => {
+    replayerRef.current?.off(ReplayerEvents.Resize, forceDimensions);
+    replayerRef.current?.off(ReplayerEvents.Finish, setPlayingFalse);
+  };
+
+  const initRoot = (root: RootElem) => {
+    if (events === undefined) {
+      return;
+    }
+
+    if (root === null) {
+      return;
+    }
+
+    if (replayerRef.current) {
+      if (events === oldEvents) {
+        // Already have a player for these events, the parent node must've re-rendered
+        return;
+      }
+
+      // We have new events, need to clear out the old iframe because a new
+      // `Replayer` instance is about to be created
+      while (root.firstChild) {
+        root.removeChild(root.firstChild);
+      }
+      cleanupListeners();
+    }
+
+    // eslint-disable-next-line no-new
+    const inst = new Replayer(events, {
+      root,
+      // blockClass: 'rr-block',
+      // liveMode: false,
+      // triggerFocus: false,
+      mouseTail: {
+        duration: 0.75 * 1000,
+        lineCap: 'round',
+        lineWidth: 2,
+        strokeStyle: theme.purple200,
+      },
+      // unpackFn: _ => _,
+      // plugins: [],
+    });
+
+    inst.on(ReplayerEvents.Resize, forceDimensions);
+    inst.on(ReplayerEvents.Finish, setPlayingFalse);
+
+    // `.current` is marked as readonly, but it's safe to set the value from
+    // inside a `useEffect` hook.
+    // See: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
+    // @ts-expect-error
+    replayerRef.current = inst;
+  };
+
+  useEffect(() => {
+    if (replayerRef.current && events) {
+      initRoot(replayerRef.current.wrapper.parentElement as RootElem);
+    }
+    return cleanupListeners;
+  }, [replayerRef.current, events]);
+
+  const getCurrentTime = useCallback(
+    () => (replayerRef.current ? Math.max(replayerRef.current.getCurrentTime(), 0) : 0),
+    [replayerRef.current]
+  );
+
+  const setCurrentTime = useCallback(
+    (time: number) => {
+      const replayer = replayerRef.current;
+      if (!replayer) {
+        return;
+      }
+
+      // TODO: it might be nice to always just pause() here
+      // Why? People can drag the scrobber, or click 'back 10s' and then be in a
+      // paused state to inspect things.
+      if (isPlaying) {
+        replayer.play(time);
+        setIsPlaying(true);
+      } else {
+        replayer.pause(time);
+        setIsPlaying(false);
+      }
+    },
+    [replayerRef.current, isPlaying]
+  );
+
+  const setSpeed = useCallback(
+    (newSpeed: number) => {
+      const replayer = replayerRef.current;
+      if (!replayer) {
+        return;
+      }
+      if (isPlaying) {
+        replayer.pause();
+        replayer.setConfig({speed: newSpeed});
+        replayer.play(getCurrentTime());
+      } else {
+        replayer.setConfig({speed: newSpeed});
+      }
+      setSpeedState(newSpeed);
+    },
+    [replayerRef.current, isPlaying]
+  );
+
+  const togglePlayPause = useCallback(
+    (play: boolean) => {
+      const replayer = replayerRef.current;
+      if (!replayer) {
+        return;
+      }
+
+      if (play) {
+        replayer.play(getCurrentTime());
+      } else {
+        replayer.pause(getCurrentTime());
+      }
+      setIsPlaying(play);
+    },
+    [replayerRef.current]
+  );
+
+  const toggleSkipInactive = useCallback(
+    (skip: boolean) => {
+      const replayer = replayerRef.current;
+      if (!replayer) {
+        return;
+      }
+      if (skip !== replayer.config.skipInactive) {
+        replayer.setConfig({skipInactive: skip});
+      }
+      setSkipInactive(skip);
+    },
+    [replayerRef.current]
+  );
+
+  const currentTime = useCurrentTime(getCurrentTime);
+
+  return (
+    <ReplayPlayerContext.Provider
+      value={{
+        currentTime,
+        dimensions,
+        duration: replayerRef.current?.getMetaData().totalTime,
+        events,
+        initRoot,
+        isPlaying,
+        setCurrentTime,
+        setSpeed,
+        skipInactive,
+        speed,
+        togglePlayPause,
+        toggleSkipInactive,
+      }}
+    >
+      {children}
+    </ReplayPlayerContext.Provider>
+  );
+}
+
+export const Consumer = ReplayPlayerContext.Consumer;

+ 194 - 0
static/app/components/replays/replayController.tsx

@@ -0,0 +1,194 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import BooleanField from 'sentry/components/forms/booleanField';
+import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
+import {Panel, PanelBody} from 'sentry/components/panels';
+import {Consumer as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+import {IconPause, IconPlay, IconRefresh, IconResize} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+
+import {formatTime} from './utils';
+
+const SECOND = 1000;
+
+interface ReplayControllerProps {
+  onFullscreen?: () => void;
+  speedOptions?: number[];
+}
+
+interface ControlsProps extends Required<ReplayControllerProps> {
+  currentTime: number;
+  duration: number | undefined;
+  isPlaying: boolean;
+  isSkippingInactive: boolean;
+  setCurrentTime: (time: number) => void;
+  setSpeed: (value: number) => void;
+  speed: number;
+  togglePlayPause: (play: boolean) => void;
+  toggleSkipInactive: (skip: boolean) => void;
+}
+
+const ReplayControls = ({
+  currentTime,
+  duration,
+  isPlaying,
+  isSkippingInactive,
+  onFullscreen,
+  setCurrentTime,
+  setSpeed,
+  speed,
+  speedOptions,
+  togglePlayPause,
+  toggleSkipInactive,
+}: ControlsProps) => {
+  return (
+    <Column>
+      <TimelineRange
+        data-test-id="replay-timeline-range"
+        name="replay-timeline"
+        min={0}
+        max={duration}
+        value={Math.round(currentTime)}
+        onChange={value => setCurrentTime(value || 0)}
+        showLabel={false}
+      />
+
+      <ButtonGrid>
+        <ButtonBar merged>
+          <Button
+            data-test-id="replay-back-10s"
+            size="xsmall"
+            title={t('Go back 10 seconds')}
+            icon={<IconRefresh color="gray500" size="sm" />}
+            onClick={() => setCurrentTime(currentTime - 10 * SECOND)}
+            aria-label={t('Go back 10 seconds')}
+          />
+          <Button
+            data-test-id="replay-play-pause"
+            size="xsmall"
+            title={isPlaying ? t('Pause the Replay') : t('Play the Replay')}
+            icon={isPlaying ? <IconPause size="sm" /> : <IconPlay size="sm" />}
+            onClick={() => togglePlayPause(!isPlaying)}
+            aria-label={isPlaying ? t('Pause the Replay') : t('Play the Replay')}
+          />
+          <Button
+            data-test-id="replay-forward-10s"
+            size="xsmall"
+            title={t('Go forward 10 seconds')}
+            icon={<IconRefresh color="gray500" size="sm" />}
+            onClick={() => setCurrentTime(currentTime + 10 * SECOND)}
+            aria-label={t('Go forward 10 seconds')}
+          />
+        </ButtonBar>
+        <span>
+          {formatTime(currentTime)} / {duration ? formatTime(duration) : '??:??'}
+        </span>
+
+        <RightLeftBooleanField
+          data-test-id="replay-skip-inactive"
+          name="skip-inactive"
+          label={t('Skip to events')}
+          onChange={() => toggleSkipInactive(!isSkippingInactive)}
+          inline={false}
+          stacked
+          hideControlState
+          value={isSkippingInactive}
+        />
+        <ButtonBar active={String(speed)} merged>
+          {speedOptions.map(opt => (
+            <Button
+              key={opt}
+              size="xsmall"
+              barId={String(opt)}
+              onClick={() => setSpeed(opt)}
+              title={t('Set playback speed to %s', `${opt}x`)}
+            >
+              {opt}x
+            </Button>
+          ))}
+        </ButtonBar>
+
+        <Button
+          data-test-id="replay-fullscreen"
+          size="xsmall"
+          title={t('View in full screen')}
+          icon={<IconResize size="sm" />}
+          onClick={onFullscreen}
+          aria-label={t('View in full screen')}
+        />
+      </ButtonGrid>
+    </Column>
+  );
+};
+
+const Column = styled('div')`
+  display: grid;
+  flex-direction: column;
+`;
+
+const ButtonGrid = styled('div')`
+  display: grid;
+  grid-column-gap: ${space(1)};
+  grid-template-columns: max-content auto max-content max-content max-content;
+  align-items: center;
+`;
+
+const TimelineRange = styled(RangeSlider)`
+  flex-grow: 1;
+  margin-top: ${space(1)};
+`;
+
+const RightLeftBooleanField = styled(BooleanField)`
+  flex-direction: row;
+  column-gap: ${space(0.5)};
+  align-items: center;
+  padding-bottom: 0;
+
+  label {
+    /* Align the label with the center of the checkbox */
+    margin-bottom: 0;
+  }
+`;
+
+export default function ReplayController({
+  onFullscreen = () => {},
+  speedOptions = [0.5, 1, 2, 4],
+}: ReplayControllerProps) {
+  return (
+    <ReplayContextProvider>
+      {({
+        currentTime,
+        duration,
+        isPlaying,
+        setCurrentTime,
+        setSpeed,
+        skipInactive,
+        speed,
+        togglePlayPause,
+        toggleSkipInactive,
+      }) => (
+        <Panel>
+          <PanelBody withPadding>
+            <ReplayControls
+              currentTime={currentTime}
+              duration={duration}
+              isPlaying={isPlaying}
+              isSkippingInactive={skipInactive}
+              onFullscreen={onFullscreen}
+              setCurrentTime={setCurrentTime}
+              setSpeed={setSpeed}
+              speed={speed}
+              speedOptions={speedOptions}
+              togglePlayPause={togglePlayPause}
+              toggleSkipInactive={toggleSkipInactive}
+            />
+          </PanelBody>
+        </Panel>
+      )}
+    </ReplayContextProvider>
+  );
+}

+ 185 - 0
static/app/components/replays/replayPlayer.tsx

@@ -0,0 +1,185 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {useResizeObserver} from '@react-aria/utils';
+
+import {Panel} from 'sentry/components/panels';
+import {Consumer as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+
+interface Props {
+  className?: string;
+}
+
+type Dimensions = {height: number; width: number};
+type RootElem = null | HTMLDivElement;
+
+type RootProps = {
+  initRoot: (root: RootElem) => void;
+  videoDimensions: Dimensions;
+  className?: string;
+};
+
+function BasePlayerRoot({className, initRoot, videoDimensions}: RootProps) {
+  const windowEl = useRef<HTMLDivElement>(null);
+  const viewEl = useRef<HTMLDivElement>(null);
+
+  const [windowDimensions, setWindowDimensions] = useState<Dimensions>();
+
+  // Create the `rrweb` instance which creates an iframe inside `viewEl`
+  useEffect(() => initRoot(viewEl.current), [viewEl.current]);
+
+  // Read the initial width & height where the player will be inserted, this is
+  // so we can shrink the video into the available space.
+  // If the size of the container changes, we can re-calculate the scaling factor
+  const updateWindowDimensions = useCallback(
+    () =>
+      setWindowDimensions({
+        width: windowEl.current?.clientWidth,
+        height: windowEl.current?.clientHeight,
+      } as Dimensions),
+    [windowEl.current]
+  );
+  useResizeObserver({ref: windowEl, onResize: updateWindowDimensions});
+  // If your browser doesn't have ResizeObserver then set the size once.
+  useEffect(() => {
+    if (typeof window.ResizeObserver !== 'undefined') {
+      return;
+    }
+    updateWindowDimensions();
+  }, [updateWindowDimensions]);
+
+  // Update the scale of the view whenever dimensions have changed.
+  useEffect(() => {
+    if (viewEl.current) {
+      const scale = Math.min((windowDimensions?.width || 0) / videoDimensions.width, 1);
+      if (scale) {
+        viewEl.current.style.transform = `scale(${scale})`;
+        viewEl.current.style.width = `${videoDimensions.width * scale}px`;
+        viewEl.current.style.height = `${videoDimensions.height * scale}px`;
+      }
+    }
+  }, [windowDimensions, videoDimensions]);
+
+  return (
+    <div ref={windowEl} data-test-id="replay-window">
+      <div ref={viewEl} data-test-id="replay-view" className={className} />
+    </div>
+  );
+}
+
+// Base styles, to make the player work
+const PlayerRoot = styled(BasePlayerRoot)`
+  /* Make sure the replayer fits inside it's container */
+  transform-origin: top left;
+
+  /* Fix the replayer layout so layers are stacked properly */
+  .replayer-wrapper > .replayer-mouse-tail {
+    position: absolute;
+    pointer-events: none;
+  }
+
+  /* Override default user-agent styles */
+  .replayer-wrapper > iframe {
+    border: none;
+  }
+`;
+
+const PlayerPanel = styled(Panel)`
+  iframe {
+    border-radius: ${p => p.theme.borderRadius};
+  }
+`;
+
+// Sentry-specific styles for the player.
+// The elements we have to work with are:
+// ```css
+// div.replayer-wrapper {}
+// div.replayer-wrapper > div.replayer-mouse {}
+// div.replayer-wrapper > canvas.replayer-mouse-tail {}
+// div.replayer-wrapper > iframe {}
+// ```
+// The mouse-tail is also configured for color/size in `app/components/replays/replayContext.tsx`
+const SentryPlayerRoot = styled(PlayerRoot)`
+  .replayer-mouse {
+    position: absolute;
+    width: 32px;
+    height: 32px;
+    transition: left 0.05s linear, top 0.05s linear;
+    background-size: contain;
+    background-repeat: no-repeat;
+    background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTkiIHZpZXdCb3g9IjAgMCAxMiAxOSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMTZWMEwxMS42IDExLjZINC44TDQuNCAxMS43TDAgMTZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNOS4xIDE2LjdMNS41IDE4LjJMMC43OTk5OTkgNy4xTDQuNSA1LjZMOS4xIDE2LjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNC42NzQ1MSA4LjYxODUxTDIuODMwMzEgOS4zOTI3MUw1LjkyNzExIDE2Ljc2OTVMNy43NzEzMSAxNS45OTUzTDQuNjc0NTEgOC42MTg1MVoiIGZpbGw9ImJsYWNrIi8+CjxwYXRoIGQ9Ik0xIDIuNFYxMy42TDQgMTAuN0w0LjQgMTAuNkg5LjJMMSAyLjRaIiBmaWxsPSJibGFjayIvPgo8L3N2Zz4K');
+    border-color: transparent;
+  }
+  .replayer-mouse:after {
+    content: '';
+    display: inline-block;
+    width: 32px;
+    height: 32px;
+    background: ${p => p.theme.purple300};
+    border-radius: 100%;
+    transform: translate(-50%, -50%);
+    opacity: 0.3;
+  }
+  .replayer-mouse.active:after {
+    animation: click 0.2s ease-in-out 1;
+  }
+  .replayer-mouse.touch-device {
+    background-image: none;
+    width: 70px;
+    height: 70px;
+    border-radius: 100%;
+    margin-left: -37px;
+    margin-top: -37px;
+    border: 4px solid rgba(73, 80, 246, 0);
+    transition: left 0s linear, top 0s linear, border-color 0.2s ease-in-out;
+  }
+  .replayer-mouse.touch-device.touch-active {
+    border-color: ${p => p.theme.purple200};
+    transition: left 0.25s linear, top 0.25s linear, border-color 0.2s ease-in-out;
+  }
+  .replayer-mouse.touch-device:after {
+    opacity: 0;
+  }
+  .replayer-mouse.touch-device.active:after {
+    animation: touch-click 0.2s ease-in-out 1;
+  }
+  @keyframes click {
+    0% {
+      opacity: 0.3;
+      width: 20px;
+      height: 20px;
+    }
+    50% {
+      opacity: 0.5;
+      width: 10px;
+      height: 10px;
+    }
+  }
+  @keyframes touch-click {
+    0% {
+      opacity: 0;
+      width: 20px;
+      height: 20px;
+    }
+    50% {
+      opacity: 0.5;
+      width: 10px;
+      height: 10px;
+    }
+  }
+`;
+
+export default function ReplayPlayer({className}: Props) {
+  return (
+    <ReplayContextProvider>
+      {({initRoot, dimensions}) => (
+        <PlayerPanel>
+          <SentryPlayerRoot
+            className={className}
+            initRoot={initRoot}
+            videoDimensions={dimensions}
+          />
+        </PlayerPanel>
+      )}
+    </ReplayContextProvider>
+  );
+}

+ 9 - 0
static/app/components/replays/useRAF.tsx

@@ -0,0 +1,9 @@
+import {useEffect} from 'react';
+
+// TODO: move into app/utils/*
+export default function useRAF(callback: () => unknown) {
+  useEffect(() => {
+    const timer = window.requestAnimationFrame(callback);
+    return () => window.cancelAnimationFrame(timer);
+  }, [callback]);
+}

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