Repository: getsentry/sentry
Pull request #91281: chore(fe): Convert `<ReleasesList />` to an FC
Author: login: leeandher
Labels: name: Scope: Frontend
description: Automatically applied to PRs that change frontend components
Created: 2025-05-08T20:15:56Z | Merged: 2025-05-13T15:12:48Z

It's a pretty big rewrite, but here are the callouts:
- Importing the deprecated ~~`withSentryRouter`~~ `useRouter()` for the inner list component. This is just so we don't have to radically change two components at once, and can focus on the AsyncComponent in this PR
- The `getSelectedProjectSlugs` and `projectHasSession` methods were unused.
- In the test file, the one 'empty state' test has been broken up into 4 to avoid excessive unmount/rerender loops, especially since the new syntax for location doesn't work with `rerender`


For testing this one out, I kinda just clicked around in `dev-ui` and double checked that all the existing behavior was maintained. If you notice anything wrong on the deploy preview let me know :+1:


Diff:
diff --git a/static/app/utils/queryClient.tsx b/static/app/utils/queryClient.tsx
index 8ee8b38c4c2205..7338e6b27ad447 100644
--- a/static/app/utils/queryClient.tsx
+++ b/static/app/utils/queryClient.tsx
@@ -96,7 +96,7 @@ export type UseApiQueryResult<TData, TError> = UseQueryResult<TData, TError> & {
  * Query keys should be an array which include an endpoint URL and options such as query params.
  * This wrapper will execute the request using the query key URL.
  *
- * See https://tanstack.com/query/v4/docs/overview for docs on React Query.
+ * See https://tanstack.com/query/v5/docs/framework/react/overview for docs on React Query.
  *
  * Example usage:
  *
diff --git a/static/app/views/releases/list/index.spec.tsx b/static/app/views/releases/list/index.spec.tsx
index e22e5f3c7ecc03..9691ac74d39625 100644
--- a/static/app/views/releases/list/index.spec.tsx
+++ b/static/app/views/releases/list/index.spec.tsx
@@ -1,9 +1,7 @@
-import {LocationFixture} from 'sentry-fixture/locationFixture';
 import {OrganizationFixture} from 'sentry-fixture/organization';
 import {ProjectFixture} from 'sentry-fixture/project';
 import {ReleaseFixture} from 'sentry-fixture/release';
 
-import {initializeOrg} from 'sentry-test/initializeOrg';
 import {
   act,
   render,
@@ -14,13 +12,15 @@ import {
 } from 'sentry-test/reactTestingLibrary';
 
 import {ReleasesSortOption} from 'sentry/constants/releases';
+import PageFiltersStore from 'sentry/stores/pageFiltersStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import ReleasesList from 'sentry/views/releases/list/';
 import {ReleasesDisplayOption} from 'sentry/views/releases/list/releasesDisplayOptions';
 import {ReleasesStatusOption} from 'sentry/views/releases/list/releasesStatusOptions';
 
 describe('ReleasesList', () => {
-  const {organization, projects, router, routerProps} = initializeOrg();
+  const organization = OrganizationFixture();
+  const projects = [ProjectFixture({features: ['releases']})];
   const semverVersionInfo = {
     buildHash: null,
     description: '1.2.3',
@@ -35,39 +35,21 @@ describe('ReleasesList', () => {
     },
   };
 
-  const props = {
-    ...routerProps,
-    router,
-    organization,
-    selection: {
-      projects: [],
-      environments: [],
-      datetime: {
-        period: '14d',
-        start: null,
-        end: null,
-        utc: null,
-      },
-    },
-    params: {orgId: organization.slug},
-    location: {
-      ...routerProps.location,
-      query: {
-        query: 'derp',
-        sort: ReleasesSortOption.SESSIONS,
-        healthStatsPeriod: '24h',
-        somethingBad: 'XXX',
-        status: ReleasesStatusOption.ACTIVE,
-      },
-    },
-  };
   let endpointMock: jest.Mock;
   let sessionApiMock: jest.Mock;
 
   beforeEach(() => {
     act(() => ProjectsStore.loadInitialData(projects));
+    PageFiltersStore.onInitializeUrlState(
+      {
+        projects: [],
+        environments: [],
+        datetime: {period: null, utc: null, start: null, end: null},
+      },
+      new Set()
+    );
     endpointMock = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/releases/',
+      url: `/organizations/${organization.slug}/releases/`,
       body: [
         ReleaseFixture({
           version: '1.0.0',
@@ -114,9 +96,8 @@ describe('ReleasesList', () => {
   });
 
   it('renders list', async () => {
-    render(<ReleasesList {...props} />, {
-      organization,
-    });
+    PageFiltersStore.updateProjects([-1], null);
+    render(<ReleasesList />, {organization});
     const items = await screen.findAllByTestId('release-panel');
 
     expect(items).toHaveLength(3);
@@ -124,29 +105,20 @@ describe('ReleasesList', () => {
     expect(within(items.at(0)!).getByText('1.0.0')).toBeInTheDocument();
     expect(within(items.at(0)!).getByText('Adoption')).toBeInTheDocument();
     expect(within(items.at(1)!).getByText('1.0.1')).toBeInTheDocument();
-    // Crash free rate loads separately
+
     expect(await within(items.at(1)!).findByText('0%')).toBeInTheDocument();
     expect(within(items.at(2)!).getByText('af4f231ec9a8')).toBeInTheDocument();
     expect(within(items.at(2)!).getByText('Project Name')).toBeInTheDocument();
   });
 
-  it('displays the right empty state', async () => {
-    let location: ReturnType<typeof LocationFixture>;
-
-    const project = ProjectFixture({
-      id: '3',
-      slug: 'test-slug',
-      name: 'test-name',
-      features: ['releases'],
-    });
-    const projectWithouReleases = ProjectFixture({
+  it('displays quickstart when appropriate', async () => {
+    const projectWithoutReleases = ProjectFixture({
       id: '4',
       slug: 'test-slug-2',
       name: 'test-name-2',
       features: [],
     });
-    const org = OrganizationFixture();
-    ProjectsStore.loadInitialData([project, projectWithouReleases]);
+    ProjectsStore.loadInitialData([projectWithoutReleases]);
     MockApiClient.addMockResponse({
       url: '/organizations/org-slug/releases/',
       body: [],
@@ -155,94 +127,105 @@ describe('ReleasesList', () => {
       url: '/organizations/org-slug/sentry-apps/',
       body: [],
     });
-    // does not have releases set up and no releases
-    location = LocationFixture({...routerProps.location, query: {}});
-    const {rerender} = render(
-      <ReleasesList
-        {...props}
-        location={location}
-        selection={{...props.selection, projects: [4]}}
-      />,
-      {
-        organization,
-      }
-    );
-
+    PageFiltersStore.updateProjects([Number(projectWithoutReleases.id)], null);
+    render(<ReleasesList />, {organization});
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
     expect(await screen.findByText('Set up Releases')).toBeInTheDocument();
     expect(screen.queryByTestId('release-panel')).not.toBeInTheDocument();
+  });
 
-    // has releases set up and no releases
-    location = LocationFixture({query: {query: 'abc'}});
-    rerender(
-      <ReleasesList
-        {...props}
-        organization={org}
-        location={location}
-        selection={{...props.selection, projects: [3]}}
-      />
-    );
+  it('displays query empty state', async () => {
+    ProjectsStore.loadInitialData([ProjectFixture({features: ['releases']})]);
+    MockApiClient.addMockResponse({url: '/organizations/org-slug/releases/', body: []});
+    render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {query: 'abc'},
+        },
+      },
+    });
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
     expect(
       await screen.findByText("There are no releases that match: 'abc'.")
     ).toBeInTheDocument();
+  });
 
-    location = LocationFixture({
-      query: {sort: ReleasesSortOption.SESSIONS, statsPeriod: '7d'},
+  it('displays date range empty state', async () => {
+    ProjectsStore.loadInitialData([ProjectFixture({features: ['releases']})]);
+    MockApiClient.addMockResponse({url: '/organizations/org-slug/releases/', body: []});
+    render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {
+            sort: ReleasesSortOption.SESSIONS,
+            statsPeriod: '7d',
+          },
+        },
+      },
     });
-    rerender(
-      <ReleasesList
-        {...props}
-        organization={org}
-        location={location}
-        selection={{...props.selection, projects: [3]}}
-      />
-    );
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
     expect(
       await screen.findByText('There are no releases with data in the last 7 days.')
     ).toBeInTheDocument();
+  });
 
-    location = LocationFixture({
-      query: {sort: ReleasesSortOption.USERS_24_HOURS, statsPeriod: '7d'},
+  it('displays the sorted empty state', async () => {
+    ProjectsStore.loadInitialData([ProjectFixture({features: ['releases']})]);
+    MockApiClient.addMockResponse({url: '/organizations/org-slug/releases/', body: []});
+    const {unmount: unmountUser24Hours} = render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {
+            sort: ReleasesSortOption.USERS_24_HOURS,
+            statsPeriod: '7d',
+          },
+        },
+      },
     });
-    rerender(
-      <ReleasesList
-        {...props}
-        organization={org}
-        location={location}
-        selection={{...props.selection, projects: [3]}}
-      />
-    );
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
     expect(
       await screen.findByText(
         'There are no releases with active user data (users in the last 24 hours).'
       )
     ).toBeInTheDocument();
+    unmountUser24Hours();
 
-    location = LocationFixture({
-      query: {sort: ReleasesSortOption.SESSIONS_24_HOURS, statsPeriod: '7d'},
+    const {unmount: unmountSession24Hours} = render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {
+            sort: ReleasesSortOption.SESSIONS_24_HOURS,
+            statsPeriod: '7d',
+          },
+        },
+      },
     });
-    rerender(
-      <ReleasesList
-        {...props}
-        organization={org}
-        location={location}
-        selection={{...props.selection, projects: [3]}}
-      />
-    );
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
     expect(
       await screen.findByText(
         'There are no releases with active session data (sessions in the last 24 hours).'
       )
     ).toBeInTheDocument();
+    unmountSession24Hours();
 
-    location = LocationFixture({query: {sort: ReleasesSortOption.BUILD}});
-    rerender(
-      <ReleasesList
-        {...props}
-        organization={org}
-        location={location}
-        selection={{...props.selection, projects: [3]}}
-      />
-    );
+    render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {sort: ReleasesSortOption.BUILD},
+        },
+      },
+    });
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
     expect(
       await screen.findByText('There are no releases with semantic versioning.')
     ).toBeInTheDocument();
@@ -258,9 +241,8 @@ describe('ReleasesList', () => {
       statusCode: 400,
     });
 
-    render(<ReleasesList {...props} selection={{...props.selection, projects: [3]}} />, {
-      organization,
-    });
+    PageFiltersStore.updateProjects([3], null);
+    render(<ReleasesList />, {organization});
 
     expect(await screen.findByText(errorMessage)).toBeInTheDocument();
 
@@ -279,15 +261,22 @@ describe('ReleasesList', () => {
       body: [],
     });
 
-    render(<ReleasesList {...props} />, {
+    const {router} = render(<ReleasesList />, {
       organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {query: 'derp'},
+        },
+      },
     });
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
 
     const input = await screen.findByDisplayValue('derp');
     expect(input).toBeInTheDocument();
 
     expect(endpointMock).toHaveBeenCalledWith(
-      '/organizations/org-slug/releases/',
+      `/organizations/${organization.slug}/releases/`,
       expect.objectContaining({
         query: expect.objectContaining({query: 'derp'}),
       })
@@ -296,57 +285,29 @@ describe('ReleasesList', () => {
     await userEvent.clear(input);
     await userEvent.type(input, 'a{enter}');
 
-    expect(router.push).toHaveBeenCalledWith(
-      expect.objectContaining({
-        query: expect.objectContaining({query: 'a'}),
-      })
-    );
+    expect(router.location.query.query).toBe('a');
   });
 
-  it('searches for a release with new searchbar (search-query-builder-releases)', async () => {
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/recent-searches/',
-      method: 'POST',
-      body: [],
-    });
-    render(<ReleasesList {...props} />, {
-      organization: {...organization, features: ['search-query-builder-releases']},
+  it('sorts releases', async () => {
+    const {router} = render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {sort: ReleasesSortOption.SESSIONS},
+        },
+      },
     });
-    const input = await screen.findByDisplayValue('derp');
-    expect(input).toBeInTheDocument();
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
 
     expect(endpointMock).toHaveBeenCalledWith(
-      '/organizations/org-slug/releases/',
+      `/organizations/${organization.slug}/releases/`,
       expect.objectContaining({
-        query: expect.objectContaining({query: 'derp'}),
-      })
-    );
-
-    await userEvent.clear(input);
-    await userEvent.type(input, 'a{enter}');
-
-    expect(router.push).toHaveBeenCalledWith(
-      expect.objectContaining({
-        query: expect.objectContaining({query: 'a'}),
+        query: expect.objectContaining({
+          sort: ReleasesSortOption.SESSIONS,
+        }),
       })
     );
-  });
-
-  it('sorts releases', async () => {
-    render(<ReleasesList {...props} />, {
-      organization,
-    });
-
-    await waitFor(() =>
-      expect(endpointMock).toHaveBeenCalledWith(
-        '/organizations/org-slug/releases/',
-        expect.objectContaining({
-          query: expect.objectContaining({
-            sort: ReleasesSortOption.SESSIONS,
-          }),
-        })
-      )
-    );
 
     await userEvent.click(screen.getByText('Sort By'));
 
@@ -355,39 +316,27 @@ describe('ReleasesList', () => {
 
     await userEvent.click(dateCreatedOption);
 
-    expect(router.push).toHaveBeenCalledWith(
-      expect.objectContaining({
-        query: expect.objectContaining({
-          sort: ReleasesSortOption.DATE,
-        }),
-      })
-    );
+    expect(router.location.query.sort).toBe(ReleasesSortOption.DATE);
   });
 
   it('disables adoption sort when more than one environment is selected', async () => {
-    const adoptionProps = {
-      ...props,
+    PageFiltersStore.updateEnvironments(['a', 'b']);
+    render(<ReleasesList />, {
       organization,
-    };
-    render(
-      <ReleasesList
-        {...adoptionProps}
-        location={{...routerProps.location, query: {sort: ReleasesSortOption.ADOPTION}}}
-        selection={{...props.selection, environments: ['a', 'b']}}
-      />,
-      {
-        organization,
-      }
-    );
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {sort: ReleasesSortOption.ADOPTION},
+        },
+      },
+    });
     const sortDropdown = await screen.findByText('Sort By');
 
     expect(sortDropdown.parentElement).toHaveTextContent('Sort ByDate Created');
   });
 
   it('display the right Crash Free column', async () => {
-    render(<ReleasesList {...props} />, {
-      organization,
-    });
+    const {router} = render(<ReleasesList />, {organization});
 
     // Find and click on the display menu's trigger button
     const statusTriggerButton = screen.getByRole('button', {
@@ -404,36 +353,25 @@ describe('ReleasesList', () => {
 
     await userEvent.click(crashFreeUsersOption);
 
-    expect(router.push).toHaveBeenCalledWith(
-      expect.objectContaining({
-        query: expect.objectContaining({
-          display: ReleasesDisplayOption.USERS,
-        }),
-      })
-    );
+    expect(router.location.query.display).toBe(ReleasesDisplayOption.USERS);
   });
 
   it('displays archived releases', async () => {
-    render(
-      <ReleasesList
-        {...props}
-        location={{
-          ...routerProps.location,
+    const {router} = render(<ReleasesList />, {
+      organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
           query: {status: ReleasesStatusOption.ARCHIVED},
-        }}
-      />,
-      {
-        organization,
-      }
-    );
-
-    await waitFor(() =>
-      expect(endpointMock).toHaveBeenLastCalledWith(
-        '/organizations/org-slug/releases/',
-        expect.objectContaining({
-          query: expect.objectContaining({status: ReleasesStatusOption.ARCHIVED}),
-        })
-      )
+        },
+      },
+    });
+    expect(await screen.findByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(endpointMock).toHaveBeenCalledWith(
+      `/organizations/${organization.slug}/releases/`,
+      expect.objectContaining({
+        query: expect.objectContaining({status: ReleasesStatusOption.ARCHIVED}),
+      })
     );
 
     expect(
@@ -441,11 +379,9 @@ describe('ReleasesList', () => {
     ).toBeInTheDocument();
 
     // Find and click on the status menu's trigger button
-    const statusTriggerButton = screen.getByRole('button', {
-      name: 'Status Archived',
-    });
-    expect(statusTriggerButton).toBeInTheDocument();
-    await userEvent.click(statusTriggerButton);
+    const archivedTrigger = screen.getByRole('button', {name: 'Status Archived'});
+    expect(archivedTrigger).toBeInTheDocument();
+    await userEvent.click(archivedTrigger);
 
     // Expect to have 2 options in the status dropdown
     const statusActiveOption = screen.getByRole('option', {name: 'Active'});
@@ -454,34 +390,27 @@ describe('ReleasesList', () => {
     expect(statusArchivedOption).toBeInTheDocument();
 
     await userEvent.click(statusActiveOption);
-    expect(router.push).toHaveBeenLastCalledWith(
-      expect.objectContaining({
-        query: expect.objectContaining({
-          status: ReleasesStatusOption.ACTIVE,
-        }),
-      })
-    );
+    expect(router.location.query.status).toBe(ReleasesStatusOption.ACTIVE);
 
-    await userEvent.click(statusTriggerButton);
+    await userEvent.click(screen.getByRole('button', {name: 'Status Active'}));
     statusArchivedOption = screen.getByRole('option', {name: 'Archived'});
     await userEvent.click(statusArchivedOption);
-    expect(router.push).toHaveBeenLastCalledWith(
-      expect.objectContaining({
-        query: expect.objectContaining({
-          status: ReleasesStatusOption.ARCHIVED,
-        }),
-      })
-    );
+    expect(router.location.query.status).toBe(ReleasesStatusOption.ARCHIVED);
   });
 
   it('calls api with only explicitly permitted query params', async () => {
-    render(<ReleasesList {...props} />, {
+    render(<ReleasesList />, {
       organization,
+      initialRouterConfig: {
+        location: {
+          pathname: `/organizations/${organization.slug}/releases/`,
+          query: {somethingBad: 'XXX'},
+        },
+      },
     });
-
     await waitFor(() => {
       expect(endpointMock).toHaveBeenCalledWith(
-        '/organizations/org-slug/releases/',
+        `/organizations/${organization.slug}/releases/`,
         expect.objectContaining({
           query: expect.not.objectContaining({
             somethingBad: 'XXX',
@@ -492,14 +421,12 @@ describe('ReleasesList', () => {
   });
 
   it('calls session api for health data', async () => {
-    render(<ReleasesList {...props} />, {
-      organization,
-    });
+    render(<ReleasesList />, {organization});
 
     await waitFor(() => expect(sessionApiMock).toHaveBeenCalledTimes(3));
 
     expect(sessionApiMock).toHaveBeenCalledWith(
-      '/organizations/org-slug/sessions/',
+      `/organizations/${organization.slug}/sessions/`,
       expect.objectContaining({
         query: expect.objectContaining({
           field: ['sum(session)'],
@@ -512,7 +439,7 @@ describe('ReleasesList', () => {
     );
 
     expect(sessionApiMock).toHaveBeenCalledWith(
-      '/organizations/org-slug/sessions/',
+      `/organizations/${organization.slug}/sessions/`,
       expect.objectContaining({
         query: expect.objectContaining({
           field: ['sum(session)'],
@@ -525,7 +452,7 @@ describe('ReleasesList', () => {
     );
 
     expect(sessionApiMock).toHaveBeenCalledWith(
-      '/organizations/org-slug/sessions/',
+      `/organizations/${organization.slug}/sessions/`,
       expect.objectContaining({
         query: expect.objectContaining({
           field: ['sum(session)'],
@@ -540,7 +467,7 @@ describe('ReleasesList', () => {
 
   it('shows health rows only for selected projects in global header', async () => {
     MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/releases/',
+      url: `/organizations/${organization.slug}/releases/`,
       body: [
         {
           ...ReleaseFixture({version: '2.0.0'}),
@@ -564,9 +491,8 @@ describe('ReleasesList', () => {
         },
       ],
     });
-    render(<ReleasesList {...props} selection={{...props.selection, projects: [2]}} />, {
-      organization,
-    });
+    PageFiltersStore.updateProjects([2], null);
+    render(<ReleasesList />, {organization});
     const hiddenProjectsMessage = await screen.findByTestId('hidden-projects');
     expect(hiddenProjectsMessage).toHaveTextContent('2 hidden projects');
 
@@ -577,12 +503,11 @@ describe('ReleasesList', () => {
 
   it('does not hide health rows when "All Projects" are selected in global header', async () => {
     MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/releases/',
+      url: `/organizations/${organization.slug}/releases/`,
       body: [ReleaseFixture({version: '2.0.0'})],
     });
-    render(<ReleasesList {...props} selection={{...props.selection, projects: [-1]}} />, {
-      organization,
-    });
+    PageFiltersStore.updateProjects([-1], null);
+    render(<ReleasesList />, {organization});
 
     expect(await screen.findByTestId('release-card-project-row')).toBeInTheDocument();
     expect(screen.queryByTestId('hidden-projects')).not.toBeInTheDocument();
@@ -590,7 +515,7 @@ describe('ReleasesList', () => {
 
   it('autocompletes semver search tag', async () => {
     MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/tags/release.version/values/',
+      url: `/organizations/${organization.slug}/tags/release.version/values/`,
       body: [
         {
           count: null,
@@ -603,12 +528,10 @@ describe('ReleasesList', () => {
       ],
     });
     MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/recent-searches/',
+      url: `/organizations/${organization.slug}/recent-searches/`,
       method: 'POST',
     });
-    render(<ReleasesList {...props} location={{...props.location, query: {}}} />, {
-      organization,
-    });
+    render(<ReleasesList />, {organization});
     const smartSearchBar = await screen.findByRole('combobox', {
       name: 'Add a search term',
     });
diff --git a/static/app/views/releases/list/index.tsx b/static/app/views/releases/list/index.tsx
index 9ef550c2816f7f..b326168e5bfb43 100644
--- a/static/app/views/releases/list/index.tsx
+++ b/static/app/views/releases/list/index.tsx
@@ -1,12 +1,11 @@
+import {useCallback, useEffect, useMemo} from 'react';
 import {forceCheck} from 'react-lazyload';
 import styled from '@emotion/styled';
-import pick from 'lodash/pick';
 
 import {fetchTagValues} from 'sentry/actionCreators/tags';
-import type {Client} from 'sentry/api';
-import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget';
 import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingError from 'sentry/components/loadingError';
 import NoProjectMessage from 'sentry/components/noProjectMessage';
 import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
 import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
@@ -20,19 +19,18 @@ import {ReleasesSortOption} from 'sentry/constants/releases';
 import {t} from 'sentry/locale';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {space} from 'sentry/styles/space';
-import type {PageFilters} from 'sentry/types/core';
-import type {Tag} from 'sentry/types/group';
-import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
-import type {Organization} from 'sentry/types/organization';
-import type {Project} from 'sentry/types/project';
+import type {Tag, TagCollection} from 'sentry/types/group';
 import type {Release} from 'sentry/types/release';
 import {ReleaseStatus} from 'sentry/types/release';
 import {DemoTourElement, DemoTourStep} from 'sentry/utils/demoMode/demoTours';
 import {SEMVER_TAGS} from 'sentry/utils/discover/fields';
-import withApi from 'sentry/utils/withApi';
-import withOrganization from 'sentry/utils/withOrganization';
-import withPageFilters from 'sentry/utils/withPageFilters';
-import withProjects from 'sentry/utils/withProjects';
+import {type ApiQueryKey, useApiQuery} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
 import Header from 'sentry/views/releases/components/header';
 import ReleaseArchivedNotice from 'sentry/views/releases/detail/overview/releaseArchivedNotice';
 import ReleaseHealthCTA from 'sentry/views/releases/list/releaseHealthCTA';
@@ -43,119 +41,94 @@ import ReleasesDisplayOptions, {ReleasesDisplayOption} from './releasesDisplayOp
 import ReleasesSortOptions from './releasesSortOptions';
 import ReleasesStatusOptions, {ReleasesStatusOption} from './releasesStatusOptions';
 
-type RouteParams = {
-  orgId: string;
-};
-
-type Props = RouteComponentProps<RouteParams> & {
-  api: Client;
-  organization: Organization;
-  projects: Project[];
-  selection: PageFilters;
-};
-
-type State = {
-  releases: Release[];
-} & DeprecatedAsyncComponent['state'];
-
-class ReleasesList extends DeprecatedAsyncComponent<Props, State> {
-  shouldReload = true;
-  shouldRenderBadRequests = true;
-
-  filterKeys = [
-    ...Object.values(SEMVER_TAGS),
-    {
-      key: 'release',
-      name: 'release',
-    },
-  ].reduce((acc, tag) => {
-    // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
-    acc[tag.key] = tag;
-    return acc;
-  }, {});
-
-  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
-    const {organization, location} = this.props;
-    const {statsPeriod} = location.query;
-    const activeSort = this.getSort();
-    const activeStatus = this.getStatus();
-
-    const query = {
-      ...pick(location.query, ['project', 'environment', 'cursor', 'query', 'sort']),
-      summaryStatsPeriod: statsPeriod,
-      per_page: 20,
-      flatten: activeSort === ReleasesSortOption.DATE ? 0 : 1,
-      adoptionStages: 1,
-      status:
-        activeStatus === ReleasesStatusOption.ARCHIVED
-          ? ReleaseStatus.ARCHIVED
-          : ReleaseStatus.ACTIVE,
-    };
-
-    const endpoints: ReturnType<DeprecatedAsyncComponent['getEndpoints']> = [
-      [
-        'releases', // stateKey
-        `/organizations/${organization.slug}/releases/`, // endpoint
-        {query}, // params
-        {disableEntireQuery: true}, // options - prevent cursor from being passed into query
-      ],
-    ];
-    return endpoints;
-  }
+const RELEASE_FILTER_KEYS = [
+  ...Object.values(SEMVER_TAGS),
+  {
+    key: 'release',
+    name: 'release',
+  },
+].reduce<TagCollection>((acc, tag) => {
+  acc[tag.key] = tag;
+  return acc;
+}, {});
+
+function makeReleaseListQueryKey({
+  organizationSlug,
+  location,
+  activeSort,
+  activeStatus,
+}: {
+  location: ReturnType<typeof useLocation>;
+  organizationSlug: string;
+  activeSort?: ReleasesSortOption;
+  activeStatus?: ReleasesStatusOption;
+}): ApiQueryKey {
+  const query = {
+    project: location.query.project,
+    environment: location.query.environment,
+    cursor: location.query.cursor,
+    query: location.query.query,
+    sort: location.query.sort,
+    summaryStatsPeriod: location.query.statsPeriod,
+    per_page: 20,
+    flatten: activeSort === ReleasesSortOption.DATE ? 0 : 1,
+    adoptionStages: 1,
+    status:
+      activeStatus === ReleasesStatusOption.ARCHIVED
+        ? ReleaseStatus.ARCHIVED
+        : ReleaseStatus.ACTIVE,
+  };
 
-  componentDidUpdate(prevProps: Props, prevState: State) {
-    super.componentDidUpdate(prevProps, prevState);
-
-    if (prevState.releases !== this.state.releases) {
-      /**
-       * Manually trigger checking for elements in viewport.
-       * Helpful when LazyLoad components enter the viewport without resize or scroll events,
-       * https://github.com/twobin/react-lazyload#forcecheck
-       *
-       * HealthStatsCharts are being rendered only when they are scrolled into viewport.
-       * This is how we re-check them without scrolling once releases change as this view
-       * uses shouldReload=true and there is no reloading happening.
-       */
-      forceCheck();
-    }
-  }
+  return [`/organizations/${organizationSlug}/releases/`, {query}];
+}
 
-  getQuery() {
-    const {query} = this.props.location.query;
+export default function ReleasesList() {
+  const api = useApi({persistInFlight: true});
+  const organization = useOrganization();
+  const {projects} = useProjects();
+  const {selection} = usePageFilters();
+  const location = useLocation();
+  const navigate = useNavigate();
 
-    return typeof query === 'string' ? query : undefined;
-  }
+  const activeQuery = useMemo(() => {
+    const {query: locationQuery} = location.query;
+    return typeof locationQuery === 'string' ? locationQuery : '';
+  }, [location.query]);
 
-  getSort(): ReleasesSortOption {
-    const {environments} = this.props.selection;
-    const {sort} = this.props.location.query;
+  const activeSort = useMemo(() => {
+    const {sort: locationSort} = location.query;
 
     // Require 1 environment for date adopted
-    if (sort === ReleasesSortOption.ADOPTION && environments.length !== 1) {
+    if (
+      locationSort === ReleasesSortOption.ADOPTION &&
+      selection.environments.length !== 1
+    ) {
       return ReleasesSortOption.DATE;
     }
 
-    const sortExists = Object.values(ReleasesSortOption).includes(sort);
+    const sortExists = Object.values(ReleasesSortOption).includes(
+      locationSort as ReleasesSortOption
+    );
     if (sortExists) {
-      return sort;
+      return locationSort as ReleasesSortOption;
     }
 
     return ReleasesSortOption.DATE;
-  }
+  }, [selection.environments, location.query]);
 
-  getDisplay(): ReleasesDisplayOption {
-    const {display} = this.props.location.query;
+  const activeDisplay = useMemo(() => {
+    const {display: locationDisplay} = location.query;
 
-    switch (display) {
+    switch (locationDisplay) {
       case ReleasesDisplayOption.USERS:
         return ReleasesDisplayOption.USERS;
       default:
         return ReleasesDisplayOption.SESSIONS;
     }
-  }
+  }, [location.query]);
 
-  getStatus(): ReleasesStatusOption {
-    const {status} = this.props.location.query;
+  const activeStatus = useMemo(() => {
+    const {status} = location.query;
 
     switch (status) {
       case ReleasesStatusOption.ARCHIVED:
@@ -163,11 +136,37 @@ class ReleasesList extends DeprecatedAsyncComponent<Props, State> {
       default:
         return ReleasesStatusOption.ACTIVE;
     }
-  }
-
-  getSelectedProject(): Project | undefined {
-    const {selection, projects} = this.props;
-
+  }, [location.query]);
+
+  const {
+    data: releases = [],
+    isPending: isReleasesPending,
+    isRefetching: isReleasesRefetching,
+    error: releasesError,
+    getResponseHeader: getReleasesResponseHeader,
+  } = useApiQuery<Release[]>(
+    makeReleaseListQueryKey({
+      organizationSlug: organization.slug,
+      location,
+      activeSort,
+      activeStatus,
+    }),
+    {staleTime: Infinity, placeholderData: prev => prev}
+  );
+
+  useEffect(() => {
+    /**
+     * Manually trigger checking for elements in viewport.
+     * Helpful when LazyLoad components enter the viewport without resize or scroll events,
+     * https://github.com/twobin/react-lazyload#forcecheck
+     *
+     * HealthStatsCharts are being rendered only when they are scrolled into viewport.
+     * This is how we re-check them without scrolling once releases change.
+     */
+    forceCheck();
+  }, [releases]);
+
+  const selectedProject = useMemo(() => {
     // Return the first project when 'All Projects' is displayed.
     // This ensures the onboarding panel is shown correctly, for example.
     if (selection.projects.length === 0) {
@@ -177,226 +176,212 @@ class ReleasesList extends DeprecatedAsyncComponent<Props, State> {
     const selectedProjectId =
       selection.projects && selection.projects.length === 1 && selection.projects[0];
     return projects?.find(p => p.id === `${selectedProjectId}`);
-  }
-
-  getSelectedProjectSlugs(): string[] {
-    const {selection, projects} = this.props;
-    const projIdSet = new Set(selection.projects);
-
-    return projects.reduce((result: string[], proj) => {
-      if (projIdSet.has(Number(proj.id))) {
-        result.push(proj.slug);
+  }, [selection.projects, projects]);
+
+  const handleSearch = useCallback(
+    (query: string) => {
+      navigate({
+        ...location,
+        query: {...location.query, cursor: undefined, query},
+      });
+    },
+    [location, navigate]
+  );
+
+  const handleSortBy = useCallback(
+    (sort: string) => {
+      navigate({
+        ...location,
+        query: {...location.query, cursor: undefined, sort},
+      });
+    },
+    [location, navigate]
+  );
+
+  const handleDisplay = useCallback(
+    (display: string) => {
+      let sort = location.query.sort;
+      if (
+        sort === ReleasesSortOption.USERS_24_HOURS &&
+        display === ReleasesDisplayOption.SESSIONS
+      ) {
+        sort = ReleasesSortOption.SESSIONS_24_HOURS;
+      } else if (
+        sort === ReleasesSortOption.SESSIONS_24_HOURS &&
+        display === ReleasesDisplayOption.USERS
+      ) {
+        sort = ReleasesSortOption.USERS_24_HOURS;
+      } else if (
+        sort === ReleasesSortOption.CRASH_FREE_USERS &&
+        display === ReleasesDisplayOption.SESSIONS
+      ) {
+        sort = ReleasesSortOption.CRASH_FREE_SESSIONS;
+      } else if (
+        sort === ReleasesSortOption.CRASH_FREE_SESSIONS &&
+        display === ReleasesDisplayOption.USERS
+      ) {
+        sort = ReleasesSortOption.CRASH_FREE_USERS;
       }
-      return result;
-    }, []);
-  }
-
-  get projectHasSessions() {
-    return this.getSelectedProject()?.hasSessions ?? null;
-  }
-
-  handleSearch = (query: string) => {
-    const {location, router} = this.props;
 
-    router.push({
-      ...location,
-      query: {...location.query, cursor: undefined, query},
-    });
-  };
-
-  handleSortBy = (sort: string) => {
-    const {location, router} = this.props;
-
-    router.push({
-      ...location,
-      query: {...location.query, cursor: undefined, sort},
-    });
-  };
-
-  handleDisplay = (display: string) => {
-    const {location, router} = this.props;
+      navigate({
+        ...location,
+        query: {...location.query, cursor: undefined, display, sort},
+      });
+    },
+    [location, navigate]
+  );
+
+  const handleStatus = useCallback(
+    (status: string) => {
+      navigate({
+        ...location,
+        query: {...location.query, cursor: undefined, status},
+      });
+    },
+    [location, navigate]
+  );
+
+  const tagValueLoader = useCallback(
+    (key: string, search: string) => {
+      const {project} = location.query;
+
+      // Coerce the url param into an array
+      const projectIds = Array.isArray(project)
+        ? project
+        : typeof project === 'string'
+          ? [project]
+          : [];
+
+      return fetchTagValues({
+        api,
+        orgSlug: organization.slug,
+        tagKey: key,
+        search,
+        projectIds,
+        endpointParams: normalizeDateTimeParams(location.query),
+      });
+    },
+    [api, location, organization]
+  );
 
-    let sort = location.query.sort;
-    if (
-      sort === ReleasesSortOption.USERS_24_HOURS &&
-      display === ReleasesDisplayOption.SESSIONS
-    ) {
-      sort = ReleasesSortOption.SESSIONS_24_HOURS;
-    } else if (
-      sort === ReleasesSortOption.SESSIONS_24_HOURS &&
-      display === ReleasesDisplayOption.USERS
-    ) {
-      sort = ReleasesSortOption.USERS_24_HOURS;
-    } else if (
-      sort === ReleasesSortOption.CRASH_FREE_USERS &&
-      display === ReleasesDisplayOption.SESSIONS
-    ) {
-      sort = ReleasesSortOption.CRASH_FREE_SESSIONS;
-    } else if (
-      sort === ReleasesSortOption.CRASH_FREE_SESSIONS &&
-      display === ReleasesDisplayOption.USERS
-    ) {
-      sort = ReleasesSortOption.CRASH_FREE_USERS;
+  const getTagValues = useCallback(
+    async (tag: Tag, currentQuery: string): Promise<string[]> => {
+      const values = await tagValueLoader(tag.key, currentQuery);
+      return values.map(({value}) => value);
+    },
+    [tagValueLoader]
+  );
+
+  const hasAnyMobileProject = selection.projects
+    .map(id => `${id}`)
+    .map(ProjectsStore.getById)
+    .some(project => project?.platform && isMobileRelease(project.platform));
+  const showReleaseAdoptionStages =
+    hasAnyMobileProject && selection.environments.length === 1;
+  const shouldShowQuickstart = Boolean(
+    selectedProject &&
+      // Has not set up releases
+      !selectedProject?.features.includes('releases') &&
+      // Has no releases
+      !releases?.length
+  );
+  const releasesPageLinks = getReleasesResponseHeader?.('Link');
+
+  const releasesErrorMessage = useMemo(() => {
+    if (!releasesError) {
+      return null;
     }
-
-    router.push({
-      ...location,
-      query: {...location.query, cursor: undefined, display, sort},
-    });
-  };
-
-  handleStatus = (status: string) => {
-    const {location, router} = this.props;
-
-    router.push({
-      ...location,
-      query: {...location.query, cursor: undefined, status},
-    });
-  };
-
-  tagValueLoader = (key: string, search: string) => {
-    const {location, organization} = this.props;
-    const {project: projectId} = location.query;
-
-    return fetchTagValues({
-      api: this.api,
-      orgSlug: organization.slug,
-      tagKey: key,
-      search,
-      projectIds: projectId ? [projectId] : undefined,
-      endpointParams: normalizeDateTimeParams(location.query),
-    });
-  };
-
-  getTagValues = async (tag: Tag, currentQuery: string): Promise<string[]> => {
-    const values = await this.tagValueLoader(tag.key, currentQuery);
-    return values.map(({value}) => value);
-  };
-
-  renderLoading() {
-    return this.renderBody();
-  }
-
-  renderError() {
-    return this.renderBody();
-  }
-
-  get shouldShowQuickstart() {
-    const {releases} = this.state;
-
-    const selectedProject = this.getSelectedProject();
-    const hasReleasesSetup = selectedProject?.features.includes('releases');
-
-    return !releases?.length && !hasReleasesSetup && selectedProject;
-  }
-
-  renderBody() {
-    const {organization, selection} = this.props;
-    const {releases, reloading, error} = this.state;
-
-    const activeSort = this.getSort();
-    const activeStatus = this.getStatus();
-    const activeDisplay = this.getDisplay();
-
-    const hasAnyMobileProject = selection.projects
-      .map(id => `${id}`)
-      .map(ProjectsStore.getById)
-      .some(project => project?.platform && isMobileRelease(project.platform));
-    const showReleaseAdoptionStages =
-      hasAnyMobileProject && selection.environments.length === 1;
-
-    return (
-      <PageFiltersContainer showAbsolute={false}>
-        <SentryDocumentTitle title={t('Releases')} orgSlug={organization.slug} />
-        <NoProjectMessage organization={organization}>
-          <Header />
-          <Layout.Body>
-            <Layout.Main fullWidth>
-              <ReleaseHealthCTA
-                organization={this.props.organization}
-                releases={this.state.releases}
-                selectedProject={this.getSelectedProject()}
-                selection={this.props.selection}
+    if (releasesError?.status === 400) {
+      return String(releasesError?.responseJSON?.detail);
+    }
+    return t('There was an error loading releases');
+  }, [releasesError]);
+
+  return (
+    <PageFiltersContainer showAbsolute={false} defaultSelection={selection}>
+      <SentryDocumentTitle title={t('Releases')} orgSlug={organization.slug} />
+      <NoProjectMessage organization={organization}>
+        <Header />
+        <Layout.Body>
+          <Layout.Main fullWidth>
+            <ReleaseHealthCTA
+              organization={organization}
+              releases={releases}
+              selectedProject={selectedProject}
+              selection={selection}
+            />
+            <ReleasesPageFilterBar condensed>
+              <DemoTourElement
+                id={DemoTourStep.RELEASES_COMPARE}
+                title={t('Compare releases')}
+                description={t(
+                  'Click here and select the "react" project to see how the release is trending compared to previous releases.'
+                )}
+                position="bottom-start"
+              >
+                <ProjectPageFilter />
+              </DemoTourElement>
+              <EnvironmentPageFilter />
+              <DatePageFilter
+                disallowArbitraryRelativeRanges
+                menuFooterMessage={t(
+                  'Changing this date range will recalculate the release metrics.'
+                )}
               />
-
-              <ReleasesPageFilterBar condensed>
-                <DemoTourElement
-                  id={DemoTourStep.RELEASES_COMPARE}
-                  title={t('Compare releases')}
-                  description={t(
-                    'Click here and select the "react" project to see how the release is trending compared to previous releases.'
-                  )}
-                  position="bottom-start"
-                >
-                  <ProjectPageFilter />
-                </DemoTourElement>
-                <EnvironmentPageFilter />
-                <DatePageFilter
-                  disallowArbitraryRelativeRanges
-                  menuFooterMessage={t(
-                    'Changing this date range will recalculate the release metrics.'
-                  )}
+            </ReleasesPageFilterBar>
+
+            {shouldShowQuickstart ? null : (
+              <SortAndFilterWrapper>
+                <StyledSearchQueryBuilder
+                  searchOnChange={organization.features.includes('ui-search-on-change')}
+                  onSearch={handleSearch}
+                  initialQuery={activeQuery}
+                  filterKeys={RELEASE_FILTER_KEYS}
+                  getTagValues={getTagValues}
+                  placeholder={t('Search by version, build, package, or stage')}
+                  searchSource="releases"
+                  showUnsubmittedIndicator
                 />
-              </ReleasesPageFilterBar>
-
-              {this.shouldShowQuickstart ? null : (
-                <SortAndFilterWrapper>
-                  <StyledSearchQueryBuilder
-                    searchOnChange={organization.features.includes('ui-search-on-change')}
-                    onSearch={this.handleSearch}
-                    initialQuery={this.getQuery() || ''}
-                    filterKeys={this.filterKeys}
-                    getTagValues={this.getTagValues}
-                    placeholder={t('Search by version, build, package, or stage')}
-                    searchSource="releases"
-                    showUnsubmittedIndicator
-                  />
-                  <ReleasesStatusOptions
-                    selected={activeStatus}
-                    onSelect={this.handleStatus}
-                  />
-                  <ReleasesSortOptions
-                    selected={activeSort}
-                    selectedDisplay={activeDisplay}
-                    onSelect={this.handleSortBy}
-                    environments={selection.environments}
-                  />
-                  <ReleasesDisplayOptions
-                    selected={activeDisplay}
-                    onSelect={this.handleDisplay}
-                  />
-                </SortAndFilterWrapper>
-              )}
-
-              {!reloading &&
-                activeStatus === ReleasesStatusOption.ARCHIVED &&
-                !!releases?.length && <ReleaseArchivedNotice multi />}
-
-              {error ? (
-                super.renderError()
-              ) : (
-                <ReleaseListInner
-                  activeDisplay={activeDisplay}
-                  loading={this.state.loading}
-                  location={this.props.location}
-                  organization={this.props.organization}
-                  releases={this.state.releases}
-                  releasesPageLinks={this.state.releasesPageLinks}
-                  reloading={this.state.reloading}
-                  router={this.props.router}
-                  selectedProject={this.getSelectedProject()}
-                  selection={this.props.selection}
-                  shouldShowQuickstart={this.shouldShowQuickstart}
-                  showReleaseAdoptionStages={showReleaseAdoptionStages}
+                <ReleasesStatusOptions selected={activeStatus} onSelect={handleStatus} />
+                <ReleasesSortOptions
+                  selected={activeSort}
+                  selectedDisplay={activeDisplay}
+                  onSelect={handleSortBy}
+                  environments={selection.environments}
                 />
-              )}
-              <FloatingFeedbackWidget />
-            </Layout.Main>
-          </Layout.Body>
-        </NoProjectMessage>
-      </PageFiltersContainer>
-    );
-  }
+                <ReleasesDisplayOptions
+                  selected={activeDisplay}
+                  onSelect={handleDisplay}
+                />
+              </SortAndFilterWrapper>
+            )}
+
+            {!(isReleasesPending || isReleasesRefetching) &&
+              activeStatus === ReleasesStatusOption.ARCHIVED &&
+              !!releases?.length && <ReleaseArchivedNotice multi />}
+
+            {releasesErrorMessage ? (
+              <LoadingError message={releasesErrorMessage} />
+            ) : (
+              <ReleaseListInner
+                activeDisplay={activeDisplay}
+                loading={isReleasesPending}
+                organization={organization}
+                releases={releases}
+                releasesPageLinks={releasesPageLinks}
+                reloading={isReleasesRefetching}
+                selectedProject={selectedProject}
+                selection={selection}
+                shouldShowQuickstart={shouldShowQuickstart}
+                showReleaseAdoptionStages={showReleaseAdoptionStages}
+              />
+            )}
+            <FloatingFeedbackWidget />
+          </Layout.Main>
+        </Layout.Body>
+      </NoProjectMessage>
+    </PageFiltersContainer>
+  );
 }
 
 const ReleasesPageFilterBar = styled(PageFilterBar)`
@@ -425,5 +410,3 @@ const StyledSearchQueryBuilder = styled(SearchQueryBuilder)`
     grid-column: 1 / -1;
   }
 `;
-
-export default withApi(withProjects(withOrganization(withPageFilters(ReleasesList))));
diff --git a/static/app/views/releases/list/releaseListInner.tsx b/static/app/views/releases/list/releaseListInner.tsx
index 8bd8538461c6c3..f59500f01f335c 100644
--- a/static/app/views/releases/list/releaseListInner.tsx
+++ b/static/app/views/releases/list/releaseListInner.tsx
@@ -12,11 +12,12 @@ import {ReleasesSortOption} from 'sentry/constants/releases';
 import {IconSearch} from 'sentry/icons/iconSearch';
 import {t} from 'sentry/locale';
 import type {PageFilters} from 'sentry/types/core';
-import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
 import type {Organization} from 'sentry/types/organization';
 import type {Project} from 'sentry/types/project';
 import {HealthStatsPeriodOption, type Release} from 'sentry/types/release';
 import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import useRouter from 'sentry/utils/useRouter';
 import ReleaseCard from 'sentry/views/releases/list/releaseCard';
 import ReleasesAdoptionChart from 'sentry/views/releases/list/releasesAdoptionChart';
 import {ReleasesDisplayOption} from 'sentry/views/releases/list/releasesDisplayOptions';
@@ -28,31 +29,30 @@ import {isMobileRelease} from 'sentry/views/releases/utils';
 interface Props {
   activeDisplay: ReleasesDisplayOption;
   loading: boolean;
-  location: Location;
   organization: Organization;
   releases: Release[];
   releasesPageLinks: any;
   reloading: boolean;
-  router: InjectedRouter;
   selectedProject: Project | undefined;
   selection: PageFilters;
-  shouldShowQuickstart: false | Project | undefined;
+  shouldShowQuickstart: boolean;
   showReleaseAdoptionStages: boolean;
 }
-export default function ReleaseListInner({
+
+function ReleaseListInner({
   activeDisplay,
   loading,
-  location,
   organization,
   releases,
   releasesPageLinks,
   reloading,
-  router,
   selectedProject,
   selection,
   shouldShowQuickstart,
   showReleaseAdoptionStages,
 }: Props) {
+  const router = useRouter();
+  const location = useLocation();
   const hasReleasesSetup = selectedProject?.features.includes('releases');
 
   const shouldShowLoadingIndicator =
@@ -130,6 +130,8 @@ export default function ReleaseListInner({
   );
 }
 
+export default ReleaseListInner;
+
 function getQuery(location: Location) {
   const {query} = location.query;
 
diff --git a/static/app/views/releases/list/releasesRequest.tsx b/static/app/views/releases/list/releasesRequest.tsx
index ee0930bc1459e1..5c3849e570ed7b 100644
--- a/static/app/views/releases/list/releasesRequest.tsx
+++ b/static/app/views/releases/list/releasesRequest.tsx
@@ -29,13 +29,7 @@ import {getCrashFreePercent} from 'sentry/views/releases/utils';
 import {ReleasesDisplayOption} from './releasesDisplayOptions';
 
 function omitIgnoredProps(props: Props) {
-  return omit(props, [
-    'api',
-    'organization',
-    'children',
-    'selection.datetime.utc',
-    'location',
-  ]);
+  return omit(props, ['api', 'organization', 'children', 'selection.datetime.utc']);
 }
 
 function getInterval(datetimeObj: DateTimeObject) {


Reviews:
user: login: ryan953
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T20:22:11Z
user: login: leeandher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T20:29:46Z
user: login: billyvg
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T14:51:11Z
user: login: leeandher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T14:52:52Z

Review comments:
diff_hunk: @@ -130,6 +134,8 @@ export default function ReleaseListInner({
   );
 }
 
+export default withSentryRouter(ReleaseListInner);
path: static/app/views/releases/list/releaseListInner.tsx
user: login: ryan953
body: could be better to call `const router = useRouter();` inside the component, and drop the `router: InjectedRouter` at the same time instead of adding an HoC. simpler to replace the useRouter call later.
created_at: 2025-05-08T20:22:11Z
author_association: MEMBER
diff_hunk: @@ -130,6 +134,8 @@ export default function ReleaseListInner({
   );
 }
 
+export default withSentryRouter(ReleaseListInner);
path: static/app/views/releases/list/releaseListInner.tsx
user: login: leeandher
body: Oops yeah, that makes so much more sense. I don't know why I though this was a class component.
created_at: 2025-05-08T20:29:46Z
author_association: MEMBER
diff_hunk: @@ -29,13 +29,7 @@ import {getCrashFreePercent} from 'sentry/views/releases/utils';
 import {ReleasesDisplayOption} from './releasesDisplayOptions';
 
 function omitIgnoredProps(props: Props) {
-  return omit(props, [
-    'api',
-    'organization',
-    'children',
-    'selection.datetime.utc',
-    'location',
path: static/app/views/releases/list/releasesRequest.tsx
user: login: billyvg
body: Is this intentional?
created_at: 2025-05-13T14:51:06Z
author_association: MEMBER
diff_hunk: @@ -29,13 +29,7 @@ import {getCrashFreePercent} from 'sentry/views/releases/utils';
 import {ReleasesDisplayOption} from './releasesDisplayOptions';
 
 function omitIgnoredProps(props: Props) {
-  return omit(props, [
-    'api',
-    'organization',
-    'children',
-    'selection.datetime.utc',
-    'location',
path: static/app/views/releases/list/releasesRequest.tsx
user: login: leeandher
body: Yep, removing `location` from the ignored props allows changes to the filtering to reload the health stats like crash free rate. Before it'd remount the whole thing so it'd reload anyway
created_at: 2025-05-13T14:52:47Z
author_association: MEMBER

PR comments:
user: login: billyvg
created_at: 2025-05-08T20:40:17Z
body: <img width="260" alt="image" src="https://github.com/user-attachments/assets/8d7d1f67-3a83-4d2b-b5a0-71d5d241c017" />
changing this from 24h to 14d doesn't seem to re-render page? on prod I see crash free rate changing
author_association: MEMBER
user: login: leeandher
created_at: 2025-05-08T22:28:34Z
body: > changing this from 24h to 14d doesn't seem to re-render page? on prod I see crash free rate changing

Fixed I believe. The sessions stat request was reloading because it was remounted every time, but now were just passing new query params to it. I removed the `location` exception and it works fine now.


The loading/reloading state is different though. It looks like we hold on to the previous value of `releases` and make it appear disabled while we refetch, which is kinda nicer than just showing a full page spinner. I'll work on getting that back before merging


author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-05-08T22:30:39Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/91281?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
All modified and coverable lines are covered by tests :white_check_mark:

:white_check_mark: All tests successful. No failed tests found.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master   #91281      +/-   ##
==========================================
- Coverage   85.14%   80.43%   -4.72%     
==========================================
  Files       10315    10315              
  Lines      585407   585314      -93     
  Branches    22641    22628      -13     
==========================================
- Hits       498471   470792   -27679     
- Misses      86505   114091   +27586     
  Partials      431      431              
```

</details>
author_association: NONE
user: login: leeandher
created_at: 2025-05-09T22:42:44Z
body: > The loading/reloading state is different though. It looks like we hold on to the previous value of releases and make it appear disabled while we refetch, which is kinda nicer than just showing a full page spinner.

Fixed in https://github.com/getsentry/sentry/pull/91281/commits/50deacbcf8979cf5fd6d25fa33167cbcadbe44ac with useQuery's  `placholderData` option 👍  pretty neat 
author_association: MEMBER