From a862622aff49700e307ebfb588deeb3de880e0bd Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 1 May 2026 15:40:28 -0700 Subject: [PATCH 1/4] perf(issues): Avoid latest deploys in empty state The issues empty state was always fetching one project summary, even when the result was only needed for the awaiting-events path. That hit the project summary serializer and could run the latest deploys query for no useful reason. Move the component to React Query, only fetch the project when we're actually rendering AwaitingEvents, and collapse latestDeploys when we do need it. Co-Authored-By: Codex GPT-5 --- static/app/views/issueList/groupListBody.tsx | 3 - .../issueList/noGroupsHandler/index.spec.tsx | 38 +- .../views/issueList/noGroupsHandler/index.tsx | 332 ++++++++---------- .../app/views/projectDetail/projectIssues.tsx | 1 - 4 files changed, 189 insertions(+), 185 deletions(-) diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index db50be1fbbc9..1a79f5cffa3e 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -9,7 +9,6 @@ import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; import {SupergroupRow} from 'sentry/components/stream/supergroups/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; -import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; @@ -95,7 +94,6 @@ export function GroupListBody({ onActionTaken, supergroupLookup, }: GroupListBodyProps) { - const api = useApi(); const organization = useOrganization(); if (loading) { @@ -114,7 +112,6 @@ export function GroupListBody({ if (!groupIds.length) { return ( { const defaultProps = { - api: new MockApiClient(), query: '', organization: OrganizationFixture(), groupIds: [], + selectedProjectIds: [], }; it('displays default empty state when first event has been sent', async () => { - MockApiClient.addMockResponse({ + const projectsMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: [], }); @@ -25,6 +25,40 @@ describe('NoGroupsHandler', () => { render(); expect(await screen.findByText('No issues match your search')).toBeInTheDocument(); + expect(projectsMock).not.toHaveBeenCalled(); + }); + + it('collapses latest deploys when looking up the selected project', async () => { + const projectsMock = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [], + }); + const sentFirstEventMock = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sent-first-event/', + body: {sentFirstEvent: false}, + }); + + render(); + + expect(await screen.findByText(/Waiting for events/i)).toBeInTheDocument(); + expect(sentFirstEventMock).toHaveBeenCalledWith( + '/organizations/org-slug/sent-first-event/', + expect.objectContaining({ + query: expect.objectContaining({ + project: [1844558], + }), + }) + ); + expect(projectsMock).toHaveBeenCalledWith( + '/organizations/org-slug/projects/', + expect.objectContaining({ + query: expect.objectContaining({ + collapse: ['latestDeploys', 'unusedFeatures'], + per_page: 1, + query: 'id:1844558', + }), + }) + ); }); it('displays default empty state when an error occurs', async () => { diff --git a/static/app/views/issueList/noGroupsHandler/index.tsx b/static/app/views/issueList/noGroupsHandler/index.tsx index 0f002c6c0e40..cbf5da7c8930 100644 --- a/static/app/views/issueList/noGroupsHandler/index.tsx +++ b/static/app/views/issueList/noGroupsHandler/index.tsx @@ -1,12 +1,13 @@ -import {Component, lazy, Suspense} from 'react'; +import {lazy, Suspense} from 'react'; +import {skipToken, useQuery} from '@tanstack/react-query'; -import type {Client} from 'sentry/api'; import {EmptyStateWarning} from 'sentry/components/emptyStateWarning'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {NoIssuesMatched} from 'sentry/views/issueList/noGroupsHandler/noIssuesMatched'; import {DEFAULT_QUERY, FOR_REVIEW_QUERIES} from 'sentry/views/issueList/utils'; @@ -15,20 +16,59 @@ import {NoUnresolvedIssues} from './noUnresolvedIssues'; const WaitingForEvents = lazy(() => import('sentry/components/waitingForEvents')); const UpdatedEmptyState = lazy(() => import('sentry/components/updatedEmptyState')); -type Props = { - api: Client; +interface NoGroupsHandlerProps { groupIds: string[]; organization: Organization; query: string; + selectedProjectIds: number[]; emptyMessage?: React.ReactNode; - selectedProjectIds?: number[]; -}; +} + +interface SentFirstEventResponse { + sentFirstEvent: boolean; +} + +interface AwaitingEventsProps { + groupIds: string[]; + organization: Organization; + projects?: Project[]; +} + +interface EmptyResultProps { + query: string; + emptyMessage?: React.ReactNode; +} -type State = { - fetchingSentFirstEvent: boolean; - firstEventProjects?: Project[] | null; - sentFirstEvent?: boolean; -}; +const PROJECT_QUERY_COLLAPSE = ['latestDeploys', 'unusedFeatures']; + +const UPDATED_EMPTY_STATE_PLATFORMS = new Set([ + 'python-django', + 'node', + 'javascript-nextjs', + 'android', + 'apple-ios', + 'dotnet', + 'dotnet-aspnetcore', + 'flutter', + 'go', + 'java', + 'java-spring-boot', + 'javascript', + 'javascript-angular', + 'javascript-react', + 'javascript-vue', + 'node-express', + 'node-nestjs', + 'php', + 'php-laravel', + 'python', + 'python-fastapi', + 'python-flask', + 'react-native', + 'ruby', + 'ruby-rails', + 'unity', +]); /** * Component which is rendered when no groups/issues were found. This could @@ -36,187 +76,121 @@ type State = { * having no issues be returned from a query. This component will conditionally * render one of those states. */ -export class NoGroupsHandler extends Component { - state: State = { - fetchingSentFirstEvent: true, - sentFirstEvent: false, - firstEventProjects: null, - }; - - componentDidMount() { - this.fetchSentFirstEvent(); - this._isMounted = true; +export function NoGroupsHandler({ + emptyMessage, + groupIds, + organization, + query, + selectedProjectIds, +}: NoGroupsHandlerProps) { + // If no projects are selected, then we must check every project the user is a + // member of and make sure there are no first events for all of the projects. + // Set project to -1 for all projects. Do not pass a project id for "my projects". + const explicitSelectedProjectIds = + selectedProjectIds.length && !selectedProjectIds.includes(-1) + ? selectedProjectIds + : undefined; + const selectedProjectQuery = explicitSelectedProjectIds + ?.map(id => `id:${id}`) + .join(' '); + + const sentFirstEventQuery = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/sent-first-event/', + { + path: {organizationIdOrSlug: organization.slug}, + query: {project: explicitSelectedProjectIds}, + staleTime: 0, + } + ), + retry: false, + }); + + const shouldFetchProject = sentFirstEventQuery.data?.sentFirstEvent === false; + + const projectsQuery = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { + path: shouldFetchProject ? {organizationIdOrSlug: organization.slug} : skipToken, + query: { + collapse: PROJECT_QUERY_COLLAPSE, + per_page: 1, + query: selectedProjectQuery, + }, + staleTime: 0, + }), + retry: false, + }); + + if (sentFirstEventQuery.isPending || (shouldFetchProject && projectsQuery.isPending)) { + return ; } - componentWillUnmount() { - this._isMounted = false; + if (sentFirstEventQuery.isError || (shouldFetchProject && projectsQuery.isError)) { + return ; } - /** - * This is a bit hacky, but this is causing flakiness in frontend tests - * `issueList/overview` is being unmounted during tests before the requests - * in `this.fetchSentFirstEvent` are completed and causing this React warning: - * - * Warning: Can't perform a React state update on an unmounted component. - * This is a no-op, but it indicates a memory leak in your application. - * To fix, cancel all subscriptions and asynchronous tasks in the - * componentWillUnmount method. - * - * This is something to revisit if we refactor API client - */ - private _isMounted = false; - - async fetchSentFirstEvent() { - this.setState({ - fetchingSentFirstEvent: true, - }); - - const {organization, selectedProjectIds, api} = this.props; - let sentFirstEvent = false; - let projects = []; - - // If no projects are selected, then we must check every project the user is a - // member of and make sure there are no first events for all of the projects - // Set project to -1 for all projects - // Do not pass a project id for "my projects" - let firstEventQuery: {project?: number[]} = {}; - const projectsQuery: {per_page: number; query?: string} = {per_page: 1}; - - if (selectedProjectIds?.length && !selectedProjectIds.includes(-1)) { - firstEventQuery = {project: selectedProjectIds}; - projectsQuery.query = selectedProjectIds.map(id => `id:${id}`).join(' '); - } - - try { - [{sentFirstEvent}, projects] = await Promise.all([ - // checks to see if selection has sent a first event - api.requestPromise(`/organizations/${organization.slug}/sent-first-event/`, { - query: firstEventQuery, - }), - // retrieves a single project to feed to WaitingForEvents from renderStreamBody - api.requestPromise(`/organizations/${organization.slug}/projects/`, { - query: projectsQuery, - }), - ]); - } catch { - this.setState({ - fetchingSentFirstEvent: false, - sentFirstEvent: true, - firstEventProjects: undefined, - }); - return; - } - - // See comment where this property is initialized - // FIXME - if (!this._isMounted) { - return; - } - - this.setState({ - fetchingSentFirstEvent: false, - sentFirstEvent, - firstEventProjects: projects, - }); + if (!sentFirstEventQuery.data?.sentFirstEvent) { + return ( + + ); } - renderLoading() { - return ; - } + return ; +} + +function AwaitingEvents({groupIds, organization, projects}: AwaitingEventsProps) { + const project = projects?.[0]; + const sampleIssueId = groupIds.length > 0 ? groupIds[0] : undefined; - renderAwaitingEvents(projects: State['firstEventProjects']) { - const {organization, groupIds} = this.props; - const project = projects && projects.length > 0 ? projects[0] : undefined; - const sampleIssueId = groupIds.length > 0 ? groupIds[0] : undefined; - - const updatedEmptyStatePlatforms = [ - 'python-django', - 'node', - 'javascript-nextjs', - 'android', - 'apple-ios', - 'dotnet', - 'dotnet-aspnetcore', - 'flutter', - 'go', - 'java', - 'java-spring-boot', - 'javascript', - 'javascript-angular', - 'javascript-react', - 'javascript-vue', - 'node-express', - 'node-nestjs', - 'php', - 'php-laravel', - 'python', - 'python-fastapi', - 'python-flask', - 'react-native', - 'ruby', - 'ruby-rails', - 'unity', - ]; - - const hasUpdatedEmptyState = - project?.platform && updatedEmptyStatePlatforms.includes(project.platform); + const hasUpdatedEmptyState = + project?.platform && UPDATED_EMPTY_STATE_PLATFORMS.has(project.platform); + + return ( + }> + {!hasUpdatedEmptyState && ( + + )} + {hasUpdatedEmptyState && } + + ); +} +function EmptyResult({emptyMessage, query}: EmptyResultProps) { + if (query === DEFAULT_QUERY) { return ( - }> - {!hasUpdatedEmptyState && ( - - )} - {hasUpdatedEmptyState && } - + ); } - renderEmpty() { - const {emptyMessage} = this.props; - if (emptyMessage) { - return ( - -

{emptyMessage}

-
- ); - } - return ; + if (FOR_REVIEW_QUERIES.includes(query || '')) { + return ( + + ); } - render() { - const {fetchingSentFirstEvent, sentFirstEvent, firstEventProjects} = this.state; - const {query} = this.props; - - if (fetchingSentFirstEvent) { - return this.renderLoading(); - } - if (!sentFirstEvent) { - return this.renderAwaitingEvents(firstEventProjects); - } - if (query === DEFAULT_QUERY) { - return ( - - ); - } - - if (FOR_REVIEW_QUERIES.includes(query || '')) { - return ( - - ); - } - - return this.renderEmpty(); + if (emptyMessage) { + return ( + +

{emptyMessage}

+
+ ); } + + return ; } diff --git a/static/app/views/projectDetail/projectIssues.tsx b/static/app/views/projectDetail/projectIssues.tsx index 3585697372db..387167aa593a 100644 --- a/static/app/views/projectDetail/projectIssues.tsx +++ b/static/app/views/projectDetail/projectIssues.tsx @@ -201,7 +201,6 @@ export function ProjectIssues({organization, location, projectId, query, api}: P Date: Fri, 1 May 2026 15:48:52 -0700 Subject: [PATCH 2/4] cleanup --- .../views/issueList/noGroupsHandler/index.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/static/app/views/issueList/noGroupsHandler/index.tsx b/static/app/views/issueList/noGroupsHandler/index.tsx index cbf5da7c8930..b76cb89130ec 100644 --- a/static/app/views/issueList/noGroupsHandler/index.tsx +++ b/static/app/views/issueList/noGroupsHandler/index.tsx @@ -3,6 +3,7 @@ import {skipToken, useQuery} from '@tanstack/react-query'; import {EmptyStateWarning} from 'sentry/components/emptyStateWarning'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; @@ -28,19 +29,11 @@ interface SentFirstEventResponse { sentFirstEvent: boolean; } -interface AwaitingEventsProps { - groupIds: string[]; - organization: Organization; - projects?: Project[]; -} - interface EmptyResultProps { query: string; emptyMessage?: React.ReactNode; } -const PROJECT_QUERY_COLLAPSE = ['latestDeploys', 'unusedFeatures']; - const UPDATED_EMPTY_STATE_PLATFORMS = new Set([ 'python-django', 'node', @@ -87,7 +80,7 @@ export function NoGroupsHandler({ // member of and make sure there are no first events for all of the projects. // Set project to -1 for all projects. Do not pass a project id for "my projects". const explicitSelectedProjectIds = - selectedProjectIds.length && !selectedProjectIds.includes(-1) + selectedProjectIds.length && !selectedProjectIds.includes(ALL_ACCESS_PROJECTS) ? selectedProjectIds : undefined; const selectedProjectQuery = explicitSelectedProjectIds @@ -112,7 +105,7 @@ export function NoGroupsHandler({ ...apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { path: shouldFetchProject ? {organizationIdOrSlug: organization.slug} : skipToken, query: { - collapse: PROJECT_QUERY_COLLAPSE, + collapse: ['latestDeploys', 'unusedFeatures'], per_page: 1, query: selectedProjectQuery, }, @@ -129,7 +122,7 @@ export function NoGroupsHandler({ return ; } - if (!sentFirstEventQuery.data?.sentFirstEvent) { + if (!sentFirstEventQuery.data?.sentFirstEvent && projectsQuery.data?.length) { return ( ; } +interface AwaitingEventsProps { + groupIds: string[]; + organization: Organization; + projects: Project[]; +} + function AwaitingEvents({groupIds, organization, projects}: AwaitingEventsProps) { const project = projects?.[0]; const sampleIssueId = groupIds.length > 0 ? groupIds[0] : undefined; From 37b7177d11984c6329d713d31ba283efaa309a7a Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 4 May 2026 18:37:48 -0700 Subject: [PATCH 3/4] fix(issues): Show waiting state without projects The react-query cleanup accidentally required the project lookup to return at least one project before showing the waiting-for-events state. Render the waiting state whenever the org has not sent a first event, and add a regression test for orgs with no projects yet. Co-Authored-By: Codex --- .../issueList/noGroupsHandler/index.spec.tsx | 24 +++++++++++++++++++ .../views/issueList/noGroupsHandler/index.tsx | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/static/app/views/issueList/noGroupsHandler/index.spec.tsx b/static/app/views/issueList/noGroupsHandler/index.spec.tsx index fddab6c3f670..bd70a962b03a 100644 --- a/static/app/views/issueList/noGroupsHandler/index.spec.tsx +++ b/static/app/views/issueList/noGroupsHandler/index.spec.tsx @@ -1,4 +1,5 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen} from 'sentry-test/reactTestingLibrary'; @@ -31,6 +32,10 @@ describe('NoGroupsHandler', () => { it('collapses latest deploys when looking up the selected project', async () => { const projectsMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', + body: [ProjectFixture({id: '1844558'})], + }); + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/issues/', body: [], }); const sentFirstEventMock = MockApiClient.addMockResponse({ @@ -77,6 +82,25 @@ describe('NoGroupsHandler', () => { }); it('displays waiting for events state when first event has not been sent', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [ProjectFixture()], + }); + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/issues/', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sent-first-event/', + body: {sentFirstEvent: false}, + }); + + render(); + + expect(await screen.findByText(/Waiting for events/i)).toBeInTheDocument(); + }); + + it('displays waiting for events state when no projects exist yet', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: [], diff --git a/static/app/views/issueList/noGroupsHandler/index.tsx b/static/app/views/issueList/noGroupsHandler/index.tsx index b76cb89130ec..608ba884160b 100644 --- a/static/app/views/issueList/noGroupsHandler/index.tsx +++ b/static/app/views/issueList/noGroupsHandler/index.tsx @@ -122,12 +122,12 @@ export function NoGroupsHandler({ return ; } - if (!sentFirstEventQuery.data?.sentFirstEvent && projectsQuery.data?.length) { + if (!sentFirstEventQuery.data?.sentFirstEvent) { return ( ); } From 8e98124de6edb94d6657f25f58045eb8c61ed6b4 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 5 May 2026 10:04:40 -0700 Subject: [PATCH 4/4] useless test --- .../issueList/noGroupsHandler/index.spec.tsx | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/static/app/views/issueList/noGroupsHandler/index.spec.tsx b/static/app/views/issueList/noGroupsHandler/index.spec.tsx index bd70a962b03a..fb7e83564e6f 100644 --- a/static/app/views/issueList/noGroupsHandler/index.spec.tsx +++ b/static/app/views/issueList/noGroupsHandler/index.spec.tsx @@ -29,43 +29,6 @@ describe('NoGroupsHandler', () => { expect(projectsMock).not.toHaveBeenCalled(); }); - it('collapses latest deploys when looking up the selected project', async () => { - const projectsMock = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/projects/', - body: [ProjectFixture({id: '1844558'})], - }); - MockApiClient.addMockResponse({ - url: '/projects/org-slug/project-slug/issues/', - body: [], - }); - const sentFirstEventMock = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/sent-first-event/', - body: {sentFirstEvent: false}, - }); - - render(); - - expect(await screen.findByText(/Waiting for events/i)).toBeInTheDocument(); - expect(sentFirstEventMock).toHaveBeenCalledWith( - '/organizations/org-slug/sent-first-event/', - expect.objectContaining({ - query: expect.objectContaining({ - project: [1844558], - }), - }) - ); - expect(projectsMock).toHaveBeenCalledWith( - '/organizations/org-slug/projects/', - expect.objectContaining({ - query: expect.objectContaining({ - collapse: ['latestDeploys', 'unusedFeatures'], - per_page: 1, - query: 'id:1844558', - }), - }) - ); - }); - it('displays default empty state when an error occurs', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/',