Skip to content

Commit 9f52a9c

Browse files
authored
fix(perps): resolve blank activity tabs from perps home (#27509)
<!-- 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** <!-- 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 intermittent Activity tab rendering issues and tab-switch latency when entering Activity from Perps Home (Perps Home -> Activity -> back -> Activity, and subsequent tab switches). Reason for the change - Users could see a selected Activity tab with no rows rendered, especially on repeated entry from Perps. - This was caused by timing issues between initial tab selection and deferred tab content loading. Improvement / solution - Updated ActivityView to set the initial tab index from route params on mount (instead of relying on post-mount tab switching), then clear redirect params on focus. - Hardened shared TabsList tab loading with InteractionManager scheduling plus a fallback timeout, so tab content still loads when interaction callbacks are delayed. - Added a stale callback safeguard in TabsList so an older interaction callback cannot cancel a newer tab's pending load. - Added/updated regression tests for async tab-loading behavior, stale-callback handling, and repeated Perps entry navigation paths. Out of scope / follow-up - This PR intentionally does not fix the Predictions tab infinite spinner. - The Predictions behavior appears to be a separate feature-specific loading lifecycle issue and is deferred to a follow-up with the Predictions owners. ## **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 a bug where Perps activity could appear blank after reopening the Activity screen from Perps home. ## **Related issues** https://consensyssoftware.atlassian.net/browse/TAT-2614 Fixes: ## **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] ``` ``` Feature: Activity tab loading and switching from Perps entry Scenario: user re-enters Activity from Perps and sees data Given the user is on Perps Home with account activity available When user opens Activity, goes back to Perps Home, and opens Activity again Then the selected Activity tab loads its rows instead of showing a blank body Scenario: user switches tabs in Activity from Perps entry Given the user opened Activity from Perps Home When user taps Transactions, Transfers, and Perps tabs Then each tab becomes selected promptly and shows loading/content without a multi-second delay Scenario: user opens Perps tab repeatedly without blank state Given the user is on Activity with Perps tab enabled When user switches away from Perps and then back to Perps multiple times Then Perps activity content continues to render reliably on each return Scenario: user does not hit tab-switch crash Given the user is on Activity opened from Perps Home When user taps between Activity tabs Then the app does not crash and no red-screen error is shown ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/b425e3dd-37c1-45f8-bf06-62e176bc4046 ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/6a92a910-4fad-44d4-9646-dfef510cc138 ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches shared `TabsList` loading/scheduling and Activity/Perps screen focus behavior; mistakes could reintroduce blank tabs or regress tab switching/refresh timing. > > **Overview** > Fixes intermittent **blank Activity tab content** when entering Activity from Perps by selecting the correct tab *at mount time* (`ActivityView` now derives `initialActiveIndex` from route params and clears redirect params on focus, instead of imperatively switching tabs post-mount). > > Hardens `TabsList` on-demand rendering by scheduling active-tab loading via `InteractionManager` **with a 250ms fallback timeout** and centralized cancellation, improving reliability when `runAfterInteractions` callbacks are delayed/missed. > > Updates Perps Activity content behavior: `PerpsTransactionsView` now **refetches on screen focus** and shows a skeleton when disconnected/focus-refreshing with no cached rows; tests were expanded/adjusted across tabs and Perps views to cover the new timing and loading guarantees. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 36e8a2a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e0486ca commit 9f52a9c

9 files changed

Lines changed: 495 additions & 160 deletions

File tree

app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx

Lines changed: 212 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Third party dependencies.
22
import React from 'react';
3-
import { render, fireEvent, act, waitFor } from '@testing-library/react-native';
3+
import { render, fireEvent, act } from '@testing-library/react-native';
44
import { View, InteractionManager } from 'react-native';
55

66
// External dependencies.
@@ -71,7 +71,7 @@ describe('TabsList', () => {
7171
</TabsList>,
7272
);
7373

74-
// Assert - Active tab loads via InteractionManager
74+
// Assert - active tab is loaded immediately
7575
expect(getByText('Tokens Content')).toBeOnTheScreen();
7676

7777
// Other tabs should not be loaded yet (on-demand loading)
@@ -82,10 +82,7 @@ describe('TabsList', () => {
8282
fireEvent.press(getAllByText('NFTs')[0]);
8383
});
8484

85-
// Wait for the deferred loading to complete
86-
await waitFor(() => {
87-
expect(getByText('NFTs Content')).toBeOnTheScreen();
88-
});
85+
expect(getByText('NFTs Content')).toBeOnTheScreen();
8986
});
9087

