From 85f92feac569ba5857f2fa7f98b3cd2bd06e79bc Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:18:07 -0800 Subject: [PATCH 01/10] Refactor useTourReducer to accept a props obj, instead of N args --- static/app/components/tours/components.tsx | 8 +++---- .../app/components/tours/tourContext.spec.tsx | 8 +++++-- static/app/components/tours/tourContext.tsx | 11 +++++---- static/app/utils/demoMode/demoTours.tsx | 24 +++++++++---------- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/static/app/components/tours/components.tsx b/static/app/components/tours/components.tsx index 42c1d4b077425c..290ff918566464 100644 --- a/static/app/components/tours/components.tsx +++ b/static/app/components/tours/components.tsx @@ -96,16 +96,16 @@ export function TourContextProvider({ }), [onStartTour, onEndTour, onStepChange, requireAllStepsRegistered] ); - const tourContextValue = useTourReducer( - { + const tourContextValue = useTourReducer({ + initialState: { isCompleted, isRegistered: false, orderedStepIds, currentStepId: null, tourKey, }, - options - ); + options, + }); const {endTour, previousStep, nextStep, currentStepId} = tourContextValue; const isTourActive = currentStepId !== null; diff --git a/static/app/components/tours/tourContext.spec.tsx b/static/app/components/tours/tourContext.spec.tsx index cf75fbc8e07297..3cdb1509d6ec83 100644 --- a/static/app/components/tours/tourContext.spec.tsx +++ b/static/app/components/tours/tourContext.spec.tsx @@ -13,7 +13,9 @@ describe('useTourReducer', () => { orderedStepIds: ORDERED_TEST_TOUR, }; function registerAllSteps() { - const {result} = renderHook(() => useTourReducer(initialState, {})); + const {result} = renderHook(() => + useTourReducer({initialState, options: {}}) + ); const {handleStepRegistration} = result.current; act(() => { ORDERED_TEST_TOUR.forEach(stepId => handleStepRegistration({id: stepId})); @@ -22,7 +24,9 @@ describe('useTourReducer', () => { } it('handles step registration correctly', () => { - const {result} = renderHook(() => useTourReducer(initialState, {})); + const {result} = renderHook(() => + useTourReducer({initialState, options: {}}) + ); const {handleStepRegistration} = result.current; let unregister = () => {}; // Should be false before any steps are registered diff --git a/static/app/components/tours/tourContext.tsx b/static/app/components/tours/tourContext.tsx index 869c88778af87c..dc495d923783a6 100644 --- a/static/app/components/tours/tourContext.tsx +++ b/static/app/components/tours/tourContext.tsx @@ -168,10 +168,13 @@ function tourReducer( } } -export function useTourReducer( - initialState: TourState, - options: TourOptions -): TourContextType { +export function useTourReducer({ + initialState, + options, +}: { + initialState: TourState; + options: TourOptions; +}): TourContextType { const {orderedStepIds} = initialState; const [state, dispatch] = useReducer(tourReducer, initialState); diff --git a/static/app/utils/demoMode/demoTours.tsx b/static/app/utils/demoMode/demoTours.tsx index 876b8890bb4208..83e45e05a79cfd 100644 --- a/static/app/utils/demoMode/demoTours.tsx +++ b/static/app/utils/demoMode/demoTours.tsx @@ -152,20 +152,20 @@ export function DemoToursProvider({children}: {children: React.ReactNode}) { [handleEndTour, handleStepChange] ); - const issuesTour = useTourReducer( - tourState[DemoTour.ISSUES], - getTourOptions(DemoTour.ISSUES) - ); + const issuesTour = useTourReducer({ + initialState: tourState[DemoTour.ISSUES], + options: getTourOptions(DemoTour.ISSUES), + }); - const releasesTour = useTourReducer( - tourState[DemoTour.RELEASES], - getTourOptions(DemoTour.RELEASES) - ); + const releasesTour = useTourReducer({ + initialState: tourState[DemoTour.RELEASES], + options: getTourOptions(DemoTour.RELEASES), + }); - const performanceTour = useTourReducer( - tourState[DemoTour.PERFORMANCE], - getTourOptions(DemoTour.PERFORMANCE) - ); + const performanceTour = useTourReducer({ + initialState: tourState[DemoTour.PERFORMANCE], + options: getTourOptions(DemoTour.PERFORMANCE), + }); const tours = useMemo( () => ({ From 8814195a3e9ddc1880481ce1bbf2f5f674cd0f39 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:21:39 -0800 Subject: [PATCH 02/10] Refactor useDispatchingReducer to accept a props obj, instead of N args --- .../app/utils/useDispatchingReducer.spec.tsx | 22 ++++++++++++------- static/app/utils/useDispatchingReducer.tsx | 14 +++++++----- .../traceState/traceStateProvider.tsx | 10 ++++----- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/static/app/utils/useDispatchingReducer.spec.tsx b/static/app/utils/useDispatchingReducer.spec.tsx index 5b4ca0f84267f8..78ba75628811d1 100644 --- a/static/app/utils/useDispatchingReducer.spec.tsx +++ b/static/app/utils/useDispatchingReducer.spec.tsx @@ -19,7 +19,7 @@ describe('useDispatchingReducer', () => { it('initializes state with initializer', () => { const reducer = jest.fn().mockImplementation(s => s); const initialState = {type: 'initial'}; - const {result} = renderHook(() => useDispatchingReducer(reducer, initialState)); + const {result} = renderHook(() => useDispatchingReducer({reducer, initialState})); expect(result.current[0]).toBe(initialState); }); @@ -27,7 +27,11 @@ describe('useDispatchingReducer', () => { const reducer = jest.fn().mockImplementation(s => s); const initialState = {type: 'initial'}; const {result} = renderHook(() => - useDispatchingReducer(reducer, undefined, () => initialState) + useDispatchingReducer({ + reducer, + initialState: undefined, + initializer: () => initialState, + }) ); expect(result.current[0]).toBe(initialState); @@ -43,7 +47,7 @@ describe('useDispatchingReducer', () => { }); it('calls reducer and updates state', async () => { const initialState = {type: 'initial'}; - const {result} = renderHook(() => useDispatchingReducer(reducer, initialState)); + const {result} = renderHook(() => useDispatchingReducer({reducer, initialState})); act(() => result.current[1]('action')); act(() => { @@ -57,7 +61,7 @@ describe('useDispatchingReducer', () => { }); it('calls before action with state and action args', () => { const initialState = {type: 'initial'}; - const {result} = renderHook(() => useDispatchingReducer(reducer, initialState)); + const {result} = renderHook(() => useDispatchingReducer({reducer, initialState})); const beforeAction = jest.fn(); result.current[2].on('before action', beforeAction); @@ -72,7 +76,7 @@ describe('useDispatchingReducer', () => { }); it('calls after action with previous, new state and action args', () => { const initialState = {type: 'initial'}; - const {result} = renderHook(() => useDispatchingReducer(reducer, initialState)); + const {result} = renderHook(() => useDispatchingReducer({reducer, initialState})); const beforeNextState = jest.fn(); result.current[2].on('before next state', beforeNextState); @@ -104,7 +108,7 @@ describe('useDispatchingReducer', () => { } }); const {result} = renderHook(() => - useDispatchingReducer(action_storing_reducer, initialState) + useDispatchingReducer({reducer: action_storing_reducer, initialState}) ); act(() => { @@ -140,7 +144,9 @@ describe('useDispatchingReducer', () => { }); const initialState = {a: {}, b: {}}; - const {result} = renderHook(() => useDispatchingReducer(finalReducer, initialState)); + const {result} = renderHook(() => + useDispatchingReducer({reducer: finalReducer, initialState}) + ); act(() => { result.current[1]('a'); @@ -164,7 +170,7 @@ describe('useDispatchingReducer', () => { }); const initialState = {}; - const {result} = renderHook(() => useDispatchingReducer(reducer, initialState)); + const {result} = renderHook(() => useDispatchingReducer({reducer, initialState})); result.current[2].on('before action', (_state: any, action: any) => { if (action === 'a') { diff --git a/static/app/utils/useDispatchingReducer.tsx b/static/app/utils/useDispatchingReducer.tsx index 64d958e13842cb..585fe69b287e15 100644 --- a/static/app/utils/useDispatchingReducer.tsx +++ b/static/app/utils/useDispatchingReducer.tsx @@ -93,11 +93,15 @@ function update>( return start; } -export function useDispatchingReducer>( - reducer: R, - initialState: ReducerState, - initializer?: (arg: ReducerState) => ReducerState -): [ReducerState, React.Dispatch>, DispatchingReducerEmitter] { +export function useDispatchingReducer>({ + reducer, + initialState, + initializer, +}: { + initialState: ReducerState; + reducer: R; + initializer?: (arg: ReducerState) => ReducerState; +}): [ReducerState, React.Dispatch>, DispatchingReducerEmitter] { const emitter = useMemo(() => new DispatchingReducerEmitter(), []); const [state, setState] = useState( initialState ?? (initializer?.(initialState) as ReducerState) diff --git a/static/app/views/performance/newTraceDetails/traceState/traceStateProvider.tsx b/static/app/views/performance/newTraceDetails/traceState/traceStateProvider.tsx index fcd7a043dc6aa2..2e3b4c292cbd49 100644 --- a/static/app/views/performance/newTraceDetails/traceState/traceStateProvider.tsx +++ b/static/app/views/performance/newTraceDetails/traceState/traceStateProvider.tsx @@ -66,9 +66,9 @@ export function TraceStateProvider(props: TraceStateProviderProps): React.ReactN // We only want to decode on load }, []); - const [traceState, traceDispatch, traceStateEmitter] = useDispatchingReducer( - TraceReducer, - { + const [traceState, traceDispatch, traceStateEmitter] = useDispatchingReducer({ + reducer: TraceReducer, + initialState: { rovingTabIndex: { index: null, items: null, @@ -89,8 +89,8 @@ export function TraceStateProvider(props: TraceStateProviderProps): React.ReactN current_tab: null, last_clicked_tab: null, }, - } - ); + }, + }); useLayoutEffect(() => { if (props.preferencesStorageKey) { From aeb7259b4f06978c2d045596bfd7193f9dadf83c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:30:52 -0800 Subject: [PATCH 03/10] Refactor useMemoWithPrevious to accept a props obj, instead of N args --- .../flamegraph/continuousFlamegraph.tsx | 61 +++++++++--------- .../profiling/flamegraph/flamegraph.tsx | 62 ++++++++++--------- static/app/utils/useMemoWithPrevious.spec.tsx | 24 +++---- static/app/utils/useMemoWithPrevious.ts | 15 ++--- .../app/views/issueDetails/groupDetails.tsx | 16 ++--- .../groupEventDetails/groupEventDetails.tsx | 8 +-- .../gsApp/views/subscriptionPage/usageLog.tsx | 8 +-- 7 files changed, 98 insertions(+), 96 deletions(-) diff --git a/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx b/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx index ba09b75c6ad64a..e9bacb846894ad 100644 --- a/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx +++ b/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx @@ -590,8 +590,8 @@ export function ContinuousFlamegraph(): ReactElement { return new FlamegraphCanvas(memoryChartCanvasRef, vec2.fromValues(0, 0)); }, [memoryChartCanvasRef]); - const flamegraphView = useMemoWithPrevious | null>( - previousView => { + const flamegraphView = useMemoWithPrevious | null>({ + factory: previousView => { if (!flamegraphCanvas) { return null; } @@ -686,19 +686,18 @@ export function ContinuousFlamegraph(): ReactElement { }, // We skip position.view dependency because it will go into an infinite loop - // eslint-disable-next-line react-hooks/exhaustive-deps - [ + deps: [ flamegraph, flamegraphCanvas, flamegraphTheme, profile, segment, configSpaceQueryParam, - ] - ); + ], + }); - const uiFramesView = useMemoWithPrevious | null>( - _previousView => { + const uiFramesView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !uiFrames) { return null; } @@ -724,18 +723,18 @@ export function ContinuousFlamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, flamegraph, uiFrames, profile, configSpaceQueryParam, - ] - ); + ], + }); - const batteryChartView = useMemoWithPrevious | null>( - _previousView => { + const batteryChartView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !batteryChart || !batteryChartCanvas) { return null; } @@ -768,7 +767,7 @@ export function ContinuousFlamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, batteryChart, @@ -776,11 +775,11 @@ export function ContinuousFlamegraph(): ReactElement { batteryChartCanvas, profile, configSpaceQueryParam, - ] - ); + ], + }); - const cpuChartView = useMemoWithPrevious | null>( - _previousView => { + const cpuChartView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !CPUChart || !cpuChartCanvas) { return null; } @@ -813,7 +812,7 @@ export function ContinuousFlamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, CPUChart, @@ -821,11 +820,11 @@ export function ContinuousFlamegraph(): ReactElement { cpuChartCanvas, profile, configSpaceQueryParam, - ] - ); + ], + }); - const memoryChartView = useMemoWithPrevious | null>( - _previousView => { + const memoryChartView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !memoryChart || !memoryChartCanvas) { return null; } @@ -858,7 +857,7 @@ export function ContinuousFlamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, memoryChart, @@ -866,11 +865,11 @@ export function ContinuousFlamegraph(): ReactElement { memoryChartCanvas, profile, configSpaceQueryParam, - ] - ); + ], + }); - const spansView = useMemoWithPrevious | null>( - _previousView => { + const spansView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!spansCanvas || !spanChart || !flamegraphView) { return null; } @@ -899,7 +898,7 @@ export function ContinuousFlamegraph(): ReactElement { return newView; }, - [ + deps: [ spanChart, spansCanvas, flamegraphView, @@ -907,8 +906,8 @@ export function ContinuousFlamegraph(): ReactElement { profileTimestamp, configSpaceQueryParam, segment, - ] - ); + ], + }); // We want to make sure that the views have the same min zoom levels so that // if you wheel zoom on one, the other one will also zoom to the same level of detail. diff --git a/static/app/components/profiling/flamegraph/flamegraph.tsx b/static/app/components/profiling/flamegraph/flamegraph.tsx index 7111fb7efa57a8..0dfeb3f96bb3f0 100644 --- a/static/app/components/profiling/flamegraph/flamegraph.tsx +++ b/static/app/components/profiling/flamegraph/flamegraph.tsx @@ -570,8 +570,8 @@ function Flamegraph(): ReactElement { return new FlamegraphCanvas(memoryChartCanvasRef, vec2.fromValues(0, 0)); }, [memoryChartCanvasRef]); - const flamegraphView = useMemoWithPrevious | null>( - previousView => { + const flamegraphView = useMemoWithPrevious | null>({ + factory: previousView => { if (!flamegraphCanvas) { return null; } @@ -659,12 +659,12 @@ function Flamegraph(): ReactElement { }, // We skip position.view dependency because it will go into an infinite loop - // eslint-disable-next-line react-hooks/exhaustive-deps - [flamegraph, flamegraphCanvas, flamegraphTheme, profileOffsetFromTransaction] - ); - const uiFramesView = useMemoWithPrevious | null>( - _previousView => { + deps: [flamegraph, flamegraphCanvas, flamegraphTheme, profileOffsetFromTransaction], + }); + + const uiFramesView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !uiFrames) { return null; } @@ -690,11 +690,17 @@ function Flamegraph(): ReactElement { return newView; }, - [flamegraphView, flamegraphCanvas, flamegraph, uiFrames, profileOffsetFromTransaction] - ); + deps: [ + flamegraphView, + flamegraphCanvas, + flamegraph, + uiFrames, + profileOffsetFromTransaction, + ], + }); - const batteryChartView = useMemoWithPrevious | null>( - _previousView => { + const batteryChartView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !batteryChart || !batteryChartCanvas) { return null; } @@ -727,18 +733,18 @@ function Flamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, batteryChart, uiFrames.minFrameDuration, batteryChartCanvas, profileOffsetFromTransaction, - ] - ); + ], + }); - const cpuChartView = useMemoWithPrevious | null>( - _previousView => { + const cpuChartView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !CPUChart || !cpuChartCanvas) { return null; } @@ -771,18 +777,18 @@ function Flamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, CPUChart, uiFrames.minFrameDuration, cpuChartCanvas, profileOffsetFromTransaction, - ] - ); + ], + }); - const memoryChartView = useMemoWithPrevious | null>( - _previousView => { + const memoryChartView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!flamegraphView || !flamegraphCanvas || !memoryChart || !memoryChartCanvas) { return null; } @@ -815,18 +821,18 @@ function Flamegraph(): ReactElement { return newView; }, - [ + deps: [ flamegraphView, flamegraphCanvas, memoryChart, uiFrames.minFrameDuration, memoryChartCanvas, profileOffsetFromTransaction, - ] - ); + ], + }); - const spansView = useMemoWithPrevious | null>( - _previousView => { + const spansView = useMemoWithPrevious | null>({ + factory: _previousView => { if (!spansCanvas || !spanChart || !flamegraphView) { return null; } @@ -850,8 +856,8 @@ function Flamegraph(): ReactElement { return newView; }, - [spanChart, spansCanvas, flamegraphView, flamegraphTheme.SIZES] - ); + deps: [spanChart, spansCanvas, flamegraphView, flamegraphTheme.SIZES], + }); // We want to make sure that the views have the same min zoom levels so that // if you wheel zoom on one, the other one will also zoom to the same level of detail. diff --git a/static/app/utils/useMemoWithPrevious.spec.tsx b/static/app/utils/useMemoWithPrevious.spec.tsx index f85aef4ddf1ff8..db5c77454f4807 100644 --- a/static/app/utils/useMemoWithPrevious.spec.tsx +++ b/static/app/utils/useMemoWithPrevious.spec.tsx @@ -4,11 +4,11 @@ import {useMemoWithPrevious} from 'sentry/utils/useMemoWithPrevious'; describe('useMemoWithPrevious', () => { it('calls factory with null', () => { - const dep = {}; + const deps = [{}]; const factory = jest.fn().mockImplementation(() => 'foo'); - const {result} = renderHook(() => useMemoWithPrevious(factory, [dep])); + const {result} = renderHook(useMemoWithPrevious, {initialProps: {factory, deps}}); expect(factory).toHaveBeenCalledWith(null); expect(result.current).toBe('foo'); }); @@ -20,18 +20,14 @@ describe('useMemoWithPrevious', () => { const firstDependency: unknown[] = []; const secondDependency: unknown[] = []; - const {rerender, result} = renderHook( - // eslint-disable-next-line react-hooks/exhaustive-deps - ({fact, dep}) => useMemoWithPrevious(fact, [dep]), - { - initialProps: { - fact: factory, - dep: firstDependency, - }, - } - ); - - rerender({fact: factory, dep: secondDependency}); + const {rerender, result} = renderHook(useMemoWithPrevious, { + initialProps: { + factory, + deps: [firstDependency], + }, + }); + + rerender({factory, deps: [secondDependency]}); expect(result.current).toBe('bar'); expect(factory.mock.calls[1][0]).toBe('foo'); diff --git a/static/app/utils/useMemoWithPrevious.ts b/static/app/utils/useMemoWithPrevious.ts index 4aa0d4a2794873..661fef7cfa7938 100644 --- a/static/app/utils/useMemoWithPrevious.ts +++ b/static/app/utils/useMemoWithPrevious.ts @@ -3,10 +3,13 @@ import {useState} from 'react'; import {useEffectAfterFirstRender} from './useEffectAfterFirstRender'; import usePrevious from './usePrevious'; -const useMemoWithPrevious = ( - factory: (previousInstance: T | null) => T, - deps: React.DependencyList -): T => { +export function useMemoWithPrevious({ + deps, + factory, +}: { + deps: React.DependencyList; + factory: (previousInstance: T | null) => T; +}): T { const [value, setValue] = useState(() => factory(null)); const previous = usePrevious(value); @@ -17,6 +20,4 @@ const useMemoWithPrevious = ( }, deps); return value; -}; - -export {useMemoWithPrevious}; +} diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx index 766140857fb1d4..0d00a9904ad40e 100644 --- a/static/app/views/issueDetails/groupDetails.tsx +++ b/static/app/views/issueDetails/groupDetails.tsx @@ -312,15 +312,15 @@ function useFetchGroupDetails(): FetchGroupDetailsState { * This is not closer to the GroupEventHeader because it is unmounted * between route changes like latest event => eventId */ - const previousEvent = useMemoWithPrevious( - previousInstance => { + const previousEvent = useMemoWithPrevious({ + factory: previousInstance => { if (event) { return event; } return previousInstance; }, - [event] - ); + deps: [event], + }); // If the environment changes, we need to refetch the group, but we can // still display the previous group while the new group is loading. @@ -328,8 +328,8 @@ function useFetchGroupDetails(): FetchGroupDetailsState { cachedGroup: typeof groupData | null; previousEnvironments: typeof environments | null; previousGroupData: typeof groupData | null; - }>( - previousInstance => { + }>({ + factory: previousInstance => { // Preserve the previous group if: // - New group is not yet available // - Previously we had group data @@ -355,8 +355,8 @@ function useFetchGroupDetails(): FetchGroupDetailsState { previousGroupData: null, }; }, - [groupData, environments, groupId] - ); + deps: [groupData, environments, groupId], + }); const group = groupData ?? previousGroupData.cachedGroup ?? null; diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx index 89d9f9a90bd613..298bc91ef2e910 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx @@ -66,15 +66,15 @@ function GroupEventDetails() { const eventWithMeta = useMemo(() => withMeta(event), [event]); const project = useProjectFromSlug({organization, projectSlug: group?.project?.slug}); const prevEnvironment = usePrevious(environments); - const prevEvent = useMemoWithPrevious( - previousInstance => { + const prevEvent = useMemoWithPrevious({ + factory: previousInstance => { if (event) { return event; } return previousInstance; }, - [event] - ); + deps: [event], + }); const hasStreamlinedUI = useHasStreamlinedUI(); // load the data diff --git a/static/gsApp/views/subscriptionPage/usageLog.tsx b/static/gsApp/views/subscriptionPage/usageLog.tsx index 1853299bbbd435..8ec00eab7483a9 100644 --- a/static/gsApp/views/subscriptionPage/usageLog.tsx +++ b/static/gsApp/views/subscriptionPage/usageLog.tsx @@ -108,10 +108,10 @@ export default function UsageLog() { {staleTime: 0} ); - const eventNames = useMemoWithPrevious( - previous => auditLogs?.eventNames ?? previous, - [auditLogs?.eventNames] - ); + const eventNames = useMemoWithPrevious({ + factory: previous => auditLogs?.eventNames ?? previous, + deps: [auditLogs?.eventNames], + }); const handleEventFilter = (value: string | undefined) => { if (typeof value === 'string') { From 088ddcef9d7ba2be7d291db3766e9e84303c7c71 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:38:10 -0800 Subject: [PATCH 04/10] Refactor useUndoableReducer to accept a props obj, instead of N args --- .../flamegraphContextProvider.tsx | 8 +-- static/app/utils/useUndoableReducer.spec.tsx | 50 ++++++++----------- static/app/utils/useUndoableReducer.tsx | 8 ++- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/static/app/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContextProvider.tsx b/static/app/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContextProvider.tsx index a71dc805370017..fc578efc5fa3e3 100644 --- a/static/app/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContextProvider.tsx +++ b/static/app/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContextProvider.tsx @@ -69,10 +69,10 @@ function getDefaultState(initialState?: DeepPartial): Flamegrap export function FlamegraphStateProvider( props: FlamegraphStateProviderProps ): React.ReactElement { - const [state, dispatch, {nextState, previousState}] = useUndoableReducer( - flamegraphStateReducer, - getDefaultState(props.initialState) - ); + const [state, dispatch, {nextState, previousState}] = useUndoableReducer({ + reducer: flamegraphStateReducer, + initialState: getDefaultState(props.initialState), + }); const flamegraphContextValue: FlamegraphStateValue = useMemo(() => { return [state, {nextState, previousState}]; diff --git a/static/app/utils/useUndoableReducer.spec.tsx b/static/app/utils/useUndoableReducer.spec.tsx index 346c6cd840b8d2..58e821751fec08 100644 --- a/static/app/utils/useUndoableReducer.spec.tsx +++ b/static/app/utils/useUndoableReducer.spec.tsx @@ -59,13 +59,9 @@ describe('makeUndoableReducer', () => { action === 'add' ? state + 1 : state - 1 ); - const {result} = renderHook( - (args: Parameters) => - useUndoableReducer(args[0], args[1]), - { - initialProps: [reducer, 100], - } - ); + const {result} = renderHook(useUndoableReducer, { + initialProps: {reducer, initialState: 100}, + }); expect(reducer).not.toHaveBeenCalled(); expect(result.current[0]).toBe(100); }); @@ -77,11 +73,9 @@ describe('makeUndoableReducer', () => { action === 'add' ? state + 1 : state - 1 ); - const {result} = renderHook( - (args: Parameters) => - useUndoableReducer(args[0], args[1]), - {initialProps: [reducer, 0]} - ); + const {result} = renderHook(useUndoableReducer, { + initialProps: {reducer, initialState: 0}, + }); act(() => result.current[1]('add')); expect(result.current[0]).toBe(1); @@ -94,11 +88,12 @@ describe('makeUndoableReducer', () => { }); it('can undo state', () => { - const {result} = renderHook( - (args: Parameters) => - useUndoableReducer(args[0], args[1]), - {initialProps: [jest.fn().mockImplementation(s => s + 1), 0]} - ); + const {result} = renderHook(useUndoableReducer, { + initialProps: { + reducer: jest.fn().mockImplementation(s => s + 1), + initialState: 0, + }, + }); act(() => result.current[1](0)); expect(result.current[0]).toBe(1); @@ -108,11 +103,12 @@ describe('makeUndoableReducer', () => { }); it('can redo state', () => { - const {result} = renderHook( - (args: Parameters) => - useUndoableReducer(args[0], args[1]), - {initialProps: [jest.fn().mockImplementation(s => s + 1), 0]} - ); + const {result} = renderHook(useUndoableReducer, { + initialProps: { + reducer: jest.fn().mockImplementation(s => s + 1), + initialState: 0, + }, + }); act(() => result.current[1](0)); // 0 + 1 act(() => result.current[1](1)); // 1 + 1 @@ -134,13 +130,9 @@ describe('makeUndoableReducer', () => { const simpleReducer = (state: any, action: any) => action.type === 'add' ? state + 1 : state - 1; - const {result} = renderHook( - (args: Parameters) => - useUndoableReducer(args[0], args[1]), - { - initialProps: [simpleReducer, 0], - } - ); + const {result} = renderHook(useUndoableReducer, { + initialProps: {reducer: simpleReducer, initialState: 0}, + }); act(() => result.current[1]({type: 'add'})); expect(result.current?.[2].previousState).toBe(0); diff --git a/static/app/utils/useUndoableReducer.tsx b/static/app/utils/useUndoableReducer.tsx index e3376fef75f8a2..5dc51a38454c08 100644 --- a/static/app/utils/useUndoableReducer.tsx +++ b/static/app/utils/useUndoableReducer.tsx @@ -81,7 +81,13 @@ type UndoableReducerState, ReducerAction export function useUndoableReducer< R extends React.Reducer, ReducerAction>, ->(reducer: R, initialState: ReducerState): UndoableReducerState { +>({ + reducer, + initialState, +}: { + initialState: ReducerState; + reducer: R; +}): UndoableReducerState { const [state, dispatch] = useReducer(makeUndoableReducer(reducer), { current: initialState, previous: undefined, From b531d1b1203fc78672760240072f2c72a8844d3d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:41:26 -0800 Subject: [PATCH 05/10] Refactor useCopyIssueDetails to accept a props obj, instead of N args --- .../groupEventDetails/groupEventDetailsContent.tsx | 2 +- .../streamline/hooks/useCopyIssueDetails.spec.tsx | 8 ++++---- .../issueDetails/streamline/hooks/useCopyIssueDetails.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx index d2e053769a43c9..f86610e1f9d6c9 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx @@ -130,7 +130,7 @@ export function EventDetailsContent({ projectId: project.id, }); - useCopyIssueDetails(group, event); + useCopyIssueDetails({group, event}); // default to show on error or isPromptDismissed === undefined const showFeedback = !isPromptDismissed || promptError || hasStreamlinedUI; diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index 0b95933b75f7dc..3c6a0ca474835b 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -390,7 +390,7 @@ describe('useCopyIssueDetails', () => { }); it('calls useCopyToClipboard hook', () => { - renderHook(() => useCopyIssueDetails(group, event)); + renderHook(useCopyIssueDetails, {initialProps: {group, event}}); // Check that the hook was called expect(copyToClipboardModule.default).toHaveBeenCalled(); @@ -399,7 +399,7 @@ describe('useCopyIssueDetails', () => { it('sets up hotkeys with the correct callbacks', () => { const useHotkeysMock = jest.spyOn(require('sentry/utils/useHotkeys'), 'useHotkeys'); - renderHook(() => useCopyIssueDetails(group, event)); + renderHook(useCopyIssueDetails, {initialProps: {group, event}}); expect(useHotkeysMock).toHaveBeenCalledWith([ { @@ -423,7 +423,7 @@ describe('useCopyIssueDetails', () => { return Promise.resolve(text); }); - renderHook(() => useCopyIssueDetails(group, undefined)); + renderHook(useCopyIssueDetails, {initialProps: {group, event: undefined}}); // Trigger the keyboard event (command+alt+c) const keyboardEvent = new KeyboardEvent('keydown', { @@ -451,7 +451,7 @@ describe('useCopyIssueDetails', () => { return Promise.resolve(text); }); - renderHook(() => useCopyIssueDetails(group, event)); + renderHook(useCopyIssueDetails, {initialProps: {group, event}}); // Trigger the keyboard event (command+alt+c) const keyboardEvent = new KeyboardEvent('keydown', { diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index 2a4725cee001f4..5721b084b750bd 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -189,7 +189,7 @@ export const issueAndEventToMarkdown = ( return markdownText; }; -export const useCopyIssueDetails = (group: Group, event?: Event) => { +export function useCopyIssueDetails({group, event}: {group: Group; event?: Event}) { const organization = useOrganization(); // These aren't guarded by useAiConfig because they are both non fetching, and should only return data when it's fetched elsewhere. @@ -233,4 +233,4 @@ export const useCopyIssueDetails = (group: Group, event?: Event) => { skipPreventDefault: NODE_ENV === 'development', }, ]); -}; +} From 98d7a253d0907ba108959a7fe15297dd7d124d4b Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:48:11 -0800 Subject: [PATCH 06/10] Refactor useReplaySummary to accept a props obj, instead of N args --- .../detail/ai/replaySummaryContext.tsx | 19 ++++---- .../detail/ai/useReplaySummary.spec.tsx | 48 +++++++++---------- .../replays/detail/ai/useReplaySummary.tsx | 11 +++-- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/static/app/views/replays/detail/ai/replaySummaryContext.tsx b/static/app/views/replays/detail/ai/replaySummaryContext.tsx index fab371f39ecabe..54511eee3091e1 100644 --- a/static/app/views/replays/detail/ai/replaySummaryContext.tsx +++ b/static/app/views/replays/detail/ai/replaySummaryContext.tsx @@ -39,14 +39,17 @@ export function ReplaySummaryContextProvider({ setupAcknowledgement.orgHasAcknowledged; const hasMobileSummary = organization.features.includes('replay-ai-summaries-mobile'); - const summaryResult = useReplaySummary(replay, { - staleTime: 0, - enabled: Boolean( - replay.getReplay().id && - projectSlug && - hasAiSummary && - (!mobileProject || hasMobileSummary) - ), + const summaryResult = useReplaySummary({ + replay, + options: { + staleTime: 0, + enabled: Boolean( + replay.getReplay().id && + projectSlug && + hasAiSummary && + (!mobileProject || hasMobileSummary) + ), + }, }); useEmitTimestampChanges(); diff --git a/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx b/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx index a0269f96ba9c97..e33201b13ad4d9 100644 --- a/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx +++ b/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx @@ -55,7 +55,8 @@ describe('useReplaySummary', () => { body: mockSummaryData, }); - const {result} = renderHookWithProviders(() => useReplaySummary(mockReplay), { + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, organization: mockOrganization, }); @@ -73,7 +74,8 @@ describe('useReplaySummary', () => { statusCode: 500, body: {detail: 'Internal server error'}, }); - const {result} = renderHookWithProviders(() => useReplaySummary(mockReplay), { + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, organization: mockOrganization, }); @@ -95,12 +97,10 @@ describe('useReplaySummary', () => { }, }); - const {result} = renderHookWithProviders( - () => useReplaySummary(mockReplay, {enabled: false, staleTime: 0}), - { - organization: mockOrganization, - } - ); + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay, options: {enabled: false, staleTime: 0}}, + organization: mockOrganization, + }); // The hook should not make API calls when disabled expect(result.current.summaryData).toBeUndefined(); @@ -117,12 +117,10 @@ describe('useReplaySummary', () => { }, }); - const {result} = renderHookWithProviders( - () => useReplaySummary(mockReplay, {enabled: true, staleTime: 0}), - { - organization: mockOrganization, - } - ); + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay, options: {enabled: true, staleTime: 0}}, + organization: mockOrganization, + }); await waitFor(() => { expect(result.current.summaryData).toBeDefined(); @@ -142,7 +140,8 @@ describe('useReplaySummary', () => { body: responsePromise, }); - const {result} = renderHookWithProviders(() => useReplaySummary(mockReplay), { + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, organization: mockOrganization, }); @@ -165,7 +164,8 @@ describe('useReplaySummary', () => { body: {status: ReplaySummaryStatus.PROCESSING, data: undefined}, }); - const {result} = renderHookWithProviders(() => useReplaySummary(mockReplay), { + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, organization: mockOrganization, }); @@ -184,7 +184,8 @@ describe('useReplaySummary', () => { }, }); - const {result} = renderHookWithProviders(() => useReplaySummary(mockReplay), { + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, organization: mockOrganization, }); @@ -200,7 +201,8 @@ describe('useReplaySummary', () => { body: {status: ReplaySummaryStatus.ERROR, data: undefined}, }); - const {result} = renderHookWithProviders(() => useReplaySummary(mockReplay), { + const {result} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, organization: mockOrganization, }); @@ -227,12 +229,10 @@ describe('useReplaySummary', () => { method: 'POST', }); - const {result, rerender} = renderHookWithProviders( - () => useReplaySummary(mockReplay), - { - organization: mockOrganization, - } - ); + const {result, rerender} = renderHookWithProviders(useReplaySummary, { + initialProps: {replay: mockReplay}, + organization: mockOrganization, + }); await waitFor(() => { expect(result.current.isPending).toBe(false); diff --git a/static/app/views/replays/detail/ai/useReplaySummary.tsx b/static/app/views/replays/detail/ai/useReplaySummary.tsx index cf617afc263d35..bb513138789da9 100644 --- a/static/app/views/replays/detail/ai/useReplaySummary.tsx +++ b/static/app/views/replays/detail/ai/useReplaySummary.tsx @@ -76,10 +76,13 @@ function createAISummaryQueryKey( return [`/projects/${orgSlug}/${projectSlug}/replays/${replayId}/summarize/`]; } -export function useReplaySummary( - replay: ReplayReader, - options?: UseApiQueryOptions -): UseReplaySummaryResult { +export function useReplaySummary({ + replay, + options, +}: { + replay: ReplayReader; + options?: UseApiQueryOptions; +}): UseReplaySummaryResult { const organization = useOrganization(); const replayRecord = replay.getReplay(); const project = useProjectFromId({project_id: replayRecord?.project_id}); From addd0d2f5fefbee040108c3e7042e00c5aac3d04 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:49:57 -0800 Subject: [PATCH 07/10] Refactor useEventColumns to accept a props obj, instead of N args --- static/app/views/issueDetails/allEventsTable.tsx | 16 ++++++++++------ .../issueDetails/streamline/eventList.spec.tsx | 4 +++- .../views/issueDetails/streamline/eventList.tsx | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/static/app/views/issueDetails/allEventsTable.tsx b/static/app/views/issueDetails/allEventsTable.tsx index 8e87e22471937b..adeecd3f544b2a 100644 --- a/static/app/views/issueDetails/allEventsTable.tsx +++ b/static/app/views/issueDetails/allEventsTable.tsx @@ -34,13 +34,13 @@ const makeGroupPreviewRequestUrl = ({groupId}: {groupId: string}) => { return `/issues/${groupId}/events/latest/`; }; -function AllEventsTable({organization, excludedTags, group}: Props) { +export default function AllEventsTable({organization, excludedTags, group}: Props) { const location = useLocation(); const theme = useTheme(); const config = getConfigForIssueType(group, group.project); const [error, setError] = useState(''); const routes = useRoutes(); - const {fields, columnTitles} = useEventColumns(group, organization); + const {fields, columnTitles} = useEventColumns({group, organization}); const now = useMemo(() => Date.now(), []); const endpointUrl = makeGroupPreviewRequestUrl({ @@ -122,7 +122,13 @@ function AllEventsTable({organization, excludedTags, group}: Props) { type ColumnInfo = {columnTitles: string[]; fields: string[]}; -export const useEventColumns = (group: Group, organization: Organization): ColumnInfo => { +export function useEventColumns({ + group, + organization, +}: { + group: Group; + organization: Organization; +}): ColumnInfo { return useMemo(() => { const isPerfIssue = group.issueCategory === IssueCategory.PERFORMANCE; const isReplayEnabled = @@ -175,7 +181,7 @@ export const useEventColumns = (group: Group, organization: Organization): Colum columnTitles, }; }, [group, organization]); -}; +} const getPlatformColumns = ( platform: PlatformKey | undefined, @@ -226,5 +232,3 @@ const getPlatformColumns = ( return platformColumns; }; - -export default AllEventsTable; diff --git a/static/app/views/issueDetails/streamline/eventList.spec.tsx b/static/app/views/issueDetails/streamline/eventList.spec.tsx index 9790b57bff8405..ca98172a3f9e6c 100644 --- a/static/app/views/issueDetails/streamline/eventList.spec.tsx +++ b/static/app/views/issueDetails/streamline/eventList.spec.tsx @@ -84,7 +84,9 @@ describe('EventList', () => { it('renders the list using a discover event query', async () => { renderAllEvents(); - const {result} = renderHook(() => useEventColumns(group, organization)); + const {result} = renderHook(useEventColumns, { + initialProps: {group, organization}, + }); expect(await screen.findByText('All Events')).toBeInTheDocument(); diff --git a/static/app/views/issueDetails/streamline/eventList.tsx b/static/app/views/issueDetails/streamline/eventList.tsx index 3dd447a8083770..b66f722013476e 100644 --- a/static/app/views/issueDetails/streamline/eventList.tsx +++ b/static/app/views/issueDetails/streamline/eventList.tsx @@ -34,7 +34,7 @@ export function EventList({group}: EventListProps) { const organization = useOrganization(); const routes = useRoutes(); const [_error, setError] = useState(''); - const {fields, columnTitles} = useEventColumns(group, organization); + const {fields, columnTitles} = useEventColumns({group, organization}); const eventView = useIssueDetailsEventView({ group, queryProps: { From 310bb2c97b502ad42ab7f461b4b66794b46adf79 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 15:54:51 -0800 Subject: [PATCH 08/10] Refactor useStreamingTimeseriesResult to accept a props obj, instead of N args --- .../views/explore/logs/useLogsTimeseries.tsx | 8 +-- .../useStreamingTimeseriesResult.spec.tsx | 59 ++++++++++++++++--- .../logs/useStreamingTimeseriesResult.tsx | 14 +++-- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/static/app/views/explore/logs/useLogsTimeseries.tsx b/static/app/views/explore/logs/useLogsTimeseries.tsx index dc3420ed15084b..4cecfe7a7db88a 100644 --- a/static/app/views/explore/logs/useLogsTimeseries.tsx +++ b/static/app/views/explore/logs/useLogsTimeseries.tsx @@ -75,11 +75,11 @@ export function useLogsTimeseries({ }, }); - return useStreamingTimeseriesResult( + return useStreamingTimeseriesResult({ tableData, - timeseriesResult.result, - timeseriesIngestDelay - ); + timeseriesResult: timeseriesResult.result, + timeseriesIngestDelay, + }); } function useLogsTimeseriesImpl({ diff --git a/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx b/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx index 7b67be87e6cea3..c5e46aa442e862 100644 --- a/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx +++ b/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx @@ -456,7 +456,12 @@ describe('useStreamingTimeseriesResult', () => { const mockTimeseriesData = getMockSingleAxisTimeseries(); const {result} = renderHook( - () => useStreamingTimeseriesResult(mockTableData, mockTimeseriesData, 0n), + () => + useStreamingTimeseriesResult({ + tableData: mockTableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { wrapper: createWrapper({autoRefresh: 'enabled', organization: logsOrganization}), } @@ -470,7 +475,12 @@ describe('useStreamingTimeseriesResult', () => { const mockTimeseriesData = getMockSingleAxisTimeseries(); const {result} = renderHook( - () => useStreamingTimeseriesResult(mockTableData, mockTimeseriesData, 0n), + () => + useStreamingTimeseriesResult({ + tableData: mockTableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { wrapper: createWrapper({autoRefresh: 'idle'}), } @@ -484,7 +494,12 @@ describe('useStreamingTimeseriesResult', () => { const mockTimeseriesData = getMockMultiAxisTimeseries(); const {result} = renderHook( - () => useStreamingTimeseriesResult(mockTableData, mockTimeseriesData, 0n), + () => + useStreamingTimeseriesResult({ + tableData: mockTableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { wrapper: createWrapper({autoRefresh: 'idle'}), } @@ -498,7 +513,12 @@ describe('useStreamingTimeseriesResult', () => { const mockTimeseriesData = getMockMultiGroupTimeseries(); const {result} = renderHook( - () => useStreamingTimeseriesResult(mockTableData, mockTimeseriesData, 0n), + () => + useStreamingTimeseriesResult({ + tableData: mockTableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { wrapper: createWrapper({autoRefresh: 'idle'}), } @@ -512,7 +532,12 @@ describe('useStreamingTimeseriesResult', () => { const mockTimeseriesData = getMockMultiAxisGroupTimeseries(); const {result} = renderHook( - () => useStreamingTimeseriesResult(mockTableData, mockTimeseriesData, 0n), + () => + useStreamingTimeseriesResult({ + tableData: mockTableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { wrapper: createWrapper({autoRefresh: 'idle'}), } @@ -527,7 +552,11 @@ describe('useStreamingTimeseriesResult', () => { const {result, rerender} = renderHook( (tableData: UseInfiniteLogsQueryResult) => - useStreamingTimeseriesResult(tableData, mockTimeseriesData, 0n), + useStreamingTimeseriesResult({ + tableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { initialProps: createMockTableData([]), wrapper: createWrapper({autoRefresh: 'enabled'}), @@ -601,7 +630,11 @@ describe('useStreamingTimeseriesResult', () => { const {result, rerender} = renderHook( (tableData: UseInfiniteLogsQueryResult) => - useStreamingTimeseriesResult(tableData, mockTimeseriesData, 0n), + useStreamingTimeseriesResult({ + tableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { initialProps: createMockTableData([]), wrapper: createWrapper({ @@ -839,7 +872,11 @@ describe('useStreamingTimeseriesResult', () => { const {result, rerender} = renderHook( (tableData: UseInfiniteLogsQueryResult) => - useStreamingTimeseriesResult(tableData, mockTimeseriesData, ingestDelayMs), + useStreamingTimeseriesResult({ + tableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: ingestDelayMs, + }), { initialProps: createMockTableData([]), wrapper: createWrapper({ @@ -946,7 +983,11 @@ describe('useStreamingTimeseriesResult', () => { const {result, rerender} = renderHook( (tableData: UseInfiniteLogsQueryResult) => - useStreamingTimeseriesResult(tableData, mockTimeseriesData, 0n), + useStreamingTimeseriesResult({ + tableData, + timeseriesResult: mockTimeseriesData, + timeseriesIngestDelay: 0n, + }), { initialProps: createMockTableData([]), wrapper: createWrapper({ diff --git a/static/app/views/explore/logs/useStreamingTimeseriesResult.tsx b/static/app/views/explore/logs/useStreamingTimeseriesResult.tsx index 746e31d6b17e76..baa67edeb4a852 100644 --- a/static/app/views/explore/logs/useStreamingTimeseriesResult.tsx +++ b/static/app/views/explore/logs/useStreamingTimeseriesResult.tsx @@ -76,11 +76,15 @@ type BufferedTimeseriesGroup = { * ↑ Merge the last timeseries bucket with the table data */ -export function useStreamingTimeseriesResult( - tableData: ReturnType, - timeseriesResult: ReturnType, - timeseriesIngestDelay: bigint -): ReturnType { +export function useStreamingTimeseriesResult({ + tableData, + timeseriesIngestDelay, + timeseriesResult, +}: { + tableData: ReturnType; + timeseriesIngestDelay: bigint; + timeseriesResult: ReturnType; +}): ReturnType { const organization = useOrganization(); const groupBys = useQueryParamsGroupBys(); const visualizes = useQueryParamsVisualizes(); From 5c1fd2f1aeb8b6b5fd819572350545343f7be5ab Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 17 Jan 2026 16:02:44 -0800 Subject: [PATCH 09/10] Refactor make a testable version of useFetchEventsTimeSeries to play better with renderHook --- .../useFetchEventsTimeSeries.spec.tsx | 116 ++++++++++-------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/static/app/utils/timeSeries/useFetchEventsTimeSeries.spec.tsx b/static/app/utils/timeSeries/useFetchEventsTimeSeries.spec.tsx index 7ea4e744ab65e7..35ad32c8a9d9e0 100644 --- a/static/app/utils/timeSeries/useFetchEventsTimeSeries.spec.tsx +++ b/static/app/utils/timeSeries/useFetchEventsTimeSeries.spec.tsx @@ -11,6 +11,18 @@ import {useFetchEventsTimeSeries} from './useFetchEventsTimeSeries'; jest.mock('sentry/utils/usePageFilters'); +function useTestableFetchEventsTimeSeries({ + dataset, + options, + referrer, +}: { + dataset: Parameters[0]; + options: Parameters[1]; + referrer: Parameters[2]; +}) { + return useFetchEventsTimeSeries(dataset, options, referrer); +} + describe('useFetchEventsTimeSeries', () => { const organization = OrganizationFixture(); @@ -42,15 +54,13 @@ describe('useFetchEventsTimeSeries', () => { body: [], }); - const {result} = renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - { - yAxis: 'epm()', - }, - REFERRER - ) - ); + const {result} = renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: {yAxis: 'epm()'}, + referrer: REFERRER, + }, + }); await waitFor(() => expect(result.current.isPending).toBe(false)); @@ -71,16 +81,16 @@ describe('useFetchEventsTimeSeries', () => { body: [], }); - const {result} = renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - { + const {result} = renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: { yAxis: ['epm()'], enabled: false, }, - REFERRER - ) - ); + referrer: REFERRER, + }, + }); await waitFor(() => expect(result.current.isPending).toBe(true)); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -90,16 +100,16 @@ describe('useFetchEventsTimeSeries', () => { it('requires a referrer', () => { expect(() => { - renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - { + renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: { yAxis: ['epm()'], enabled: false, }, - '' - ) - ); + referrer: '', + }, + }); }).toThrow(); }); @@ -110,16 +120,16 @@ describe('useFetchEventsTimeSeries', () => { body: [], }); - const {result} = renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - { + const {result} = renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: { yAxis: 'p50(span.duration)', query: new MutableSearch('span.op:db*'), }, - REFERRER - ) - ); + referrer: REFERRER, + }, + }); await waitFor(() => expect(result.current.isPending).toBe(false)); @@ -153,10 +163,10 @@ describe('useFetchEventsTimeSeries', () => { body: [], }); - const {result} = renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - { + const {result} = renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: { yAxis: 'p50(span.duration)', interval: '2h', pageFilters: { @@ -170,9 +180,9 @@ describe('useFetchEventsTimeSeries', () => { }, }, }, - REFERRER - ) - ); + referrer: REFERRER, + }, + }); await waitFor(() => expect(result.current.isPending).toBe(false)); @@ -207,10 +217,10 @@ describe('useFetchEventsTimeSeries', () => { body: [], }); - const {result} = renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - { + const {result} = renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: { yAxis: 'p50(span.duration)', topEvents: 5, groupBy: ['span.category', 'transaction'], @@ -219,9 +229,9 @@ describe('useFetchEventsTimeSeries', () => { kind: 'desc', }, }, - REFERRER - ) - ); + referrer: REFERRER, + }, + }); await waitFor(() => expect(result.current.isPending).toBe(false)); @@ -257,13 +267,17 @@ describe('useFetchEventsTimeSeries', () => { body: [], }); - const {result} = renderHookWithProviders(() => - useFetchEventsTimeSeries( - DiscoverDatasets.SPANS, - {yAxis: 'epm()', logQuery: ['span.op:db*'], metricQuery: ['span.op:db*']}, - REFERRER - ) - ); + const {result} = renderHookWithProviders(useTestableFetchEventsTimeSeries, { + initialProps: { + dataset: DiscoverDatasets.SPANS, + options: { + yAxis: 'epm()', + logQuery: ['span.op:db*'], + metricQuery: ['span.op:db*'], + }, + referrer: REFERRER, + }, + }); await waitFor(() => expect(result.current.isPending).toBe(false)); From a2d42d1824fe0743ee5ba46b98d4cb06ab0d8839 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 20 Jan 2026 10:31:35 -0800 Subject: [PATCH 10/10] pass props into rerender() --- .../app/views/replays/detail/ai/useReplaySummary.spec.tsx | 2 +- static/app/views/replays/detail/ai/useReplaySummary.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx b/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx index e33201b13ad4d9..857d9bd2b1d4a7 100644 --- a/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx +++ b/static/app/views/replays/detail/ai/useReplaySummary.spec.tsx @@ -243,7 +243,7 @@ describe('useReplaySummary', () => { // Update the segment count and expect a POST. mockReplayRecord.count_segments = 2; mockReplay.getReplay = jest.fn().mockReturnValue(mockReplayRecord); - rerender(); + rerender({replay: mockReplay}); await waitFor(() => { expect(mockPostRequest).toHaveBeenCalledWith( diff --git a/static/app/views/replays/detail/ai/useReplaySummary.tsx b/static/app/views/replays/detail/ai/useReplaySummary.tsx index bb513138789da9..36e47b80a14ce2 100644 --- a/static/app/views/replays/detail/ai/useReplaySummary.tsx +++ b/static/app/views/replays/detail/ai/useReplaySummary.tsx @@ -76,13 +76,11 @@ function createAISummaryQueryKey( return [`/projects/${orgSlug}/${projectSlug}/replays/${replayId}/summarize/`]; } -export function useReplaySummary({ - replay, - options, -}: { +export function useReplaySummary(props: { replay: ReplayReader; options?: UseApiQueryOptions; }): UseReplaySummaryResult { + const {replay, options} = props; const organization = useOrganization(); const replayRecord = replay.getReplay(); const project = useProjectFromId({project_id: replayRecord?.project_id});