Browse Source

ref(frontend): Convert DebugMeta to FC (#75379)

To change this component for some issue details work, I needed to either
add a prop to get a hooks value, or change it to a functional component.
Way more work this way but I think its a bit better. Few notes:

- Still uses previous version of list virtualizer
- No longer uses query params for managing modal values
Leander Rodrigues 7 months ago
parent
commit
6a229a41e9

+ 0 - 1
static/app/components/events/eventEntry.tsx

@@ -122,7 +122,6 @@ function EventEntryContent({
           event={event}
           projectSlug={projectSlug}
           groupId={group?.id}
-          organization={organization as Organization}
           data={entry.data}
         />
       );

+ 104 - 18
static/app/components/events/interfaces/debugMeta/index.spec.tsx

@@ -1,42 +1,128 @@
-import {Fragment} from 'react';
 import {EventFixture} from 'sentry-fixture/event';
 import {EntryDebugMetaFixture} from 'sentry-fixture/eventEntry';
+import {ImageFixture} from 'sentry-fixture/image';
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+} from 'sentry-test/reactTestingLibrary';
 
 import {DebugMeta} from 'sentry/components/events/interfaces/debugMeta';
-import {getFileName} from 'sentry/components/events/interfaces/debugMeta/utils';
-import GlobalModal from 'sentry/components/globalModal';
+import {ImageStatus} from 'sentry/types/debugImage';
 
 describe('DebugMeta', function () {
   it('opens details modal', async function () {
     const eventEntryDebugMeta = EntryDebugMetaFixture();
     const event = EventFixture({entries: [eventEntryDebugMeta]});
-    const {organization, project, router} = initializeOrg();
-    const routerProps = {router, location: router.location};
+    const {organization, project} = initializeOrg();
+    const image = eventEntryDebugMeta.data.images[0];
+    const mockGetDebug = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/files/dsyms/?debug_id=${image?.debug_id}`,
+      method: 'GET',
+      body: [],
+    });
 
     render(
-      <Fragment>
-        <GlobalModal />
-        <DebugMeta
-          organization={organization}
-          projectSlug={project.slug}
-          event={event}
-          data={eventEntryDebugMeta.data}
-          {...routerProps}
-        />
-      </Fragment>
+      <DebugMeta
+        projectSlug={project.slug}
+        event={event}
+        data={eventEntryDebugMeta.data}
+      />,
+      {organization}
     );
+    renderGlobalModal();
 
-    await screen.findByRole('heading', {name: 'Images Loaded'});
+    screen.getByRole('heading', {name: 'Images Loaded'});
+    const imageName = image?.debug_file as string;
+    expect(screen.queryByText(imageName)).not.toBeInTheDocument();
 
     await userEvent.click(screen.getByRole('button', {name: 'Show Details'}));
+    expect(screen.getByText('Ok')).toBeInTheDocument();
+    expect(screen.getByText(imageName)).toBeInTheDocument();
+    expect(screen.getByText('Symbolication')).toBeInTheDocument();
+    expect(mockGetDebug).not.toHaveBeenCalled();
 
+    const codeFile = image?.code_file as string;
+    expect(screen.queryByText(codeFile)).not.toBeInTheDocument();
     await userEvent.click(screen.getByRole('button', {name: 'View'}));
+    expect(screen.getByText(codeFile)).toBeInTheDocument();
+    expect(mockGetDebug).toHaveBeenCalled();
+  });
+
+  it('searches image contents', async function () {
+    const eventEntryDebugMeta = EntryDebugMetaFixture();
+    const event = EventFixture({entries: [eventEntryDebugMeta]});
+    const {organization, project} = initializeOrg();
+    const image = eventEntryDebugMeta.data.images[0];
+
+    render(
+      <DebugMeta
+        projectSlug={project.slug}
+        event={event}
+        data={eventEntryDebugMeta.data}
+      />,
+      {organization}
+    );
+    const imageName = image?.debug_file as string;
+    const codeFile = image?.code_file as string;
 
+    screen.getByRole('heading', {name: 'Images Loaded'});
+    await userEvent.click(screen.getByRole('button', {name: 'Show Details'}));
+    const imageNode = screen.getByText(imageName);
+    expect(imageNode).toBeInTheDocument();
+
+    const searchBar = screen.getByRole('textbox');
+    await userEvent.type(searchBar, 'some jibberish');
+    expect(screen.queryByText(imageName)).not.toBeInTheDocument();
     expect(
-      await screen.findByText(getFileName(eventEntryDebugMeta.data.images[0]?.code_file)!)
+      screen.getByText('Sorry, no images match your search query')
     ).toBeInTheDocument();
+    await userEvent.clear(searchBar);
+    expect(screen.getByText(imageName)).toBeInTheDocument();
+    await userEvent.type(searchBar, codeFile);
+    expect(screen.getByText(imageName)).toBeInTheDocument();
+  });
+
+  it('filters images', async function () {
+    const firstImage = ImageFixture();
+    const secondImage = {
+      ...ImageFixture(),
+      debug_status: ImageStatus.MISSING,
+      debug_file: 'test_file',
+      code_file: '/Users/foo/Coding/sentry-native/build/./test_file',
+    };
+    const eventEntryDebugMeta = {
+      ...EntryDebugMetaFixture(),
+      data: {
+        images: [firstImage, secondImage],
+      },
+    };
+
+    const event = EventFixture({entries: [eventEntryDebugMeta]});
+    const {organization, project} = initializeOrg();
+
+    render(
+      <DebugMeta
+        projectSlug={project.slug}
+        event={event}
+        data={eventEntryDebugMeta.data}
+      />,
+      {organization}
+    );
+
+    screen.getByRole('heading', {name: 'Images Loaded'});
+    await userEvent.click(screen.getByRole('button', {name: 'Show Details'}));
+    expect(screen.getByText(firstImage?.debug_file as string)).toBeInTheDocument();
+    expect(screen.getByText(secondImage?.debug_file as string)).toBeInTheDocument();
+
+    const filterButton = screen.getByRole('button', {name: '2 Active Filters'});
+    expect(filterButton).toBeInTheDocument();
+    await userEvent.click(filterButton);
+    await userEvent.click(screen.getByRole('option', {name: 'Missing'}));
+    expect(screen.getByText(firstImage?.debug_file as string)).toBeInTheDocument();
+    expect(screen.queryByText(secondImage?.debug_file as string)).not.toBeInTheDocument();
   });
 });

+ 273 - 422
static/app/components/events/interfaces/debugMeta/index.tsx

@@ -1,5 +1,4 @@
-import {createRef, Fragment, PureComponent} from 'react';
-import type {WithRouterProps} from 'react-router';
+import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
 import type {ListRowProps} from 'react-virtualized';
 import {AutoSizer, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
 import styled from '@emotion/styled';
@@ -7,6 +6,10 @@ import styled from '@emotion/styled';
 import {openModal, openReprocessEventModal} from 'sentry/actionCreators/modal';
 import {Button} from 'sentry/components/button';
 import type {SelectOption, SelectSection} from 'sentry/components/compactSelect';
+import {
+  DebugImageDetails,
+  modalCss,
+} from 'sentry/components/events/interfaces/debugMeta/debugImageDetails';
 import {getImageRange, parseAddress} from 'sentry/components/events/interfaces/utils';
 import {PanelTable} from 'sentry/components/panels/panelTable';
 import {t} from 'sentry/locale';
@@ -16,14 +19,13 @@ import type {Image} from 'sentry/types/debugImage';
 import {ImageStatus} from 'sentry/types/debugImage';
 import type {Event} from 'sentry/types/event';
 import type {Group} from 'sentry/types/group';
-import type {Organization} from 'sentry/types/organization';
 import type {Project} from 'sentry/types/project';
 import {defined} from 'sentry/utils';
-// eslint-disable-next-line no-restricted-imports
-import withSentryRouter from 'sentry/utils/withSentryRouter';
+import useOrganization from 'sentry/utils/useOrganization';
 import SectionToggleButton from 'sentry/views/issueDetails/sectionToggleButton';
 import {FoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection';
 import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
+import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils';
 
 import SearchBarAction from '../searchBarAction';
 
@@ -40,261 +42,107 @@ import {
 
 const IMAGE_INFO_UNAVAILABLE = '-1';
 
-type DefaultProps = {
-  data: {
-    images: Array<Image | null>;
-  };
-};
-
 type Images = Array<React.ComponentProps<typeof DebugImage>['image']>;
 
-type Props = DefaultProps &
-  WithRouterProps & {
-    event: Event;
-    organization: Organization;
-    projectSlug: Project['slug'];
-    groupId?: Group['id'];
+interface DebugMetaProps {
+  data: {
+    images: Array<Image | null>;
   };
+  event: Event;
+  projectSlug: Project['slug'];
+  groupId?: Group['id'];
+}
 
-type State = {
+interface FilterState {
+  allImages: Images;
   filterOptions: SelectSection<string>[];
   filterSelections: SelectOption<string>[];
-  filteredImages: Images;
-  filteredImagesByFilter: Images;
-  filteredImagesBySearch: Images;
-  isOpen: boolean;
-  scrollbarWidth: number;
-  searchTerm: string;
-  panelTableHeight?: number;
-};
+}
 
 const cache = new CellMeasurerCache({
   fixedWidth: true,
   defaultHeight: 81,
 });
 
-class DebugMetaWithRouter extends PureComponent<Props, State> {
-  static defaultProps: DefaultProps = {
-    data: {images: []},
-  };
-
-  state: State = {
-    searchTerm: '',
-    scrollbarWidth: 0,
-    isOpen: false,
-    filterOptions: [],
-    filterSelections: [],
-    filteredImages: [],
-    filteredImagesByFilter: [],
-    filteredImagesBySearch: [],
-  };
-
-  componentDidMount() {
-    this.unsubscribeFromDebugMetaStore = DebugMetaStore.listen(
-      this.onDebugMetaStoreChange,
-      undefined
-    );
-
-    cache.clearAll();
-    this.getRelevantImages();
-    this.openImageDetailsModal();
-  }
-
-  componentDidUpdate(prevProps: Props, prevState: State) {
-    if (
-      this.state.isOpen ||
-      (prevState.filteredImages.length === 0 && this.state.filteredImages.length > 0)
-    ) {
-      this.getPanelBodyHeight();
-    }
-
-    this.openImageDetailsModal();
-
-    if (this.props.event?.id !== prevProps.event?.id) {
-      this.getRelevantImages();
-      this.updateGrid();
-    }
-  }
-
-  componentWillUnmount() {
-    if (this.unsubscribeFromDebugMetaStore) {
-      this.unsubscribeFromDebugMetaStore();
-    }
-  }
-
-  unsubscribeFromDebugMetaStore: any;
-
-  panelTableRef = createRef<HTMLDivElement>();
-  listRef: List | null = null;
-
-  onDebugMetaStoreChange = (store: {filter: string}) => {
-    const {searchTerm} = this.state;
-
-    if (store.filter !== searchTerm) {
-      this.setState(
-        {searchTerm: store.filter, isOpen: true},
-        this.filterImagesBySearchTerm
-      );
-    }
-  };
-
-  getScrollbarWidth() {
-    const panelTableWidth = this.panelTableRef?.current?.clientWidth ?? 0;
-
-    const gridInnerWidth =
-      this.panelTableRef?.current?.querySelector(
-        '.ReactVirtualized__Grid__innerScrollContainer'
-      )?.clientWidth ?? 0;
-
-    const scrollbarWidth = panelTableWidth - gridInnerWidth;
-
-    if (scrollbarWidth !== this.state.scrollbarWidth) {
-      this.setState({scrollbarWidth});
-    }
-  }
-
-  updateGrid = () => {
-    if (this.listRef) {
-      cache.clearAll();
-      this.listRef.forceUpdateGrid();
-      this.getScrollbarWidth();
-    }
-  };
-
-  isValidImage(image: Image | null) {
-    // in particular proguard images do not have a code file, skip them
-    if (image === null || image.code_file === null || image.type === 'proguard') {
-      return false;
-    }
+function applyImageFilters(
+  images: Images,
+  filterSelections: SelectOption<string>[],
+  searchTerm: string
+) {
+  const selections = new Set(filterSelections.map(option => option.value));
 
-    if (getFileName(image.code_file) === 'dyld_sim') {
-      // this is only for simulator builds
-      return false;
-    }
+  let filteredImages = images;
 
-    return true;
+  if (selections.size > 0) {
+    filteredImages = filteredImages.filter(image => selections.has(image.status));
   }
 
-  filterImage(image: Image, searchTerm: string) {
-    // When searching for an address, check for the address range of the image
-    // instead of an exact match.  Note that images cannot be found by index
-    // if they are at 0x0.  For those relative addressing has to be used.
-    if (searchTerm.indexOf('0x') === 0) {
-      const needle = parseAddress(searchTerm);
-      if (needle > 0 && image.image_addr !== '0x0') {
-        const [startAddress, endAddress] = getImageRange(image as any); // TODO(PRISCILA): remove any
-        return needle >= startAddress && needle < endAddress;
+  if (searchTerm !== '') {
+    filteredImages = filteredImages.filter(image => {
+      const term = searchTerm.toLowerCase();
+      // When searching for an address, check for the address range of the image
+      // instead of an exact match.  Note that images cannot be found by index
+      // if they are at 0x0.  For those relative addressing has to be used.
+      if (term.indexOf('0x') === 0) {
+        const needle = parseAddress(term);
+        if (needle > 0 && image.image_addr !== '0x0') {
+          const [startAddress, endAddress] = getImageRange(image as any); // TODO(PRISCILA): remove any
+          return needle >= startAddress && needle < endAddress;
+        }
       }
-    }
 
-    // the searchTerm ending at "!" is the end of the ID search.
-    const relMatch = searchTerm.match(/^\s*(.*?)!/); // debug_id!address
-    const idSearchTerm = normalizeId(relMatch?.[1] || searchTerm);
-
-    return (
-      // Prefix match for identifiers
-      normalizeId(image.code_id).indexOf(idSearchTerm) === 0 ||
-      normalizeId(image.debug_id).indexOf(idSearchTerm) === 0 ||
-      // Any match for file paths
-      (image.code_file?.toLowerCase() || '').includes(searchTerm) ||
-      (image.debug_file?.toLowerCase() || '').includes(searchTerm)
-    );
-  }
-
-  filterImagesBySearchTerm() {
-    const {filteredImages, filterSelections, searchTerm} = this.state;
-    const filteredImagesBySearch = filteredImages.filter(image =>
-      this.filterImage(image, searchTerm.toLowerCase())
-    );
-
-    const filteredImagesByFilter = this.getFilteredImagesByFilter(
-      filteredImagesBySearch,
-      filterSelections
-    );
-
-    this.setState(
-      {
-        filteredImagesBySearch,
-        filteredImagesByFilter,
-      },
-      this.updateGrid
-    );
+      // the searchTerm ending at "!" is the end of the ID search.
+      const relMatch = term.match(/^\s*(.*?)!/); // debug_id!address
+      const idSearchTerm = normalizeId(relMatch?.[1] || term);
+
+      return (
+        // Prefix match for identifiers
+        normalizeId(image.code_id).indexOf(idSearchTerm) === 0 ||
+        normalizeId(image.debug_id).indexOf(idSearchTerm) === 0 ||
+        // Any match for file paths
+        (image.code_file?.toLowerCase() || '').includes(term) ||
+        (image.debug_file?.toLowerCase() || '').includes(term)
+      );
+    });
   }
 
-  openImageDetailsModal = async () => {
-    const {filteredImages} = this.state;
-
-    if (!filteredImages.length) {
-      return;
-    }
-
-    const {location, organization, projectSlug, groupId, event} = this.props;
-    const {query} = location;
-
-    const {imageCodeId, imageDebugId} = query;
-
-    if (!imageCodeId && !imageDebugId) {
-      return;
-    }
-
-    const image =
-      imageCodeId !== IMAGE_INFO_UNAVAILABLE || imageDebugId !== IMAGE_INFO_UNAVAILABLE
-        ? filteredImages.find(
-            ({code_id, debug_id}) => code_id === imageCodeId || debug_id === imageDebugId
-          )
-        : undefined;
-
-    const mod = await import(
-      'sentry/components/events/interfaces/debugMeta/debugImageDetails'
-    );
-
-    const {DebugImageDetails, modalCss} = mod;
-
-    openModal(
-      deps => (
-        <DebugImageDetails
-          {...deps}
-          image={image}
-          organization={organization}
-          projSlug={projectSlug}
-          event={event}
-          onReprocessEvent={
-            defined(groupId) ? this.handleReprocessEvent(groupId) : undefined
-          }
-        />
-      ),
-      {
-        modalCss,
-        onClose: this.handleCloseImageDetailsModal,
-      }
-    );
-  };
-
-  toggleImagesLoaded = () => {
-    this.setState(state => ({
-      isOpen: !state.isOpen,
-    }));
-  };
-
-  getPanelBodyHeight() {
-    const panelTableHeight = this.panelTableRef?.current?.offsetHeight;
-
-    if (!panelTableHeight) {
-      return;
-    }
-
-    this.setState({panelTableHeight});
-  }
+  return filteredImages;
+}
 
-  getRelevantImages() {
-    const {data} = this.props;
+export function DebugMeta({data, projectSlug, groupId, event}: DebugMetaProps) {
+  const organization = useOrganization();
+  const listRef = useRef<List>(null);
+  const panelTableRef = useRef<HTMLDivElement>(null);
+  const [filterState, setFilterState] = useState<FilterState>({
+    filterOptions: [],
+    filterSelections: [],
+    allImages: [],
+  });
+  const [searchTerm, setSearchTerm] = useState('');
+  const [scrollbarWidth, setScrollbarWidth] = useState(0);
+  const [isOpen, setIsOpen] = useState(false);
+  const hasStreamlinedUI = useHasStreamlinedUI();
+
+  const getRelevantImages = useCallback(() => {
     const {images} = data;
 
     // There are a bunch of images in debug_meta that are not relevant to this
     // component. Filter those out to reduce the noise. Most importantly, this
     // includes proguard images, which are rendered separately.
 
-    const relevantImages = images.filter(this.isValidImage);
+    const relevantImages = images.filter(image => {
+      // in particular proguard images do not have a code file, skip them
+      if (image === null || image.code_file === null || image.type === 'proguard') {
+        return false;
+      }
+
+      if (getFileName(image.code_file) === 'dyld_sim') {
+        // this is only for simulator builds
+        return false;
+      }
+
+      return true;
+    });
 
     if (!relevantImages.length) {
       return;
@@ -325,114 +173,159 @@ class DebugMetaWithRouter extends PureComponent<Props, State> {
       return true;
     }) as Images;
 
-    const filteredImages = [...usedImages, ...unusedImages];
+    const allImages = [...usedImages, ...unusedImages];
 
-    const filterOptions = this.getFilterOptions(filteredImages);
-    const defaultFilterSelections = (
-      'options' in filterOptions[0] ? filterOptions[0].options : []
-    ).filter(opt => opt.value !== ImageStatus.UNUSED);
-
-    this.setState({
-      filteredImages,
-      filterOptions,
-      filterSelections: defaultFilterSelections,
-      filteredImagesByFilter: this.getFilteredImagesByFilter(
-        filteredImages,
-        defaultFilterSelections
-      ),
-      filteredImagesBySearch: filteredImages,
-    });
-  }
-
-  getFilterOptions(images: Images): SelectSection<string>[] {
-    return [
+    const filterOptions = [
       {
         label: t('Status'),
-        options: [...new Set(images.map(image => image.status))].map(status => ({
+        options: [...new Set(allImages.map(image => image.status))].map(status => ({
           value: status,
           textValue: status,
           label: <Status status={status} />,
         })),
       },
     ];
-  }
-
-  getFilteredImagesByFilter(
-    filteredImages: Images,
-    filterOptions: SelectOption<string>[]
-  ) {
-    const checkedOptions = new Set(filterOptions.map(option => option.value));
 
-    if (![...checkedOptions].length) {
-      return filteredImages;
-    }
+    const defaultFilterSelections = (
+      'options' in filterOptions[0] ? filterOptions[0].options : []
+    ).filter(opt => opt.value !== ImageStatus.UNUSED);
 
-    return filteredImages.filter(image => checkedOptions.has(image.status));
-  }
+    setFilterState({
+      allImages,
+      filterOptions,
+      filterSelections: defaultFilterSelections,
+    });
+  }, [data]);
 
-  handleChangeFilter = (filterSelections: SelectOption<string>[]) => {
-    const {filteredImagesBySearch} = this.state;
-    const filteredImagesByFilter = this.getFilteredImagesByFilter(
-      filteredImagesBySearch,
-      filterSelections
-    );
+  const handleReprocessEvent = useCallback(
+    (id: Group['id']) => {
+      openReprocessEventModal({
+        organization,
+        groupId: id,
+      });
+    },
+    [organization]
+  );
 
-    this.setState({filterSelections, filteredImagesByFilter}, this.updateGrid);
-  };
+  const getScrollbarWidth = useCallback(() => {
+    const panelTableWidth = panelTableRef?.current?.clientWidth ?? 0;
 
-  handleChangeSearchTerm = (searchTerm = '') => {
-    DebugMetaStore.updateFilter(searchTerm);
-  };
+    const gridInnerWidth =
+      panelTableRef?.current?.querySelector(
+        '.ReactVirtualized__Grid__innerScrollContainer'
+      )?.clientWidth ?? 0;
 
-  handleResetFilter = () => {
-    this.setState({filterSelections: []}, this.filterImagesBySearchTerm);
-  };
+    setScrollbarWidth(panelTableWidth - gridInnerWidth);
+  }, [panelTableRef]);
 
-  handleResetSearchBar = () => {
-    this.setState(prevState => ({
-      searchTerm: '',
-      filteredImagesByFilter: prevState.filteredImages,
-      filteredImagesBySearch: prevState.filteredImages,
-    }));
-  };
+  const updateGrid = useCallback(() => {
+    if (listRef.current) {
+      cache.clearAll();
+      listRef.current.forceUpdateGrid();
+      getScrollbarWidth();
+    }
+  }, [listRef, getScrollbarWidth]);
 
-  handleOpenImageDetailsModal = (
-    code_id: Image['code_id'],
-    debug_id: Image['debug_id']
-  ) => {
-    const {location, router} = this.props;
-
-    router.push({
-      ...location,
-      query: {
-        ...location.query,
-        imageCodeId: code_id ?? IMAGE_INFO_UNAVAILABLE,
-        imageDebugId: debug_id ?? IMAGE_INFO_UNAVAILABLE,
-      },
-    });
-  };
+  const getEmptyMessage = useCallback(
+    images => {
+      const {filterSelections} = filterState;
 
-  handleCloseImageDetailsModal = () => {
-    const {location, router} = this.props;
+      if (images.length) {
+        return {};
+      }
 
-    router.push({
-      ...location,
-      query: {...location.query, imageCodeId: undefined, imageDebugId: undefined},
-    });
-  };
+      if (searchTerm && !images.length) {
+        const hasActiveFilter = filterSelections.length > 0;
 
-  handleReprocessEvent = (groupId: Group['id']) => () => {
-    const {organization} = this.props;
-    openReprocessEventModal({
-      organization,
-      groupId,
-      onClose: this.openImageDetailsModal,
-    });
-  };
+        return {
+          emptyMessage: t('Sorry, no images match your search query'),
+          emptyAction: hasActiveFilter ? (
+            <Button
+              onClick={() => setFilterState(fs => ({...fs, filterSelections: []}))}
+              priority="primary"
+            >
+              {t('Reset filter')}
+            </Button>
+          ) : (
+            <Button onClick={() => setSearchTerm('')} priority="primary">
+              {t('Clear search bar')}
+            </Button>
+          ),
+        };
+      }
 
-  renderRow = ({index, key, parent, style}: ListRowProps) => {
-    const {filteredImagesByFilter: images} = this.state;
+      return {
+        emptyMessage: t('There are no images to be displayed'),
+      };
+    },
+    [filterState, searchTerm]
+  );
+
+  const handleOpenImageDetailsModal = useCallback(
+    (imageCodeId: Image['code_id'], imageDebugId: Image['debug_id']) => {
+      const {allImages} = filterState;
+      if (!imageCodeId && !imageDebugId) {
+        return;
+      }
 
+      const image =
+        imageCodeId !== IMAGE_INFO_UNAVAILABLE || imageDebugId !== IMAGE_INFO_UNAVAILABLE
+          ? allImages.find(
+              ({code_id, debug_id}) =>
+                code_id === imageCodeId || debug_id === imageDebugId
+            )
+          : undefined;
+
+      openModal(
+        deps => (
+          <DebugImageDetails
+            {...deps}
+            image={image}
+            organization={organization}
+            projSlug={projectSlug}
+            event={event}
+            onReprocessEvent={
+              defined(groupId) ? () => handleReprocessEvent(groupId) : undefined
+            }
+          />
+        ),
+        {modalCss}
+      );
+    },
+    [filterState, event, groupId, handleReprocessEvent, organization, projectSlug]
+  );
+
+  // This hook replaces the componentDidMount/WillUnmount calls from its class component
+  useEffect(() => {
+    const removeListener = DebugMetaStore.listen((store: {filter: string}) => {
+      setSearchTerm(store.filter);
+      setIsOpen(true);
+    }, undefined);
+    cache.clearAll();
+    getRelevantImages();
+    return () => {
+      removeListener();
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    //  componentDidUpdate
+    getRelevantImages();
+    updateGrid();
+  }, [event, getRelevantImages, updateGrid]);
+
+  useEffect(() => {
+    updateGrid();
+  }, [filterState, updateGrid]);
+
+  function renderRow({
+    index,
+    key,
+    parent,
+    style,
+    images,
+  }: ListRowProps & {images: Images}) {
     return (
       <CellMeasurer
         cache={cache}
@@ -444,38 +337,24 @@ class DebugMetaWithRouter extends PureComponent<Props, State> {
         <DebugImage
           style={style}
           image={images[index]}
-          onOpenImageDetailsModal={this.handleOpenImageDetailsModal}
+          onOpenImageDetailsModal={handleOpenImageDetailsModal}
         />
       </CellMeasurer>
     );
-  };
-
-  renderList() {
-    const {filteredImagesByFilter: images, panelTableHeight} = this.state;
-
-    if (!panelTableHeight) {
-      return images.map((image, index) => (
-        <DebugImage
-          key={index}
-          image={image}
-          onOpenImageDetailsModal={this.handleOpenImageDetailsModal}
-        />
-      ));
-    }
+  }
 
+  function renderList(images: Images) {
     return (
-      <AutoSizer disableHeight onResize={this.updateGrid}>
+      <AutoSizer disableHeight onResize={updateGrid}>
         {({width}) => (
           <StyledList
-            ref={(el: List | null) => {
-              this.listRef = el;
-            }}
+            ref={listRef}
             deferredMeasurementCache={cache}
             height={IMAGE_AND_CANDIDATE_LIST_MAX_HEIGHT}
             overscanRowCount={5}
             rowCount={images.length}
             rowHeight={cache.rowHeight}
-            rowRenderer={this.renderRow}
+            rowRenderer={listRowProps => renderRow({...listRowProps, images})}
             width={width}
             isScrolling={false}
           />
@@ -484,96 +363,68 @@ class DebugMetaWithRouter extends PureComponent<Props, State> {
     );
   }
 
-  getEmptyMessage() {
-    const {searchTerm, filteredImagesByFilter: images, filterSelections} = this.state;
+  const {allImages, filterOptions, filterSelections} = filterState;
 
-    if (images.length) {
-      return {};
-    }
+  const filteredImages = applyImageFilters(allImages, filterSelections, searchTerm);
 
-    if (searchTerm && !images.length) {
-      const hasActiveFilter = filterSelections.length > 0;
+  const {images} = data;
 
-      return {
-        emptyMessage: t('Sorry, no images match your search query'),
-        emptyAction: hasActiveFilter ? (
-          <Button onClick={this.handleResetFilter} priority="primary">
-            {t('Reset filter')}
-          </Button>
-        ) : (
-          <Button onClick={this.handleResetSearchBar} priority="primary">
-            {t('Clear search bar')}
-          </Button>
-        ),
-      };
-    }
-
-    return {
-      emptyMessage: t('There are no images to be displayed'),
-    };
+  if (shouldSkipSection(filteredImages, images)) {
+    return null;
   }
 
-  render() {
-    const {
-      searchTerm,
-      filterOptions,
-      scrollbarWidth,
-      isOpen,
-      filterSelections,
-      filteredImagesByFilter: filteredImages,
-    } = this.state;
-    const {data} = this.props;
-    const {images} = data;
-
-    if (shouldSkipSection(filteredImages, images)) {
-      return null;
-    }
-
-    const showFilters = filterOptions.some(
-      section => 'options' in section && section.options.length > 1
-    );
-
-    const actions = (
-      <SectionToggleButton isExpanded={isOpen} onExpandChange={this.toggleImagesLoaded} />
-    );
-
-    return (
-      <InterimSection
-        type={FoldSectionKey.DEBUGMETA}
-        guideTarget="images-loaded"
-        title={t('Images Loaded')}
-        help={t(
-          'A list of dynamic libraries or shared objects loaded into process memory at the time of the crash. Images contribute application code that is referenced in stack traces.'
-        )}
-        actions={actions}
-      >
-        {isOpen && (
-          <Fragment>
-            <StyledSearchBarAction
-              placeholder={t('Search images loaded')}
-              onChange={value => this.handleChangeSearchTerm(value)}
-              query={searchTerm}
-              filterOptions={showFilters ? filterOptions : undefined}
-              onFilterChange={this.handleChangeFilter}
-              filterSelections={filterSelections}
-            />
-            <StyledPanelTable
-              isEmpty={!filteredImages.length}
-              scrollbarWidth={scrollbarWidth}
-              headers={[t('Status'), t('Image'), t('Processing'), t('Details'), '']}
-              {...this.getEmptyMessage()}
-            >
-              <div ref={this.panelTableRef}>{this.renderList()}</div>
-            </StyledPanelTable>
-          </Fragment>
-        )}
-      </InterimSection>
-    );
-  }
+  const showFilters = filterOptions.some(
+    section => 'options' in section && section.options.length > 1
+  );
+
+  const actions = hasStreamlinedUI ? null : (
+    <SectionToggleButton
+      isExpanded={isOpen}
+      onExpandChange={() => {
+        setIsOpen(open => !open);
+      }}
+    />
+  );
+
+  return (
+    <InterimSection
+      type={FoldSectionKey.DEBUGMETA}
+      guideTarget="images-loaded"
+      title={t('Images Loaded')}
+      help={t(
+        'A list of dynamic libraries or shared objects loaded into process memory at the time of the crash. Images contribute application code that is referenced in stack traces.'
+      )}
+      actions={actions}
+    >
+      {!isOpen ? null : (
+        <Fragment>
+          <StyledSearchBarAction
+            placeholder={t('Search images loaded')}
+            onChange={value => DebugMetaStore.updateFilter(value)}
+            query={searchTerm}
+            filterOptions={showFilters ? filterOptions : undefined}
+            onFilterChange={selections => {
+              setFilterState(fs => ({
+                ...fs,
+                filterSelections: selections,
+              }));
+            }}
+            filterSelections={filterSelections}
+          />
+          <StyledPanelTable
+            isEmpty={!filteredImages.length}
+            scrollbarWidth={scrollbarWidth}
+            headers={[t('Status'), t('Image'), t('Processing'), t('Details'), '']}
+            {...getEmptyMessage(filteredImages)}
+          >
+            <div ref={panelTableRef}>{renderList(filteredImages)}</div>
+          </StyledPanelTable>
+        </Fragment>
+      )}
+    </InterimSection>
+  );
 }
 
-export const DebugMeta = withSentryRouter(DebugMetaWithRouter);
-
 const StyledPanelTable = styled(PanelTable)<{scrollbarWidth?: number}>`
   overflow: hidden;
   > * {