9188
it('switches tab content when tab is pressed', () => {
@@ -198,6 +195,68 @@ describe('TabsList', () => {
198195
expect(getByText('Tab 2 Content')).toBeOnTheScreen();
199196
});
200197

198+
it('goToTabIndex loads target tab immediately', async () => {
199+
const ref = React.createRef<TabsListRef>();
200+
const tabs = ['Tab 1', 'Tab 2'];
201+
202+
const { getByText } = render(
203+
<TabsList ref={ref}>
204+
{tabs.map((label, index) => (
205+
<View key={`tab${index}`} {...({ tabLabel: label } as TabViewProps)}>
206+
<Text>{label} Content</Text>
207+
</View>
208+
))}
209+
</TabsList>,
210+
);
211+
212+
// Act
213+
await act(async () => {
214+
ref.current?.goToTabIndex(1);
215+
});
216+
217+
// Assert
218+
expect(getByText('Tab 2 Content')).toBeOnTheScreen();
219+
});
220+
221+
it('renders initial active tab immediately', () => {
222+
// Act
223+
const { getByText } = render(
224+
<TabsList initialActiveIndex={0}>
225+
<View {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
226+
<Text>Content 1</Text>
227+
</View>
228+
<View {...({ tabLabel: 'Tab 2' } as TabViewProps)}>
229+
<Text>Content 2</Text>
230+
</View>
231+
</TabsList>,
232+
);
233+
234+
// Assert
235+
expect(getByText('Content 1')).toBeOnTheScreen();
236+
});
237+
238+
it('loads target tab on user press immediately', async () => {
239+
// Arrange
240+
const { getAllByText, getByText } = render(
241+
<TabsList initialActiveIndex={0}>
242+
<View {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
243+
<Text>Content 1</Text>
244+
</View>
245+
<View {...({ tabLabel: 'Tab 2' } as TabViewProps)}>
246+
<Text>Content 2</Text>
247+
</View>
248+
</TabsList>,
249+
);
250+
251+
// Act
252+
await act(async () => {
253+
fireEvent.press(getAllByText('Tab 2')[0]);
254+
});
255+
256+
// Assert
257+
expect(getByText('Content 2')).toBeOnTheScreen();
258+
});
259+
201260
it('exposes getCurrentIndex method via ref', () => {
202261
// Arrange
203262
const ref = React.createRef<TabsListRef>();
@@ -454,17 +513,8 @@ describe('TabsList', () => {
454513
// even when the tab was temporarily removed and re-added
455514
});
456515

457-
describe('Deferred Content Loading', () => {
458-
it('loads active tab content via InteractionManager', () => {
459-
// Arrange
460-
const mockRunAfterInteractions = jest.fn((callback) => {
461-
callback();
462-
return { cancel: jest.fn() };
463-
});
464-
(InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
465-
mockRunAfterInteractions,
466-
);
467-
516+
describe('Content Loading', () => {
517+
it('loads active tab content via InteractionManager scheduling', () => {
468518
// Act
469519
const { getByText } = render(
470520
<TabsList initialActiveIndex={0}>
@@ -477,9 +527,9 @@ describe('TabsList', () => {
477527
</TabsList>,
478528
);
479529

480-
// Assert - InteractionManager used for initial tab load
481-
expect(mockRunAfterInteractions).toHaveBeenCalled();
530+
// Assert
482531
expect(getByText('Content 1')).toBeOnTheScreen();
532+
expect(InteractionManager.runAfterInteractions).toHaveBeenCalled();
483533
});
484534

485535
it('defers loading of inactive tabs until switched to', () => {
@@ -499,17 +549,8 @@ describe('TabsList', () => {
499549
expect(queryByText('Content 2')).toBeNull();
500550
});
501551

502-
it('cancels pending content load when switching tabs quickly', async () => {
552+
it('switches quickly while keeping loads scheduled and stable', async () => {
503553
// Arrange
504-
const mockCancel = jest.fn();
505-
let capturedCallback: (() => void) | null = null;
506-
(InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
507-
(callback: () => void) => {
508-
capturedCallback = callback;
509-
return { cancel: mockCancel };
510-
},
511-
);
512-
513554
const { getAllByText } = render(
514555
<TabsList initialActiveIndex={0}>
515556
<View {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
@@ -524,29 +565,20 @@ describe('TabsList', () => {
524565
</TabsList>,
525566
);
526567

527-
// Act - Switch tabs quickly before interaction completes
568+
// Act
528569
await act(async () => {
529570
fireEvent.press(getAllByText('Tab 2')[0]);
530571
fireEvent.press(getAllByText('Tab 3')[0]);
531-
if (capturedCallback) {
532-
capturedCallback();
533-
}
534572
});
535573

536-
// Assert - Previous interaction was cancelled
537-
expect(mockCancel).toHaveBeenCalled();
574+
// Assert
575+
expect(InteractionManager.runAfterInteractions).toHaveBeenCalled();
538576
});
539577

540-
it('loads already-loaded tabs immediately without InteractionManager delay', async () => {
578+
it('does not re-schedule loading for already-loaded tabs', async () => {
541579
// Arrange
542-
const mockRunAfterInteractions = jest.fn((callback) => {
543-
callback();
544-
return { cancel: jest.fn() };
545-
});
546-
(InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
547-
mockRunAfterInteractions,
548-
);
549-
580+
const mockRunAfterInteractions =
581+
InteractionManager.runAfterInteractions as jest.Mock;
550582
const { getAllByText, getByText } = render(
551583
<TabsList initialActiveIndex={0}>
552584
<View {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
@@ -563,19 +595,152 @@ describe('TabsList', () => {
563595
fireEvent.press(getAllByText('Tab 2')[0]);
564596
});
565597

566-
const callCountAfterFirstSwitch =
598+
const callCountAfterFirstLoad =
567599
mockRunAfterInteractions.mock.calls.length;
568600

569601
// Act - Switch back to Tab 1 (already loaded)
570602
await act(async () => {
571603
fireEvent.press(getAllByText('Tab 1')[0]);
572604
});
573605

574-
// Assert - Already loaded tab displays immediately without new InteractionManager call
606+
// Assert
575607
expect(getByText('Content 1')).toBeOnTheScreen();
576608
expect(mockRunAfterInteractions).toHaveBeenCalledTimes(
577-
callCountAfterFirstSwitch,
609+
callCountAfterFirstLoad,
610+
);
611+
});
612+
613+
it('stale callback from previous tab does not cancel current tab load', async () => {
614+
jest.useFakeTimers();
615+
616+
// Capture callbacks so we can fire them manually
617+
let capturedCallbackA: (() => void) | null = null;
618+
619+
(InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
620+
(cb: () => void) => {
621+
// Capture only the first callback (Tab 1); don't auto-invoke any
622+
if (!capturedCallbackA) capturedCallbackA = cb;
623+
return { cancel: jest.fn() };
624+
},
578625
);
626+
627+
try {
628+
const { getAllByText, getByText, queryByText } = render(
629+
<TabsList initialActiveIndex={0}>
630+
<View {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
631+
<Text>Content 1</Text>
632+
</View>
633+
<View {...({ tabLabel: 'Tab 2' } as TabViewProps)}>
634+
<Text>Content 2</Text>
635+
</View>
636+
</TabsList>,
637+
);
638+
639+
// Tab 1 scheduled but not yet loaded (InteractionManager not fired)
640+
expect(queryByText('Content 1')).toBeNull();
641+
642+
// Switch to Tab 2 before Tab 1's callback fires
643+
await act(async () => {
644+
fireEvent.press(getAllByText('Tab 2')[0]);
645+
});
646+
647+
// Now fire Tab 1's stale callback — this must NOT cancel Tab 2's fallback
648+
await act(async () => {
649+
capturedCallbackA?.();
650+
});
651+
652+
// Tab 2 must still load via its fallback timeout
653+
await act(async () => {
654+
jest.advanceTimersByTime(250);
655+
});
656+
expect(getByText('Content 2')).toBeOnTheScreen();
657+
} finally {
658+
jest.useRealTimers();
659+
}
660+
});
661+
662+
it('uses fallback timeout if InteractionManager callback does not run', async () => {
663+
jest.useFakeTimers();
664+
(InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
665+
() => ({ cancel: jest.fn() }),
666+
);
667+
668+
try {
669+
const { getAllByText, getByText, queryByText } = render(
670+
<TabsList initialActiveIndex={0}>
671+
<View {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
672+
<Text>Content 1</Text>
673+
</View>
674+
<View {...({ tabLabel: 'Tab 2' } as TabViewProps)}>
675+
<Text>Content 2</Text>
676+
</View>
677+
</TabsList>,
678+
);
679+
680+
expect(queryByText('Content 1')).toBeNull();
681+
expect(queryByText('Content 2')).toBeNull();
682+
683+
await act(async () => {
684+
jest.advanceTimersByTime(250);
685+
});
686+
expect(getByText('Content 1')).toBeOnTheScreen();
687+
688+
await act(async () => {
689+
fireEvent.press(getAllByText('Tab 2')[0]);
690+
});
691+
expect(queryByText('Content 2')).toBeNull();
692+
693+
await act(async () => {
694+
jest.advanceTimersByTime(250);
695+
});
696+
expect(getByText('Content 2')).toBeOnTheScreen();
697+
} finally {
698+
jest.useRealTimers();
699+
}
700+
});
701+
702+
it('does not lose scheduled load across a rerender', async () => {
703+
jest.useFakeTimers();
704+
(InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
705+
() => ({ cancel: jest.fn() }),
706+
);
707+
708+
try {
709+
const renderTabs = () => (
710+
<TabsList initialActiveIndex={0}>
711+
<View key="tab1" {...({ tabLabel: 'Tab 1' } as TabViewProps)}>
712+
<Text>Content 1</Text>
713+
</View>
714+
<View key="tab2" {...({ tabLabel: 'Tab 2' } as TabViewProps)}>
715+
<Text>Content 2</Text>
716+
</View>
717+
</TabsList>
718+
);
719+
720+
const { getAllByText, getByText, queryByText, rerender } =
721+
render(renderTabs());
722+
723+
expect(queryByText('Content 1')).toBeNull();
724+
725+
await act(async () => {
726+
jest.advanceTimersByTime(250);
727+
});
728+
expect(getByText('Content 1')).toBeOnTheScreen();
729+
730+
await act(async () => {
731+
fireEvent.press(getAllByText('Tab 2')[0]);
732+
});
733+
expect(queryByText('Content 2')).toBeNull();
734+
735+
rerender(renderTabs());
736+
737+
await act(async () => {
738+
jest.advanceTimersByTime(250);
739+
});
740+
expect(getByText('Content 2')).toBeOnTheScreen();
741+
} finally {
742+
jest.useRealTimers();
743+
}
579744
});
580745
});
581746

0 commit comments

Comments
 (0)