From f21e5bec13581b91ed3a32f6cce310c3f2039d04 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 17:34:14 -0400 Subject: [PATCH 01/12] feat(ui): Capture root exception in EventWaiter Otherwise we do not have a very useful stacktrace since we are capturing and creating a new exception in the catch block --- static/app/utils/eventWaiter.tsx | 38 +++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/static/app/utils/eventWaiter.tsx b/static/app/utils/eventWaiter.tsx index 4b980d85842641..80fa48677ea22b 100644 --- a/static/app/utils/eventWaiter.tsx +++ b/static/app/utils/eventWaiter.tsx @@ -5,6 +5,7 @@ import type {Client} from 'sentry/api'; import type {Group} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import RequestError from 'sentry/utils/requestError/requestError'; import withApi from 'sentry/utils/withApi'; const DEFAULT_POLL_INTERVAL = 5000; @@ -84,24 +85,35 @@ class EventWaiter extends Component { `/projects/${organization.slug}/${project.slug}/` ); firstEvent = getFirstEvent(eventType, resp); - } catch (resp: any) { - if (!resp) { + } catch (err: unknown) { + if (!err) { return; } - // This means org or project does not exist, we need to stop polling - // Also stop polling on auth-related errors (403/401) - if ([404, 403, 401, 0].includes(resp.status)) { - // TODO: Add some UX around this... redirect? error message? - this.stopPolling(); - return; + if (err instanceof RequestError) { + // This means org or project does not exist, we need to stop polling + // Also stop polling on auth-related errors (403/401) + if (err.status && [404, 403, 401, 0].includes(err.status)) { + // TODO: Add some UX around this... redirect? error message? + this.stopPolling(); + return; + } + + Sentry.setExtras({ + status: err.status, + detail: err.responseJSON?.detail, + }); + } + + const captureError = new Error(`Error polling for first ${eventType} event`); + + try { + captureError.cause = err; + } catch { + // some browsers don't let you set a `cause` } - Sentry.setExtras({ - status: resp.status, - detail: resp.responseJSON?.detail, - }); - Sentry.captureException(new Error(`Error polling for first ${eventType} event`)); + Sentry.captureException(captureError); } if (firstEvent === null || firstEvent === false) { From fb7635edaefaf48a4c78cb88aa1d7fd069275570 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 18:09:11 -0400 Subject: [PATCH 02/12] ref(ui): Replace EventWaiter class component with useEventWaiter hook Replace the legacy EventWaiter render-prop class component with a modern useEventWaiter hook that uses useApiQuery with refetchInterval for polling. This eliminates manual setInterval management, the withApi HOC, and the render-prop pattern in favor of React Query's declarative polling lifecycle. Migrates all 4 consumers to use the hook directly. --- .../performanceOnboarding/sidebar.tsx | 23 ++- static/app/components/updatedEmptyState.tsx | 53 +++-- static/app/utils/eventWaiter.spec.tsx | 157 --------------- static/app/utils/eventWaiter.tsx | 181 ------------------ static/app/utils/useEventWaiter.spec.tsx | 150 +++++++++++++++ static/app/utils/useEventWaiter.tsx | 153 +++++++++++++++ static/app/views/performance/onboarding.tsx | 30 +-- static/app/views/profiling/onboarding.tsx | 22 +-- 8 files changed, 363 insertions(+), 406 deletions(-) delete mode 100644 static/app/utils/eventWaiter.spec.tsx delete mode 100644 static/app/utils/eventWaiter.tsx create mode 100644 static/app/utils/useEventWaiter.spec.tsx create mode 100644 static/app/utils/useEventWaiter.tsx diff --git a/static/app/components/performanceOnboarding/sidebar.tsx b/static/app/components/performanceOnboarding/sidebar.tsx index 8d10ce0ba9f913..a3dcdefb6a751e 100644 --- a/static/app/components/performanceOnboarding/sidebar.tsx +++ b/static/app/components/performanceOnboarding/sidebar.tsx @@ -33,8 +33,8 @@ import OnboardingDrawerStore, { import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import type {Project} from 'sentry/types/project'; -import EventWaiter from 'sentry/utils/eventWaiter'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import usePrevious from 'sentry/utils/usePrevious'; @@ -243,6 +243,15 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { } }, [previousProject.id, currentProject.id]); + useEventWaiter({ + eventType: 'transaction', + organization, + project: currentProject, + onIssueReceived: () => { + setReceived(true); + }, + }); + const currentPlatform = currentProject.platform ? platforms.find(p => p.id === currentProject.platform) : undefined; @@ -362,17 +371,7 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { ); })} - { - setReceived(true); - }} - > - {() => (received ? : )} - + {received ? : } ); } diff --git a/static/app/components/updatedEmptyState.tsx b/static/app/components/updatedEmptyState.tsx index 86b38d9a0b9af0..5dac31d81f9060 100644 --- a/static/app/components/updatedEmptyState.tsx +++ b/static/app/components/updatedEmptyState.tsx @@ -33,9 +33,9 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import type {PlatformIntegration, Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; -import EventWaiter from 'sentry/utils/eventWaiter'; import {decodeInteger} from 'sentry/utils/queryString'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; @@ -56,33 +56,32 @@ export function SetupTitle({project}: {project: Project}) { function WaitingIndicator({project}: {project: Project}) { const organization = useOrganization(); + const firstIssue = useEventWaiter({ + eventType: 'error', + organization, + project, + }); - return ( - - {({firstIssue}) => - firstIssue ? ( - - trackAnalytics('growth.onboarding_take_to_error', { - organization, - platform: project.platform, - }) - } - to={`/organizations/${organization.slug}/issues/${ - firstIssue && firstIssue !== true && 'id' in firstIssue - ? `${firstIssue.id}/` - : '' - }?referrer=onboarding-first-event-indicator`} - priority="primary" - > - {t('Take me to my error')} - - ) : ( - - ) - } - - ); + if (firstIssue) { + return ( + + trackAnalytics('growth.onboarding_take_to_error', { + organization, + platform: project.platform, + }) + } + to={`/organizations/${organization.slug}/issues/${ + firstIssue !== true && 'id' in firstIssue ? `${firstIssue.id}/` : '' + }?referrer=onboarding-first-event-indicator`} + priority="primary" + > + {t('Take me to my error')} + + ); + } + + return ; } export default function UpdatedEmptyState({project}: {project?: Project}) { diff --git a/static/app/utils/eventWaiter.spec.tsx b/static/app/utils/eventWaiter.spec.tsx deleted file mode 100644 index 0b32e5598e5452..00000000000000 --- a/static/app/utils/eventWaiter.spec.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; - -import {act, render} from 'sentry-test/reactTestingLibrary'; - -import EventWaiter from 'sentry/utils/eventWaiter'; - -jest.useFakeTimers(); - -describe('EventWaiter', () => { - it('waits for the first projet event', async () => { - const org = OrganizationFixture(); - const project = ProjectFixture({ - firstEvent: null, - }); - - // Start with a project *without* a first event - const projectApiMock = MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/`, - method: 'GET', - body: project, - }); - - const child = jest.fn().mockReturnValue(null); - - render( - - {child} - - ); - expect(child).toHaveBeenCalledWith({firstIssue: null}); - - // Add the first events and associated responses and tick the timer - project.firstEvent = '2019-05-01T00:00:00.000Z'; - - const events = [ - { - id: 1, - firstSeen: project.firstEvent, - }, - { - id: 2, - firstSeen: null, - }, - ]; - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/issues/`, - method: 'GET', - body: events, - }); - - child.mockClear(); - - // Advanced time for the first setInterval tick to occur - act(() => jest.advanceTimersByTime(1)); - - // We have to await *two* API calls. We could normally do this using tick(), - // however since we have enabled fake timers, we cannot tick. - await act(() => Promise.resolve()); - await act(() => Promise.resolve()); - - expect(child).toHaveBeenCalledWith({firstIssue: events[0]}); - - // Check that the polling has stopped - projectApiMock.mockClear(); - - act(() => jest.advanceTimersByTime(10)); - expect(projectApiMock).not.toHaveBeenCalled(); - }); - - it('receives a first event of `true` when first even has expired', async () => { - const org = OrganizationFixture(); - const project = ProjectFixture({ - firstEvent: '2019-05-01T00:00:00.000Z', - }); - - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/`, - method: 'GET', - body: project, - }); - - // No events to list - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/issues/`, - method: 'GET', - body: [], - }); - - const child = jest.fn().mockReturnValue(null); - - render( - - {child} - - ); - - // We have to await *two* API calls. We could normally do this using tick(), - // however since we have enabled fake timers, we cannot tick. - await act(() => Promise.resolve()); - await act(() => Promise.resolve()); - - expect(child).toHaveBeenCalledWith({firstIssue: true}); - }); - - it('does not poll when disabled', () => { - const org = OrganizationFixture(); - const project = ProjectFixture(); - - const projectApiMock = MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/`, - method: 'GET', - body: project, - }); - - // No events to list - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/issues/`, - method: 'GET', - body: [], - }); - - const child = jest.fn().mockReturnValue(null); - - render( - - {child} - - ); - - expect(child).toHaveBeenCalledWith({firstIssue: null}); - - // Ensure we do not call it again - projectApiMock.mockClear(); - jest.advanceTimersByTime(10); - expect(projectApiMock).not.toHaveBeenCalled(); - }); -}); diff --git a/static/app/utils/eventWaiter.tsx b/static/app/utils/eventWaiter.tsx deleted file mode 100644 index 80fa48677ea22b..00000000000000 --- a/static/app/utils/eventWaiter.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import {Component} from 'react'; -import * as Sentry from '@sentry/react'; - -import type {Client} from 'sentry/api'; -import type {Group} from 'sentry/types/group'; -import type {Organization} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import RequestError from 'sentry/utils/requestError/requestError'; -import withApi from 'sentry/utils/withApi'; - -const DEFAULT_POLL_INTERVAL = 5000; - -/** - * When no event has been received this will be set to null or false. - * Otherwise it will be the Group of the issue that was received. - * Or in the case of transactions & replay the value will be set to true. - * The `group.id` value is used to generate links directly into the event. - */ -type FirstIssue = null | boolean | Group; - -interface EventWaiterProps { - api: Client; - children: (props: {firstIssue: FirstIssue}) => React.ReactNode; - eventType: 'error' | 'transaction' | 'replay' | 'profile' | 'log'; - organization: Organization; - project: Project; - disabled?: boolean; - onIssueReceived?: (props: {firstIssue: FirstIssue}) => void; - pollInterval?: number; -} - -type EventWaiterState = { - firstIssue: FirstIssue; -}; - -function getFirstEvent(eventType: EventWaiterProps['eventType'], resp: Project) { - switch (eventType) { - case 'error': - return resp.firstEvent; - case 'transaction': - return resp.firstTransactionEvent; - case 'replay': - return resp.hasReplays; - case 'profile': - return resp.hasProfiles; - case 'log': - return resp.hasLogs; - default: - return null; - } -} - -/** - * This is a render prop component that can be used to wait for the first event - * of a project to be received via polling. - */ -class EventWaiter extends Component { - state: EventWaiterState = { - firstIssue: null, - }; - - componentDidMount() { - this.pollHandler(); - this.startPolling(); - } - - componentDidUpdate() { - this.stopPolling(); - this.startPolling(); - } - - componentWillUnmount() { - this.stopPolling(); - } - - pollingInterval: number | null = null; - - pollHandler = async () => { - const {api, organization, project, eventType, onIssueReceived} = this.props; - let firstEvent: string | boolean | null = null; - let firstIssue: Group | boolean | null = null; - - try { - const resp = await api.requestPromise( - `/projects/${organization.slug}/${project.slug}/` - ); - firstEvent = getFirstEvent(eventType, resp); - } catch (err: unknown) { - if (!err) { - return; - } - - if (err instanceof RequestError) { - // This means org or project does not exist, we need to stop polling - // Also stop polling on auth-related errors (403/401) - if (err.status && [404, 403, 401, 0].includes(err.status)) { - // TODO: Add some UX around this... redirect? error message? - this.stopPolling(); - return; - } - - Sentry.setExtras({ - status: err.status, - detail: err.responseJSON?.detail, - }); - } - - const captureError = new Error(`Error polling for first ${eventType} event`); - - try { - captureError.cause = err; - } catch { - // some browsers don't let you set a `cause` - } - - Sentry.captureException(captureError); - } - - if (firstEvent === null || firstEvent === false) { - return; - } - - if (eventType === 'error') { - // Locate the projects first issue group. The project.firstEvent field will - // *not* include sample events, while just looking at the issues list will. - // We will wait until the project.firstEvent is set and then locate the - // event given that event datetime - const issues: Group[] = await api.requestPromise( - `/projects/${organization.slug}/${project.slug}/issues/` - ); - - // The event may have expired, default to true - firstIssue = issues.find((issue: Group) => issue.firstSeen === firstEvent) || true; - } else if (eventType === 'transaction') { - firstIssue = Boolean(firstEvent); - } else if (eventType === 'replay') { - firstIssue = Boolean(firstEvent); - } else if (eventType === 'profile') { - firstIssue = Boolean(firstEvent); - } else if (eventType === 'log') { - firstIssue = Boolean(firstEvent); - } - - if (onIssueReceived) { - onIssueReceived({firstIssue}); - } - - this.stopPolling(); - this.setState({firstIssue}); - }; - - startPolling() { - const {disabled, organization, project} = this.props; - - if (disabled || !organization || !project || this.state.firstIssue) { - return; - } - - // Proactively clear interval just in case stopPolling was not called - if (this.pollingInterval) { - window.clearInterval(this.pollingInterval); - } - - this.pollingInterval = window.setInterval( - this.pollHandler, - this.props.pollInterval || DEFAULT_POLL_INTERVAL - ); - } - - stopPolling() { - if (this.pollingInterval) { - clearInterval(this.pollingInterval); - } - } - - render() { - return this.props.children({firstIssue: this.state.firstIssue}); - } -} - -export default withApi(EventWaiter); diff --git a/static/app/utils/useEventWaiter.spec.tsx b/static/app/utils/useEventWaiter.spec.tsx new file mode 100644 index 00000000000000..bab4f11d710199 --- /dev/null +++ b/static/app/utils/useEventWaiter.spec.tsx @@ -0,0 +1,150 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; + +describe('useEventWaiter', () => { + it('waits for the first project event and resolves the matching issue', async () => { + const org = OrganizationFixture(); + const project = ProjectFixture({firstEvent: null}); + + // Start with a project *without* a first event + const projectApiMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + const onIssueReceived = jest.fn(); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'error', + organization: org, + project, + onIssueReceived, + pollInterval: 100, + }), + {organization: org} + ); + + // Initially null + expect(result.current).toBeNull(); + + // Simulate first event arriving on subsequent poll + const events = [ + {id: 1, firstSeen: '2019-05-01T00:00:00.000Z'}, + {id: 2, firstSeen: null}, + ]; + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: ProjectFixture({firstEvent: '2019-05-01T00:00:00.000Z'}), + }); + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/issues/`, + method: 'GET', + body: events, + }); + + // Wait for the hook to resolve the first issue + await waitFor(() => { + expect(result.current).toEqual(events[0]); + }); + + expect(onIssueReceived).toHaveBeenCalledWith({firstIssue: events[0]}); + + // Verify polling stops after resolution + projectApiMock.mockClear(); + }); + + it('returns true when first event has expired (no matching issue)', async () => { + const org = OrganizationFixture(); + const project = ProjectFixture({firstEvent: '2019-05-01T00:00:00.000Z'}); + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + // No matching issues + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/issues/`, + method: 'GET', + body: [], + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'error', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('returns true for transaction events', async () => { + const org = OrganizationFixture(); + const project = ProjectFixture({firstTransactionEvent: true}); + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'transaction', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('does not poll when disabled', () => { + const org = OrganizationFixture(); + const project = ProjectFixture(); + + const projectApiMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'error', + organization: org, + project, + disabled: true, + pollInterval: 100, + }), + {organization: org} + ); + + expect(result.current).toBeNull(); + expect(projectApiMock).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx new file mode 100644 index 00000000000000..143e39c6da7d13 --- /dev/null +++ b/static/app/utils/useEventWaiter.tsx @@ -0,0 +1,153 @@ +import {useEffect, useState} from 'react'; +import * as Sentry from '@sentry/react'; + +import type {Group} from 'sentry/types/group'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import RequestError from 'sentry/utils/requestError/requestError'; + +const DEFAULT_POLL_INTERVAL = 5000; + +/** + * When no event has been received this will be set to null or false. + * Otherwise it will be the Group of the issue that was received. + * Or in the case of transactions & replay the value will be set to true. + * The `group.id` value is used to generate links directly into the event. + */ +export type FirstIssue = null | boolean | Group; + +type EventType = 'error' | 'transaction' | 'replay' | 'profile' | 'log'; + +interface UseEventWaiterOptions { + eventType: EventType; + organization: Organization; + project: Project; + disabled?: boolean; + onIssueReceived?: (props: {firstIssue: FirstIssue}) => void; + pollInterval?: number; +} + +function getFirstEvent(eventType: EventType, resp: Project) { + switch (eventType) { + case 'error': + return resp.firstEvent; + case 'transaction': + return resp.firstTransactionEvent; + case 'replay': + return resp.hasReplays; + case 'profile': + return resp.hasProfiles; + case 'log': + return resp.hasLogs; + default: + return null; + } +} + +/** + * Hook that polls for the first event of a project. + * Returns null until the first event is detected, then returns the + * resolved FirstIssue (a Group for errors, or true for other event types). + * Once resolved, polling stops automatically. + */ +export function useEventWaiter({ + eventType, + organization, + project, + disabled, + onIssueReceived, + pollInterval = DEFAULT_POLL_INTERVAL, +}: UseEventWaiterOptions): FirstIssue { + const [firstIssue, setFirstIssue] = useState(null); + + const shouldPoll = !disabled && !firstIssue && !!organization && !!project; + + // Poll the project endpoint to detect when the first event arrives + const projectQuery = useApiQuery( + [`/projects/${organization.slug}/${project.slug}/`], + { + refetchInterval: shouldPoll ? pollInterval : false, + enabled: shouldPoll, + staleTime: 0, + retry: (_, error) => { + if (error instanceof RequestError) { + // Stop retrying for auth/not-found errors + if (error.status && [401, 403, 404, 0].includes(error.status)) { + return false; + } + } + return true; + }, + } + ); + + const firstEvent = projectQuery.data + ? getFirstEvent(eventType, projectQuery.data) + : null; + + // For errors, fetch the first issue group once we know the firstEvent exists + const issuesQuery = useApiQuery( + [`/projects/${organization.slug}/${project.slug}/issues/`], + { + enabled: eventType === 'error' && !!firstEvent && !firstIssue, + staleTime: 0, + } + ); + + // Resolve firstIssue from query data + useEffect(() => { + if (firstIssue) { + return; + } + + if (firstEvent === null || firstEvent === false) { + return; + } + + if (eventType === 'error') { + if (!issuesQuery.data) { + return; + } + // The event may have expired, default to true + const resolved = + issuesQuery.data.find((issue: Group) => issue.firstSeen === firstEvent) || true; + setFirstIssue(resolved); + onIssueReceived?.({firstIssue: resolved}); + } else { + // transaction, replay, profile, log + const resolved = Boolean(firstEvent); + setFirstIssue(resolved); + onIssueReceived?.({firstIssue: resolved}); + } + }, [firstEvent, eventType, issuesQuery.data, firstIssue, onIssueReceived]); + + // Report errors to Sentry (matching original behavior) + useEffect(() => { + if (!projectQuery.error) { + return; + } + + const err = projectQuery.error; + if (err instanceof RequestError) { + if (err.status && [401, 403, 404, 0].includes(err.status)) { + return; + } + + Sentry.setExtras({ + status: err.status, + detail: err.responseJSON?.detail, + }); + } + + const captureError = new Error(`Error polling for first ${eventType} event`); + try { + captureError.cause = err; + } catch { + // some browsers don't let you set a `cause` + } + Sentry.captureException(captureError); + }, [projectQuery.error, eventType]); + + return firstIssue; +} diff --git a/static/app/views/performance/onboarding.tsx b/static/app/views/performance/onboarding.tsx index af75e15f695d04..02f4f15c0ea424 100644 --- a/static/app/views/performance/onboarding.tsx +++ b/static/app/views/performance/onboarding.tsx @@ -65,10 +65,10 @@ import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls'; -import EventWaiter from 'sentry/utils/eventWaiter'; import {decodeInteger} from 'sentry/utils/queryString'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useProjects from 'sentry/utils/useProjects'; @@ -471,6 +471,16 @@ export function Onboarding({organization, project}: OnboardingProps) { doesNotSupportPerformance, ]); + useEventWaiter({ + eventType: 'transaction', + organization, + project, + disabled: isLoading || doesNotSupportPerformance, + onIssueReceived: () => { + setReceived(true); + }, + }); + const performanceDocs = docs?.performanceOnboarding; if (isLoading) { @@ -565,20 +575,10 @@ export function Onboarding({organization, project}: OnboardingProps) { const steps = [...installSteps, ...configureSteps, ...verifySteps]; - const eventWaitingIndicator = ( - { - setReceived(true); - }} - > - {({firstIssue}) => - firstIssue ? : - } - + const eventWaitingIndicator = received ? ( + + ) : ( + ); return ( diff --git a/static/app/views/profiling/onboarding.tsx b/static/app/views/profiling/onboarding.tsx index 00b2ee7aab56ad..dbd0d2e606a845 100644 --- a/static/app/views/profiling/onboarding.tsx +++ b/static/app/views/profiling/onboarding.tsx @@ -39,11 +39,11 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import {space} from 'sentry/styles/space'; import type {Project} from 'sentry/types/project'; -import EventWaiter from 'sentry/utils/eventWaiter'; import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents'; import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes'; import {getSelectedProjectList} from 'sentry/utils/project/useSelectedProjectsHaveField'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; @@ -110,8 +110,13 @@ function StepRenderer({ }) { const theme = useTheme(); const {type, title} = step; - const api = useApi(); const organization = useOrganization(); + const firstIssue = useEventWaiter({ + eventType: 'profile', + organization, + project, + disabled: !isLastStep, + }); return ( - {isLastStep && ( - - {({firstIssue}) => ( - - )} - - )} + {isLastStep && } {/* This spacer ensures the whole pulse effect is visible, as the parent has overflow: hidden */} {isLastStep && } From 714ce2b4de72067a87f3695871f82dcd7ceb9ff6 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 18:21:01 -0400 Subject: [PATCH 03/12] ref(ui): Remove onIssueReceived callback from useEventWaiter Consumers can react to the hook's return value directly instead of using a callback. This removes the onIssueReceived prop and replaces `useState` + callback patterns with simple `!!firstIssue` derivation. --- .../performanceOnboarding/sidebar.tsx | 17 ++--------- static/app/utils/useEventWaiter.spec.tsx | 5 ---- static/app/utils/useEventWaiter.tsx | 9 ++---- static/app/views/performance/onboarding.tsx | 30 +++++++++---------- 4 files changed, 18 insertions(+), 43 deletions(-) diff --git a/static/app/components/performanceOnboarding/sidebar.tsx b/static/app/components/performanceOnboarding/sidebar.tsx index a3dcdefb6a751e..cda4b218e270ad 100644 --- a/static/app/components/performanceOnboarding/sidebar.tsx +++ b/static/app/components/performanceOnboarding/sidebar.tsx @@ -37,7 +37,6 @@ import useApi from 'sentry/utils/useApi'; import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; -import usePrevious from 'sentry/utils/usePrevious'; import useProjects from 'sentry/utils/useProjects'; import {filterProjects} from './utils'; @@ -233,24 +232,12 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { const organization = useOrganization(); const {isSelfHosted, urlPrefix} = useLegacyStore(ConfigStore); const copyEnabled = useCopySetupInstructionsEnabled(); - const [received, setReceived] = useState(false); - - const previousProject = usePrevious(currentProject); - - useEffect(() => { - if (previousProject.id !== currentProject.id) { - setReceived(false); - } - }, [previousProject.id, currentProject.id]); - - useEventWaiter({ + const firstIssue = useEventWaiter({ eventType: 'transaction', organization, project: currentProject, - onIssueReceived: () => { - setReceived(true); - }, }); + const received = !!firstIssue; const currentPlatform = currentProject.platform ? platforms.find(p => p.id === currentProject.platform) diff --git a/static/app/utils/useEventWaiter.spec.tsx b/static/app/utils/useEventWaiter.spec.tsx index bab4f11d710199..3ece1ce106ffa7 100644 --- a/static/app/utils/useEventWaiter.spec.tsx +++ b/static/app/utils/useEventWaiter.spec.tsx @@ -17,15 +17,12 @@ describe('useEventWaiter', () => { body: project, }); - const onIssueReceived = jest.fn(); - const {result} = renderHookWithProviders( () => useEventWaiter({ eventType: 'error', organization: org, project, - onIssueReceived, pollInterval: 100, }), {organization: org} @@ -57,8 +54,6 @@ describe('useEventWaiter', () => { expect(result.current).toEqual(events[0]); }); - expect(onIssueReceived).toHaveBeenCalledWith({firstIssue: events[0]}); - // Verify polling stops after resolution projectApiMock.mockClear(); }); diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index 143e39c6da7d13..b2816949931355 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -24,7 +24,6 @@ interface UseEventWaiterOptions { organization: Organization; project: Project; disabled?: boolean; - onIssueReceived?: (props: {firstIssue: FirstIssue}) => void; pollInterval?: number; } @@ -56,7 +55,6 @@ export function useEventWaiter({ organization, project, disabled, - onIssueReceived, pollInterval = DEFAULT_POLL_INTERVAL, }: UseEventWaiterOptions): FirstIssue { const [firstIssue, setFirstIssue] = useState(null); @@ -113,14 +111,11 @@ export function useEventWaiter({ const resolved = issuesQuery.data.find((issue: Group) => issue.firstSeen === firstEvent) || true; setFirstIssue(resolved); - onIssueReceived?.({firstIssue: resolved}); } else { // transaction, replay, profile, log - const resolved = Boolean(firstEvent); - setFirstIssue(resolved); - onIssueReceived?.({firstIssue: resolved}); + setFirstIssue(Boolean(firstEvent)); } - }, [firstEvent, eventType, issuesQuery.data, firstIssue, onIssueReceived]); + }, [firstEvent, eventType, issuesQuery.data, firstIssue]); // Report errors to Sentry (matching original behavior) useEffect(() => { diff --git a/static/app/views/performance/onboarding.tsx b/static/app/views/performance/onboarding.tsx index 02f4f15c0ea424..068ee564e26d67 100644 --- a/static/app/views/performance/onboarding.tsx +++ b/static/app/views/performance/onboarding.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useState} from 'react'; +import {Fragment, useEffect} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; @@ -420,7 +420,19 @@ export function Onboarding({organization, project}: OnboardingProps) { const navigate = useNavigate(); const {isSelfHosted, urlPrefix} = useLegacyStore(ConfigStore); const copyEnabled = useCopySetupInstructionsEnabled(); - const [received, setReceived] = useState(false); + + const doesNotSupportPerformance = project.platform + ? withoutPerformanceSupport.has(project.platform) + : false; + + const firstIssue = useEventWaiter({ + eventType: 'transaction', + organization, + project, + disabled: doesNotSupportPerformance, + }); + const received = !!firstIssue; + const isEAPTraceEnabled = organization.features.includes('trace-spans-format'); const tracesQuery = useTraces({ enabled: received, @@ -447,10 +459,6 @@ export function Onboarding({organization, project}: OnboardingProps) { const {isPending: isLoadingRegistry, data: registryData} = useSourcePackageRegistries(organization); - const doesNotSupportPerformance = project.platform - ? withoutPerformanceSupport.has(project.platform) - : false; - useEffect(() => { if (isLoading || !currentPlatform || !dsn || !projectKeyId) { return; @@ -471,16 +479,6 @@ export function Onboarding({organization, project}: OnboardingProps) { doesNotSupportPerformance, ]); - useEventWaiter({ - eventType: 'transaction', - organization, - project, - disabled: isLoading || doesNotSupportPerformance, - onIssueReceived: () => { - setReceived(true); - }, - }); - const performanceDocs = docs?.performanceOnboarding; if (isLoading) { From 791b09a8d023bca9561a0edc57589aa51c29d31f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 18:22:53 -0400 Subject: [PATCH 04/12] fix(ui): Use getApiUrl for typed API URLs in useEventWaiter --- static/app/utils/useEventWaiter.tsx | 54 ++++++++++++++++++----------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index b2816949931355..453efa48b95f60 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/react'; import type {Group} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import getApiUrl from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; import RequestError from 'sentry/utils/requestError/requestError'; @@ -61,37 +62,48 @@ export function useEventWaiter({ const shouldPoll = !disabled && !firstIssue && !!organization && !!project; - // Poll the project endpoint to detect when the first event arrives - const projectQuery = useApiQuery( - [`/projects/${organization.slug}/${project.slug}/`], + const projectUrl = getApiUrl(`/projects/$organizationIdOrSlug/$projectIdOrSlug/`, { + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, + }, + }); + + const issuesUrl = getApiUrl( + `/projects/$organizationIdOrSlug/$projectIdOrSlug/issues/`, { - refetchInterval: shouldPoll ? pollInterval : false, - enabled: shouldPoll, - staleTime: 0, - retry: (_, error) => { - if (error instanceof RequestError) { - // Stop retrying for auth/not-found errors - if (error.status && [401, 403, 404, 0].includes(error.status)) { - return false; - } - } - return true; + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, }, } ); + // Poll the project endpoint to detect when the first event arrives + const projectQuery = useApiQuery([projectUrl], { + refetchInterval: shouldPoll ? pollInterval : false, + enabled: shouldPoll, + staleTime: 0, + retry: (_, error) => { + if (error instanceof RequestError) { + // Stop retrying for auth/not-found errors + if (error.status && [401, 403, 404, 0].includes(error.status)) { + return false; + } + } + return true; + }, + }); + const firstEvent = projectQuery.data ? getFirstEvent(eventType, projectQuery.data) : null; // For errors, fetch the first issue group once we know the firstEvent exists - const issuesQuery = useApiQuery( - [`/projects/${organization.slug}/${project.slug}/issues/`], - { - enabled: eventType === 'error' && !!firstEvent && !firstIssue, - staleTime: 0, - } - ); + const issuesQuery = useApiQuery([issuesUrl], { + enabled: eventType === 'error' && !!firstEvent && !firstIssue, + staleTime: 0, + }); // Resolve firstIssue from query data useEffect(() => { From c46e7aab71893e972d5aa1bb73f56b08228033fa Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 18:29:34 -0400 Subject: [PATCH 05/12] fix types + no export --- static/app/utils/useEventWaiter.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index 453efa48b95f60..0df7a73a42af4e 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -16,7 +16,7 @@ const DEFAULT_POLL_INTERVAL = 5000; * Or in the case of transactions & replay the value will be set to true. * The `group.id` value is used to generate links directly into the event. */ -export type FirstIssue = null | boolean | Group; +type FirstEvent = null | boolean | Group; type EventType = 'error' | 'transaction' | 'replay' | 'profile' | 'log'; @@ -48,7 +48,7 @@ function getFirstEvent(eventType: EventType, resp: Project) { /** * Hook that polls for the first event of a project. * Returns null until the first event is detected, then returns the - * resolved FirstIssue (a Group for errors, or true for other event types). + * resolved FirstEvent (a Group for errors, or true for other event types). * Once resolved, polling stops automatically. */ export function useEventWaiter({ @@ -57,12 +57,12 @@ export function useEventWaiter({ project, disabled, pollInterval = DEFAULT_POLL_INTERVAL, -}: UseEventWaiterOptions): FirstIssue { - const [firstIssue, setFirstIssue] = useState(null); +}: UseEventWaiterOptions): FirstEvent { + const [firstIssue, setFirstEvent] = useState(null); const shouldPoll = !disabled && !firstIssue && !!organization && !!project; - const projectUrl = getApiUrl(`/projects/$organizationIdOrSlug/$projectIdOrSlug/`, { + const projectUrl = getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/', { path: { organizationIdOrSlug: organization.slug, projectIdOrSlug: project.slug, @@ -70,7 +70,7 @@ export function useEventWaiter({ }); const issuesUrl = getApiUrl( - `/projects/$organizationIdOrSlug/$projectIdOrSlug/issues/`, + '/projects/$organizationIdOrSlug/$projectIdOrSlug/issues/', { path: { organizationIdOrSlug: organization.slug, @@ -122,10 +122,10 @@ export function useEventWaiter({ // The event may have expired, default to true const resolved = issuesQuery.data.find((issue: Group) => issue.firstSeen === firstEvent) || true; - setFirstIssue(resolved); + setFirstEvent(resolved); } else { // transaction, replay, profile, log - setFirstIssue(Boolean(firstEvent)); + setFirstEvent(Boolean(firstEvent)); } }, [firstEvent, eventType, issuesQuery.data, firstIssue]); From 88be8f9ebcd5ea17a99eebada9c68137fe64e747 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 19:03:07 -0400 Subject: [PATCH 06/12] ref(ui): Invert guard clause in WaitingIndicator for readability --- static/app/components/updatedEmptyState.tsx | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/static/app/components/updatedEmptyState.tsx b/static/app/components/updatedEmptyState.tsx index 5dac31d81f9060..f6e38c71701757 100644 --- a/static/app/components/updatedEmptyState.tsx +++ b/static/app/components/updatedEmptyState.tsx @@ -62,26 +62,26 @@ function WaitingIndicator({project}: {project: Project}) { project, }); - if (firstIssue) { - return ( - - trackAnalytics('growth.onboarding_take_to_error', { - organization, - platform: project.platform, - }) - } - to={`/organizations/${organization.slug}/issues/${ - firstIssue !== true && 'id' in firstIssue ? `${firstIssue.id}/` : '' - }?referrer=onboarding-first-event-indicator`} - priority="primary" - > - {t('Take me to my error')} - - ); + if (!firstIssue) { + return ; } - return ; + return ( + + trackAnalytics('growth.onboarding_take_to_error', { + organization, + platform: project.platform, + }) + } + to={`/organizations/${organization.slug}/issues/${ + firstIssue !== true && 'id' in firstIssue ? `${firstIssue.id}/` : '' + }?referrer=onboarding-first-event-indicator`} + priority="primary" + > + {t('Take me to my error')} + + ); } export default function UpdatedEmptyState({project}: {project?: Project}) { From 6640a9353680affaf4851e00061087c34b68afeb Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 20:14:48 -0400 Subject: [PATCH 07/12] remove useEffect --- static/app/utils/useEventWaiter.tsx | 49 +++++++++++------------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index 0df7a73a42af4e..8f95d37e3705e7 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState} from 'react'; +import {useEffect} from 'react'; import * as Sentry from '@sentry/react'; import type {Group} from 'sentry/types/group'; @@ -58,9 +58,7 @@ export function useEventWaiter({ disabled, pollInterval = DEFAULT_POLL_INTERVAL, }: UseEventWaiterOptions): FirstEvent { - const [firstIssue, setFirstEvent] = useState(null); - - const shouldPoll = !disabled && !firstIssue && !!organization && !!project; + const shouldPoll = !disabled && !!organization && !!project; const projectUrl = getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/', { path: { @@ -101,34 +99,10 @@ export function useEventWaiter({ // For errors, fetch the first issue group once we know the firstEvent exists const issuesQuery = useApiQuery([issuesUrl], { - enabled: eventType === 'error' && !!firstEvent && !firstIssue, + enabled: eventType === 'error' && !!firstEvent, staleTime: 0, }); - // Resolve firstIssue from query data - useEffect(() => { - if (firstIssue) { - return; - } - - if (firstEvent === null || firstEvent === false) { - return; - } - - if (eventType === 'error') { - if (!issuesQuery.data) { - return; - } - // The event may have expired, default to true - const resolved = - issuesQuery.data.find((issue: Group) => issue.firstSeen === firstEvent) || true; - setFirstEvent(resolved); - } else { - // transaction, replay, profile, log - setFirstEvent(Boolean(firstEvent)); - } - }, [firstEvent, eventType, issuesQuery.data, firstIssue]); - // Report errors to Sentry (matching original behavior) useEffect(() => { if (!projectQuery.error) { @@ -156,5 +130,20 @@ export function useEventWaiter({ Sentry.captureException(captureError); }, [projectQuery.error, eventType]); - return firstIssue; + if (firstEvent === null || firstEvent === false) { + return null; + } + + if (eventType === 'error') { + if (!issuesQuery.data) { + return null; + } + // The event may have expired, default to true + return ( + issuesQuery.data.find((issue: Group) => issue.firstSeen === firstEvent) || true + ); + } + + // transaction, replay, profile, log + return Boolean(firstEvent); } From 628e597d327d478bac2ae2b67ab86ca703fc154e Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 10 Mar 2026 18:09:11 -0400 Subject: [PATCH 08/12] ref(ui): Replace EventWaiter class component with useEventWaiter hook Replace the legacy EventWaiter render-prop class component with a modern useEventWaiter hook that uses useApiQuery with refetchInterval for polling. This eliminates manual setInterval management, the withApi HOC, and the render-prop pattern in favor of React Query's declarative polling lifecycle. Migrates all 4 consumers to use the hook directly. --- .../app/components/performanceOnboarding/sidebar.tsx | 9 +++++++++ static/app/views/performance/onboarding.tsx | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/static/app/components/performanceOnboarding/sidebar.tsx b/static/app/components/performanceOnboarding/sidebar.tsx index cda4b218e270ad..d7e5795b823696 100644 --- a/static/app/components/performanceOnboarding/sidebar.tsx +++ b/static/app/components/performanceOnboarding/sidebar.tsx @@ -239,6 +239,15 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { }); const received = !!firstIssue; + useEventWaiter({ + eventType: 'transaction', + organization, + project: currentProject, + onIssueReceived: () => { + setReceived(true); + }, + }); + const currentPlatform = currentProject.platform ? platforms.find(p => p.id === currentProject.platform) : undefined; diff --git a/static/app/views/performance/onboarding.tsx b/static/app/views/performance/onboarding.tsx index 068ee564e26d67..cd0f0d824f171a 100644 --- a/static/app/views/performance/onboarding.tsx +++ b/static/app/views/performance/onboarding.tsx @@ -479,6 +479,16 @@ export function Onboarding({organization, project}: OnboardingProps) { doesNotSupportPerformance, ]); + useEventWaiter({ + eventType: 'transaction', + organization, + project, + disabled: isLoading || doesNotSupportPerformance, + onIssueReceived: () => { + setReceived(true); + }, + }); + const performanceDocs = docs?.performanceOnboarding; if (isLoading) { From 62f8ac5dc424360970fea03cad8b0e504d89455a Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 11 Mar 2026 13:33:59 -0400 Subject: [PATCH 09/12] move cause to error constructor option --- static/app/utils/useEventWaiter.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index 8f95d37e3705e7..f1805954bb8d61 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -121,13 +121,9 @@ export function useEventWaiter({ }); } - const captureError = new Error(`Error polling for first ${eventType} event`); - try { - captureError.cause = err; - } catch { - // some browsers don't let you set a `cause` - } - Sentry.captureException(captureError); + Sentry.captureException( + new Error(`Error polling for first ${eventType} event`, {cause: err}) + ); }, [projectQuery.error, eventType]); if (firstEvent === null || firstEvent === false) { From 2c244babe983a448424951c1334774fcf65d70c8 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 11 Mar 2026 13:40:36 -0400 Subject: [PATCH 10/12] Revert "ref(ui): Replace EventWaiter class component with useEventWaiter hook" This reverts commit fd3fc4962cc703bc1cb324d6a83739e8f225e190. --- .../app/components/performanceOnboarding/sidebar.tsx | 9 --------- static/app/views/performance/onboarding.tsx | 10 ---------- 2 files changed, 19 deletions(-) diff --git a/static/app/components/performanceOnboarding/sidebar.tsx b/static/app/components/performanceOnboarding/sidebar.tsx index d7e5795b823696..cda4b218e270ad 100644 --- a/static/app/components/performanceOnboarding/sidebar.tsx +++ b/static/app/components/performanceOnboarding/sidebar.tsx @@ -239,15 +239,6 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { }); const received = !!firstIssue; - useEventWaiter({ - eventType: 'transaction', - organization, - project: currentProject, - onIssueReceived: () => { - setReceived(true); - }, - }); - const currentPlatform = currentProject.platform ? platforms.find(p => p.id === currentProject.platform) : undefined; diff --git a/static/app/views/performance/onboarding.tsx b/static/app/views/performance/onboarding.tsx index cd0f0d824f171a..068ee564e26d67 100644 --- a/static/app/views/performance/onboarding.tsx +++ b/static/app/views/performance/onboarding.tsx @@ -479,16 +479,6 @@ export function Onboarding({organization, project}: OnboardingProps) { doesNotSupportPerformance, ]); - useEventWaiter({ - eventType: 'transaction', - organization, - project, - disabled: isLoading || doesNotSupportPerformance, - onIssueReceived: () => { - setReceived(true); - }, - }); - const performanceDocs = docs?.performanceOnboarding; if (isLoading) { From a7671ba52094e12e5f28d79711c42ae17e44f7b5 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 11 Mar 2026 13:48:36 -0400 Subject: [PATCH 11/12] explicit undefined check --- static/app/utils/useEventWaiter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index f1805954bb8d61..54dcf3997a7731 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -111,7 +111,7 @@ export function useEventWaiter({ const err = projectQuery.error; if (err instanceof RequestError) { - if (err.status && [401, 403, 404, 0].includes(err.status)) { + if (err.status !== undefined && [401, 403, 404, 0].includes(err.status)) { return; } From c1d4612f8030937e694b811ca13ff5239c1b81d1 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 11 Mar 2026 16:57:25 -0400 Subject: [PATCH 12/12] oops, fix condition to stop polling + added test --- static/app/utils/useEventWaiter.spec.tsx | 45 +++++++++++++++++++++++- static/app/utils/useEventWaiter.tsx | 14 +++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/static/app/utils/useEventWaiter.spec.tsx b/static/app/utils/useEventWaiter.spec.tsx index 3ece1ce106ffa7..bb681b0a62d887 100644 --- a/static/app/utils/useEventWaiter.spec.tsx +++ b/static/app/utils/useEventWaiter.spec.tsx @@ -1,7 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; -import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {useEventWaiter} from 'sentry/utils/useEventWaiter'; @@ -142,4 +142,47 @@ describe('useEventWaiter', () => { expect(result.current).toBeNull(); expect(projectApiMock).not.toHaveBeenCalled(); }); + + it('stops polling after first event is detected', async () => { + jest.useFakeTimers(); + + const org = OrganizationFixture(); + const project = ProjectFixture({firstEvent: null}); + + // API returns a project with firstTransactionEvent already set + const projectApiMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: ProjectFixture({firstTransactionEvent: true}), + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'transaction', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + // Flush the initial fetch + await act(async () => { + await jest.advanceTimersByTimeAsync(1); + }); + + expect(result.current).toBe(true); + expect(projectApiMock).toHaveBeenCalledTimes(1); + + // Advance well past multiple poll intervals + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + // Polling should have stopped — no calls beyond the initial fetch + expect(projectApiMock).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); }); diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx index 54dcf3997a7731..03eb5aeb083b03 100644 --- a/static/app/utils/useEventWaiter.tsx +++ b/static/app/utils/useEventWaiter.tsx @@ -79,7 +79,19 @@ export function useEventWaiter({ // Poll the project endpoint to detect when the first event arrives const projectQuery = useApiQuery([projectUrl], { - refetchInterval: shouldPoll ? pollInterval : false, + refetchInterval: query => { + if (!shouldPoll) { + return false; + } + // Stop polling once the first event has been detected + if (query.state.data) { + const [projectData] = query.state.data; + if (getFirstEvent(eventType, projectData)) { + return false; + } + } + return pollInterval; + }, enabled: shouldPoll, staleTime: 0, retry: (_, error) => {