Skip to content

Commit 19722d7

Browse files
authored
refactor(predict): migrate useUnrealizedPnL to React Query (MetaMask#26877)
## Summary - Migrate `useUnrealizedPnL` from manual `useState`/`useEffect` to React Query's `useQuery`, completing React Query adoption across the Predict module - Extract query key factory and `queryOptions()` into `queries/unrealizedPnL.ts` following the established pattern (`balance.ts`, `positions.ts`) - Register the new query in `predictQueries` index - Update consumer (`PredictPositionsHeader`) to use the simplified `loadUnrealizedPnL()` signature (no more `{ isRefresh }` arg — React Query handles this) ## Test plan - [ ] `npx jest useUnrealizedPnL` — all tests pass - [ ] `npx jest PredictPositionsHeader` — consumer tests still pass - [ ] Verify unrealized P&L displays correctly on the Positions screen - [ ] Verify pull-to-refresh updates P&L data <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how unrealized P&L is fetched/cached (React Query) and when the UI shows/refreshes it (focus + position-gated), which could affect staleness and error/display states. > > **Overview** > Migrates `useUnrealizedPnL` from manual `useState`/`useEffect` fetching to a React Query `useQuery` implementation backed by a new `predictQueries.unrealizedPnL` entry and `queries/unrealizedPnL.ts` (keys + `queryOptions`). > > Updates `PredictPositionsHeader` to consume the new hook shape (`data`/`error` as `Error`), **only render P&L when there are active (non-claimable) positions**, and refresh by invalidating the unrealized P&L query on screen focus and via the imperative `refresh()` handler. > > Ensures unrealized P&L cache is refreshed after relevant actions by invalidating `predictQueries.unrealizedPnL` on order placement and on confirmed deposit/claim/withdraw toast events; tests are adjusted accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3100866. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent bb33d3c commit 19722d7

9 files changed

Lines changed: 203 additions & 397 deletions

File tree

app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ jest.mock('@tanstack/react-query', () => ({
7676
}),
7777
}));
7878

