Skip to content

Commit 65a4ede

Browse files
chore(runway): cherry-pick fix(perps): decouple home sort from market list and remove leverage skeleton blink [TAT-2544 TAT-2454] cp-7.66.0 (#26052)
- fix(perps): decouple home sort from market list and remove leverage skeleton blink [TAT-2544 TAT-2454] cp-7.66.0 (#25977) ## **Description** 1. Decouple Perps Home sorting from Market List sorting The Perps Home sections (Crypto, Stocks, Commodities, Forex) were reading the same sort preference that the Market List search writes to. This meant that when a user changed the sort in the market search (e.g., to "Funding Rate"), the home screen sections would also re-sort — which isn't the intended behavior. Home sections should always surface the highest-volume markets regardless of what the user does in search. The fix removes the dependency on `selectPerpsMarketFilterPreferences` in `usePerpsHomeData` and hardcodes the home sort to Volume descending. The Market List continues to use and persist its own sort preference independently. 2. Remove skeleton loader blink on leverage bottom sheet When adjusting leverage via the slider or preset buttons, the liquidation banner and liquidation price would rapidly toggle between a skeleton placeholder and the actual value. This created a jarring "blink" effect — especially during slider drags, where every tick triggered the skeleton. The current price row right below it just updates its value in place with no loading state, making the inconsistency more noticeable. The fix caches the last valid liquidation price and percentage in refs, so the UI always shows either the latest calculated value or the previously known one. On first open (before any calculation has completed), it shows -- as a placeholder instead of a skeleton shimmer. The banner colors (safe/caution/medium/high) are unaffected because they're driven by the leverage slider position, not the liquidation price calculation. ## **Changelog** CHANGELOG entry: Fixed a flickering skeleton loader on the leverage bottom sheet when adjusting leverage, and decoupled Perps Home sorting from the market search sort preference so home sections always sort by volume. ## **Related issues** Fixes: [TAT-2544](https://consensyssoftware.atlassian.net/browse/TAT-2544) Fixes: [TAT-2454](https://consensyssoftware.atlassian.net/browse/TAT-2454) ## **Manual testing steps** ```gherkin Feature: Perps Home sections sort independently from Market List Scenario: Home sections always sort by volume Given user is on the Perps Home screen When user opens the Market List and changes sort to "Funding Rate" And user navigates back to Perps Home Then all home sections (Crypto, Stocks, Commodities, Forex) remain sorted by descending volume Feature: Leverage bottom sheet liquidation display Scenario: No skeleton blink when dragging leverage slider Given user opens the leverage bottom sheet on any market When user drags the leverage slider back and forth Then the liquidation banner text and price update in place without skeleton flashing And the banner color transitions smoothly based on leverage risk level Scenario: No skeleton blink when tapping preset buttons Given user opens the leverage bottom sheet When user taps different preset leverage buttons (2x, 5x, 10x, etc.) Then the liquidation values tick to the new values without a skeleton placeholder appearing Scenario: Initial open shows placeholder Given user opens the leverage bottom sheet for the first time When the liquidation price has not yet been calculated Then the liquidation price and percentage show "--" until the first value arrives ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/f69fdc60-82c9-479f-ba8d-7d7274b1713b https://github.com/user-attachments/assets/ea03375d-1803-4cc5-8066-2169b8e3b130 ## **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. [TAT-2544]: https://consensyssoftware.atlassian.net/browse/TAT-2544?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches user-facing trading UX and introduces timing/state logic around debounced async liquidation-price updates, which could regress displayed values if edge cases slip through. > > **Overview** > **Perps Home sorting is now fixed to volume defaults.** `usePerpsHomeData` no longer reads persisted market-list sort preferences and instead always sorts home sections by `Volume` with the default direction. > > **Leverage bottom sheet liquidation UI is reworked to avoid flicker and stale data.** The skeleton implementation is replaced with cached/placeholder display plus a recalculation state that only shows shimmer placeholders after user-initiated leverage changes (with a minimum display time to avoid stale async completions), while passive price updates keep showing the last valid value. Styling is tweaked with a fixed `minHeight` on the warning container to prevent layout shifts, and tests are updated/expanded to cover the new placeholder/skeleton behavior and stale-cache prevention. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1e53413. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [f95e2bb](f95e2bb) [TAT-2544]: https://consensyssoftware.atlassian.net/browse/TAT-2544?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com>
1 parent eb0441a commit 65a4ede

4 files changed

Lines changed: 262 additions & 85 deletions

File tree

app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.styles.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export const createStyles = (colors: Theme['colors']) =>
4646
borderRadius: 8,
4747
marginBottom: 24,
4848
marginHorizontal: 12,
49+
// Fixed min-height to accommodate two lines of BodySM text (~18px line
50+
// height × 2 lines + 24px vertical padding). Prevents layout shift when
51+
// the warning text wraps between one and two lines as the percentage
52+
// value changes (e.g. "9.0%" → "10.0%").
53+
minHeight: 60,
4954
},
5055
warningContainerSafe: {
5156
backgroundColor: LEVERAGE_BACKGROUND_COLORS.SAFE, // Green background

app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ jest.mock('react-native-gesture-handler', () => ({
3232

3333
jest.mock('react-native-linear-gradient', () => 'LinearGradient');
3434

35+
// Mock SkeletonPlaceholder
36+
jest.mock('react-native-skeleton-placeholder', () => {
37+
const { View } = jest.requireActual('react-native');
38+
const MockSkeletonPlaceholder = ({
39+
children,
40+
}: {
41+
children: React.ReactNode;
42+
}) => <View testID="skeleton-placeholder">{children}</View>;
43+
44+
MockSkeletonPlaceholder.Item = (props: {
45+
width: number | string;
46+
height: number;
47+
borderRadius: number;
48+
}) => <View testID="skeleton-item" {...props} />;
49+
50+
return {
51+
__esModule: true,
52+
default: MockSkeletonPlaceholder,
53+
};
54+
});
55+
3556
// Mock safe area context (required for BottomSheet)
3657
jest.mock('react-native-safe-area-context', () => {
3758
const inset = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -620,15 +641,15 @@ describe('PerpsLeverageBottomSheet', () => {
620641
expect(screen.queryByText('5x')).toBeNull();
621642
});
622643

623-
it('does not set skeleton state when pressing already active quick select button', () => {
644+
it('keeps displaying values when pressing already active quick select button', () => {
624645
// Arrange
625646
render(<PerpsLeverageBottomSheet {...defaultProps} leverage={5} />);
626647

627648
// Act - Press the currently active 5x button
628649
const buttons5x = screen.getAllByText('5x');
629650
fireEvent.press(buttons5x[0]);
630651

631-
// Assert - Component continues to render without errors
652+
// Assert - Component continues to render with values (no skeleton blink)
632653
expect(screen.getByText('Set 5x')).toBeOnTheScreen();
633654
});
634655
});
@@ -1156,50 +1177,51 @@ describe('PerpsLeverageBottomSheet', () => {
11561177
});
11571178
});
11581179

1159-
describe('Skeleton Loading State', () => {
1160-
it('displays skeleton when liquidation price is calculating', () => {
1180+
describe('Liquidation Display Without Skeleton', () => {
1181+
it('shows placeholder text when liquidation price is not yet available', () => {
11611182
// Arrange
11621183
const mockUsePerpsLiquidationPrice = jest.requireMock(
11631184
'../../hooks/usePerpsLiquidationPrice',
11641185
);
11651186
mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce(
11661187
{
1167-
liquidationPrice: '0.00',
1168-
isCalculating: true, // Loading state
1188+
liquidationPrice: '',
1189+
isCalculating: true,
11691190
error: null,
11701191
},
11711192
);
11721193

11731194
// Act
11741195
render(<PerpsLeverageBottomSheet {...defaultProps} />);
11751196

1176-
// Assert - Component renders in loading state
1177-
expect(screen.getByText('Set 5x')).toBeOnTheScreen();
1197+
// Assert - Shows placeholder '--' instead of skeleton
1198+
expect(screen.getByText('--')).toBeOnTheScreen();
1199+
expect(
1200+
screen.getByText('perps.order.leverage_modal.title'),
1201+
).toBeOnTheScreen();
11781202
});
11791203

1180-
it('hides liquidation price when calculating', () => {
1204+
it('displays liquidation price immediately when available', () => {
11811205
// Arrange
11821206
const mockUsePerpsLiquidationPrice = jest.requireMock(
11831207
'../../hooks/usePerpsLiquidationPrice',
11841208
);
11851209
mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce(
11861210
{
1187-
liquidationPrice: '',
1188-
isCalculating: true,
1211+
liquidationPrice: '2400.00',
1212+
isCalculating: false,
11891213
error: null,
11901214
},
11911215
);
11921216

11931217
// Act
11941218
render(<PerpsLeverageBottomSheet {...defaultProps} />);
11951219

1196-
// Assert - Component still renders
1197-
expect(
1198-
screen.getByText('perps.order.leverage_modal.title'),
1199-
).toBeOnTheScreen();
1220+
// Assert - Liquidation price displays (format may vary but price is shown)
1221+
expect(screen.getByText(/\$2,400/)).toBeOnTheScreen();
12001222
});
12011223

1202-
it('sets skeleton state to true when isCalculating is true', () => {
1224+
it('renders component during calculating state without skeleton blink', () => {
12031225
// Arrange
12041226
const mockUsePerpsLiquidationPrice = jest.requireMock(
12051227
'../../hooks/usePerpsLiquidationPrice',
@@ -1215,20 +1237,23 @@ describe('PerpsLeverageBottomSheet', () => {
12151237
// Act
12161238
render(<PerpsLeverageBottomSheet {...defaultProps} />);
12171239

1218-
// Assert - Title still renders, skeleton is shown
1240+
// Assert - Component renders with price text visible (no skeleton)
12191241
expect(
12201242
screen.getByText('perps.order.leverage_modal.title'),
12211243
).toBeOnTheScreen();
1244+
expect(screen.getByText('Set 5x')).toBeOnTheScreen();
1245+
// Price is shown (not replaced by skeleton) since it's a valid value
1246+
expect(screen.getByText(/\$2,400/)).toBeOnTheScreen();
12221247
});
12231248

1224-
it('sets skeleton state to false when isCalculating is false', () => {
1225-
// Arrange
1249+
it('shows warning text with percentage when liquidation data is available', () => {
1250+
// Arrange - Explicitly mock to ensure known liquidation price
12261251
const mockUsePerpsLiquidationPrice = jest.requireMock(
12271252
'../../hooks/usePerpsLiquidationPrice',
12281253
);
12291254
mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValueOnce(
12301255
{
1231-
liquidationPrice: '2400.00',
1256+
liquidationPrice: '2400.00', // 3000 * (1 - 1/5) = 2400, gives 20% drop
12321257
isCalculating: false,
12331258
error: null,
12341259
},
@@ -1237,8 +1262,57 @@ describe('PerpsLeverageBottomSheet', () => {
12371262
// Act
12381263
render(<PerpsLeverageBottomSheet {...defaultProps} />);
12391264

1240-
// Assert - Liquidation price displays (format may vary but price is shown)
1241-
expect(screen.getByText(/\$2,400/)).toBeOnTheScreen();
1265+
// Assert - Warning text with percentage is shown, not a skeleton
1266+
expect(screen.getByText(/20\.0%/)).toBeOnTheScreen();
1267+
expect(screen.getByText(/drops/)).toBeOnTheScreen();
1268+
});
1269+
});
1270+
1271+
describe('Stale Cache Prevention After Leverage Change', () => {
1272+
it('shows skeleton instead of stale cached price when leverage changes via quick select', () => {
1273+
// Arrange — default mock returns a calculated price for 5x leverage
1274+
const mockUsePerpsLiquidationPrice = jest.requireMock(
1275+
'../../hooks/usePerpsLiquidationPrice',
1276+
);
1277+
render(<PerpsLeverageBottomSheet {...defaultProps} leverage={5} />);
1278+
1279+
// The initial render should show a price (from the default mock), no skeletons
1280+
expect(screen.queryAllByTestId('skeleton-placeholder')).toHaveLength(0);
1281+
1282+
// Simulate the real hook behavior: after leverage changes, the hook
1283+
// starts a debounced API call and sets isCalculating: true while the
1284+
// old liquidationPrice persists in state until the new result arrives.
1285+
mockUsePerpsLiquidationPrice.usePerpsLiquidationPrice.mockReturnValue({
1286+
liquidationPrice: '2400.00', // Old price still in hook state
1287+
isCalculating: true,
1288+
error: null,
1289+
});
1290+
1291+
// Act — press 2x quick select button. We use 2x because it only appears
1292+
// in the quick select row (not in the slider labels like 10x/20x do),
1293+
// so getByText('2x') returns a single unambiguous element.
1294+
const button2x = screen.getByText('2x');
1295+
fireEvent.press(button2x);
1296+
1297+
// Assert — should show skeleton placeholders, NOT the stale cached price from 5x
1298+
const skeletons = screen.getAllByTestId('skeleton-placeholder');
1299+
expect(skeletons.length).toBeGreaterThanOrEqual(2); // warning text + price value
1300+
});
1301+
1302+
it('does not show skeleton when pressing the already active leverage', () => {
1303+
// Arrange — default mock returns calculated price for 5x
1304+
render(<PerpsLeverageBottomSheet {...defaultProps} leverage={5} />);
1305+
1306+
// The initial render should show a calculated price, no skeletons
1307+
expect(screen.queryAllByTestId('skeleton-placeholder')).toHaveLength(0);
1308+
1309+
// Act — press the same leverage that's already selected
1310+
const buttons5x = screen.getAllByText('5x');
1311+
fireEvent.press(buttons5x[0]);
1312+
1313+
// Assert — cache should NOT be invalidated, no skeletons should appear
1314+
expect(screen.getByText('Set 5x')).toBeOnTheScreen();
1315+
expect(screen.queryAllByTestId('skeleton-placeholder')).toHaveLength(0);
12421316
});
12431317
});
12441318

0 commit comments

Comments
 (0)