From cb0aa6e2f817dc94c4771be8902723bd89e000d7 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 30 Jan 2026 13:31:10 +0000 Subject: [PATCH 01/13] bump semvar version to 7.62.2 && build version to 3587 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 9e25057ff2d5..196bbf066751 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.62.1" + versionName "7.62.2" versionCode 3561 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index 98bf78583617..3f3666647702 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3468,13 +3468,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.62.1 + VERSION_NAME: 7.62.2 - opts: is_expand: false VERSION_NUMBER: 3561 - opts: is_expand: false - FLASK_VERSION_NAME: 7.62.1 + FLASK_VERSION_NAME: 7.62.2 - opts: is_expand: false FLASK_VERSION_NUMBER: 3561 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index fb58dff5dd5b..c61c67791c33 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.1; + MARKETING_VERSION = 7.62.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.1; + MARKETING_VERSION = 7.62.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.1; + MARKETING_VERSION = 7.62.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.1; + MARKETING_VERSION = 7.62.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.1; + MARKETING_VERSION = 7.62.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.62.1; + MARKETING_VERSION = 7.62.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 9e247258c323..bd5a497e77a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.62.1", + "version": "7.62.2", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From 03f318a38e3cf50f7898eec13f14549194e601d6 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:23:51 +0800 Subject: [PATCH 02/13] fix: cherry-pick 429 rate limiting fix with coin naming convention (#25443) ## **Description** Cherry-pick of commit 425beaead7 (Nick's 429 rate limiting fix) to release/7.62.2 branch. This hotfix addresses HyperLiquid WebSocket rate limiting issues (429 errors) that occur during rapid market switching or TP/SL updates. The fix introduces cache-first patterns to reduce API weight and avoid hitting rate limits. **Key changes:** - Add optional `position` parameter to `updatePositionTPSL` to skip REST API fetch when WebSocket data is available - Implement `getOrFetchPrice` helper for WebSocket-first price retrieval (0 weight vs 20 weight) - Add atomic cache getter `getOrdersCacheIfInitialized()` to prevent race conditions - Add `getOrFetchFills` for cache-first fills retrieval - Move `positionOpenedTimestamp` calculation into `useHasExistingPosition` hook - Add `currentPositionRef` sync in PerpsMarketDetailsView to prevent stale closure issues **Conflict resolution notes:** The main branch uses `symbol` naming convention while release/7.62.2 uses `coin`. All conflicts were resolved by keeping the `coin` naming convention while adopting the cache-first optimization patterns. ## **Changelog** CHANGELOG entry: Fixed rate limiting issues (429 errors) when rapidly switching markets or updating TP/SL orders ## **Related issues** Fixes: Rate limiting issues on HyperLiquid API during rapid market navigation ## **Manual testing steps** ```gherkin Feature: Rate limiting prevention for Perps Scenario: User rapidly switches between markets Given user has the Perps feature enabled And user is viewing a market details page When user rapidly navigates between different markets (BTC -> ETH -> SOL -> BTC) Then no 429 rate limit errors should appear And market data should load correctly for each market Scenario: User updates TP/SL via stop loss prompt banner Given user has an open position without stop loss And the stop loss prompt banner is visible When user taps "Set Stop Loss" on the banner Then the stop loss should be set successfully And no rate limit errors should occur Scenario: User edits existing TP/SL on a position Given user has an open position with TP/SL set When user navigates to modify TP/SL And user updates the stop loss price Then the update should succeed without 429 errors ``` ## **Screenshots/Recordings** ### **Before** N/A - Bug fix for rate limiting, no visual changes ### **After** N/A - Bug fix for rate limiting, no visual changes ## **Pre-merge author checklist** - [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. ## **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. --- > [!NOTE] > **Medium Risk** > Touches perps trading/provider logic (`updatePositionTPSL`, order/price/fill retrieval) and async UI state around stop-loss actions, so regressions could impact TP/SL updates or stale-data handling, but changes are bounded and add explicit fallbacks/tests. > > **Overview** > **Goal:** reduce HyperLiquid REST API weight (and 429s) during rapid market switching and TP/SL operations by preferring WebSocket caches. > > Adds cache-first primitives in `HyperLiquidSubscriptionService` (fills cache + atomic `getOrdersCacheIfInitialized`) and `HyperLiquidProvider` (`getOrFetchPrice`, `getOrFetchFills`), and refactors provider call sites to use these helpers with stricter invalid-price validation and single-DEX REST fallbacks. > > Updates TP/SL flow to pass live WebSocket `position` into `updatePositionTPSL` (avoiding a REST positions fetch), uses cached orders to cancel existing TP/SL when available, and surfaces partial-failure feedback in `PerpsOrderView` when order succeeds but TP/SL update fails. > > Moves `positionOpenedTimestamp` derivation into `useHasExistingPosition` (WebSocket fills first, REST historical fallback), and hardens `PerpsMarketDetailsView` stop-loss banner interactions against stale closures/market switches; the banner UI switches from a `Switch` to a "Set" button with a delayed success checkmark + fade-out. Tests and test IDs are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0edee81a893f625bba8ca5d960f534326bdca43f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.5 --- app/components/UI/Perps/Perps.testIds.ts | 3 +- .../PerpsMarketDetailsView.test.tsx | 135 +++--- .../PerpsMarketDetailsView.tsx | 124 +++-- .../PerpsOrderView/PerpsOrderView.test.tsx | 10 + .../Views/PerpsOrderView/PerpsOrderView.tsx | 42 +- .../PerpsStopLossPromptBanner.test.tsx | 52 +- .../PerpsStopLossPromptBanner.tsx | 102 ++-- .../providers/HyperLiquidProvider.test.ts | 450 +++++++++++++++++- .../providers/HyperLiquidProvider.ts | 320 +++++++++---- .../UI/Perps/controllers/types/index.ts | 23 + .../hooks/useHasExistingPosition.test.ts | 343 ++++++++++++- .../UI/Perps/hooks/useHasExistingPosition.ts | 140 +++++- .../UI/Perps/hooks/usePerpsTPSLUpdate.test.ts | 4 + .../UI/Perps/hooks/usePerpsTPSLUpdate.ts | 30 +- .../HyperLiquidSubscriptionService.test.ts | 54 +++ .../HyperLiquidSubscriptionService.ts | 66 +++ 16 files changed, 1585 insertions(+), 313 deletions(-) diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 9f8bb83a32bd..09c7185cd97a 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -472,7 +472,8 @@ export const PerpsTutorialSelectorsIDs = { export const PerpsStopLossPromptSelectorsIDs = { CONTAINER: 'perps-stop-loss-prompt-container', ADD_MARGIN_BUTTON: 'perps-stop-loss-prompt-add-margin-button', - TOGGLE: 'perps-stop-loss-prompt-toggle', + SET_STOP_LOSS_BUTTON: 'perps-stop-loss-prompt-set-button', + SUCCESS_ICON: 'perps-stop-loss-prompt-success-icon', LOADING: 'perps-stop-loss-prompt-loading', } as const; diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 83f0959dbbff..abf579d8c3fb 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -252,21 +252,28 @@ jest.mock('../../hooks/usePerpsOpenOrders', () => ({ usePerpsOpenOrders: () => mockUsePerpsOpenOrdersImpl(), })); -const mockUsePerpsOrderFillsImpl = jest.fn< +const mockUsePerpsLiveFillsImpl = jest.fn< ReturnType< - typeof import('../../hooks/usePerpsOrderFills').usePerpsOrderFills + typeof import('../../hooks/stream/usePerpsLiveFills').usePerpsLiveFills >, [] >(() => ({ - orderFills: [], - isLoading: false, - error: null, - refresh: jest.fn(), - isRefreshing: false, + fills: [], + isInitialLoading: false, })); -jest.mock('../../hooks/usePerpsOrderFills', () => ({ - usePerpsOrderFills: () => mockUsePerpsOrderFillsImpl(), +jest.mock('../../hooks/stream/usePerpsLiveFills', () => ({ + usePerpsLiveFills: () => mockUsePerpsLiveFillsImpl(), +})); + +// Mock Engine for REST fallback tests +const mockGetOrderFills = jest.fn(); +jest.mock('../../../../../core/Engine', () => ({ + context: { + PerpsController: { + getOrderFills: (...args: unknown[]) => mockGetOrderFills(...args), + }, + }, })); // Mock for usePerpsMarkets that can be modified per test @@ -568,6 +575,7 @@ describe('PerpsMarketDetailsView', () => { error: null, existingPosition: null, refreshPosition: jest.fn(), + positionOpenedTimestamp: undefined, }); // Reset navigation mocks @@ -600,12 +608,9 @@ describe('PerpsMarketDetailsView', () => { }; // Reset order fills mock to default - mockUsePerpsOrderFillsImpl.mockReturnValue({ - orderFills: [], - isLoading: false, - error: null, - refresh: jest.fn(), - isRefreshing: false, + mockUsePerpsLiveFillsImpl.mockReturnValue({ + fills: [], + isInitialLoading: false, }); }); @@ -834,6 +839,7 @@ describe('PerpsMarketDetailsView', () => { }, }, refreshPosition: jest.fn(), + positionOpenedTimestamp: undefined, }); const { getByTestId, queryByText, queryByTestId } = renderWithProvider( @@ -924,6 +930,7 @@ describe('PerpsMarketDetailsView', () => { error: null, existingPosition: null, refreshPosition: mockRefreshPosition, // No-op function for WebSocket positions + positionOpenedTimestamp: undefined, }); const { getByTestId } = renderWithProvider( @@ -975,6 +982,7 @@ describe('PerpsMarketDetailsView', () => { }, }, refreshPosition: mockRefreshPosition, + positionOpenedTimestamp: undefined, }); const { getByTestId } = renderWithProvider( @@ -1011,6 +1019,7 @@ describe('PerpsMarketDetailsView', () => { error: null, existingPosition: null, refreshPosition: mockRefreshPosition, + positionOpenedTimestamp: undefined, }); const { getByTestId } = renderWithProvider( @@ -1075,6 +1084,7 @@ describe('PerpsMarketDetailsView', () => { error: null, existingPosition: null, refreshPosition: mockRefreshPosition, + positionOpenedTimestamp: undefined, }); const { getByTestId } = renderWithProvider( @@ -1692,10 +1702,13 @@ describe('PerpsMarketDetailsView', () => { }); }); - describe('Position opened timestamp calculation', () => { - it('computes position opened timestamp from order fills data', () => { - // Arrange - const timestamp = Date.now(); + describe('Position opened timestamp', () => { + // Note: The timestamp calculation logic has been moved to useHasExistingPosition hook + // These tests verify the component correctly uses the hook's positionOpenedTimestamp + + it('uses positionOpenedTimestamp from useHasExistingPosition hook', () => { + // Arrange - Hook provides the timestamp + const timestamp = Date.now() - 5 * 60 * 1000; mockUseHasExistingPosition.mockReturnValue({ hasPosition: true, isLoading: false, @@ -1718,51 +1731,50 @@ describe('PerpsMarketDetailsView', () => { }, }, refreshPosition: jest.fn(), + positionOpenedTimestamp: timestamp, // Hook now provides this }); - mockUsePerpsOrderFillsImpl.mockReturnValue({ - orderFills: [ - { - orderId: 'order-1', - symbol: 'BTC', - side: 'buy', - direction: 'Open Long', - timestamp: timestamp - 2000, - size: '0.3', - price: '43000', - pnl: '0', - fee: '0.001', - feeToken: 'USDC', - }, - { - orderId: 'order-2', - symbol: 'BTC', - side: 'buy', - direction: 'Open Long', - timestamp, - size: '0.5', - price: '44000', - pnl: '0', - fee: '0.001', - feeToken: 'USDC', - }, - { - orderId: 'order-3', - symbol: 'ETH', - side: 'sell', - direction: 'Open Short', - timestamp: timestamp - 1000, - size: '1.0', - price: '3000', - pnl: '0', - fee: '0.001', - feeToken: 'USDC', - }, - ], + // Act + const { getByTestId } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + // Assert - component renders with position data + expect( + getByTestId(PerpsMarketDetailsViewSelectorsIDs.CONTAINER), + ).toBeTruthy(); + }); + + it('handles undefined positionOpenedTimestamp from hook', () => { + // Arrange - Hook returns undefined timestamp (e.g., new position) + mockUseHasExistingPosition.mockReturnValue({ + hasPosition: true, isLoading: false, error: null, - refresh: jest.fn(), - isRefreshing: false, + existingPosition: { + symbol: 'BTC', + size: '0.5', + entryPrice: '44000', + positionValue: '22000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '40000', + maxLeverage: 20, + returnOnEquity: '1.14', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + refreshPosition: jest.fn(), + positionOpenedTimestamp: undefined, // No timestamp available yet }); // Act @@ -1775,11 +1787,10 @@ describe('PerpsMarketDetailsView', () => { }, ); - // Assert + // Assert - component still renders correctly expect( getByTestId(PerpsMarketDetailsViewSelectorsIDs.CONTAINER), ).toBeTruthy(); - expect(mockUsePerpsOrderFillsImpl).toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 88fde5329156..6036cfcf4f49 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -13,6 +13,12 @@ import React, { useState, } from 'react'; import { Linking, RefreshControl, ScrollView, View } from 'react-native'; +import type { + Position, + PerpsMarketData, + PerpsNavigationParamList, + TPSLTrackingData, +} from '../../controllers/types'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; @@ -79,11 +85,6 @@ import { PerpsEventValues, } from '../../constants/eventNames'; import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; -import type { - PerpsMarketData, - PerpsNavigationParamList, - TPSLTrackingData, -} from '../../controllers/types'; import { usePerpsConnection, usePerpsNavigation, @@ -108,7 +109,6 @@ import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; import { usePerpsMarketStats } from '../../hooks/usePerpsMarketStats'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; -import { usePerpsOrderFills } from '../../hooks/usePerpsOrderFills'; import { usePerpsTPSLUpdate } from '../../hooks/usePerpsTPSLUpdate'; import { useStopLossPrompt } from '../../hooks/useStopLossPrompt'; import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences'; @@ -198,6 +198,17 @@ const PerpsMarketDetailsView: React.FC = () => { // Stop loss prompt banner state - for loading/success when setting stop loss via banner const [isSettingStopLoss, setIsSettingStopLoss] = useState(false); const [isStopLossSuccess, setIsStopLossSuccess] = useState(false); + // Preserve banner variant during success fade-out (hook's variant becomes null after SL is set) + const preservedBannerVariantRef = useRef<'stop_loss' | 'add_margin' | null>( + null, + ); + // Track current market symbol for staleness checks in async callbacks + // Using a ref allows reading the CURRENT value at execution time, not closure-captured value + const currentMarketSymbolRef = useRef(market?.symbol); + // Track current position for callbacks that are stored (e.g., route params) and called later + // This prevents stale closure issues where the captured position is outdated + // Initialized to null, will be updated via useEffect when existingPosition is available + const currentPositionRef = useRef(null); const isEligible = useSelector(selectPerpsEligibility); @@ -222,6 +233,11 @@ const PerpsMarketDetailsView: React.FC = () => { setOptimisticWatchlist(null); }, [market?.symbol]); + // Keep current market symbol ref in sync for staleness checks in async callbacks + useEffect(() => { + currentMarketSymbolRef.current = market?.symbol; + }, [market?.symbol]); + // Clear optimistic state once Redux has caught up useEffect(() => { if ( @@ -377,11 +393,7 @@ const PerpsMarketDetailsView: React.FC = () => { // Only zoom when: // 1. The interval has changed (user pressed button) // 2. New data exists and matches the selected period - if ( - hasIntervalChanged && - candleData && - candleData.interval === selectedCandlePeriod - ) { + if (hasIntervalChanged && candleData?.interval === selectedCandlePeriod) { chartRef.current?.zoomToLatestCandle(visibleCandleCount); // Update the ref to track this interval change previousIntervalRef.current = selectedCandlePeriod; @@ -389,32 +401,21 @@ const PerpsMarketDetailsView: React.FC = () => { }, [candleData, selectedCandlePeriod, visibleCandleCount]); // Check if user has an existing position for this market - const { isLoading: isLoadingPosition, existingPosition } = - useHasExistingPosition({ - asset: market?.symbol || '', - loadOnMount: true, - }); - - // Fetch order fills to get position opened timestamp - const { orderFills } = usePerpsOrderFills({ - skipInitialFetch: false, + // Also provides positionOpenedTimestamp for stop loss prompt timing + const { + isLoading: isLoadingPosition, + existingPosition, + positionOpenedTimestamp, + } = useHasExistingPosition({ + asset: market?.symbol || '', + loadOnMount: true, }); - // Get position opened timestamp from fills data - const positionOpenedTimestamp = useMemo(() => { - if (!existingPosition || !orderFills) return undefined; - - // Find the most recent "Open" fill for this asset - const openFill = orderFills - .filter((fill) => { - const isMatchingAsset = fill.symbol === existingPosition.coin; - const isOpenDirection = fill.direction?.startsWith('Open'); - return isMatchingAsset && isOpenDirection; - }) - .sort((a, b) => b.timestamp - a.timestamp)[0]; // Most recent first - - return openFill?.timestamp; - }, [existingPosition, orderFills]); + // Keep current position ref in sync for callbacks stored in route params + // This must be after useHasExistingPosition since it depends on existingPosition + useEffect(() => { + currentPositionRef.current = existingPosition; + }, [existingPosition]); // Compute TP/SL lines for the chart based on existing position // Use chartCurrentPrice (from candle close) to ensure price line syncs with live candle @@ -439,12 +440,11 @@ const PerpsMarketDetailsView: React.FC = () => { // Stop loss prompt banner logic // Hook handles visibility orchestration including fade-out animation const { - variant: bannerVariant, + variant: bannerVariantFromHook, liquidationDistance, suggestedStopLossPrice, suggestedStopLossPercent, isVisible: isBannerVisible, - isDismissing: isBannerDismissing, onDismissComplete: handleBannerDismissComplete, } = useStopLossPrompt({ position: existingPosition, @@ -452,9 +452,24 @@ const PerpsMarketDetailsView: React.FC = () => { positionOpenedTimestamp, }); + // Preserve banner variant when we have a valid one (for use during success fade-out) + // The hook's variant becomes null after SL is set, but we need to keep rendering + // Use useEffect to avoid ref mutation during render (React best practice) + useEffect(() => { + if (bannerVariantFromHook && !isStopLossSuccess) { + preservedBannerVariantRef.current = bannerVariantFromHook; + } + }, [bannerVariantFromHook, isStopLossSuccess]); + + // Use preserved variant during success fade-out, otherwise use hook's variant + const bannerVariant = isStopLossSuccess + ? preservedBannerVariantRef.current + : bannerVariantFromHook; + // Reset stop loss success state when market or position changes useEffect(() => { setIsStopLossSuccess(false); + preservedBannerVariantRef.current = null; }, [market?.symbol, existingPosition?.coin]); // Track Perps asset screen load performance with simplified API @@ -729,12 +744,20 @@ const PerpsMarketDetailsView: React.FC = () => { stopLossPrice?: string, trackingData?: TPSLTrackingData, ) => { - await handleUpdateTPSL( - existingPosition, + // Use ref to get CURRENT position at execution time, not the closure-captured position + // This prevents "No position found" errors when the position updates during navigation + const currentPosition = currentPositionRef.current; + if (!currentPosition) { + return { success: false }; + } + // Return value checked for consistency - error toast is shown internally by hook + const result = await handleUpdateTPSL( + currentPosition, takeProfitPrice, stopLossPrice, trackingData, ); + return result; }, }); }, [existingPosition, currentPrice, navigation, handleUpdateTPSL]); @@ -817,6 +840,9 @@ const PerpsMarketDetailsView: React.FC = () => { const handleSetStopLossFromBanner = useCallback(async () => { if (!existingPosition || !suggestedStopLossPrice) return; + // Capture symbol before async to detect market changes during API call + const originalSymbol = existingPosition.coin; + setIsSettingStopLoss(true); try { @@ -828,13 +854,25 @@ const PerpsMarketDetailsView: React.FC = () => { }; // Set the stop loss using the suggested price (keep existing TP if any) - await handleUpdateTPSL( + const result = await handleUpdateTPSL( existingPosition, existingPosition.takeProfitPrice, // Keep existing TP suggestedStopLossPrice, // Use suggested SL trackingData, ); + // Only trigger success state if the update actually succeeded + if (!result.success) { + // Error toast is already shown by handleUpdateTPSL + return; + } + + // Staleness check: user may have navigated to a different market during API call + // Use ref to get CURRENT market symbol, not the closure-captured value + if (originalSymbol !== currentMarketSymbolRef.current) { + return; + } + // Trigger success state to start fade-out animation setIsStopLossSuccess(true); @@ -1053,8 +1091,8 @@ const PerpsMarketDetailsView: React.FC = () => { )} {/* Stop Loss Prompt Banner - Shows when position needs attention */} - {/* Uses hook's isVisible which includes fade-out animation state */} - {isBannerVisible && bannerVariant && ( + {/* Keep mounted while isStopLossSuccess is true to allow fade animation to complete */} + {(isBannerVisible || isStopLossSuccess) && bannerVariant && ( = () => { onSetStopLoss={handleSetStopLossFromBanner} onAddMargin={handleAddMarginFromBanner} isLoading={isSettingStopLoss} - isSuccess={isStopLossSuccess || isBannerDismissing} + isSuccess={isStopLossSuccess} onFadeOutComplete={handleBannerFadeOutComplete} testID={ PerpsMarketDetailsViewSelectorsIDs.STOP_LOSS_PROMPT_BANNER diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index be42abc02474..f9cda2b47f1c 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -262,6 +262,11 @@ jest.mock('../../hooks', () => ({ creationFailed: jest.fn(), }, }, + positionManagement: { + tpsl: { + updateTPSLError: jest.fn(), + }, + }, dataFetching: { market: { error: { @@ -1783,6 +1788,11 @@ describe('PerpsOrderView', () => { creationFailed: jest.fn(), }, }, + positionManagement: { + tpsl: { + updateTPSLError: jest.fn(), + }, + }, dataFetching: { market: { error: { diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index cb0d8cfb6095..b818c3d55687 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -871,11 +871,22 @@ const PerpsOrderViewContentBase: React.FC = ({ delete orderWithoutTPSL.stopLossPrice; await executeOrder(orderWithoutTPSL); - await updatePositionTPSL({ + const tpslResult = await updatePositionTPSL({ coin: orderForm.asset, takeProfitPrice: orderForm.takeProfitPrice, stopLossPrice: orderForm.stopLossPrice, }); + + // Show error toast if TP/SL update failed (order succeeded but TP/SL didn't) + if (!tpslResult.success) { + const errorMessage = + tpslResult.error || strings('perps.errors.unknown'); + showToast( + PerpsToastOptions.positionManagement.tpsl.updateTPSLError( + errorMessage, + ), + ); + } } else { await executeOrder(orderParams); } @@ -884,26 +895,24 @@ const PerpsOrderViewContentBase: React.FC = ({ isSubmittingRef.current = false; } }, [ - orderValidation.isValid, - orderValidation.errors, + isButtonColorTestEnabled, track, orderForm.asset, orderForm.direction, - orderForm.type, - orderForm.leverage, - orderForm.limitPrice, orderForm.takeProfitPrice, orderForm.stopLossPrice, + orderForm.type, + orderForm.leverage, orderForm.amount, - positionSize, - assetData.price, + orderForm.limitPrice, + buttonColorVariant, + orderValidation.isValid, + orderValidation.errors, + currentMarketPosition, navigation, navigationMarketData, - currentMarketPosition, - executeOrder, - showToast, - PerpsToastOptions.formValidation.orderForm, - updatePositionTPSL, + positionSize, + assetData.price, marginRequired, feeResults.totalFee, feeResults.metamaskFee, @@ -911,8 +920,11 @@ const PerpsOrderViewContentBase: React.FC = ({ feeResults.feeDiscountPercentage, feeResults.estimatedPoints, source, - isButtonColorTestEnabled, - buttonColorVariant, + showToast, + PerpsToastOptions.formValidation.orderForm, + PerpsToastOptions.positionManagement.tpsl, + executeOrder, + updatePositionTPSL, ]); // Memoize the tooltip handlers to prevent recreating them on every render diff --git a/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.test.tsx b/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.test.tsx index 0df0d57bd8e1..90413706211b 100644 --- a/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.test.tsx +++ b/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { fireEvent, act } from '@testing-library/react-native'; -import { Switch } from 'react-native'; import PerpsStopLossPromptBanner from './PerpsStopLossPromptBanner'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; @@ -105,14 +104,16 @@ describe('PerpsStopLossPromptBanner', () => { expect( getByTestId(PerpsStopLossPromptSelectorsIDs.CONTAINER), ).toBeTruthy(); - expect(getByTestId(PerpsStopLossPromptSelectorsIDs.TOGGLE)).toBeTruthy(); + expect( + getByTestId(PerpsStopLossPromptSelectorsIDs.SET_STOP_LOSS_BUTTON), + ).toBeTruthy(); expect(getByText(/\$47,500/)).toBeTruthy(); expect(getByText(/-50%/)).toBeTruthy(); }); - it('calls onSetStopLoss when toggle switched on', () => { + it('calls onSetStopLoss when button pressed', () => { const onSetStopLoss = jest.fn(); - const { UNSAFE_getByType } = renderWithProvider( + const { getByTestId } = renderWithProvider( { { state: initialState }, ); - const toggle = UNSAFE_getByType(Switch); - fireEvent(toggle, 'onValueChange', true); + fireEvent.press( + getByTestId(PerpsStopLossPromptSelectorsIDs.SET_STOP_LOSS_BUTTON), + ); expect(onSetStopLoss).toHaveBeenCalledTimes(1); }); - it('does not call onSetStopLoss when toggle switched off', () => { + it('does not call onSetStopLoss when button is disabled (loading)', () => { const onSetStopLoss = jest.fn(); - const { UNSAFE_getByType } = renderWithProvider( + const { getByTestId } = renderWithProvider( , { state: initialState }, ); - const toggle = UNSAFE_getByType(Switch); - fireEvent(toggle, 'onValueChange', false); + // Button is disabled when loading, press should not trigger callback + fireEvent.press( + getByTestId(PerpsStopLossPromptSelectorsIDs.SET_STOP_LOSS_BUTTON), + ); expect(onSetStopLoss).not.toHaveBeenCalled(); }); - it('shows loading indicator instead of toggle when loading', () => { - const { getByTestId, queryByTestId } = renderWithProvider( + it('shows loading indicator instead of button text when loading', () => { + const { getByTestId } = renderWithProvider( { ); expect(getByTestId(PerpsStopLossPromptSelectorsIDs.LOADING)).toBeTruthy(); - expect(queryByTestId(PerpsStopLossPromptSelectorsIDs.TOGGLE)).toBeFalsy(); + // Button still exists but shows loading indicator + expect( + getByTestId(PerpsStopLossPromptSelectorsIDs.SET_STOP_LOSS_BUTTON), + ).toBeTruthy(); }); it('does not trigger action when loading', () => { @@ -232,9 +240,9 @@ describe('PerpsStopLossPromptBanner', () => { { state: initialState }, ); - // Fast-forward past animation duration (300ms) + // Fast-forward past SUCCESS_DISPLAY_DELAY_MS (2000ms) + FADE_OUT_DURATION_MS (300ms) await act(async () => { - jest.advanceTimersByTime(400); + jest.advanceTimersByTime(2500); }); expect(onFadeOutComplete).toHaveBeenCalledTimes(1); @@ -258,8 +266,9 @@ describe('PerpsStopLossPromptBanner', () => { { state: initialState }, ); + // Even after waiting longer than the animation time, callback should not be called await act(async () => { - jest.advanceTimersByTime(400); + jest.advanceTimersByTime(2500); }); expect(onFadeOutComplete).not.toHaveBeenCalled(); @@ -284,7 +293,7 @@ describe('PerpsStopLossPromptBanner', () => { }); it('handles missing onSetStopLoss callback', () => { - const { UNSAFE_getByType } = renderWithProvider( + const { getByTestId } = renderWithProvider( { { state: initialState }, ); - const toggle = UNSAFE_getByType(Switch); - // Should not throw when toggle is pressed without callback - expect(() => fireEvent(toggle, 'onValueChange', true)).not.toThrow(); + // Button exists but is disabled without callback + const button = getByTestId( + PerpsStopLossPromptSelectorsIDs.SET_STOP_LOSS_BUTTON, + ); + // Should not throw when button is pressed without callback + expect(() => fireEvent.press(button)).not.toThrow(); }); it('handles missing onAddMargin callback', () => { diff --git a/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.tsx b/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.tsx index 9235d035d537..8a78a0f8924e 100644 --- a/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.tsx +++ b/app/components/UI/Perps/components/PerpsStopLossPromptBanner/PerpsStopLossPromptBanner.tsx @@ -1,5 +1,5 @@ import React, { memo, useCallback, useEffect, useRef } from 'react'; -import { View, Switch, ActivityIndicator, Animated } from 'react-native'; +import { View, ActivityIndicator, Animated } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import Text, { TextVariant, @@ -9,6 +9,11 @@ import Button, { ButtonVariants, ButtonSize, } from '../../../../../component-library/components/Buttons/Button'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../component-library/components/Icons/Icon'; import { useTheme } from '../../../../../util/theme'; import { strings } from '../../../../../../locales/i18n'; import { PerpsStopLossPromptSelectorsIDs } from '../../Perps.testIds'; @@ -19,6 +24,8 @@ import { import styleSheet from './PerpsStopLossPromptBanner.styles'; import type { PerpsStopLossPromptBannerProps } from './PerpsStopLossPromptBanner.types'; +/** Delay before fade-out starts, allowing user to see success checkmark */ +const SUCCESS_DISPLAY_DELAY_MS = 2000; /** Duration of the fade-out animation in milliseconds */ const FADE_OUT_DURATION_MS = 300; @@ -70,18 +77,32 @@ const PerpsStopLossPromptBanner: React.FC = // Animation value for fade-out effect const fadeAnim = useRef(new Animated.Value(1)).current; + // Reset opacity when isSuccess transitions to false (e.g., market change during animation) + // This ensures the banner is visible when shown for a new market + useEffect(() => { + if (!isSuccess) { + fadeAnim.setValue(1); + } + }, [isSuccess, fadeAnim]); + // Trigger fade-out animation when isSuccess becomes true + // Wait for SUCCESS_DISPLAY_DELAY_MS first so user sees success checkmark useEffect(() => { if (isSuccess) { - Animated.timing(fadeAnim, { - toValue: 0, - duration: FADE_OUT_DURATION_MS, - useNativeDriver: true, - }).start(() => { - // Call callback when animation completes - onFadeOutComplete?.(); - }); + const delayTimer = setTimeout(() => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: FADE_OUT_DURATION_MS, + useNativeDriver: true, + }).start(() => { + // Call callback when animation completes + onFadeOutComplete?.(); + }); + }, SUCCESS_DISPLAY_DELAY_MS); + + return () => clearTimeout(delayTimer); } + return undefined; }, [isSuccess, fadeAnim, onFadeOutComplete]); // Safe press handlers that won't trigger if callback is not provided @@ -89,15 +110,12 @@ const PerpsStopLossPromptBanner: React.FC = onAddMargin?.(); }, [onAddMargin]); - // Toggle handler - directly triggers stop loss action - const handleToggleChange = useCallback( - (value: boolean) => { - if (value && !isLoading) { - onSetStopLoss?.(); - } - }, - [isLoading, onSetStopLoss], - ); + // Button press handler - directly triggers stop loss action + const handleSetStopLossPress = useCallback(() => { + if (!isLoading && !isSuccess) { + onSetStopLoss?.(); + } + }, [isLoading, isSuccess, onSetStopLoss]); // Format the suggested stop loss price for display const formattedStopLossPrice = suggestedStopLossPrice @@ -176,28 +194,32 @@ const PerpsStopLossPromptBanner: React.FC = })} - - {isLoading ? ( - - ) : ( - - )} - +