79+
const mockNavigate = jest.fn();
80+
jest.mock('@react-navigation/native', () => ({
81+
...jest.requireActual('@react-navigation/native'),
82+
useNavigation: () => ({
83+
navigate: mockNavigate,
84+
}),
85+
useFocusEffect: jest.fn(),
86+
}));
87+
7988
const mockExecuteGuardedAction = jest.fn(async (action) => await action());
8089
jest.mock('../../hooks/usePredictActionGuard', () => ({
8190
usePredictActionGuard: () => ({
@@ -87,7 +96,7 @@ jest.mock('../../hooks/usePredictActionGuard', () => ({
8796
const mockRefetchClaimablePositions = jest.fn();
8897
jest.mock('../../hooks/usePredictPositions', () => ({
8998
usePredictPositions: () => ({
90-
data: [],
99+
data: [{ id: 'position-1' }],
91100
isLoading: false,
92101
error: null,
93102
refetch: mockRefetchClaimablePositions,
@@ -104,14 +113,6 @@ jest.mock('../../hooks/usePredictClaim', () => ({
104113
}),
105114
}));
106115

107-
const mockNavigate = jest.fn();
108-
jest.mock('@react-navigation/native', () => ({
109-
...jest.requireActual('@react-navigation/native'),
110-
useNavigation: () => ({
111-
navigate: mockNavigate,
112-
}),
113-
}));
114-
115116
jest.mock('../../../../../../locales/i18n', () => ({
116117
strings: jest.fn((key: string, params?: Record<string, unknown>) => {
117118
const mockStrings: Record<string, string> = {
@@ -191,16 +192,15 @@ describe('MarketsWonCard', () => {
191192
mockBalanceResult.isLoading = false;
192193

193194
mockUseUnrealizedPnL.mockReturnValue({
194-
unrealizedPnL: {
195+
data: {
195196
user: '0x1234567890123456789012345678901234567890',
196197
cashUpnl: 8.63,
197198
percentUpnl: 3.9,
198199
},
199200
isLoading: false,
200-
isRefreshing: false,
201+
isFetching: false,
201202
error: null,
202-
loadUnrealizedPnL: jest.fn(),
203-
});
203+
} as unknown as ReturnType<typeof useUnrealizedPnL>);
204204
});
205205

206206
afterEach(() => {
@@ -265,27 +265,15 @@ describe('MarketsWonCard', () => {
265265
});
266266

267267
describe('refresh', () => {
268-
it('reloads balance and unrealized P&L when refresh is called', async () => {
269-
const mockLoadUnrealizedPnL = jest.fn();
270-
mockUseUnrealizedPnL.mockReturnValue({
271-
unrealizedPnL: {
272-
user: '0x1234567890123456789012345678901234567890',
273-
cashUpnl: 8.63,
274-
percentUpnl: 3.9,
275-
},
276-
isLoading: false,
277-
isRefreshing: false,
278-
error: null,
279-
loadUnrealizedPnL: mockLoadUnrealizedPnL,
280-
});
268+
it('invalidates balance and unrealized P&L queries when refresh is called', async () => {
281269
const ref = React.createRef<{ refresh: () => Promise<void> }>();
282270
const state = createTestState(100.5);
283271

284272
renderWithProvider(<MarketsWonCard ref={ref} />, { state });
285273

286274
await ref.current?.refresh();
287275

288-
expect(mockLoadUnrealizedPnL).toHaveBeenCalledWith({ isRefresh: true });
276+
expect(mockInvalidateQueries).toHaveBeenCalled();
289277
});
290278
});
291279

@@ -303,16 +291,15 @@ describe('MarketsWonCard', () => {
303291

304292
it('displays skeleton loader when unrealized P&L is loading', () => {
305293
mockUseUnrealizedPnL.mockReturnValue({
306-
unrealizedPnL: {
294+
data: {
307295
user: '0x1234567890123456789012345678901234567890',
308296
cashUpnl: 0,
309297
percentUpnl: 0,
310298
},
311299
isLoading: true,
312-
isRefreshing: false,
300+
isFetching: true,
313301
error: null,
314-
loadUnrealizedPnL: jest.fn(),
315-
});
302+
} as unknown as ReturnType<typeof useUnrealizedPnL>);
316303
const state = createTestState(100.5);
317304

318305
renderWithProvider(<MarketsWonCard />, { state });
@@ -326,12 +313,11 @@ describe('MarketsWonCard', () => {
326313
mockBalanceResult.data = undefined;
327314
mockBalanceResult.isLoading = false;
328315
mockUseUnrealizedPnL.mockReturnValue({
329-
unrealizedPnL: null,
316+
data: undefined,
330317
isLoading: false,
331-
isRefreshing: false,
318+
isFetching: false,
332319
error: null,
333-
loadUnrealizedPnL: jest.fn(),
334-
});
320+
} as unknown as ReturnType<typeof useUnrealizedPnL>);
335321
const state = createTestState();
336322

337323
const { toJSON } = renderWithProvider(<MarketsWonCard />, { state });
@@ -357,16 +343,15 @@ describe('MarketsWonCard', () => {
357343
mockBalanceResult.error = null;
358344
mockBalanceResult.data = 100.5;
359345
mockUseUnrealizedPnL.mockReturnValue({
360-
unrealizedPnL: {
346+
data: {
361347
user: '0x1234567890123456789012345678901234567890',
362348
cashUpnl: 8.63,
363349
percentUpnl: 3.9,
364350
},
365351
isLoading: false,
366-
isRefreshing: false,
367-
error: 'P&L fetch failed',
368-
loadUnrealizedPnL: jest.fn(),
369-
});
352+
isFetching: false,
353+
error: new Error('P&L fetch failed'),
354+
} as unknown as ReturnType<typeof useUnrealizedPnL>);
370355
const state = createTestState(100.5);
371356

372357
renderWithProvider(<MarketsWonCard onError={mockOnError} />, { state });
@@ -379,16 +364,15 @@ describe('MarketsWonCard', () => {
379364
mockBalanceResult.error = { message: 'Balance error' };
380365
mockBalanceResult.data = 100.5;
381366
mockUseUnrealizedPnL.mockReturnValue({
382-
unrealizedPnL: {
367+
data: {
383368
user: '0x1234567890123456789012345678901234567890',
384369
cashUpnl: 8.63,
385370
percentUpnl: 3.9,
386371
},
387372
isLoading: false,
388-
isRefreshing: false,
389-
error: 'P&L error',
390-
loadUnrealizedPnL: jest.fn(),
391-
});
373+
isFetching: false,
374+
error: new Error('P&L error'),
375+
} as unknown as ReturnType<typeof useUnrealizedPnL>);
392376
const state = createTestState(100.5);
393377

394378
renderWithProvider(<MarketsWonCard onError={mockOnError} />, { state });

app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ import {
88
ButtonSize as ButtonSizeHero,
99
} from '@metamask/design-system-react-native';
1010
import { useTailwind } from '@metamask/design-system-twrnc-preset';
11-
import { NavigationProp, useNavigation } from '@react-navigation/native';
11+
import {
12+
NavigationProp,
13+
useNavigation,
14+
useFocusEffect,
15+
} from '@react-navigation/native';
1216
import React, {
1317
forwardRef,
18+
useCallback,
1419
useEffect,
1520
useImperativeHandle,
1621
useMemo,
@@ -41,6 +46,7 @@ import { usePredictClaim } from '../../hooks/usePredictClaim';
4146
import { usePredictDeposit } from '../../hooks/usePredictDeposit';
4247
import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL';
4348
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
49+
import { usePredictPositions } from '../../hooks/usePredictPositions';
4450
import { selectPredictWonPositions } from '../../selectors/predictController';
4551
import { PredictPosition } from '../../types';
4652
import { PredictNavigationParamList } from '../../types/navigation';
@@ -89,16 +95,32 @@ const PredictPositionsHeader = forwardRef<
8995
selectPredictWonPositions({ address: selectedAddress }),
9096
);
9197

98+
const { data: activePositions } = usePredictPositions({ claimable: false });
99+
const hasPositions = (activePositions?.length ?? 0) > 0;
100+
92101
const {
93-
unrealizedPnL,
102+
data: pnlData,
94103
isLoading: isUnrealizedPnLLoading,
95-
loadUnrealizedPnL,
96104
error: pnlError,
97105
} = useUnrealizedPnL();
98106

107+
// Only show P&L when the user has active (non-claimable) positions
108+
const unrealizedPnL = hasPositions ? (pnlData ?? null) : null;
109+
110+
// Invalidate unrealized P&L query when screen comes into focus
111+
const pnlQueryKey = useMemo(
112+
() => predictQueries.unrealizedPnL.keys.byAddress(selectedAddress),
113+
[selectedAddress],
114+
);
115+
useFocusEffect(
116+
useCallback(() => {
117+
queryClient.invalidateQueries({ queryKey: pnlQueryKey });
118+
}, [queryClient, pnlQueryKey]),
119+
);
120+
99121
// Notify parent of errors while keeping state isolated
100122
useEffect(() => {
101-
const combinedError = balanceError?.message ?? pnlError ?? null;
123+
const combinedError = balanceError?.message ?? pnlError?.message ?? null;
102124
onError?.(combinedError);
103125
}, [balanceError, pnlError, onError]);
104126

@@ -122,7 +144,7 @@ const PredictPositionsHeader = forwardRef<
122144
useImperativeHandle(ref, () => ({
123145
refresh: async () => {
124146
await Promise.all([
125-
loadUnrealizedPnL({ isRefresh: true }),
147+
queryClient.invalidateQueries({ queryKey: pnlQueryKey }),
126148
queryClient.invalidateQueries({
127149
queryKey: predictQueries.balance.keys.all(),
128150
}),

app/components/UI/Predict/hooks/usePredictPlaceOrder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ export function usePredictPlaceOrder(
194194
queryKey: predictQueries.activity.keys.all(),
195195
});
196196

197+
queryClient.invalidateQueries({
198+
queryKey: predictQueries.unrealizedPnL.keys.all(),
199+
});
200+
197201
if (side === Side.BUY) {
198202
showOrderPlacedToast();
199203
} else {

app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ describe('usePredictToastRegistrations', () => {
189189
queryKey: ['predict', 'balance'],
190190
}),
191191
);
192+
expect(mockInvalidateQueries).toHaveBeenCalledWith(
193+
expect.objectContaining({
194+
queryKey: ['predict', 'unrealizedPnL'],
195+
}),
196+
);
192197
});
193198

194199
it('uses account ready fallback when deposit confirmed amount is missing', () => {
@@ -335,6 +340,11 @@ describe('usePredictToastRegistrations', () => {
335340
queryKey: ['predict', 'balance'],
336341
}),
337342
);
343+
expect(mockInvalidateQueries).toHaveBeenCalledWith(
344+
expect.objectContaining({
345+
queryKey: ['predict', 'unrealizedPnL'],
346+
}),
347+
);
338348
});
339349

340350
it('shows error toast with retry on failed status', async () => {
@@ -429,6 +439,11 @@ describe('usePredictToastRegistrations', () => {
429439
queryKey: ['predict', 'balance'],
430440
}),
431441
);
442+
expect(mockInvalidateQueries).toHaveBeenCalledWith(
443+
expect.objectContaining({
444+
queryKey: ['predict', 'unrealizedPnL'],
445+
}),
446+
);
432447
});
433448

434449
it('uses payload amount for withdraw success toast when state amount is unavailable', () => {

app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => {
161161
queryClient.invalidateQueries({
162162
queryKey: predictQueries.activity.keys.all(),
163163
});
164+
165+
queryClient.invalidateQueries({
166+
queryKey: predictQueries.unrealizedPnL.keys.all(),
167+
});
164168
}
165169

166170
if (type === 'deposit') {

0 commit comments

Comments
 (0)