Skip to content

Commit b283dbd

Browse files
feat: hub page discovery tabs A/B test (MetaMask#29193)
## **Description** Implements the treatment UI for the `coreMCU589AbtestHubPageDiscoveryTabs` A/B test When the treatment variant is active, the homepage replaces the standard scrollable layout with a top-tab navigation bar exposing three verticals: - **Portfolio:** existing homepage sections with balance header and pull-to-refresh - **Perpetuals:** `PerpsHomeView` wrapped in connection + stream providers - **Predictions:** `PredictFeed` wrapped in preview sheet provider When the control variant is active (or the flag is absent), the existing homepage layout is fully preserved. Key implementation details: - `HomepageDiscoveryTabs`: new component that owns tab layout, per-tab gradient crossfade, and wallet header hide/show coordination across all three tabs - `TabsList` — design-system tab primitive with `keepMounted` support. Perpetuals and Predictions both use `keepMounted={false}`: - **Performance**: both screens have a heavy hydration cost on first mount; keeping them alive while invisible wastes resources on tabs the user may never visit - **Open connections**: `PerpsHomeView` establishes WebSocket channels for live market data via its stream providers on mount; leaving these running in the background wastes bandwidth and server-side connection slots - **Memory**: `PredictFeed` and its preview sheet provider hold feed state that can safely be discarded between visits - Portfolio uses `keepMounted={true}` (the default) since it is the landing tab and its scroll position and section state should survive switching away and back - Wallet header animates up/down on scroll via Reanimated shared values; icon collapse and gradient opacity are synced via `TabIconAnimationContext` - Scroll event forwarding keeps existing `HOME_VIEWED` section analytics working in the Portfolio tab - A/B gating is handled by `useABTest(HUB_PAGE_DISCOVERY_TABS_AB_KEY)` [Figma Design](https://www.figma.com/design/z0panHXrMSMUSof2SaPkd4/Home-2026?m=auto&node-id=4280-62214&t=AAKr2hzmyPx57F4Y-1) for reference. Only difference between this UI and the Figma is that the tabs take up the entire space ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-591 ## **Manual testing steps** ```gherkin Feature: Hub Page Discovery Tabs Scenario: treatment variant shows top tabs Given the coreMCU589AbtestHubPageDiscoveryTabs flag is set to "treatment" When user opens the app to the Wallet screen Then three tabs are visible: Portfolio, Perpetuals, Predictions And switching between tabs is smooth with no layout shift And the wallet header hides on scroll and restores on tab switch And pull-to-refresh works on the Portfolio tab Scenario: control variant preserves existing layout Given the coreMCU589AbtestHubPageDiscoveryTabs flag is set to "control" When user opens the app to the Wallet screen Then the existing homepage layout is shown with no tabs ``` ## **Screenshots/Recordings** ### iOS Dark Mode https://github.com/user-attachments/assets/7df6a46d-b3b3-44cc-a697-b796581dd759 Light Mode (No Gradient) https://github.com/user-attachments/assets/97b1f901-6092-42f9-926c-e8e6785c6f4e ### Android Dark Mode https://github.com/user-attachments/assets/4232f3fc-a47c-4516-93c2-104e4a3fb5cb Light Mode (No Gradient) https://github.com/user-attachments/assets/3f11dd8f-696f-438e-9867-1b08dc69200d ### **Before** `~` ### **After** `~` ## **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. #### 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`. --> - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Introduces a new A/B-test-gated navigation structure and shared header animation/scroll handling on the Wallet home screen, which could affect core navigation and scroll/refresh behavior. Risk is mitigated by control-path preservation and added unit coverage, but Reanimated worklet interactions and tab switching edge cases remain sensitive. > > **Overview** > When the Hub Page Discovery Tabs A/B test is in *treatment* (and homepage sections are enabled), Wallet home now renders a new top-level `HomepageDiscoveryTabs` experience with three tabs: Portfolio, Perpetuals (`PerpsHomeView`), and Predictions (`PredictFeed`). The control path preserves the existing scroll layout. > > Adds `useDiscoveryScrollManager`, a Reanimated-backed hook that hides/shows the shared Wallet header based on scroll threshold, restores per-tab header state on tab entry, forwards scroll events back to JS (for analytics), and emits `onHeaderHiddenChange` to sync sibling animations. > > Updates `Wallet` to measure/animate the header via shared values, pass refresh/portfolio header/scroll callbacks into the new tabs view, and crossfade a dark-mode gradient overlay per active tab. `PerpsHomeView` and `PredictFeed` gain `hideHeader` and header-sync props, and `useFeedScrollManager` can now notify header hidden/show changes; tests are added/updated across these components and the new hook. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 38e679d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 584d490 commit b283dbd

13 files changed

Lines changed: 2056 additions & 179 deletions

File tree

app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ import PerpsHomeView from './PerpsHomeView';
44
import { PERPS_EVENT_VALUE } from '@metamask/perps-controller';
55
import { selectPerpsFeedbackEnabledFlag } from '../../selectors/featureFlags';
66
import { mockTheme } from '../../../../../util/theme';
7+
import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager';
8+
9+
// Mock useDiscoveryScrollManager
10+
const mockPerpsOnTabEnter = jest.fn();
11+
const mockPerpsScrollHandler = jest.fn();
12+
jest.mock('../../../Predict/hooks/useDiscoveryScrollManager', () => ({
13+
useDiscoveryScrollManager: jest.fn(() => ({
14+
scrollHandler: mockPerpsScrollHandler,
15+
onTabEnter: mockPerpsOnTabEnter,
16+
headerHidden: false,
17+
})),
18+
}));
19+
20+
// Mock react-native-reanimated
21+
jest.mock('react-native-reanimated', () => {
22+
const Reanimated = jest.requireActual('react-native-reanimated/mock');
23+
Reanimated.default.ScrollView = jest.requireActual('react-native').ScrollView;
24+
return Reanimated;
25+
});
726

827
// Mock navigation
928
const mockNavigate = jest.fn();
@@ -877,4 +896,77 @@ describe('PerpsHomeView', () => {
877896
});
878897
});
879898
});
899+
900+
describe('hideHeader prop', () => {
901+
it('renders the header by default', () => {
902+
const { getByTestId } = render(<PerpsHomeView />);
903+
expect(getByTestId('back-button')).toBeTruthy();
904+
expect(getByTestId('perps-home-search-toggle')).toBeTruthy();
905+
});
906+
907+
it('hides the header when hideHeader is true', () => {
908+
const { queryByTestId } = render(<PerpsHomeView hideHeader />);
909+
expect(queryByTestId('back-button')).toBeNull();
910+
expect(queryByTestId('perps-home-search-toggle')).toBeNull();
911+
});
912+
913+
it('still renders content when hideHeader is true', () => {
914+
const { UNSAFE_getByType } = render(<PerpsHomeView hideHeader />);
915+
expect(
916+
UNSAFE_getByType('PerpsMarketBalanceActions' as never),
917+
).toBeTruthy();
918+
});
919+
});
920+
921+
describe('tabEnterCallbackRef prop', () => {
922+
it('populates tabEnterCallbackRef.current with onTabEnter after mount', () => {
923+
const ref = { current: null } as React.MutableRefObject<
924+
(() => void) | null
925+
>;
926+
render(<PerpsHomeView tabEnterCallbackRef={ref} />);
927+
expect(ref.current).toBe(mockPerpsOnTabEnter);
928+
});
929+
930+
it('updates tabEnterCallbackRef.current when onTabEnter changes', () => {
931+
const ref = { current: null } as React.MutableRefObject<
932+
(() => void) | null
933+
>;
934+
const newOnTabEnter = jest.fn();
935+
(useDiscoveryScrollManager as jest.Mock).mockReturnValueOnce({
936+
scrollHandler: mockPerpsScrollHandler,
937+
onTabEnter: newOnTabEnter,
938+
headerHidden: false,
939+
});
940+
render(<PerpsHomeView tabEnterCallbackRef={ref} />);
941+
expect(ref.current).toBe(newOnTabEnter);
942+
});
943+
944+
it('does not throw when tabEnterCallbackRef is not provided', () => {
945+
expect(() => render(<PerpsHomeView />)).not.toThrow();
946+
});
947+
});
948+
949+
describe('useDiscoveryScrollManager integration', () => {
950+
it('passes walletHeaderHeight to useDiscoveryScrollManager', () => {
951+
render(<PerpsHomeView walletHeaderHeight={56} />);
952+
expect(useDiscoveryScrollManager).toHaveBeenCalledWith(
953+
expect.objectContaining({ walletHeaderHeight: 56 }),
954+
);
955+
});
956+
957+
it('passes onHeaderHiddenChange to useDiscoveryScrollManager', () => {
958+
const onHeaderHiddenChange = jest.fn();
959+
render(<PerpsHomeView onHeaderHiddenChange={onHeaderHiddenChange} />);
960+
expect(useDiscoveryScrollManager).toHaveBeenCalledWith(
961+
expect.objectContaining({ onHeaderHiddenChange }),
962+
);
963+
});
964+
965+
it('uses default walletHeaderHeight of 0 when not provided', () => {
966+
render(<PerpsHomeView />);
967+
expect(useDiscoveryScrollManager).toHaveBeenCalledWith(
968+
expect.objectContaining({ walletHeaderHeight: 0 }),
969+
);
970+
});
971+
});
880972
});

