Skip to content

Commit 1e56dee

Browse files
authored
feat(predict): add auto-refresh for optimistic positions (MetaMask#23051)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Implements automatic 2-second refresh interval for optimistic positions in PredictPosition and PredictPositionDetail components. Positions automatically update in real-time and stop refreshing when resolved. - Add auto-refresh effect that triggers every 2 seconds for optimistic positions - Track currentPosition state to display latest values from API - Clear interval automatically when position becomes non-optimistic - Clean up interval on component unmount to prevent memory leaks - Add comprehensive test coverage for auto-refresh behavior - Test refresh start, stop, cleanup, and position updates <!-- 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? --> ## **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: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-322?atlOrigin=eyJpIjoiODc3OGE2YzIxNWFiNGZhNzllNTY4YzI4N2Q4YTdkNmUiLCJwIjoiaiJ9 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. ## **Pre-merge reviewer checklist** - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce a polling hook to auto-refresh optimistic positions and wire it into PredictPosition and PredictPositionDetail with updated UI/behavior and comprehensive tests. > > - **Core**: > - Add `usePredictOptimisticPositionRefresh` hook to sequentially poll `usePredictPositions` (default 2s), update position by `marketId`/`outcomeId`, and clean up on unmount or when no longer optimistic. > - **Components**: > - `PredictPosition` and `PredictPositionDetail` now consume the hook and render using `currentPosition` (show skeletons while optimistic). > - Pass updated `currentPosition` to `onPress` and navigate with refreshed data. > - Disable cash out button when position is optimistic. > - **Navigation**: > - Use `Routes.PREDICT.MODALS.SELL_PREVIEW` for cash-out navigation. > - **Tests**: > - Add extensive tests for the hook and update component tests to cover immediate start, interval polling, stop on resolve, unmount cleanup, UI updates, and error/slow-network handling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit db0b529. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f8c1671 commit 1e56dee

6 files changed

Lines changed: 900 additions & 35 deletions

File tree

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

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import React from 'react';
2-
import { render, screen, fireEvent } from '@testing-library/react-native';
2+
import {
3+
render,
4+
screen,
5+
fireEvent,
6+
act,
7+
waitFor,
8+
} from '@testing-library/react-native';
39
import PredictPosition from './PredictPosition';
410
import {
511
PredictPositionStatus,
612
type PredictPosition as PredictPositionType,
713
} from '../../types';
814
import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
15+
import { usePredictPositions } from '../../hooks/usePredictPositions';
916

1017
jest.mock('../../../../../../locales/i18n', () => ({
1118
strings: jest.fn((key: string, vars?: Record<string, string | number>) => {
@@ -16,6 +23,17 @@ jest.mock('../../../../../../locales/i18n', () => ({
1623
}),
1724
}));
1825

26+
const mockLoadPositions = jest.fn();
27+
jest.mock('../../hooks/usePredictPositions', () => ({
28+
usePredictPositions: jest.fn(() => ({
29+
positions: [],
30+
loadPositions: mockLoadPositions,
31+
isLoading: false,
32+
isRefreshing: false,
33+
error: null,
34+
})),
35+
}));
36+
1937
const basePosition: PredictPositionType = {
2038
id: 'pos-1',
2139
providerId: 'polymarket',
@@ -51,6 +69,27 @@ const renderComponent = (
5169
};
5270

5371
describe('PredictPosition', () => {
72+
const mockUsePredictPositions = usePredictPositions as jest.MockedFunction<
73+
typeof usePredictPositions
74+
>;
75+
76+
beforeEach(() => {
77+
jest.useFakeTimers();
78+
mockLoadPositions.mockClear();
79+
mockUsePredictPositions.mockReturnValue({
80+
positions: [],
81+
loadPositions: mockLoadPositions,
82+
isLoading: false,
83+
isRefreshing: false,
84+
error: null,
85+
});
86+
});
87+
88+
afterEach(() => {
89+
jest.clearAllMocks();
90+
jest.useRealTimers();
91+
});
92+
5493
it('renders primary position info', () => {
5594
renderComponent();
5695

@@ -237,4 +276,205 @@ describe('PredictPosition', () => {
237276
expect(screen.getByText('$123.45 on Yes to win $10')).toBeOnTheScreen();
238277
});
239278
});
279+
280+
describe('optimistic position auto-refresh', () => {
281+
it('starts auto-refresh immediately when position is optimistic', async () => {
282+
mockLoadPositions.mockResolvedValue(undefined);
283+
renderComponent({ optimistic: true });
284+
285+
await waitFor(() => {
286+
expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true });
287+
});
288+
});
289+
290+
it('does not start auto-refresh when position is not optimistic', async () => {
291+
renderComponent({ optimistic: false });
292+
293+
await act(async () => {
294+
await jest.advanceTimersByTimeAsync(2000);
295+
});
296+
297+
expect(mockLoadPositions).not.toHaveBeenCalled();
298+
});
299+
300+
it('continues auto-refresh at 2-second intervals after each load completes', async () => {
301+
mockLoadPositions.mockResolvedValue(undefined);
302+
renderComponent({ optimistic: true });
303+
304+
// First load happens immediately
305+
await waitFor(() => {
306+
expect(mockLoadPositions).toHaveBeenCalledTimes(1);
307+
});
308+
309+
// Second load happens 2 seconds after first completes
310+
await act(async () => {
311+
await jest.advanceTimersByTimeAsync(2000);
312+
});
313+
314+
expect(mockLoadPositions).toHaveBeenCalledTimes(2);
315+
316+
// Third load happens 2 seconds after second completes
317+
await act(async () => {
318+
await jest.advanceTimersByTimeAsync(2000);
319+
});
320+
321+
expect(mockLoadPositions).toHaveBeenCalledTimes(3);
322+
});
323+
324+
it('stops auto-refresh when position becomes non-optimistic', async () => {
325+
const optimisticPosition = { ...basePosition, optimistic: true };
326+
const resolvedPosition = { ...basePosition, optimistic: false };
327+
328+
mockLoadPositions.mockResolvedValue(undefined);
329+
mockUsePredictPositions.mockReturnValue({
330+
positions: [optimisticPosition],
331+
loadPositions: mockLoadPositions,
332+
isLoading: false,
333+
isRefreshing: false,
334+
error: null,
335+
});
336+
337+
const { rerender } = renderComponent({ optimistic: true });
338+
339+
// First load happens immediately
340+
await waitFor(() => {
341+
expect(mockLoadPositions).toHaveBeenCalledTimes(1);
342+
});
343+
344+
mockLoadPositions.mockClear();
345+
346+
mockUsePredictPositions.mockReturnValue({
347+
positions: [resolvedPosition],
348+
loadPositions: mockLoadPositions,
349+
isLoading: false,
350+
isRefreshing: false,
351+
error: null,
352+
});
353+
354+
rerender(<PredictPosition position={resolvedPosition} />);
355+
356+
await waitFor(() => {
357+
expect(screen.getByText('$2,345.67')).toBeOnTheScreen();
358+
});
359+
360+
await act(async () => {
361+
await jest.advanceTimersByTimeAsync(2000);
362+
});
363+
364+
expect(mockLoadPositions).not.toHaveBeenCalled();
365+
});
366+
367+
it('cleans up auto-refresh on unmount', async () => {
368+
mockLoadPositions.mockResolvedValue(undefined);
369+
const { unmount } = renderComponent({ optimistic: true });
370+
371+
// First load happens immediately
372+
await waitFor(() => {
373+
expect(mockLoadPositions).toHaveBeenCalledTimes(1);
374+
});
375+
376+
mockLoadPositions.mockClear();
377+
378+
unmount();
379+
380+
await act(async () => {
381+
await jest.advanceTimersByTimeAsync(2000);
382+
});
383+
384+
expect(mockLoadPositions).not.toHaveBeenCalled();
385+
});
386+
387+
it('updates displayed position when positions hook returns new data', async () => {
388+
const optimisticPosition = {
389+
...basePosition,
390+
optimistic: true,
391+
currentValue: 100,
392+
percentPnl: 0,
393+
};
394+
const resolvedPosition = {
395+
...basePosition,
396+
optimistic: false,
397+
currentValue: 2500,
398+
percentPnl: 10.5,
399+
};
400+
401+
mockUsePredictPositions.mockReturnValue({
402+
positions: [optimisticPosition],
403+
loadPositions: mockLoadPositions,
404+
isLoading: false,
405+
isRefreshing: false,
406+
error: null,
407+
});
408+
409+
const { rerender } = renderComponent({ optimistic: true });
410+
411+
expect(screen.queryByText('$2,500')).toBeNull();
412+
413+
mockUsePredictPositions.mockReturnValue({
414+
positions: [resolvedPosition],
415+
loadPositions: mockLoadPositions,
416+
isLoading: false,
417+
isRefreshing: false,
418+
error: null,
419+
});
420+
421+
rerender(<PredictPosition position={optimisticPosition} />);
422+
423+
await waitFor(() => {
424+
expect(screen.getByText('$2,500')).toBeOnTheScreen();
425+
expect(screen.getByText('10.5%')).toBeOnTheScreen();
426+
});
427+
});
428+
429+
it('calls onPress with updated position after refresh', async () => {
430+
const mockOnPress = jest.fn();
431+
const optimisticPosition = {
432+
...basePosition,
433+
optimistic: true,
434+
currentValue: 100,
435+
};
436+
const resolvedPosition = {
437+
...basePosition,
438+
optimistic: false,
439+
currentValue: 2500,
440+
};
441+
442+
mockUsePredictPositions.mockReturnValue({
443+
positions: [optimisticPosition],
444+
loadPositions: mockLoadPositions,
445+
isLoading: false,
446+
isRefreshing: false,
447+
error: null,
448+
});
449+
450+
const { rerender } = renderComponent({ optimistic: true }, mockOnPress);
451+
452+
mockUsePredictPositions.mockReturnValue({
453+
positions: [resolvedPosition],
454+
loadPositions: mockLoadPositions,
455+
isLoading: false,
456+
isRefreshing: false,
457+
error: null,
458+
});
459+
460+
rerender(
461+
<PredictPosition position={optimisticPosition} onPress={mockOnPress} />,
462+
);
463+
464+
await waitFor(() => {
465+
expect(screen.getByText('$2,500')).toBeOnTheScreen();
466+
});
467+
468+
fireEvent.press(
469+
screen.getByTestId(PredictPositionSelectorsIDs.CURRENT_POSITION_CARD),
470+
);
471+
472+
expect(mockOnPress).toHaveBeenCalledWith(
473+
expect.objectContaining({
474+
currentValue: 2500,
475+
optimistic: false,
476+
}),
477+
);
478+
});
479+
});
240480
});

app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import styleSheet from './PredictPosition.styles';
1111
import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
1212
import { strings } from '../../../../../../locales/i18n';
1313
import { Skeleton } from '../../../../../component-library/components/Skeleton';
14+
import { usePredictOptimisticPositionRefresh } from '../../hooks/usePredictOptimisticPositionRefresh';
1415

1516
interface PredictPositionProps {
1617
position: PredictPositionType;
@@ -21,6 +22,12 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
2122
position,
2223
onPress,
2324
}: PredictPositionProps) => {
25+
const { styles } = useStyles(styleSheet, {});
26+
27+
const currentPosition = usePredictOptimisticPositionRefresh({
28+
position,
29+
});
30+
2431
const {
2532
icon,
2633
title,
@@ -30,14 +37,13 @@ const PredictPosition: React.FC<PredictPositionProps> = ({
3037
currentValue,
3138
size,
3239
optimistic,
33-
} = position;
34-
const { styles } = useStyles(styleSheet, {});
40+
} = currentPosition;
3541

3642
return (
3743
<TouchableOpacity
3844
testID={PredictPositionSelectorsIDs.CURRENT_POSITION_CARD}
3945
style={styles.positionContainer}
40-
onPress={() => onPress?.(position)}
46+
onPress={() => onPress?.(currentPosition)}
4147
>
4248
<View style={styles.positionImageContainer}>
4349
<Image source={{ uri: icon }} style={styles.positionImage} />

0 commit comments

Comments
 (0)