Skip to content

Commit 6b17dc5

Browse files
authored
fix(predict): keep live position data in sync across screens (#29527)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR fixes inconsistencies in Predict position values across the home screen, market details, and card/list surfaces. It moves active-position live updates into `usePredictPositions` via `usePredictLivePositions`, syncs websocket-derived values back into the shared React Query positions cache, removes duplicate component-level live subscriptions, scopes live subscriptions to focused screens only, and fixes market websocket unsubscribe behavior so overlapping token subscriptions do not break updates on other active screens. It also updates the positions header to read claimable positions from `usePredictPositions` instead of Redux/controller state so it stays aligned with the shared query source. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed live prediction position values so they stay updated across the home screen and market details views. ## **Related issues** Refs: https://consensyssoftware.atlassian.net/browse/PRED-820 ## **Manual testing steps** ```gherkin Feature: live predict positions stay synchronized across screens Scenario: home positions continue updating after visiting market details Given the user has at least one active Predict position And the user is on the Wallet home screen with the Predictions positions section visible When the user waits for live position values to update And opens one of the active positions into market details And waits for live position values to update on market details And navigates back to the Wallet home screen Then the same home position continues receiving live value updates Scenario: only the focused screen keeps market subscriptions active Given the user has active Predict positions on the Wallet home screen When the user opens a single position in market details Then market details only shows live updates for the focused position tokens And no unrelated position updates continue streaming from the hidden home screen Scenario: claimable positions still render correctly Given the user has claimable Predict positions When the user opens surfaces that show claimable positions Then claimable amounts still render correctly And the positions header claim button amount matches the claimable positions list ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- Generated with the help of the pr-description AI skill --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how live price updates propagate through shared React Query caches and alters WebSocket subscription/unsubscribe behavior, which could affect update frequency and data correctness across multiple screens. > > **Overview** > Ensures Predict **active position** values stay consistent across home, market details, and card surfaces by adding an opt-in `livePriceUpdates` flag to `usePredictPositions` that enables `usePredictLivePositions` to *sync websocket-derived PnL/value updates back into the shared positions query cache*. > > Removes component-level live position mapping (`PredictPicks`, `PredictPicksForCard`) in favor of consuming already-updated `usePredictPositions` data, and scopes live subscriptions to focused screens while skipping claimable positions. > > Fixes Polymarket market-price WebSocket unsubscribe logic to avoid unsubscribing token IDs still required by other active subscriptions, and updates `PredictPositionsHeader` to derive won/claimable positions from `usePredictPositions` instead of Redux state; tests were updated/added to cover the new live-update and unsubscribe semantics. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8eda7a8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c4049aa commit 6b17dc5

23 files changed

Lines changed: 866 additions & 229 deletions

app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const PredictGameDetailsContent: React.FC<PredictGameDetailsContentProps> = ({
8080
marketId: market.id,
8181
childMarketIds: market.childMarketIds,
8282
claimable: false,
83+
livePriceUpdates: true,
8384
});
8485
const { data: claimablePositions = [] } = usePredictPositions({
8586
marketId: market.id,

app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const PredictHomePositions = forwardRef<
4242
refetch,
4343
isLoading: isActiveLoading,
4444
error: activeError,
45-
} = usePredictPositions({ claimable: false });
45+
} = usePredictPositions({ claimable: false, livePriceUpdates: true });
4646

4747
const {
4848
data: claimablePositions = [],

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,6 @@ jest.mock('../../hooks/usePredictCashOut', () => ({
4545
usePredictCashOut: () => ({ onCashOut: mockOnCashOut }),
4646
}));
4747

48-
jest.mock('../../hooks/usePredictLivePositions', () => ({
49-
usePredictLivePositions: jest.fn((positions: unknown[]) => ({
50-
livePositions: positions ?? [],
51-
isConnected: false,
52-
lastUpdateTime: null,
53-
})),
54-
}));
5548
jest.mock('../../utils/format');
5649

5750
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;

app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Box } from '@metamask/design-system-react-native';
22
import React from 'react';
33
import { useSelector } from 'react-redux';
4-
import { usePredictLivePositions } from '../../hooks/usePredictLivePositions';
54
import { usePredictCashOut } from '../../hooks/usePredictCashOut';
65
import {
76
PredictMarket,
@@ -29,7 +28,6 @@ const PredictPicks: React.FC<PredictPicksProps> = ({
2928
claimablePositions,
3029
testID = PREDICT_PICKS_TEST_ID,
3130
}) => {
32-
const { livePositions } = usePredictLivePositions(positions);
3331
const { onCashOut } = usePredictCashOut({
3432
market,
3533
callerName: 'PredictPicks',
@@ -43,7 +41,7 @@ const PredictPicks: React.FC<PredictPicksProps> = ({
4341
if (usePositionDetail) {
4442
return (
4543
<Box testID={testID} twClassName="flex-col pt-3">
46-
{livePositions.map((position) => (
44+
{positions.map((position) => (
4745
<PredictPositionDetail
4846
key={position.id}
4947
position={position}
@@ -65,7 +63,7 @@ const PredictPicks: React.FC<PredictPicksProps> = ({
6563

6664
return (
6765
<Box testID={testID} twClassName="flex-col">
68-
{livePositions.map((position) => (
66+
{positions.map((position) => (
6967
<PredictPickItem
7068
key={position.id}
7169
position={position}

app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.test.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,6 @@ import { formatPrice } from '../../utils/format';
77

88
import { POLYMARKET_PROVIDER_ID } from '../../providers/polymarket/constants';
99
jest.mock('../../hooks/usePredictPositions');
10-
jest.mock('../../hooks/usePredictLivePositions', () => ({
11-
usePredictLivePositions: jest.fn((positions: unknown[]) => ({
12-
livePositions: positions ?? [],
13-
isConnected: false,
14-
lastUpdateTime: null,
15-
})),
16-
}));
1710
jest.mock('../../utils/format');
1811

1912
const mockUsePredictPositions = usePredictPositions as jest.Mock;
@@ -327,12 +320,12 @@ describe('PredictPicksForCard', () => {
327320

328321
expect(mockUsePredictPositions).toHaveBeenCalledWith({
329322
marketId: 'specific-market-456',
330-
refetchInterval: 10000,
331323
enabled: true,
324+
livePriceUpdates: true,
332325
});
333326
});
334327

335-
it('passes refetchInterval of 10000ms to hook when no positions prop', () => {
328+
it('enables livePriceUpdates when no positions prop', () => {
336329
mockUsePredictPositions.mockReturnValue({
337330
data: [],
338331
isLoading: false,
@@ -345,7 +338,7 @@ describe('PredictPicksForCard', () => {
345338

346339
expect(mockUsePredictPositions).toHaveBeenCalledWith(
347340
expect.objectContaining({
348-
refetchInterval: 10000,
341+
livePriceUpdates: true,
349342
}),
350343
);
351344
});
@@ -362,8 +355,8 @@ describe('PredictPicksForCard', () => {
362355

363356
expect(mockUsePredictPositions).toHaveBeenCalledWith({
364357
marketId: 'market-1',
365-
refetchInterval: undefined,
366358
enabled: false,
359+
livePriceUpdates: false,
367360
});
368361
});
369362
});

app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React from 'react';
22
import { Box } from '@metamask/design-system-react-native';
33

44
import { usePredictPositions } from '../../hooks/usePredictPositions';
5-
import { usePredictLivePositions } from '../../hooks/usePredictLivePositions';
65
import type { PredictPosition } from '../../types';
76
import PredictPicksForCardItem from './PredictPicksForCardItem';
87
import {
@@ -33,14 +32,13 @@ const PredictPicksForCard: React.FC<PredictPicksForCardProps> = ({
3332
}) => {
3433
const { data: fetchedPositions = [] } = usePredictPositions({
3534
marketId,
36-
refetchInterval: positionsProp ? undefined : 10000,
3735
enabled: !positionsProp,
36+
livePriceUpdates: !positionsProp,
3837
});
3938

4039
const basePositions = positionsProp ?? fetchedPositions;
41-
const { livePositions } = usePredictLivePositions(basePositions);
4240

43-
if (livePositions.length === 0) {
41+
if (basePositions.length === 0) {
4442
return null;
4543
}
4644

@@ -52,7 +50,7 @@ const PredictPicksForCard: React.FC<PredictPicksForCardProps> = ({
5250
twClassName="h-px bg-border-muted my-2"
5351
/>
5452
)}
55-
{livePositions.map((position) => (
53+
{basePositions.map((position) => (
5654
<PredictPicksForCardItem
5755
key={position.id}
5856
position={position}

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

Lines changed: 46 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react';
33
import Routes from '../../../../../constants/navigation/Routes';
44
import renderWithProvider from '../../../../../util/test/renderWithProvider';
55
import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL';
6+
import { usePredictPositions } from '../../hooks/usePredictPositions';
67
import { PredictPosition, PredictPositionStatus } from '../../types';
78
import MarketsWonCard from './PredictPositionsHeader';
89

@@ -94,14 +95,9 @@ jest.mock('../../hooks/usePredictActionGuard', () => ({
9495
}));
9596

9697
const mockRefetchClaimablePositions = jest.fn();
97-
jest.mock('../../hooks/usePredictPositions', () => ({
98-
usePredictPositions: () => ({
99-
data: [{ id: 'position-1' }],
100-
isLoading: false,
101-
error: null,
102-
refetch: mockRefetchClaimablePositions,
103-
}),
104-
}));
98+
let mockActivePositions: PredictPosition[] = [];
99+
let mockClaimablePositions: PredictPosition[] = [];
100+
jest.mock('../../hooks/usePredictPositions');
105101

106102
const mockClaim = jest.fn();
107103
jest.mock('../../hooks/usePredictClaim', () => ({
@@ -127,36 +123,13 @@ jest.mock('../../../../../../locales/i18n', () => ({
127123
}),
128124
}));
129125

130-
function createTestState(
131-
_availableBalance?: number,
132-
claimableAmount?: number,
133-
privacyMode = false,
134-
) {
126+
function createTestState(_availableBalance?: number, privacyMode = false) {
135127
const testAddress = '0x1234567890123456789012345678901234567890';
136128
const testAccountId = 'test-account-id';
137129

138-
const claimablePositions = claimableAmount
139-
? ([
140-
{
141-
id: 'position-1',
142-
status: PredictPositionStatus.WON,
143-
cashPnl: claimableAmount,
144-
currentValue: claimableAmount,
145-
marketId: 'market-1',
146-
title: 'Test Market',
147-
outcome: 'Yes',
148-
},
149-
] as unknown as PredictPosition[])
150-
: [];
151-
152130
return {
153131
engine: {
154132
backgroundState: {
155-
PredictController: {
156-
claimablePositions: {
157-
[testAddress]: claimablePositions,
158-
},
159-
},
160133
AccountsController: {
161134
internalAccounts: {
162135
selectedAccount: testAccountId,
@@ -185,11 +158,16 @@ describe('MarketsWonCard', () => {
185158
const mockUseUnrealizedPnL = useUnrealizedPnL as jest.MockedFunction<
186159
typeof useUnrealizedPnL
187160
>;
161+
const mockUsePredictPositions = usePredictPositions as jest.MockedFunction<
162+
typeof usePredictPositions
163+
>;
188164

189165
beforeEach(() => {
190166
jest.clearAllMocks();
191167
mockBalanceResult.data = 100.5;
192168
mockBalanceResult.isLoading = false;
169+
mockActivePositions = [{ id: 'position-1' } as PredictPosition];
170+
mockClaimablePositions = [];
193171

194172
mockUseUnrealizedPnL.mockReturnValue({
195173
data: {
@@ -201,13 +179,35 @@ describe('MarketsWonCard', () => {
201179
isFetching: false,
202180
error: null,
203181
} as unknown as ReturnType<typeof useUnrealizedPnL>);
182+
mockUsePredictPositions.mockImplementation(
183+
({ claimable }: { claimable?: boolean } = {}) =>
184+
({
185+
data: claimable ? mockClaimablePositions : mockActivePositions,
186+
isLoading: false,
187+
error: null,
188+
refetch: mockRefetchClaimablePositions,
189+
}) as unknown as ReturnType<typeof usePredictPositions>,
190+
);
204191
});
205192

206193
afterEach(() => {
207-
jest.resetAllMocks();
194+
jest.clearAllMocks();
208195
});
209196

210197
describe('rendering', () => {
198+
it('does not enable live updates for active position count query', () => {
199+
const state = createTestState(100.5);
200+
201+
renderWithProvider(<MarketsWonCard />, { state });
202+
203+
const activePositionsCall = mockUsePredictPositions.mock.calls.find(
204+
([options]) => options?.claimable === false,
205+
);
206+
207+
expect(activePositionsCall?.[0]).toMatchObject({ claimable: false });
208+
expect(activePositionsCall?.[0]?.livePriceUpdates).toBeUndefined();
209+
});
210+
211211
it('displays available balance and unrealized P&L', () => {
212212
const state = createTestState(100.5);
213213

@@ -230,15 +230,26 @@ describe('MarketsWonCard', () => {
230230
});
231231

232232
it('hides monetary values when privacy mode is enabled', () => {
233-
const state = createTestState(100.5, 24.66, true);
233+
mockClaimablePositions = [
234+
{
235+
id: 'position-1',
236+
status: PredictPositionStatus.WON,
237+
cashPnl: 24.66,
238+
currentValue: 24.66,
239+
marketId: 'market-1',
240+
title: 'Test Market',
241+
outcome: 'Yes',
242+
} as PredictPosition,
243+
];
244+
const state = createTestState(100.5, true);
234245

235246
renderWithProvider(<MarketsWonCard />, { state });
236247

237248
expect(screen.queryByText('$100.50')).toBeNull();
238249
expect(screen.queryByText('+$8.63 (+3.9%)')).toBeNull();
239250
expect(screen.queryByText('Claim $24.66')).toBeNull();
240251
expect(screen.getByText('••••••••••••')).toBeOnTheScreen();
241-
expect(screen.getByText('•••••••••')).toBeOnTheScreen();
252+
expect(screen.getAllByText('•••••••••').length).toBeGreaterThan(0);
242253
});
243254
});
244255

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit';
4646
import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL';
4747
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
4848
import { usePredictPositions } from '../../hooks/usePredictPositions';
49-
import { selectPredictWonPositions } from '../../selectors/predictController';
50-
import { PredictPosition } from '../../types';
49+
import { PredictPosition, PredictPositionStatus } from '../../types';
5150
import { PredictNavigationParamList } from '../../types/navigation';
5251
import {
5352
formatPercentage,
@@ -95,12 +94,20 @@ const PredictPositionsHeader = forwardRef<
9594
const evmAccount = getEvmAccountFromSelectedAccountGroup();
9695
const selectedAddress = evmAccount?.address ?? '0x0';
9796
const { isDepositPending } = usePredictDeposit();
98-
const wonPositions = useSelector(
99-
selectPredictWonPositions({ address: selectedAddress }),
100-
);
101-
102-
const { data: activePositions } = usePredictPositions({ claimable: false });
97+
const { data: activePositions } = usePredictPositions({
98+
claimable: false,
99+
});
100+
const { data: claimablePositions = [] } = usePredictPositions({
101+
claimable: true,
102+
});
103103
const hasPositions = (activePositions?.length ?? 0) > 0;
104+
const wonPositions = useMemo(
105+
() =>
106+
claimablePositions.filter(
107+
(position) => position.status === PredictPositionStatus.WON,
108+
),
109+
[claimablePositions],
110+
);
104111

105112
const {
106113
data: pnlData,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ describe('PredictSportCardFooter', () => {
306306
expect(mockUsePredictPositions).toHaveBeenCalledWith({
307307
marketId: 'specific-market-123',
308308
claimable: false,
309-
refetchInterval: 10000,
309+
livePriceUpdates: true,
310310
});
311311
});
312312

app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const PredictSportCardFooter: React.FC<PredictSportCardFooterProps> = ({
5353
const { data: positions = [], isLoading } = usePredictPositions({
5454
marketId: market.id,
5555
claimable: false,
56-
refetchInterval: 10000,
56+
livePriceUpdates: true,
5757
});
5858

5959
const { data: claimablePositions = [] } = usePredictPositions({

0 commit comments

Comments
 (0)