app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import React, {
55
useEffect,
66
useMemo,
77
} from 'react';
8-
import { View, ScrollView, Modal } from 'react-native';
8+
import { View, Modal, NativeScrollEvent } from 'react-native';
99
import { useSelector } from 'react-redux';
1010
import {
1111
SafeAreaView,
@@ -57,6 +57,8 @@ import PerpsHomeHeader from '../../components/PerpsHomeHeader';
5757
import type { PerpsNavigationParamList } from '../../types/navigation';
5858
import { MetaMetricsEvents } from '../../../../../core/Analytics';
5959
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
60+
import Reanimated, { SharedValue } from 'react-native-reanimated';
61+
import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager';
6062
import styleSheet from './PerpsHomeView.styles';
6163
import { TraceName } from '../../../../../util/trace';
6264
import {
@@ -72,7 +74,23 @@ import PerpsNavigationCard, {
7274
NavigationItem,
7375
} from '../../components/PerpsNavigationCard/PerpsNavigationCard';
7476

75-
const PerpsHomeView = () => {
77+
interface PerpsHomeViewProps {
78+
hideHeader?: boolean;
79+
walletHeaderTranslateY?: SharedValue<number>;
80+
walletHeaderHeight?: number;
81+
/** Ref populated with this tab's onTabEnter so the parent can call it on tab switch. */
82+
tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>;
83+
/** Forwarded to useDiscoveryScrollManager to sync icon animations with header hide/show. */
84+
onHeaderHiddenChange?: (hidden: boolean) => void;
85+
}
86+
87+
const PerpsHomeView = ({
88+
hideHeader = false,
89+
walletHeaderTranslateY,
90+
walletHeaderHeight = 0,
91+
tabEnterCallbackRef,
92+
onHeaderHiddenChange,
93+
}: PerpsHomeViewProps) => {
7694
const { styles } = useStyles(styleSheet, {});
7795
const insets = useSafeAreaInsets();
7896
const navigation = useNavigation();
@@ -124,6 +142,38 @@ const PerpsHomeView = () => {
124142
const { handleSectionLayout, handleScroll, resetTracking } =
125143
usePerpsHomeSectionTracking();
126144

145+
// Bridge analytics handler into the Reanimated worklet via onScrollEvent
146+
const handleScrollEvent = useCallback(
147+
(scrollY: number, viewportHeight: number) => {
148+
handleScroll({
149+
nativeEvent: {
150+
contentOffset: { x: 0, y: scrollY },
151+
layoutMeasurement: { width: 0, height: viewportHeight },
152+
} as NativeScrollEvent,
153+
});
154+
},
155+
[handleScroll],
156+
);
157+
158+
const { scrollHandler: perpsScrollHandler, onTabEnter: perpsOnTabEnter } =
159+
useDiscoveryScrollManager({
160+
walletHeaderHeight,
161+
walletHeaderTranslateY,
162+
onScrollEvent: handleScrollEvent,
163+
onHeaderHiddenChange,
164+
});
165+
166+
// Expose onTabEnter to the parent so it can restore this tab's header state on switch.
167+
useEffect(() => {
168+
if (tabEnterCallbackRef) {
169+
tabEnterCallbackRef.current = perpsOnTabEnter;
170+
return () => {
171+
tabEnterCallbackRef.current = null;
172+
};
173+
}
174+
return undefined;
175+
}, [tabEnterCallbackRef, perpsOnTabEnter]);
176+
127177
// Get balance state directly from Redux
128178
const { account: perpsAccount } = usePerpsLiveAccount({ throttleMs: 1000 });
129179
const totalBalance = perpsAccount?.totalBalance || '0';
@@ -417,20 +467,25 @@ const PerpsHomeView = () => {
417467
const handleBackPress = perpsNavigation.navigateToWallet;
418468

419469
return (
420-
<SafeAreaView style={styles.container}>
470+
<SafeAreaView
471+
style={styles.container}
472+
edges={hideHeader ? { bottom: 'additive' } : undefined}
473+
>
421474
{/* Header */}
422-
<PerpsHomeHeader
423-
onBack={handleBackPress}
424-
onSearchToggle={handleSearchToggle}
425-
testID="perps-home"
426-
/>
475+
{!hideHeader && (
476+
<PerpsHomeHeader
477+
onBack={handleBackPress}
478+
onSearchToggle={handleSearchToggle}
479+
testID="perps-home"
480+
/>
481+
)}
427482

428483
{/* Main Content - ScrollView with all carousels */}
429-
<ScrollView
484+
<Reanimated.ScrollView
430485
style={styles.scrollView}
431486
contentContainerStyle={styles.scrollViewContent}
432487
showsVerticalScrollIndicator={false}
433-
onScroll={handleScroll}
488+
onScroll={perpsScrollHandler}
434489
scrollEventThrottle={16}
435490
>
436491
<PerpsHomeHeader
@@ -553,7 +608,7 @@ const PerpsHomeView = () => {
553608

554609
{/* Bottom spacing for tab bar */}
555610
<View style={bottomSpacerStyle} />
556-
</ScrollView>
611+
</Reanimated.ScrollView>
557612

558613
{/* Close All Positions Bottom Sheet */}
559614
{showCloseAllSheet && (

0 commit comments

Comments
 (0)