diff --git a/.yarnrc.yml b/.yarnrc.yml
index dad119471ed4..8a6d20d53c67 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -7,11 +7,6 @@ enableScripts: false
nodeLinker: node-modules
npmAuditIgnoreAdvisories:
- ### Advisories:
- # Issue: Regular Expression Denial of Service (ReDoS) in cross-spawn
- # URL - https://github.com/advisories/GHSA-3xgq-45jj-v275
- # The affected versions <6.0.6, is only present in wdio which is a dev dependency
- - 1104663
yarnPath: .yarn/releases/yarn-4.10.3.cjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2138c62d19a..985f34f0f8c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [7.57.1]
+
+### Fixed
+
+- fix: show edit account bottomsheet on android when its behind the keyboard ([#21477](https://github.com/MetaMask/metamask-mobile/pull/21477))
+- fix: Patch touchable issue in React Native ([#21568](https://github.com/MetaMask/metamask-mobile/pull/21568))
+
## [7.57.0]
### Added
@@ -403,35 +410,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [7.56.5]
### Fixed
-- fix: use SharedDeeplinkManager to parse instead of Linking API ([#20960](https://github.com/MetaMask/metamask-mobile/pull/20960))
+* fix: use SharedDeeplinkManager to parse instead of Linking API ([#20960](https://github.com/MetaMask/metamask-mobile/pull/20960))
## [7.56.4]
### Fixed
-- fix: address feature flag config issue
+* fix: address feature flag config issue
## [7.56.3]
### Fixed
-- fix: remove unintended metrics from transaction finalised event ([#20733](https://github.com/MetaMask/metamask-mobile/pull/20733))
-- fix: force rendering on token list when order changes ([#20771](https://github.com/MetaMask/metamask-mobile/pull/20771))
-- fix: add contentful max version number segmentation ([#20769](https://github.com/MetaMask/metamask-mobile/pull/20769))
+* fix: remove unintended metrics from transaction finalised event ([#20733](https://github.com/MetaMask/metamask-mobile/pull/20733))
+* fix: force rendering on token list when order changes ([#20771](https://github.com/MetaMask/metamask-mobile/pull/20771))
+* fix: add contentful max version number segmentation ([#20769](https://github.com/MetaMask/metamask-mobile/pull/20769))
## [7.56.2]
### Fixed
-- fix: address feature flag config issue
+* fix: address feature flag config issue
## [7.56.1]
### Fixed
-- fix: in recipient validations for internal accounts ([#20694](https://github.com/MetaMask/metamask-mobile/pull/20694))
-- feat: iOS Rehydration Flow Update to release/7.56.1 ([#20681](https://github.com/MetaMask/metamask-mobile/pull/20681))
-- feat: social login success screen added for social login users and ios platform. ([#20679](https://github.com/MetaMask/metamask-mobile/pull/20679))
-- fix: Returned Scrollview to Perps and Defi tab cp-7.56.1 ([#20650](https://github.com/MetaMask/metamask-mobile/pull/20650))
-- fix: missing transactions in activity after perps deposit (\#20507) ([09ef7e5](https://github.com/MetaMask/metamask-mobile/commit/09ef7e5f5111d0d3592b5e6d60499f31dc22f013))
-- fix: cp-7.56.1 Temp Revert page-level scroll for Wallet (#20579) ([#20616](https://github.com/MetaMask/metamask-mobile/pull/20616))
-- fix: Temp Revert page-level scroll for Wallet (\#20579) ([9022244](https://github.com/MetaMask/metamask-mobile/commit/902224410fbdf37250b990c75986eb7a948fb5ec))
+* fix: in recipient validations for internal accounts ([#20694](https://github.com/MetaMask/metamask-mobile/pull/20694))
+* feat: iOS Rehydration Flow Update to release/7.56.1 ([#20681](https://github.com/MetaMask/metamask-mobile/pull/20681))
+* feat: social login success screen added for social login users and ios platform. ([#20679](https://github.com/MetaMask/metamask-mobile/pull/20679))
+* fix: Returned Scrollview to Perps and Defi tab cp-7.56.1 ([#20650](https://github.com/MetaMask/metamask-mobile/pull/20650))
+* fix: missing transactions in activity after perps deposit (\#20507) ([09ef7e5](https://github.com/MetaMask/metamask-mobile/commit/09ef7e5f5111d0d3592b5e6d60499f31dc22f013))
+* fix: cp-7.56.1 Temp Revert page-level scroll for Wallet (#20579) ([#20616](https://github.com/MetaMask/metamask-mobile/pull/20616))
+* fix: Temp Revert page-level scroll for Wallet (\#20579) ([9022244](https://github.com/MetaMask/metamask-mobile/commit/902224410fbdf37250b990c75986eb7a948fb5ec))
## [7.56.0]
@@ -2498,7 +2505,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat: add InlineAlert component ([#13709](https://github.com/MetaMask/metamask-mobile/pull/13709))
- feat: add MultipleAlertModal component ([#13683](https://github.com/MetaMask/metamask-mobile/pull/13683))
- feat: Add Snaps UI `Selector` component ([#13747](https://github.com/MetaMask/metamask-mobile/pull/13747))
-- feat: added mocks to sonar.coverage.exclusions ([#13787](https://github.com/MetaMask/metamask-mobile/pull/13787))
+- feat: added **/**mocks**/** to sonar.coverage.exclusions ([#13787](https://github.com/MetaMask/metamask-mobile/pull/13787))
- feat: add `GeneralAlertBanner` component ([#13627](https://github.com/MetaMask/metamask-mobile/pull/13627))
### Fixed
@@ -7632,7 +7639,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957)
- [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954)
-[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.57.0...HEAD
+[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.57.1...HEAD
+[7.57.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.57.0...v7.57.1
[7.57.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.56.5...v7.57.0
[7.56.5]: https://github.com/MetaMask/metamask-mobile/compare/v7.56.4...v7.56.5
[7.56.4]: https://github.com/MetaMask/metamask-mobile/compare/v7.56.3...v7.56.4
diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx
new file mode 100644
index 000000000000..20affdd5ca88
--- /dev/null
+++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { Text, View } from 'react-native';
+import ConditionalScrollView from './ConditionalScrollView';
+
+describe('ConditionalScrollView', () => {
+ const testContent = (
+
+ Test Content
+
+ );
+
+ describe('when isScrollEnabled is true', () => {
+ it('wraps children in ScrollView and renders content', () => {
+ const { getByTestId, getByText } = render(
+
+ {testContent}
+ ,
+ );
+
+ expect(getByTestId('scroll-container')).toBeDefined();
+ expect(getByText('Test Content')).toBeDefined();
+ });
+
+ it('passes scrollViewProps to ScrollView', () => {
+ const testID = 'test-scroll-view';
+ const { getByTestId } = render(
+
+ {testContent}
+ ,
+ );
+
+ const scrollView = getByTestId(testID);
+ expect(scrollView.props.showsVerticalScrollIndicator).toBe(false);
+ expect(scrollView.props.bounces).toBe(false);
+ });
+ });
+
+ describe('when isScrollEnabled is false', () => {
+ it('renders children without ScrollView wrapper', () => {
+ const { getByText, queryByTestId } = render(
+
+ {testContent}
+ ,
+ );
+
+ expect(queryByTestId('should-not-exist')).toBeNull();
+ expect(getByText('Test Content')).toBeDefined();
+ });
+ });
+
+ describe('dynamic behavior', () => {
+ it('switches between ScrollView and direct rendering when isScrollEnabled changes', () => {
+ const result = render(
+
+ {testContent}
+ ,
+ );
+
+ expect(result.getByTestId('scroll-view')).toBeDefined();
+
+ result.rerender(
+
+ {testContent}
+ ,
+ );
+
+ expect(result.queryByTestId('scroll-view')).toBeNull();
+
+ result.rerender(
+
+ {testContent}
+ ,
+ );
+
+ expect(result.getByTestId('scroll-view')).toBeDefined();
+ });
+ });
+});
diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx
new file mode 100644
index 000000000000..c6ab4878ca17
--- /dev/null
+++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { ScrollView } from 'react-native';
+import { ConditionalScrollViewProps } from './ConditionalScrollView.types';
+
+/**
+ * ConditionalScrollView renders either a ScrollView or content directly based on isScrollEnabled prop.
+ * This is useful for homepage redesign where we want to remove nested scroll views in favor of a global scroll container.
+ */
+const ConditionalScrollView: React.FC = ({
+ children,
+ isScrollEnabled,
+ scrollViewProps,
+}) =>
+ isScrollEnabled ? (
+ {children}
+ ) : (
+ <>{children}>
+ );
+
+export default ConditionalScrollView;
diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts
new file mode 100644
index 000000000000..07f452c2c6c8
--- /dev/null
+++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts
@@ -0,0 +1,16 @@
+import { ScrollViewProps } from 'react-native';
+
+export interface ConditionalScrollViewProps {
+ /**
+ * Content to render inside the conditional scroll view
+ */
+ children: React.ReactNode;
+ /**
+ * If true, wraps children in ScrollView. If false, renders children directly.
+ */
+ isScrollEnabled: boolean;
+ /**
+ * Optional props to pass to ScrollView when isScrollEnabled is true
+ */
+ scrollViewProps?: ScrollViewProps;
+}
diff --git a/app/component-library/components-temp/ConditionalScrollView/index.ts b/app/component-library/components-temp/ConditionalScrollView/index.ts
new file mode 100644
index 000000000000..81b7bba21a00
--- /dev/null
+++ b/app/component-library/components-temp/ConditionalScrollView/index.ts
@@ -0,0 +1 @@
+export { default } from './ConditionalScrollView';
diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx
index 3c261f7d4382..35efd2dba349 100644
--- a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx
+++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx
@@ -1,7 +1,7 @@
// Third party dependencies.
import React from 'react';
import { render, fireEvent, act, waitFor } from '@testing-library/react-native';
-import { View } from 'react-native';
+import { View, InteractionManager } from 'react-native';
// External dependencies.
import { Text } from '@metamask/design-system-react-native';
@@ -10,9 +10,25 @@ import { Text } from '@metamask/design-system-react-native';
import TabsList from './TabsList';
import { TabViewProps, TabsListRef } from './TabsList.types';
+// Mock InteractionManager
+jest.mock('react-native/Libraries/Interaction/InteractionManager', () => ({
+ runAfterInteractions: jest.fn((callback) => {
+ // Execute callback immediately for tests
+ callback();
+ return { cancel: jest.fn() };
+ }),
+}));
+
describe('TabsList', () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Reset to default behavior that executes callbacks immediately
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ (callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ },
+ );
});
it('renders correctly with multiple tabs', () => {
@@ -55,16 +71,18 @@ describe('TabsList', () => {
,
);
- // Assert - Active tab loads immediately
+ // Assert - Active tab loads via InteractionManager
expect(getByText('Tokens Content')).toBeOnTheScreen();
// Other tabs should not be loaded yet (on-demand loading)
expect(queryByText('NFTs Content')).toBeNull();
// When user clicks the NFTs tab, it should load
- fireEvent.press(getAllByText('NFTs')[0]);
+ await act(async () => {
+ fireEvent.press(getAllByText('NFTs')[0]);
+ });
- // Wait for the delayed loading to complete
+ // Wait for the deferred loading to complete
await waitFor(() => {
expect(getByText('NFTs Content')).toBeOnTheScreen();
});
@@ -78,7 +96,7 @@ describe('TabsList', () => {
];
// Act
- const { getByText, queryByText, getAllByText } = render(
+ const { getByText, getAllByText } = render(
{tabs.map((tab, index) => (
{
// Switch to second tab
fireEvent.press(getAllByText('NFTs')[0]);
- // Assert - NFTs content should be on screen, Tokens content exists but not visible
+ // Assert - NFTs content should be on screen
expect(getByText('NFTs Content')).toBeOnTheScreen();
- expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible
});
it('calls onChangeTab callback when tab changes', async () => {
@@ -301,14 +318,15 @@ describe('TabsList', () => {
it('handles all tabs disabled by setting activeIndex to -1', () => {
// Arrange
+ const ref = React.createRef();
const tabs = [
{ label: 'Tab 1', content: 'Tab 1 Content' },
{ label: 'Tab 2', content: 'Tab 2 Content' },
];
// Act
- const { queryByText } = render(
-
+ render(
+
{
,
);
- // Assert - No content should be displayed when all tabs are disabled
- expect(queryByText('Tab 1 Content')).toBeNull();
- expect(queryByText('Tab 2 Content')).toBeNull();
+ // Assert - activeIndex set to -1 when all tabs are disabled
+ expect(ref.current?.getCurrentIndex()).toBe(-1);
});
it('switches to first enabled tab when initialActiveIndex points to disabled tab', () => {
// Arrange
+ const ref = React.createRef();
const tabs = [
{ label: 'Disabled Tab', content: 'Disabled Content' },
{ label: 'Active Tab', content: 'Active Content' },
@@ -338,8 +356,8 @@ describe('TabsList', () => {
];
// Act
- const { getByText, queryByText } = render(
-
+ const { getByText } = render(
+
{
,
);
- // Assert - Should display the first enabled tab (index 1) instead of the disabled tab (index 0)
+ // Assert - Should switch to first enabled tab (index 1) when initial tab is disabled
+ expect(ref.current?.getCurrentIndex()).toBe(1);
expect(getByText('Active Content')).toBeOnTheScreen();
- expect(queryByText('Disabled Content')).toBeNull();
- expect(queryByText('Another Content')).toBeNull();
});
it('preserves active tab selection when tabs array changes dynamically', () => {
@@ -387,7 +404,6 @@ describe('TabsList', () => {
// Assert - Perps content should be visible after clicking
expect(getByText('Perps Content')).toBeOnTheScreen();
- expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible
// Create tabs without Perps (simulating when isPerpsEnabled becomes false)
const tabsWithoutPerps = [
@@ -438,195 +454,152 @@ describe('TabsList', () => {
// even when the tab was temporarily removed and re-added
});
- it('preserves tab selection by key when tab order changes', () => {
- // Arrange - Create tabs in original order
- const originalOrder = [
- { key: 'tokens-tab', label: 'Tokens', content: 'Tokens Content' },
- { key: 'perps-tab', label: 'Perps', content: 'Perps Content' },
- { key: 'nfts-tab', label: 'NFTs', content: 'NFTs Content' },
- ];
-
- // Create tabs in different order (simulating dynamic reordering)
- const reorderedTabs = [
- { key: 'tokens-tab', label: 'Tokens', content: 'Tokens Content' },
- { key: 'nfts-tab', label: 'NFTs', content: 'NFTs Content' },
- { key: 'perps-tab', label: 'Perps', content: 'Perps Content' },
- ];
+ describe('Deferred Content Loading', () => {
+ it('loads active tab content via InteractionManager', () => {
+ // Arrange
+ const mockRunAfterInteractions = jest.fn((callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ });
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ mockRunAfterInteractions,
+ );
- const { rerender, getByText, getAllByText, queryByText } = render(
-
- {originalOrder.map((tab) => (
-
- {tab.content}
+ // Act
+ const { getByText } = render(
+
+
+ Content 1
- ))}
- ,
- );
-
- // Act - Switch to Perps tab (originally at index 1)
- fireEvent.press(getAllByText('Perps')[0]);
-
- // Assert - Perps content should be visible
- expect(getByText('Perps Content')).toBeOnTheScreen();
-
- // Act - Reorder tabs (Perps now at index 2)
- rerender(
-
- {reorderedTabs.map((tab) => (
-
- {tab.content}
+
+ Content 2
- ))}
- ,
- );
+ ,
+ );
- // Assert - The reordering shows NFTs Content, which means the activeIndex (1)
- // now points to NFTs instead of Perps. This is expected behavior when tabs are reordered
- // Note: Previously loaded tabs may not persist through reordering - this is acceptable
- expect(getByText('NFTs Content')).toBeOnTheScreen();
- expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible
- // Perps content may not be loaded after reordering since it's no longer active
- // expect(queryByText('Perps Content')).toBeTruthy(); // Content exists in DOM but not visible
- });
+ // Assert - InteractionManager used for initial tab load
+ expect(mockRunAfterInteractions).toHaveBeenCalled();
+ expect(getByText('Content 1')).toBeOnTheScreen();
+ });
- describe('Swipe Gesture Navigation', () => {
- it('renders with GestureDetector wrapper', () => {
+ it('defers loading of inactive tabs until switched to', () => {
// Arrange & Act
- const { getByTestId } = render(
-
-
- Tab 1 Content
+ const { queryByText } = render(
+
+
+ Content 1
-
- Tab 2 Content
+
+ Content 2
,
);
- // Assert - Component should render with gesture support
- const tabsList = getByTestId('tabs-list');
- expect(tabsList).toBeOnTheScreen();
+ // Assert - Inactive tab content not loaded
+ expect(queryByText('Content 2')).toBeNull();
});
- it('navigates to next tab programmatically via ref', () => {
+ it('cancels pending content load when switching tabs quickly', async () => {
// Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
- const { getByText } = render(
-
-
- Tab 1 Content
+ const mockCancel = jest.fn();
+ let capturedCallback: (() => void) | null = null;
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ (callback: () => void) => {
+ capturedCallback = callback;
+ return { cancel: mockCancel };
+ },
+ );
+
+ const { getAllByText } = render(
+
+
+ Content 1
-
- Tab 2 Content
+
+ Content 2
-
- Tab 3 Content
+
+ Content 3
,
);
- // Assert initial state
- expect(getByText('Tab 1 Content')).toBeOnTheScreen();
-
- // Act - Navigate to next tab programmatically
- act(() => {
- tabsRef.current?.goToTabIndex(1);
+ // Act - Switch tabs quickly before interaction completes
+ await act(async () => {
+ fireEvent.press(getAllByText('Tab 2')[0]);
+ fireEvent.press(getAllByText('Tab 3')[0]);
+ if (capturedCallback) {
+ capturedCallback();
+ }
});
- // Assert - Should navigate to Tab 2
- expect(mockOnChangeTab).toHaveBeenCalledWith({
- i: 1,
- ref: expect.anything(),
- });
+ // Assert - Previous interaction was cancelled
+ expect(mockCancel).toHaveBeenCalled();
});
- it('skips disabled tabs when navigating programmatically', () => {
+ it('loads already-loaded tabs immediately without InteractionManager delay', async () => {
// Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
- const { getByText } = render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
+ const mockRunAfterInteractions = jest.fn((callback) => {
+ callback();
+ return { cancel: jest.fn() };
+ });
+ (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation(
+ mockRunAfterInteractions,
+ );
+
+ const { getAllByText, getByText } = render(
+
+
+ Content 1
-
- Tab 3 Content
+
+ Content 2
,
);
- // Assert initial state
- expect(getByText('Tab 1 Content')).toBeOnTheScreen();
-
- // Act - Try to navigate to disabled tab (should be ignored)
- act(() => {
- tabsRef.current?.goToTabIndex(1);
+ // Load Tab 2 for the first time
+ await act(async () => {
+ fireEvent.press(getAllByText('Tab 2')[0]);
});
- // Assert - Should not navigate to disabled tab
- expect(mockOnChangeTab).not.toHaveBeenCalled();
+ const callCountAfterFirstSwitch =
+ mockRunAfterInteractions.mock.calls.length;
- // Act - Navigate to enabled tab
- act(() => {
- tabsRef.current?.goToTabIndex(2);
+ // Act - Switch back to Tab 1 (already loaded)
+ await act(async () => {
+ fireEvent.press(getAllByText('Tab 1')[0]);
});
- // Assert - Should navigate to Tab 3
- expect(mockOnChangeTab).toHaveBeenCalledWith({
- i: 2,
- ref: expect.anything(),
- });
+ // Assert - Already loaded tab displays immediately without new InteractionManager call
+ expect(getByText('Content 1')).toBeOnTheScreen();
+ expect(mockRunAfterInteractions).toHaveBeenCalledTimes(
+ callCountAfterFirstSwitch,
+ );
});
+ });
- it('handles swipe gesture integration with disabled tabs', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const { getByTestId, getByText } = render(
-
+ describe('Gesture Detection', () => {
+ it('renders with GestureDetector wrapper', () => {
+ // Arrange & Act
+ const { getByTestId } = render(
+
Tab 1 Content
-
+
Tab 2 Content
-
- Tab 3 Content
-
,
);
- // Assert - Component should render with gesture support and handle disabled tabs
+ // Assert - Component should render with gesture support
const tabsList = getByTestId('tabs-list');
expect(tabsList).toBeOnTheScreen();
-
- // Initial content should be visible
- expect(getByText('Tab 1 Content')).toBeOnTheScreen();
});
it('maintains performance by only rendering active tab content', () => {
- // Arrange
+ // Arrange & Act
const { getByText, queryByText } = render(
@@ -646,20 +619,12 @@ describe('TabsList', () => {
expect(getByText('Tab 2 Content')).toBeOnTheScreen();
expect(queryByText('Tab 3 Content')).toBeNull();
});
- });
- describe('Enhanced Edge Cases', () => {
- it('handles rapid tab switching during initialization', () => {
+ it('allows navigation through multiple tabs using ref', async () => {
// Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
+ const ref = React.createRef();
+ const { getByText } = render(
+
Tab 1 Content
@@ -672,1127 +637,74 @@ describe('TabsList', () => {
,
);
- // Act - Rapid tab switching
- act(() => {
- tabsRef.current?.goToTabIndex(1); // 0 -> 1: should trigger onChangeTab
- });
-
- act(() => {
- tabsRef.current?.goToTabIndex(2); // 1 -> 2: should trigger onChangeTab
- });
+ expect(getByText('Tab 2 Content')).toBeOnTheScreen();
- act(() => {
- tabsRef.current?.goToTabIndex(0); // 2 -> 0: should trigger onChangeTab
+ // Act - Navigate backward to Tab 1
+ await act(async () => {
+ ref.current?.goToTabIndex(0);
});
- // Assert - Should handle rapid switching gracefully
- expect(mockOnChangeTab).toHaveBeenCalledTimes(3);
- expect(tabsRef.current?.getCurrentIndex()).toBe(0);
- });
-
- it('handles tab array changes during active session', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const { rerender, getByText, queryByText } = render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
- ,
- );
-
- // Assert initial state
- expect(getByText('Tab 1 Content')).toBeOnTheScreen();
-
- // Act - Add more tabs
- rerender(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
-
- Tab 3 Content
-
- ,
- );
-
- // Assert - Should maintain active tab
+ // Assert
expect(getByText('Tab 1 Content')).toBeOnTheScreen();
- expect(queryByText('Tab 2 Content')).toBeNull();
- expect(queryByText('Tab 3 Content')).toBeNull();
- });
-
- it('handles mixed enabled/disabled tab scenarios', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
-
- Tab 3 Content
-
-
- Tab 4 Content
-
- ,
- );
+ expect(ref.current?.getCurrentIndex()).toBe(0);
- // Act - Try to navigate to disabled tabs
- act(() => {
- tabsRef.current?.goToTabIndex(1); // Disabled
- tabsRef.current?.goToTabIndex(2); // Disabled
- });
-
- // Assert - Should not navigate to disabled tabs
- expect(mockOnChangeTab).not.toHaveBeenCalled();
-
- // Act - Navigate to enabled tab
- act(() => {
- tabsRef.current?.goToTabIndex(3); // Enabled
+ // Act - Navigate forward to Tab 3
+ await act(async () => {
+ ref.current?.goToTabIndex(2);
});
- // Assert - Should navigate to enabled tab
- expect(mockOnChangeTab).toHaveBeenCalledWith({
- i: 3,
- ref: expect.anything(),
- });
+ // Assert
+ expect(getByText('Tab 3 Content')).toBeOnTheScreen();
+ expect(ref.current?.getCurrentIndex()).toBe(2);
});
});
- describe('Single Tab Support', () => {
- it('handles single tab correctly', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const { getByText } = render(
-
-
- Only Tab Content
-
- ,
- );
-
- // Assert - Single tab should be rendered and active
- expect(getByText('Only Tab Content')).toBeOnTheScreen();
- });
-
- it('handles single tab with swipe gestures disabled', () => {
+ describe('Edge Cases', () => {
+ it('handles non-React element children with default values', () => {
// Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
+ const nonReactElementChild = 'Plain text';
- const { getByText } = render(
-
-
- Only Tab Content
+ // Act
+ const { toJSON } = render(
+
+
+ Tab 1 Content
+ {nonReactElementChild as unknown as React.ReactElement}
,
);
- // Assert - Single tab should be rendered
- expect(getByText('Only Tab Content')).toBeOnTheScreen();
-
- // Act - Try programmatic navigation (should not do anything)
- act(() => {
- tabsRef.current?.goToTabIndex(1); // Invalid index
- });
-
- // Assert - Should remain on the single tab, no callback triggered
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- expect(tabsRef.current?.getCurrentIndex()).toBe(0);
+ // Assert - Component handles non-React elements gracefully
+ expect(toJSON()).toMatchSnapshot();
});
- it('handles single disabled tab', () => {
+ it('uses initialActiveIndex when it points to an enabled tab', () => {
// Arrange
- const mockOnChangeTab = jest.fn();
+ const ref = React.createRef();
+ const tabs = [
+ { label: 'Tab 1', content: 'Tab 1 Content' },
+ { label: 'Tab 2', content: 'Tab 2 Content' },
+ { label: 'Tab 3', content: 'Tab 3 Content' },
+ ];
+
+ // Act - initialActiveIndex points to Tab 3 (index 2) which is enabled
const { getByText } = render(
-
-
- Disabled Tab Content
+
+
+ {tabs[0].content}
- ,
- );
-
- // Assert - Single disabled tab should be rendered but not active
- // Content should not be rendered when activeIndex is -1
- expect(() => getByText('Disabled Tab Content')).toThrow();
- });
-
- it('handles single tab with ref methods', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
-
- Only Tab Content
+
+ {tabs[1].content}
- ,
- );
-
- // Assert - Ref methods should work correctly
- expect(tabsRef.current?.getCurrentIndex()).toBe(0);
-
- // Act - Try to navigate to same tab (should not trigger callback)
- act(() => {
- tabsRef.current?.goToTabIndex(0);
- });
-
- // Assert - Should not trigger callback for same index
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('handles single tab layout and animation', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const { getByTestId, getByText } = render(
-
-
- Only Tab Content
+
+ {tabs[2].content}
,
);
- // Assert - Component should render correctly
- const tabsList = getByTestId('single-tab-list');
- expect(tabsList).toBeOnTheScreen();
- expect(getByText('Only Tab Content')).toBeOnTheScreen();
-
- // Single tab should not enable scrolling
- // The underline animation should still work for the single tab
- });
-
- it('supports both single child and array of children (TypeScript compatibility)', () => {
- // Arrange & Act - This test verifies TypeScript accepts both patterns
- const SingleChildComponent = () => (
-
-
- Single Content
-
-
- );
-
- const MultipleChildrenComponent = () => (
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
-
- );
-
- // Assert - Both should render without TypeScript errors
- const { getByText: getSingleText, unmount: unmountSingle } = render(
- ,
- );
- expect(getSingleText('Single Content')).toBeOnTheScreen();
-
- unmountSingle();
-
- const { getByText: getMultipleText } = render(
- ,
- );
- expect(getMultipleText('Tab 1 Content')).toBeOnTheScreen();
- });
- });
-
- describe('Swipe Navigation Coverage', () => {
- it('covers early return when tabs.length <= 1', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
-
- Only Tab Content
-
- ,
- );
-
- // Act - Try to trigger swipe navigation with single tab
- // This should hit the early return: if (tabs.length <= 1) return;
- act(() => {
- // Simulate internal call to navigateToTab - this would normally be called by gesture
- // but we can't easily trigger the gesture in tests, so we test the logic directly
- tabsRef.current?.goToTabIndex(0); // Same index, should not trigger callback
- });
-
- // Assert - No navigation should occur with single tab
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('covers navigation direction logic for next tab', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
-
- Tab 3 Content
-
- ,
- );
-
- // Act - Navigate to next enabled tab (should skip disabled tab 3)
- act(() => {
- tabsRef.current?.goToTabIndex(1); // Go to tab 2 first
- });
-
- // Assert - Should navigate to tab 2
- expect(mockOnChangeTab).toHaveBeenCalledWith({
- i: 1,
- ref: expect.anything(),
- });
- });
-
- it('covers navigation direction logic for previous tab', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
-
- Tab 3 Content
-
- ,
- );
-
- // Act - Navigate to previous enabled tab (should skip disabled tab 1)
- act(() => {
- tabsRef.current?.goToTabIndex(1); // Should go to tab 2 (skipping disabled tab 1)
- });
-
- // Assert - Should navigate to tab 2
- expect(mockOnChangeTab).toHaveBeenCalledWith({
- i: 1,
- ref: expect.anything(),
- });
- });
-
- it('covers targetIndex validation and bounds checking', () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabsRef = React.createRef();
-
- render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
- ,
- );
-
- // Act - Try to navigate to out-of-bounds indices
- act(() => {
- tabsRef.current?.goToTabIndex(-1); // Negative index
- tabsRef.current?.goToTabIndex(99); // Index beyond array length
- });
-
- // Assert - No navigation should occur for invalid indices
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
- });
-
- describe('Lazy Loading and Swipe Functionality', () => {
- it('loads active tab immediately and others on-demand when accessed', async () => {
- // Arrange
- const tabs = [
- { label: 'Active', content: 'Active Content' },
- { label: 'Background', content: 'Background Content' },
- { label: 'Disabled', content: 'Disabled Content' },
- ];
-
- // Act
- const { getByText, queryByText, getAllByText } = render(
-
-
- {tabs[0].content}
-
-
- {tabs[1].content}
-
-
- {tabs[2].content}
-
- ,
- );
-
- // Assert - Active tab loads immediately
- expect(getByText('Active Content')).toBeOnTheScreen();
-
- // Other tabs should not be loaded yet (on-demand loading)
- expect(queryByText('Background Content')).toBeNull();
-
- // When user clicks the background tab, it should load
- fireEvent.press(getAllByText('Background')[0]);
-
- // Wait for the delayed loading to complete
- await waitFor(() => {
- expect(getByText('Background Content')).toBeOnTheScreen();
- });
- // Disabled tab should not be loaded
- expect(queryByText('Disabled Content')).toBeNull();
- });
-
- it('handles horizontal scroll view for swipeable content', () => {
- // Arrange
- const tabs = [
- { label: 'Tab 1', content: 'Content 1' },
- { label: 'Tab 2', content: 'Content 2' },
- ];
-
- // Act
- const { getByText, getByTestId } = render(
-
- {tabs.map((tab, index) => (
-
- {tab.content}
-
- ))}
- ,
- );
-
- // Assert - Component renders with ScrollView structure
- const tabsList = getByTestId('swipeable-tabs');
- expect(tabsList).toBeOnTheScreen();
- expect(getByText('Content 1')).toBeOnTheScreen();
- });
-
- it('handles scroll events to change active tab', async () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabs = [
- { label: 'Tab 1', content: 'Content 1' },
- { label: 'Tab 2', content: 'Content 2' },
- ];
-
- // Act
- const { getByTestId } = render(
-
- {tabs.map((tab, index) => (
-
- {tab.content}
-
- ))}
- ,
- );
-
- const scrollView = getByTestId('scroll-tabs');
-
- // Simulate scroll to second tab
- await act(async () => {
- fireEvent.scroll(scrollView, {
- nativeEvent: {
- contentOffset: { x: 400, y: 0 }, // Assuming 400px width per tab
- },
- });
- });
-
- // Assert - Should trigger tab change
- // Note: Scroll event simulation in tests doesn't work the same as real scrolling
- // This test would pass in actual app usage but fails in test environment
- // expect(mockOnChangeTab).toHaveBeenCalledWith({
- // i: 1,
- // ref: expect.anything(),
- // });
- });
-
- it('maintains individual tab heights without constraint', () => {
- // Arrange
- const tabs = [
- { label: 'Short', content: 'Short' },
- {
- label: 'Tall',
- content:
- 'Very tall content that should not be constrained by other tabs',
- },
- ];
-
- // Act
- const { getAllByText } = render(
-
- {tabs.map((tab, index) => (
-
- {tab.content}
-
- ))}
- ,
- );
-
- // Assert - Each tab content should render with its natural height
- expect(getAllByText('Short')[0]).toBeOnTheScreen(); // Use getAllByText to handle multiple matches
- // The component should not enforce a fixed height constraint
- });
-
- it('skips disabled tabs during swipe navigation', async () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabs = [
- { label: 'Tab 1', content: 'Content 1' },
- { label: 'Tab 2', content: 'Content 2', disabled: true },
- { label: 'Tab 3', content: 'Content 3' },
- ];
-
- // Act
- const { getByTestId } = render(
-
-
- {tabs[0].content}
-
-
- {tabs[1].content}
-
-
- {tabs[2].content}
-
- ,
- );
-
- const scrollView = getByTestId('skip-disabled-tabs');
-
- // Simulate scroll to third tab (skipping disabled second tab)
- await act(async () => {
- fireEvent.scroll(scrollView, {
- nativeEvent: {
- contentOffset: { x: 800, y: 0 }, // Scroll to third tab position
- },
- });
- });
-
- // Assert - Should navigate to third tab, skipping disabled second tab
- // Note: Scroll event simulation in tests doesn't work the same as real scrolling
- // expect(mockOnChangeTab).toHaveBeenCalledWith({
- // i: 2,
- // ref: expect.anything(),
- // });
- });
-
- it('handles container width changes for responsive behavior', () => {
- // Arrange
- const tabs = [
- { label: 'Tab 1', content: 'Content 1' },
- { label: 'Tab 2', content: 'Content 2' },
- ];
-
- // Act
- const { getByTestId } = render(
-
- {tabs.map((tab, index) => (
-
- {tab.content}
-
- ))}
- ,
- );
-
- const tabsList = getByTestId('responsive-tabs');
-
- // Simulate layout change
- act(() => {
- fireEvent(tabsList, 'layout', {
- nativeEvent: {
- layout: { width: 500, height: 300 },
- },
- });
- });
-
- // Assert - Component should handle layout changes gracefully
- expect(tabsList).toBeOnTheScreen();
- });
-
- it('loads tabs on demand when accessed via swipe', async () => {
- // Arrange
- const mockOnChangeTab = jest.fn();
- const tabs = [
- { label: 'Tab 1', content: 'Content 1' },
- { label: 'Tab 2', content: 'Content 2' },
- ];
-
- // Act
- const { getByText, getByTestId } = render(
-
- {tabs.map((tab, index) => (
-
- {tab.content}
-
- ))}
- ,
- );
-
- // Assert initial state
- expect(getByText('Content 1')).toBeOnTheScreen();
-
- const scrollView = getByTestId('on-demand-tabs');
-
- // Simulate swipe to second tab
- await act(async () => {
- fireEvent.scroll(scrollView, {
- nativeEvent: {
- contentOffset: { x: 400, y: 0 },
- },
- });
- });
-
- // Assert - Second tab should be loaded and callback triggered
- // Note: Scroll event simulation in tests doesn't work the same as real scrolling
- // expect(mockOnChangeTab).toHaveBeenCalledWith({
- // i: 1,
- // ref: expect.anything(),
- // });
- });
- });
-
- describe('Children Processing Coverage', () => {
- it('covers children array processing and validation', () => {
- // Arrange - Multiple React elements for array processing
- const mockOnChangeTab = jest.fn();
-
- const { getByText } = render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
- ,
- );
-
- // Assert - Should handle children array processing gracefully
- expect(getByText('Tab 1 Content')).toBeOnTheScreen();
- });
-
- it('covers horizontal vs vertical gesture detection', () => {
- // Arrange - Test that vertical gestures don't interfere with scrolling
- const mockOnChangeTab = jest.fn();
-
- render(
-
-
- Tab 1 Content
-
-
- Tab 2 Content
-
- ,
- );
-
- // Assert - This test verifies the gesture configuration exists
- // The actual gesture behavior is tested through the pan gesture setup
- // which now includes activeOffsetX and failOffsetY for proper gesture handling
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('covers missing tabLabel prop handling', () => {
- // Arrange - Children without tabLabel prop
- const { getByText } = render(
-
-
- Content 1
-
-
- Content 2
-
- ,
- );
-
- // Assert - Should generate default labels and render first tab
- expect(getByText('Content 1')).toBeOnTheScreen();
- });
-
- it('covers missing key prop handling', () => {
- // Arrange - Children without key prop
- const { getByText } = render(
-
-
- No Key Content
-
- ,
- );
-
- // Assert - Should generate default key and render
- expect(getByText('No Key Content')).toBeOnTheScreen();
- });
- });
-
- describe('Tab State Management Edge Cases', () => {
- it('handles tab key preservation when tabs change', () => {
- const mockOnChangeTab = jest.fn();
- const { rerender } = render(
-
-
- Content 1
-
-
- Content 2
-
-
- Content 3
-
- ,
- );
-
- // Change tabs but keep the same key for active tab
- rerender(
-
-
- New Content 1
-
-
- Content 2
-
-
- Content 4
-
- ,
- );
-
- // Should handle tab structure changes gracefully - no onChangeTab call expected during prop changes
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('handles fallback when current tab becomes disabled', () => {
- const mockOnChangeTab = jest.fn();
- const { rerender } = render(
-
-
- Content 1
-
-
- Content 2
-
-
- Content 3
-
- ,
- );
-
- // Disable the currently active tab
- rerender(
-
-
- Content 1
-
-
- Content 2
-
-
- Content 3
-
- ,
- );
-
- // Should handle disabled tab gracefully - no onChangeTab call expected during prop changes
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('handles fallback to initialActiveIndex when current becomes invalid', () => {
- const mockOnChangeTab = jest.fn();
- const { rerender } = render(
-
-
- Content 1
-
-
- Content 2
-
-
- Content 3
-
- ,
- );
-
- // Remove tabs to make current activeIndex invalid
- rerender(
-
-
- Content 2
-
- ,
- );
-
- // Should handle tab removal gracefully - no onChangeTab call expected during prop changes
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('finds first enabled tab when initialActiveIndex is disabled', () => {
- const mockOnChangeTab = jest.fn();
- render(
-
-
- Content 1
-
-
- Content 2
-
-
- Content 3
-
- ,
- );
-
- // Component should handle disabled initial tab - onChangeTab not called during initialization
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('handles case when no enabled tabs exist', () => {
- const mockOnChangeTab = jest.fn();
- render(
-
-
- Content 1
-
-
- Content 2
-
-
- Content 3
-
- ,
- );
-
- // Should handle all disabled tabs gracefully - no onChangeTab call during initialization
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
- });
-
- describe('Scroll Event Handling', () => {
- it('handles scroll events with zero container width', () => {
- const mockOnChangeTab = jest.fn();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Simulate scroll event with zero container width
- const scrollEvent = {
- nativeEvent: {
- contentOffset: { x: 100, y: 0 },
- contentSize: { width: 400, height: 300 },
- layoutMeasurement: { width: 0, height: 300 },
- },
- };
-
- fireEvent.scroll(scrollView, scrollEvent);
-
- // Should handle zero container width gracefully
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('handles programmatic scroll flag correctly', () => {
- const mockOnChangeTab = jest.fn();
- const ref = React.createRef();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Trigger programmatic scroll via ref
- act(() => {
- ref.current?.goToTabIndex(1);
- });
-
- // Simulate scroll event during programmatic scroll
- const scrollEvent = {
- nativeEvent: {
- contentOffset: { x: 200, y: 0 },
- contentSize: { width: 400, height: 300 },
- layoutMeasurement: { width: 400, height: 300 },
- },
- };
-
- fireEvent.scroll(scrollView, scrollEvent);
-
- // Should ignore scroll events during programmatic scroll
- // The onChangeTab call should only be from the programmatic scroll, not the scroll event
- expect(mockOnChangeTab).toHaveBeenCalledTimes(1);
- });
-
- it('handles scroll begin events correctly', () => {
- const mockOnChangeTab = jest.fn();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Simulate scroll begin
- fireEvent(scrollView, 'onScrollBeginDrag');
-
- // Should handle scroll begin without errors
- expect(scrollView).toBeOnTheScreen();
- });
-
- it('handles scroll end events correctly', () => {
- const mockOnChangeTab = jest.fn();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Simulate scroll end
- fireEvent(scrollView, 'onScrollEndDrag');
- fireEvent(scrollView, 'onMomentumScrollEnd');
-
- // Should handle scroll end without errors
- expect(scrollView).toBeOnTheScreen();
- });
-
- it('clears scroll timeout on new scroll begin', () => {
- const mockOnChangeTab = jest.fn();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Start scroll, then immediately start another
- fireEvent(scrollView, 'onScrollBeginDrag');
- fireEvent(scrollView, 'onScrollEndDrag');
- fireEvent(scrollView, 'onScrollBeginDrag'); // Should clear previous timeout
-
- // Should handle timeout clearing gracefully
- expect(scrollView).toBeOnTheScreen();
- });
- });
-
- describe('Layout Handling', () => {
- it('handles layout changes correctly', () => {
- const mockOnChangeTab = jest.fn();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Simulate layout change
- const layoutEvent = {
- nativeEvent: {
- layout: { x: 0, y: 0, width: 400, height: 300 },
- },
- };
-
- fireEvent(scrollView, 'onLayout', layoutEvent);
-
- // Should update container width
- expect(scrollView).toBeOnTheScreen();
- });
-
- it('handles multiple layout changes', () => {
- const mockOnChangeTab = jest.fn();
- const { getByTestId } = render(
-
- Content 1
- Content 2
- ,
- );
-
- const scrollView = getByTestId('tabs-list-content');
-
- // Simulate multiple layout changes
- const layoutEvent1 = {
- nativeEvent: {
- layout: { x: 0, y: 0, width: 300, height: 300 },
- },
- };
-
- const layoutEvent2 = {
- nativeEvent: {
- layout: { x: 0, y: 0, width: 500, height: 300 },
- },
- };
-
- fireEvent(scrollView, 'onLayout', layoutEvent1);
- fireEvent(scrollView, 'onLayout', layoutEvent2);
-
- // Should handle multiple layout changes
- expect(scrollView).toBeOnTheScreen();
- });
- });
-
- describe('Ref Method Edge Cases', () => {
- it('handles goToTabIndex with invalid indices', () => {
- const ref = React.createRef();
- const mockOnChangeTab = jest.fn();
- render(
-
- Content 1
- Content 2
- ,
- );
-
- // Try invalid indices
- act(() => {
- ref.current?.goToTabIndex(-1);
- ref.current?.goToTabIndex(10);
- });
-
- // Should handle invalid indices gracefully
- expect(mockOnChangeTab).not.toHaveBeenCalled();
- });
-
- it('handles goToTabIndex with disabled tab', () => {
- const ref = React.createRef();
- const mockOnChangeTab = jest.fn();
- render(
-
- Content 1
-
- Content 2
-
- ,
- );
-
- // Try to go to disabled tab
- act(() => {
- ref.current?.goToTabIndex(1);
- });
-
- // Should handle disabled tab gracefully
- expect(mockOnChangeTab).not.toHaveBeenCalled();
+ // Assert - Should show the tab at initialActiveIndex
+ expect(getByText('Tab 3 Content')).toBeOnTheScreen();
+ expect(ref.current?.getCurrentIndex()).toBe(2);
});
});
});
diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx
index e85cae59aaca..9449c08e4e98 100644
--- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx
+++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx
@@ -1,4 +1,3 @@
-// Third party dependencies.
import React, {
useState,
useEffect,
@@ -8,18 +7,12 @@ import React, {
useMemo,
useRef,
} from 'react';
-import {
- ScrollView,
- Dimensions,
- NativeScrollEvent,
- NativeSyntheticEvent,
-} from 'react-native';
-// External dependencies.
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { Box } from '@metamask/design-system-react-native';
+import { GestureDetector, Gesture } from 'react-native-gesture-handler';
+import { runOnJS } from 'react-native-reanimated';
+import { InteractionManager } from 'react-native';
-// Internal dependencies.
import TabsBar from '../TabsBar';
import { TabsListProps, TabsListRef, TabItem } from './TabsList.types';
@@ -36,21 +29,10 @@ const TabsList = forwardRef(
},
ref,
) => {
- const tw = useTailwind();
const [activeIndex, setActiveIndex] = useState(initialActiveIndex);
- const [containerWidth, setContainerWidth] = useState(
- Dimensions.get('window').width,
- );
const [loadedTabs, setLoadedTabs] = useState>(new Set());
- const scrollViewRef = useRef(null);
- const isScrolling = useRef(false);
- const isProgrammaticScroll = useRef(false);
- const scrollTimeout = useRef(null);
- const loadTabTimeout = useRef(null);
- const programmaticScrollTimeout = useRef(null);
- const goToTabTimeout = useRef(null);
+ const interactionHandleRef = useRef<{ cancel: () => void } | null>(null);
- // Extract tab items from children
const tabs: TabItem[] = useMemo(
() =>
React.Children.map(children, (child, index) => {
@@ -80,100 +62,42 @@ const TabsList = forwardRef(
[children],
);
- // Create a separate array of only enabled tabs for ScrollView content
- const enabledTabs = useMemo(
- () =>
- tabs
- .map((tab, index) => ({ ...tab, originalIndex: index }))
- .filter((tab) => !tab.isDisabled),
- [tabs],
- );
-
- // Create mapping functions between tab index and content index
- const getContentIndexFromTabIndex = useCallback(
- (tabIndex: number): number => {
- if (
- tabIndex < 0 ||
- tabIndex >= tabs.length ||
- tabs[tabIndex]?.isDisabled
- ) {
- return -1;
- }
- return enabledTabs.findIndex(
- (enabledTab) => enabledTab.originalIndex === tabIndex,
- );
- },
- [tabs, enabledTabs],
- );
-
- const getTabIndexFromContentIndex = useCallback(
- (contentIndex: number): number => {
- if (contentIndex < 0 || contentIndex >= enabledTabs.length) {
- return -1;
+ // Cache only the actively viewed tab (no preloading of adjacent tabs)
+ // Use InteractionManager to defer content loading until after animations complete
+ useEffect(() => {
+ if (activeIndex >= 0 && activeIndex < tabs.length) {
+ if (interactionHandleRef.current) {
+ interactionHandleRef.current.cancel();
}
- return enabledTabs[contentIndex]?.originalIndex ?? -1;
- },
- [enabledTabs],
- );
- // Check if there are any enabled tabs and if current active tab is enabled
- const hasAnyEnabledTabs = useMemo(
- () => tabs.some((tab) => !tab.isDisabled),
- [tabs],
- );
+ const isAlreadyLoaded = loadedTabs.has(activeIndex);
- const shouldShowContent = useMemo(() => {
- // Don't show any content if all tabs are disabled
- if (!hasAnyEnabledTabs) return false;
- // Don't show content if active tab is disabled
- if (activeIndex < 0 || activeIndex >= tabs.length) return false;
- return !tabs[activeIndex]?.isDisabled;
- }, [hasAnyEnabledTabs, activeIndex, tabs]);
+ if (isAlreadyLoaded) {
+ return;
+ }
- // Load tab content on-demand when tab becomes active for the first time
- useEffect(() => {
- if (activeIndex >= 0 && activeIndex < tabs.length) {
- setLoadedTabs((prev) => {
- // Only update if the tab isn't already loaded
- if (!prev.has(activeIndex)) {
- return new Set(prev).add(activeIndex);
- }
- return prev;
+ const handle = InteractionManager.runAfterInteractions(() => {
+ setLoadedTabs((prev) => {
+ const newLoadedTabs = new Set(prev);
+ newLoadedTabs.add(activeIndex);
+ return newLoadedTabs.size !== prev.size ? newLoadedTabs : prev;
+ });
});
+
+ interactionHandleRef.current = handle;
}
- }, [activeIndex, tabs.length]);
- // Cleanup effect to clear all timers on unmount
- useEffect(
- () => () => {
- if (scrollTimeout.current) {
- clearTimeout(scrollTimeout.current);
- scrollTimeout.current = null;
+ return () => {
+ if (interactionHandleRef.current) {
+ interactionHandleRef.current.cancel();
}
- if (loadTabTimeout.current) {
- clearTimeout(loadTabTimeout.current);
- loadTabTimeout.current = null;
- }
- if (programmaticScrollTimeout.current) {
- clearTimeout(programmaticScrollTimeout.current);
- programmaticScrollTimeout.current = null;
- }
- if (goToTabTimeout.current) {
- clearTimeout(goToTabTimeout.current);
- goToTabTimeout.current = null;
- }
- },
- [],
- );
+ };
+ }, [activeIndex, tabs.length, loadedTabs]);
- // Update active index when initialActiveIndex or tabs change
useEffect(() => {
- // Store the current active tab key for preservation
const currentActiveTabKey = tabs[activeIndex]?.key;
- // First, try to preserve the current active tab by key when tabs array changes
if (currentActiveTabKey && tabs.length > 0) {
- // Try to find the current active tab by key in the new tabs array
const newIndexForCurrentTab = tabs.findIndex(
(tab) => tab.key === currentActiveTabKey,
);
@@ -182,46 +106,28 @@ const TabsList = forwardRef(
!tabs[newIndexForCurrentTab].isDisabled &&
newIndexForCurrentTab !== activeIndex
) {
- // Preserve the current selection if the tab still exists and is enabled
setActiveIndex(newIndexForCurrentTab);
return;
}
}
- // Fallback: When current tab is no longer available, try to keep current index if valid
if (
activeIndex >= 0 &&
activeIndex < tabs.length &&
!tabs[activeIndex]?.isDisabled
) {
- // Current activeIndex is still valid, keep it
return;
}
- // If current activeIndex is invalid, fall back to initialActiveIndex or first enabled tab
const targetTab = tabs[initialActiveIndex];
if (targetTab && !targetTab.isDisabled) {
setActiveIndex(initialActiveIndex);
} else {
- // Find first enabled tab
const firstEnabledIndex = tabs.findIndex((tab) => !tab.isDisabled);
setActiveIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : -1);
}
}, [initialActiveIndex, tabs, activeIndex]);
- // Scroll to active tab when activeIndex changes
- useEffect(() => {
- if (scrollViewRef.current && containerWidth > 0) {
- const contentIndex = getContentIndexFromTabIndex(activeIndex);
- if (contentIndex >= 0) {
- scrollViewRef.current.scrollTo({
- x: contentIndex * containerWidth,
- animated: !isScrolling.current, // Don't animate if user is currently scrolling
- });
- }
- }
- }, [activeIndex, containerWidth, getContentIndexFromTabIndex]);
-
const handleTabPress = useCallback(
(tabIndex: number) => {
if (
@@ -232,197 +138,78 @@ const TabsList = forwardRef(
return;
}
- // Get the content index for this tab
- const contentIndex = getContentIndexFromTabIndex(tabIndex);
- if (contentIndex < 0) return;
-
- // Only update state and call callback if the tab actually changed
const tabChanged = tabIndex !== activeIndex;
- // Update activeIndex immediately for TabsBar animation
setActiveIndex(tabIndex);
- // Ensure the tab is loaded
- if (!loadedTabs.has(tabIndex)) {
- // Synchronous updates for tests
- if (process.env.JEST_WORKER_ID) {
- setLoadedTabs((prev) => new Set(prev).add(tabIndex));
- } else {
- if (loadTabTimeout.current) {
- clearTimeout(loadTabTimeout.current);
- }
- loadTabTimeout.current = setTimeout(() => {
- setLoadedTabs((prev) => new Set(prev).add(tabIndex));
- loadTabTimeout.current = null;
- }, 10); // Brief delay for smooth loading
- }
- }
-
- // Mark as programmatic scroll
- isProgrammaticScroll.current = true;
-
- // Scroll to the content index, not the tab index
- if (scrollViewRef.current && containerWidth > 0) {
- scrollViewRef.current.scrollTo({
- x: contentIndex * containerWidth,
- animated: true,
- });
+ if (
+ (process.env.JEST_WORKER_ID || process.env.E2E) &&
+ !loadedTabs.has(tabIndex)
+ ) {
+ setLoadedTabs((prev) => new Set(prev).add(tabIndex));
}
- // Only call onChangeTab if the tab actually changed
if (onChangeTab && tabChanged) {
onChangeTab({
i: tabIndex,
ref: tabs[tabIndex]?.content || null,
});
}
-
- // Reset programmatic scroll flag
- if (programmaticScrollTimeout.current) {
- clearTimeout(programmaticScrollTimeout.current);
- }
- programmaticScrollTimeout.current = setTimeout(() => {
- isProgrammaticScroll.current = false;
- programmaticScrollTimeout.current = null;
- }, 400);
},
- [
- activeIndex,
- tabs,
- onChangeTab,
- containerWidth,
- getContentIndexFromTabIndex,
- loadedTabs,
- ],
+ [activeIndex, tabs, onChangeTab, loadedTabs],
);
- const handleScroll = useCallback(
- (scrollEvent: NativeSyntheticEvent) => {
- if (isProgrammaticScroll.current) return;
-
- const { contentOffset } = scrollEvent.nativeEvent;
- if (containerWidth <= 0) return;
-
- // Calculate which content index we're at
- const contentIndex = Math.round(contentOffset.x / containerWidth);
-
- // Convert content index back to tab index
- const newTabIndex = getTabIndexFromContentIndex(contentIndex);
-
- if (newTabIndex >= 0 && newTabIndex !== activeIndex) {
- // Update activeIndex immediately to trigger TabsBar animation alongside content scroll
- // This matches the behavior of tab clicks
- setActiveIndex(newTabIndex);
- setLoadedTabs((prev) => new Set(prev).add(newTabIndex));
-
- if (onChangeTab) {
- onChangeTab({
- i: newTabIndex,
- ref: tabs[newTabIndex]?.content || null,
- });
- }
+ const goToPreviousTab = useCallback(() => {
+ // Iterate backwards to find the next enabled tab
+ for (let i = activeIndex - 1; i >= 0; i--) {
+ if (!tabs[i]?.isDisabled) {
+ handleTabPress(i);
+ return;
}
- },
- [
- activeIndex,
- containerWidth,
- onChangeTab,
- tabs,
- getTabIndexFromContentIndex,
- ],
- );
-
- const handleScrollBegin = useCallback(() => {
- // Clear any existing timeout
- if (scrollTimeout.current) {
- clearTimeout(scrollTimeout.current);
}
+ }, [activeIndex, tabs, handleTabPress]);
- // Only mark as user scroll if it's not programmatic
- if (!isProgrammaticScroll.current) {
- isScrolling.current = true;
+ const goToNextTab = useCallback(() => {
+ // Iterate forwards to find the next enabled tab
+ for (let i = activeIndex + 1; i < tabs.length; i++) {
+ if (!tabs[i]?.isDisabled) {
+ handleTabPress(i);
+ return;
+ }
}
- }, []);
-
- const handleScrollEnd = useCallback(() => {
- // Reset scrolling flag
- scrollTimeout.current = setTimeout(() => {
- isScrolling.current = false;
- }, 150);
- }, []);
+ }, [activeIndex, tabs, handleTabPress]);
- const handleLayout = useCallback(
- (layoutEvent: { nativeEvent: { layout: { width: number } } }) => {
- const { width } = layoutEvent.nativeEvent.layout;
- setContainerWidth(width);
- },
- [],
+ const swipeGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .activeOffsetX([-50, 50])
+ .failOffsetY([-15, 15])
+ .maxPointers(1)
+ .onEnd((gestureEvent) => {
+ 'worklet';
+ const { translationX, velocityX } = gestureEvent;
+
+ // Match ScrollView paging behavior with lower thresholds for natural feel
+ if (Math.abs(translationX) > 50 || Math.abs(velocityX) > 500) {
+ if (translationX > 0) {
+ runOnJS(goToPreviousTab)();
+ } else if (translationX < 0) {
+ runOnJS(goToNextTab)();
+ }
+ }
+ }),
+ [goToPreviousTab, goToNextTab],
);
- // Expose methods via ref
useImperativeHandle(
ref,
() => ({
goToTabIndex: (tabIndex: number) => {
- if (
- tabIndex < 0 ||
- tabIndex >= tabs.length ||
- tabs[tabIndex]?.isDisabled
- ) {
- return;
- }
-
- const contentIndex = getContentIndexFromTabIndex(tabIndex);
- if (contentIndex < 0) return;
-
- // Only update state and call callback if the tab actually changed
- const tabChanged = tabIndex !== activeIndex;
-
- // Update activeIndex immediately for TabsBar animation
- setActiveIndex(tabIndex);
-
- // Ensure the tab is loaded
- if (!loadedTabs.has(tabIndex)) {
- setLoadedTabs((prev) => new Set(prev).add(tabIndex));
- }
-
- // Mark as programmatic scroll
- isProgrammaticScroll.current = true;
-
- if (scrollViewRef.current && containerWidth > 0) {
- scrollViewRef.current.scrollTo({
- x: contentIndex * containerWidth,
- animated: true,
- });
- }
-
- // Only call onChangeTab if the tab actually changed
- if (onChangeTab && tabChanged) {
- onChangeTab({
- i: tabIndex,
- ref: tabs[tabIndex]?.content || null,
- });
- }
-
- // Reset programmatic scroll flag
- if (goToTabTimeout.current) {
- clearTimeout(goToTabTimeout.current);
- }
- goToTabTimeout.current = setTimeout(() => {
- isProgrammaticScroll.current = false;
- goToTabTimeout.current = null;
- }, 400);
+ handleTabPress(tabIndex);
},
getCurrentIndex: () => activeIndex,
}),
- [
- activeIndex,
- tabs,
- onChangeTab,
- containerWidth,
- getContentIndexFromTabIndex,
- loadedTabs,
- ],
+ [activeIndex, handleTabPress],
);
const tabBarPropsComputed = useMemo(
@@ -438,43 +225,31 @@ const TabsList = forwardRef(
return (
- {/* Render TabsBar */}
- {/* Horizontal ScrollView for tab contents */}
-
- {enabledTabs.map((enabledTab) => (
-
- {loadedTabs.has(enabledTab.originalIndex) && shouldShowContent
- ? enabledTab.content
- : null}
-
- ))}
-
+
+
+ {tabs.map((tab, index) => {
+ const isActive = index === activeIndex;
+ const isLoaded = loadedTabs.has(index);
+
+ if (!isLoaded) return null;
+
+ return (
+
+ {tab.content}
+
+ );
+ })}
+
+
);
},
diff --git a/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap b/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap
index e11054f6e10e..5e5cfa951c54 100644
--- a/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap
+++ b/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`TabsList handles empty children gracefully 1`] = `
+exports[`TabsList Edge Cases handles non-React element children with default values 1`] = `
+ >
+
+
+
+ Tab 1
+
+
+ Tab 1
+
+
+
+
+
+
+ Tab 2
+
+
+ Tab 2
+
+
+
+
-
+
+
+
+ Tab 1 Content
+
+
+
+
+
+`;
+
+exports[`TabsList handles empty children gracefully 1`] = `
+
+
-
-
+
+
+
`;
@@ -324,89 +626,62 @@ exports[`TabsList passes BoxProps to underlying Box component 1`] = `
-
-
+
-
-
- Tab 1
- Content
-
-
+ Tab 1
+ Content
+
-
-
+
`;
@@ -764,105 +1039,61 @@ exports[`TabsList renders correctly with multiple tabs 1`] = `
-
-
+
-
-
- Tab 1
- Content
-
-
+ Tab 1
+ Content
+
-
-
-
+
`;
diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap
index b3ce36d57db8..f094f0109ad2 100644
--- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap
+++ b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap
@@ -8,7 +8,6 @@ exports[`AssetElement should render correctly 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx
index a5cf7e096f42..6a3a15eb925c 100644
--- a/app/components/UI/AssetElement/index.tsx
+++ b/app/components/UI/AssetElement/index.tsx
@@ -40,7 +40,6 @@ interface AssetElementProps {
const createStyles = (colors: Colors) =>
StyleSheet.create({
itemWrapper: {
- flex: 1,
flexDirection: 'row',
height: 64,
alignItems: 'center',
diff --git a/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap
index 4d73eab508b0..892cedb76123 100644
--- a/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap
+++ b/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap
@@ -31,7 +31,6 @@ exports[`Balance should render correctly with main and secondary balance 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -268,7 +267,6 @@ exports[`Balance should render correctly without a secondary balance 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx
index 2cf3d679286b..7c4a85e19533 100644
--- a/app/components/UI/Bridge/Views/BridgeView/index.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx
@@ -205,6 +205,7 @@ const BridgeView = () => {
});
const isSubmitDisabled =
+ isLoading ||
hasInsufficientBalance ||
isSubmittingTx ||
(isHardwareAddress && isSolanaSourced) ||
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts
index 912dbe62b145..6eea4d028f58 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts
@@ -205,14 +205,27 @@ export const useBridgeQuoteData = ({
bridgeFeatureFlags.priceImpactThreshold.normal)),
);
+ const abortController = useRef(new AbortController());
+ useEffect(
+ () => () => {
+ abortController.current?.abort();
+ abortController.current = null;
+ },
+ [],
+ );
+
const validateQuote = useCallback(async () => {
// Increment validation ID for this request
const validationId = ++currentValidationIdRef.current;
+ // Cancel any ongoing request
+ abortController.current?.abort();
+ abortController.current = new AbortController();
if (activeQuote && (isSolanaSwap || isSolanaToNonSolana)) {
try {
const validationResult = await validateBridgeTx({
quoteResponse: activeQuote,
+ signal: abortController.current?.signal,
});
// Check if this is still the current validation after async operation
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts
index a8d3ddace78b..0e53507e4c55 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts
@@ -656,6 +656,7 @@ describe('useBridgeQuoteData', () => {
expect(mockValidateBridgeTx).toHaveBeenCalledWith({
quoteResponse: mockQuote,
+ signal: expect.any(AbortSignal),
});
});
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts
index f4e0e12f9992..ca09395bf106 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts
@@ -17,7 +17,7 @@ import { useUnifiedSwapBridgeContext } from '../useUnifiedSwapBridgeContext';
import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController';
import { selectSourceWalletAddress } from '../../../../../selectors/bridge';
-export const DEBOUNCE_WAIT = 700;
+export const DEBOUNCE_WAIT = 300;
/**
* Hook for handling bridge quote request updates
diff --git a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap
index 2f79c643c135..e3226a12b4c0 100644
--- a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap
+++ b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap
@@ -319,7 +319,6 @@ exports[`CardAssetItem Component handles test network correctly 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -852,7 +851,6 @@ exports[`CardAssetItem Component renders non-native token and matches snapshot 1
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -1329,7 +1327,6 @@ exports[`CardAssetItem Component renders with all props and matches snapshot 1`]
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -1801,7 +1798,6 @@ exports[`CardAssetItem Component renders with privacy mode enabled and matches s
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -2273,7 +2269,6 @@ exports[`CardAssetItem Component renders with required props and matches snapsho
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts b/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts
index 017c1a83db21..24173dbaf414 100644
--- a/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts
+++ b/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts
@@ -49,15 +49,16 @@ const styleSheet = (params: {
borderRadius: 12,
},
textContainer: {
+ flex: 1,
alignItems: 'center',
- justifyContent: 'flex-start',
+ justifyContent: 'center',
backgroundColor: colors.background.section,
borderRadius: 8,
},
textWrapper: {
- flex: 1,
textAlign: 'center',
- marginTop: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
},
textWrapperIcon: {
fontSize: 18,
diff --git a/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap b/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap
index 114197694019..85bb3f2eb2f4 100644
--- a/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap
+++ b/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap
@@ -116,7 +116,8 @@ exports[`CollectibleModal should render correctly 1`] = `
"alignItems": "center",
"backgroundColor": "#f3f5f9",
"borderRadius": 8,
- "justifyContent": "flex-start",
+ "flex": 1,
+ "justifyContent": "center",
},
{
"borderRadius": 12,
diff --git a/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap b/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap
index 1c39ce3a1b42..8df3c85ba4a5 100644
--- a/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap
+++ b/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap
@@ -73,7 +73,6 @@ exports[`Collectibles should render correctly collectibles 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -110,7 +109,8 @@ exports[`Collectibles should render correctly collectibles 1`] = `
"alignItems": "center",
"backgroundColor": "#f3f5f9",
"borderRadius": 8,
- "justifyContent": "flex-start",
+ "flex": 1,
+ "justifyContent": "center",
},
undefined,
undefined,
diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
index b5ae74d45c42..8fcd492461da 100644
--- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
+++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
@@ -5,6 +5,7 @@ const createStyles = () =>
// custom network styles
container: {
flex: 1,
+ paddingVertical: 12,
},
scrollContentContainer: {
paddingBottom: 100,
diff --git a/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx
index 748dd18967e6..7f5de8520ba0 100644
--- a/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx
+++ b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx
@@ -10,6 +10,10 @@ jest.mock('../../../util/networks', () => ({
isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false),
}));
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn().mockReturnValue('1.0.0'),
+}));
+
jest.mock('../../../selectors/defiPositionsController', () => ({
...jest.requireActual('../../../selectors/defiPositionsController'),
selectDeFiPositionsByAddress: jest.fn(),
@@ -214,13 +218,14 @@ describe('DeFiPositionsList', () => {
expect(
await findByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER),
).toBeOnTheScreen();
- expect(await findByText('Protocol 1')).toBeOnTheScreen();
- expect(await findByText('$100.00')).toBeOnTheScreen();
- const flatList = await findByTestId(
+ const listContainer = await findByTestId(
WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
);
- expect(flatList.props.data.length).toEqual(1);
+ expect(listContainer).toBeOnTheScreen();
+
+ expect(await findByText('Protocol 1')).toBeOnTheScreen();
+ expect(await findByText('$100.00')).toBeOnTheScreen();
});
it('renders protocol name and aggregated value for all chains when all networks is selected', async () => {
@@ -254,14 +259,16 @@ describe('DeFiPositionsList', () => {
expect(
await findByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_NETWORK_FILTER),
).toBeOnTheScreen();
+
+ const listContainer = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
+ );
+ expect(listContainer).toBeOnTheScreen();
+
expect(await findByText('Protocol 1')).toBeOnTheScreen();
expect(await findByText('Protocol 2')).toBeOnTheScreen();
expect(await findByText('$100.00')).toBeOnTheScreen();
expect(await findByText('$10.00')).toBeOnTheScreen();
- const flatList = await findByTestId(
- WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
- );
- expect(flatList.props.data.length).toEqual(2);
});
it('renders the loading positions message when positions are not yet available', async () => {
@@ -414,14 +421,13 @@ describe('DeFiPositionsList', () => {
await findByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER),
).toBeOnTheScreen();
- // Should show the filtered protocol name
- expect(await findByText('Protocol 1 (Filtered)')).toBeOnTheScreen();
- expect(await findByText('$100.00')).toBeOnTheScreen();
-
- const flatList = await findByTestId(
+ const listContainer = await findByTestId(
WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
);
- expect(flatList.props.data.length).toEqual(1);
+ expect(listContainer).toBeOnTheScreen();
+
+ expect(await findByText('Protocol 1 (Filtered)')).toBeOnTheScreen();
+ expect(await findByText('$100.00')).toBeOnTheScreen();
});
it('shows no positions when defiPositionsByEnabledNetworks returns empty data', async () => {
@@ -531,13 +537,161 @@ describe('DeFiPositionsList', () => {
),
).toBeOnTheScreen();
+ const listContainer = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
+ );
+ expect(listContainer).toBeOnTheScreen();
+
expect(await findByText('Protocol 1')).toBeOnTheScreen();
expect(await findByText('$100.00')).toBeOnTheScreen();
+ });
+ });
- const flatList = await findByTestId(
+ describe('Homepage Redesign V1 Feature', () => {
+ it('removes scrolling container in favour of global scroll container when isHomepageRedesignV1Enabled is true', async () => {
+ const { findByTestId, queryByTestId } = renderWithProvider(
+ ,
+ {
+ state: {
+ ...mockInitialState,
+ engine: {
+ ...mockInitialState.engine,
+ backgroundState: {
+ ...mockInitialState.engine.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ },
+ );
+
+ const container = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER,
+ );
+ expect(container).toBeOnTheScreen();
+
+ const listContainer = await findByTestId(
WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
);
- expect(flatList.props.data.length).toEqual(1);
+ expect(listContainer).toBeOnTheScreen();
+
+ const scrollView = queryByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_SCROLL_VIEW,
+ );
+ expect(scrollView).toBeNull();
+ });
+
+ it('renders empty state without scroll container when isHomepageRedesignV1Enabled is true', async () => {
+ const defiPositionsModule = jest.requireMock(
+ '../../../selectors/defiPositionsController',
+ );
+ defiPositionsModule.selectDeFiPositionsByAddress.mockReturnValue({});
+ defiPositionsModule.selectDefiPositionsByEnabledNetworks.mockReturnValue(
+ {},
+ );
+
+ const { findByTestId } = renderWithProvider(
+ ,
+ {
+ state: {
+ ...mockInitialState,
+ engine: {
+ ...mockInitialState.engine,
+ backgroundState: {
+ ...mockInitialState.engine.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ },
+ );
+
+ const container = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER,
+ );
+ expect(container).toBeOnTheScreen();
+ });
+
+ it('renders multiple positions without scroll container when isHomepageRedesignV1Enabled is true', async () => {
+ const { findByTestId, findByText, queryByTestId } = renderWithProvider(
+ ,
+ {
+ state: {
+ ...mockInitialState,
+ engine: {
+ ...mockInitialState.engine,
+ backgroundState: {
+ ...mockInitialState.engine.backgroundState,
+ PreferencesController: {
+ ...mockInitialState.engine.backgroundState
+ .PreferencesController,
+ tokenNetworkFilter: {
+ [MOCK_CHAIN_ID_1]: true,
+ [MOCK_CHAIN_ID_2]: true,
+ },
+ },
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ },
+ );
+
+ const listContainer = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
+ );
+ expect(listContainer).toBeOnTheScreen();
+
+ expect(await findByText('Protocol 1')).toBeOnTheScreen();
+ expect(await findByText('Protocol 2')).toBeOnTheScreen();
+
+ const scrollView = queryByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_SCROLL_VIEW,
+ );
+ expect(scrollView).toBeNull();
+ });
+
+ it('renders scroll container when isHomepageRedesignV1Enabled is false', async () => {
+ const { findByTestId } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ const listContainer = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_LIST,
+ );
+ expect(listContainer).toBeOnTheScreen();
+
+ const scrollView = await findByTestId(
+ WalletViewSelectorsIDs.DEFI_POSITIONS_SCROLL_VIEW,
+ );
+ expect(scrollView).toBeOnTheScreen();
});
});
});
diff --git a/app/components/UI/DeFiPositions/DeFiPositionsList.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.tsx
index c26907c15415..b53213e29ec6 100644
--- a/app/components/UI/DeFiPositions/DeFiPositionsList.tsx
+++ b/app/components/UI/DeFiPositions/DeFiPositionsList.tsx
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
-import { View, FlatList } from 'react-native';
+import { View } from 'react-native';
import { strings } from '../../../../locales/i18n';
import { useSelector } from 'react-redux';
import {
@@ -34,6 +34,9 @@ import { useStyles } from '../../hooks/useStyles';
import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors';
import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks';
import { DefiEmptyState } from '../DefiEmptyState';
+import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage';
+import ConditionalScrollView from '../../../component-library/components-temp/ConditionalScrollView';
+
export interface DeFiPositionsListProps {
tabLabel: string;
}
@@ -48,6 +51,9 @@ const DeFiPositionsList: React.FC = () => {
selectDefiPositionsByEnabledNetworks,
);
const privacyMode = useSelector(selectPrivacyMode);
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
const formattedDeFiPositions = useMemo(() => {
if (!defiPositions) {
@@ -132,29 +138,40 @@ const DeFiPositionsList: React.FC = () => {
}
}
- return (
-
-
- (
+ const content = (
+
+ {formattedDeFiPositions.map(
+ ({ chainId, protocolId, protocolAggregate }) => (
- )}
- keyExtractor={(protocolChainAggregate) =>
- `${protocolChainAggregate.chainId}-${protocolChainAggregate.protocolAggregate.protocolDetails.name}`
- }
- scrollEnabled
- ListEmptyComponent={}
- />
+ ),
+ )}
+
+ );
+
+ return (
+
+
+ {formattedDeFiPositions.length > 0 ? (
+
+ {content}
+
+ ) : (
+
+ )}
);
};
diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap
index 21a277a75162..5d30af5b4ff6 100644
--- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap
+++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap
@@ -261,7 +261,6 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
diff --git a/app/components/UI/NftGrid/NftGrid.test.tsx b/app/components/UI/NftGrid/NftGrid.test.tsx
index 2723837f28a8..25b6db7dfaab 100644
--- a/app/components/UI/NftGrid/NftGrid.test.tsx
+++ b/app/components/UI/NftGrid/NftGrid.test.tsx
@@ -75,12 +75,19 @@ jest.mock('@shopify/flash-list', () => ({
return (
{ListHeaderComponent}
- {data && data.length > 0
- ? data.map((item: unknown, index: number) => (
+ {data && data.length > 0 ? (
+ <>
+ {data.map((item: unknown, index: number) => (
{renderItem({ item, index })}
- ))
- : ListEmptyComponent}
- {ListFooterComponent}
+ ))}
+ {ListFooterComponent}
+ >
+ ) : (
+ <>
+ {ListEmptyComponent}
+ {ListFooterComponent}
+ >
+ )}
);
},
@@ -100,6 +107,10 @@ jest.mock('./NftGridHeader', () => {
);
});
+jest.mock('./NftGridSkeleton', () => {
+ const { View } = jest.requireActual('react-native');
+ return () => ;
+});
// Mock CollectiblesEmptyState - has complex dependencies
jest.mock('../CollectiblesEmptyState', () => ({
@@ -160,6 +171,7 @@ jest.mock('../CollectibleMedia', () => () => null);
jest.mock('@metamask/design-system-react-native', () => ({
Text: ({ children }: { children: React.ReactNode }) => children,
TextVariant: { BodyMd: 'BodyMd', BodySm: 'BodySm' },
+ FontWeight: { Medium: 'Medium' },
Box: ({
children,
testID,
@@ -260,7 +272,16 @@ jest.mock('../../../util/trace', () => ({
// Mock useTailwind
jest.mock('@metamask/design-system-twrnc-preset', () => ({
- useTailwind: () => (className: string) => ({ className }),
+ useTailwind: () => {
+ const styleFunc = (className: string | string[]) => {
+ if (Array.isArray(className)) {
+ return className.reduce((acc, cls) => ({ ...acc, [cls]: true }), {});
+ }
+ return { [className]: true };
+ };
+ styleFunc.style = styleFunc;
+ return styleFunc;
+ },
}));
describe('NftGrid', () => {
@@ -293,6 +314,31 @@ describe('NftGrid', () => {
const mockCollectibles = { '0x1': [mockNft] };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
+ .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
+ const store = mockStore(initialState);
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen();
+ expect(getByTestId('nft-grid-header')).toBeOnTheScreen();
+ });
+ });
+
+ it('renders NFT grid directly without FlashList when homepage redesign is enabled', async () => {
+ const mockCollectibles = { '0x1': [mockNft] };
+ mockUseSelector
+ .mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -316,6 +362,7 @@ describe('NftGrid', () => {
const mockCollectibles = { '0x1': [mockNft] };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -339,6 +386,7 @@ describe('NftGrid', () => {
const mockCollectibles = { '0x1': [mockNft] };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -358,18 +406,22 @@ describe('NftGrid', () => {
});
});
- it('shows view all button when maxItems is exceeded', async () => {
+ it('shows view all button when homepage redesign is enabled and NFT count exceeds limit', async () => {
const mockCollectibles = {
- '0x1': [mockNft, { ...mockNft, tokenId: '789' }],
+ '0x1': Array.from({ length: 20 }, (_, i) => ({
+ ...mockNft,
+ tokenId: `${i}`,
+ })),
};
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18)
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
const { getByTestId } = render(
-
+
,
);
@@ -382,16 +434,22 @@ describe('NftGrid', () => {
});
});
- it('hides view all button when maxItems is not exceeded', async () => {
- const mockCollectibles = { '0x1': [mockNft] };
+ it('hides view all button when homepage redesign is disabled', async () => {
+ const mockCollectibles = {
+ '0x1': Array.from({ length: 20 }, (_, i) => ({
+ ...mockNft,
+ tokenId: `${i}`,
+ })),
+ };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled (maxItems = undefined)
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
const { queryByTestId } = render(
-
+
,
);
@@ -413,6 +471,7 @@ describe('NftGrid', () => {
};
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -468,6 +527,7 @@ describe('NftGrid', () => {
const mockCollectibles = { '0x1': [mockNft] };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -496,6 +556,7 @@ describe('NftGrid', () => {
const mockCollectibles = { '0x1': [nftWithoutName] };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -514,10 +575,11 @@ describe('NftGrid', () => {
});
});
- it('shows spinner in footer when NFTs are being fetched', async () => {
+ it('renders NFT items when not fetching without homepage redesign', async () => {
const mockCollectibles = { '0x1': [mockNft] };
mockUseSelector
- .mockReturnValueOnce(true) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -532,7 +594,31 @@ describe('NftGrid', () => {
});
await waitFor(() => {
- expect(getByTestId('collectible-contracts-spinner')).toBeOnTheScreen();
+ expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen();
+ expect(getByTestId('nft-grid-header')).toBeOnTheScreen();
+ });
+ });
+
+ it('shows empty state when not fetching with homepage redesign enabled and no collectibles', async () => {
+ const mockCollectibles = { '0x1': [] };
+ mockUseSelector
+ .mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled
+ .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
+ const store = mockStore(initialState);
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('collectibles-empty-state')).toBeOnTheScreen();
});
});
@@ -540,6 +626,7 @@ describe('NftGrid', () => {
const mockCollectibles = { '0x1': [mockNft] };
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -561,6 +648,7 @@ describe('NftGrid', () => {
it('shows empty state when no collectibles and not fetching', async () => {
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce({}); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -579,9 +667,10 @@ describe('NftGrid', () => {
});
});
- it('hides empty state when fetching NFTs', async () => {
+ it('hides empty state when fetching NFTs without homepage redesign', async () => {
mockUseSelector
.mockReturnValueOnce(true) // isNftFetchingProgress
+ .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled
.mockReturnValueOnce({}); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
@@ -600,18 +689,46 @@ describe('NftGrid', () => {
});
});
+ it('renders NFT items when not fetching with homepage redesign enabled', async () => {
+ const mockCollectibles = { '0x1': [mockNft] };
+ mockUseSelector
+ .mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled
+ .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
+ const store = mockStore(initialState);
+
+ const { getByTestId, queryByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen();
+ expect(queryByTestId('collectibles-empty-state')).toBeNull();
+ });
+ });
+
it('navigates to full view when view all button is pressed', async () => {
const mockCollectibles = {
- '0x1': [mockNft, { ...mockNft, tokenId: '789' }],
+ '0x1': Array.from({ length: 20 }, (_, i) => ({
+ ...mockNft,
+ tokenId: `${i}`,
+ })),
};
mockUseSelector
.mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18)
.mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
const store = mockStore(initialState);
const { getByTestId } = render(
-
+
,
);
@@ -626,4 +743,76 @@ describe('NftGrid', () => {
expect(mockNavigate).toHaveBeenCalledWith('NftFullView');
});
+
+ it('limits NFTs to 18 when homepage redesign is enabled and not full view', async () => {
+ const mockCollectibles = {
+ '0x1': Array.from({ length: 25 }, (_, i) => ({
+ ...mockNft,
+ tokenId: `${i}`,
+ name: `NFT ${i}`,
+ })),
+ };
+ mockUseSelector
+ .mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18)
+ .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
+ const store = mockStore(initialState);
+
+ const { getByTestId, queryByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ // Should render first 18 NFTs
+ expect(getByTestId('collectible-NFT 0-0')).toBeOnTheScreen();
+ expect(getByTestId('collectible-NFT 17-17')).toBeOnTheScreen();
+
+ // Should NOT render NFTs beyond 18
+ expect(queryByTestId('collectible-NFT 18-18')).toBeNull();
+ expect(queryByTestId('collectible-NFT 24-24')).toBeNull();
+
+ // View all button should be present
+ expect(getByTestId('view-all-nfts-button')).toBeOnTheScreen();
+ });
+ });
+
+ it('does not limit NFTs when full view is enabled', async () => {
+ const mockCollectibles = {
+ '0x1': Array.from({ length: 25 }, (_, i) => ({
+ ...mockNft,
+ tokenId: `${i}`,
+ name: `NFT ${i}`,
+ })),
+ };
+ mockUseSelector
+ .mockReturnValueOnce(false) // isNftFetchingProgress
+ .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled
+ .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector
+ const store = mockStore(initialState);
+
+ const { getByTestId, queryByTestId } = render(
+
+
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ // Should render all NFTs when full view
+ expect(getByTestId('collectible-NFT 0-0')).toBeOnTheScreen();
+ expect(getByTestId('collectible-NFT 24-24')).toBeOnTheScreen();
+
+ // View all button should NOT be present in full view
+ expect(queryByTestId('view-all-nfts-button')).toBeNull();
+ });
+ });
});
diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx
index 7c52a9ec5ed0..724be654f224 100644
--- a/app/components/UI/NftGrid/NftGrid.tsx
+++ b/app/components/UI/NftGrid/NftGrid.tsx
@@ -5,9 +5,9 @@ import React, {
useEffect,
useCallback,
} from 'react';
-import { FlashList, FlashListProps } from '@shopify/flash-list';
+import { FlashList } from '@shopify/flash-list';
import { useSelector } from 'react-redux';
-import { RefreshTestId, SpinnerTestId } from './constants';
+import { RefreshTestId } from './constants';
import { endTrace, trace, TraceName } from '../../../util/trace';
import { Nft } from '@metamask/assets-controllers';
import {
@@ -19,12 +19,12 @@ import NftGridItem from './NftGridItem';
import ActionSheet from '@metamask/react-native-actionsheet';
import NftGridItemActionSheet from './NftGridItemActionSheet';
import NftGridHeader from './NftGridHeader';
+import NftGridSkeleton from './NftGridSkeleton';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics';
import { CollectiblesEmptyState } from '../CollectiblesEmptyState';
import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors';
-import { ActivityIndicator } from 'react-native';
import {
Box,
Button,
@@ -38,6 +38,7 @@ import ButtonIcon, {
} from '../../../component-library/components/Buttons/ButtonIcon';
import { IconName } from '../../../component-library/components/Icons/Icon';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage';
interface NFTNavigationParamList {
AddAsset: { assetType: string };
@@ -45,8 +46,6 @@ interface NFTNavigationParamList {
}
interface NftGridProps {
- flashListProps?: Partial>;
- maxItems?: number;
isFullView?: boolean;
}
@@ -57,7 +56,7 @@ const NftRow = ({
items: Nft[];
onLongPress: (nft: Nft) => void;
}) => (
-
+
{items.map((item, index) => {
// Create a truly unique key combining multiple identifiers
const uniqueKey = `${item.address}-${item.tokenId}-${item.chainId}-${index}`;
@@ -75,11 +74,7 @@ const NftRow = ({
);
-const NftGrid = ({
- flashListProps,
- maxItems,
- isFullView = false,
-}: NftGridProps) => {
+const NftGrid = ({ isFullView = false }: NftGridProps) => {
const navigation =
useNavigation>();
const { trackEvent, createEventBuilder } = useMetrics();
@@ -89,6 +84,9 @@ const NftGrid = ({
const tw = useTailwind();
const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector);
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
const actionSheetRef = useRef();
@@ -106,6 +104,13 @@ const NftGrid = ({
return owned;
}, [collectiblesByEnabledNetworks]);
+ const maxItems = useMemo(() => {
+ if (isFullView) {
+ return undefined;
+ }
+ return isHomepageRedesignV1Enabled ? 18 : undefined;
+ }, [isFullView, isHomepageRedesignV1Enabled]);
+
const groupedCollectibles: Nft[][] = useMemo(() => {
const groups: Nft[][] = [];
const itemsToProcess = maxItems
@@ -133,50 +138,25 @@ const NftGrid = ({
setIsAddNFTEnabled(true);
}, [navigation, trackEvent, createEventBuilder]);
- const additionalButtons = (
-
- );
-
const handleViewAllNfts = useCallback(() => {
navigation.navigate(Routes.WALLET.NFTS_FULL_VIEW);
}, [navigation]);
- // Determine if we should show the "View all NFTs" button
- const shouldShowViewAllButton =
- maxItems && allFilteredCollectibles.length > maxItems;
-
- // Default flashListProps for full view
- const defaultFullViewProps = useMemo(
- () => ({
- contentContainerStyle: tw`px-4`,
- scrollEnabled: true,
- }),
- [tw],
- );
-
- // Merge default props with passed props
- const mergedFlashListProps = useMemo(() => {
- if (isFullView) {
- return { ...defaultFullViewProps, ...flashListProps };
- }
- return flashListProps;
- }, [isFullView, defaultFullViewProps, flashListProps]);
-
- return (
- <>
-
+ const nftRowList =
+ !isFullView && isHomepageRedesignV1Enabled ? (
+
+
+
+ {groupedCollectibles.map((items, index) => (
+
+ ))}
+
+
+ ) : (
}
data={groupedCollectibles}
@@ -187,36 +167,44 @@ const NftGrid = ({
testID={RefreshTestId}
decelerationRate="fast"
refreshControl={}
- ListEmptyComponent={
- !isNftFetchingProgress ? (
-
- ) : null
- }
- ListFooterComponent={
- <>
- {isNftFetchingProgress && (
-
- )}
- >
- }
- {...mergedFlashListProps}
+ contentContainerStyle={!isFullView ? undefined : tw`px-4`}
/>
+ );
-
+
+ }
+ hideSort
+ style={isFullView ? tw`px-4 pb-4` : tw`pb-3`}
/>
-
+ {isNftFetchingProgress ? (
+
+ ) : allFilteredCollectibles.length > 0 ? (
+ nftRowList
+ ) : (
+
+ )}
{/* View all NFTs button - shown when there are more items than maxItems */}
- {shouldShowViewAllButton && (
+ {maxItems && allFilteredCollectibles.length > maxItems && (
)}
+
>
);
};
diff --git a/app/components/UI/NftGrid/NftGridItem.tsx b/app/components/UI/NftGrid/NftGridItem.tsx
index 6b7fa4bdc2b3..7a08b6ae94f6 100644
--- a/app/components/UI/NftGrid/NftGridItem.tsx
+++ b/app/components/UI/NftGrid/NftGridItem.tsx
@@ -2,22 +2,16 @@ import React, { useCallback } from 'react';
import { Nft } from '@metamask/assets-controllers';
import { debounce } from 'lodash';
import { useNavigation } from '@react-navigation/native';
-import { StyleSheet, TouchableOpacity, View } from 'react-native';
-import { Text, TextVariant } from '@metamask/design-system-react-native';
+import { Pressable } from 'react-native';
+import {
+ Box,
+ Text,
+ TextVariant,
+ FontWeight,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import CollectibleMedia from '../CollectibleMedia';
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- collectible: {
- aspectRatio: 1,
- },
- collectibleIcon: {
- aspectRatio: 1,
- },
-});
-
const debouncedNavigation = debounce((navigation, collectible) => {
navigation.navigate('NftDetails', { collectible });
}, 0);
@@ -30,28 +24,29 @@ const NftGridItem = ({
onLongPress: (nft: Nft) => void;
}) => {
const navigation = useNavigation();
+ const tw = useTailwind();
const onPress = useCallback(() => {
debouncedNavigation(navigation, item);
}, [navigation, item]);
return (
-
- onLongPress(item)}
- testID={`collectible-${item.name}-${item.tokenId}`}
- >
+ onLongPress(item)}
+ testID={`collectible-${item.name}-${item.tokenId}`}
+ >
+
-
-
+
@@ -61,12 +56,13 @@ const NftGridItem = ({
{/* TODO: check if is better to use collection name from nft contract? */}
{item.collection?.name}
-
+
);
};
diff --git a/app/components/UI/NftGrid/NftGridSkeleton.test.tsx b/app/components/UI/NftGrid/NftGridSkeleton.test.tsx
new file mode 100644
index 000000000000..28398865e583
--- /dev/null
+++ b/app/components/UI/NftGrid/NftGridSkeleton.test.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import NftGridSkeleton from './NftGridSkeleton';
+
+// Mock the theme hook
+jest.mock('../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ background: {
+ section: '#f3f5f9',
+ subsection: '#ffffff',
+ },
+ },
+ }),
+}));
+
+// Mock the tailwind hook
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: () => ({
+ style: jest.fn((...args) => {
+ if (Array.isArray(args[0])) {
+ return args[0].join(' ');
+ }
+ return args.join(' ');
+ }),
+ }),
+}));
+
+describe('NftGridSkeleton', () => {
+ it('renders without errors', () => {
+ const { root } = render();
+
+ expect(root).toBeDefined();
+ });
+});
diff --git a/app/components/UI/NftGrid/NftGridSkeleton.tsx b/app/components/UI/NftGrid/NftGridSkeleton.tsx
new file mode 100644
index 000000000000..ddde4667ad0f
--- /dev/null
+++ b/app/components/UI/NftGrid/NftGridSkeleton.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { View } from 'react-native';
+import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
+import { useTheme } from '../../../util/theme';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+
+const NftGridSkeleton = () => {
+ const { colors } = useTheme();
+ const tw = useTailwind();
+
+ return (
+
+
+
+ {Array.from({ length: 18 }, (_, index) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default NftGridSkeleton;
diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts
index 1468372c3b55..96959c519983 100644
--- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts
+++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts
@@ -16,9 +16,6 @@ const styleSheet = (params: { theme: Theme }) => {
content: {
flex: 1,
},
- contentContainer: {
- flexGrow: 1,
- },
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx
index 93e0b875222e..2beadf591ac5 100644
--- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx
@@ -45,6 +45,11 @@ jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({
})),
}));
+// Mock homepage redesign selector
+jest.mock('../../../../../selectors/featureFlagController/homepage', () => ({
+ selectHomepageRedesignV1Enabled: jest.fn(),
+}));
+
// Mock PerpsConnectionProvider
jest.mock('../../providers/PerpsConnectionProvider', () => ({
PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) =>
@@ -149,6 +154,7 @@ jest.mock('../../components/PerpsTabControlBar', () => ({
jest.mock('../../../../../../e2e/selectors/Perps/Perps.selectors', () => ({
PerpsTabViewSelectorsIDs: {
START_NEW_TRADE_CTA: 'perps-tab-view-start-new-trade-cta',
+ SCROLL_VIEW: 'perps-tab-scroll-view',
},
PerpsPositionsViewSelectorsIDs: {
POSITIONS_SECTION_TITLE: 'perps-positions-section-title',
@@ -156,6 +162,11 @@ jest.mock('../../../../../../e2e/selectors/Perps/Perps.selectors', () => ({
},
}));
+// Import after mock to use the mocked values
+const { PerpsTabViewSelectorsIDs } = jest.requireMock(
+ '../../../../../../e2e/selectors/Perps/Perps.selectors',
+);
+
jest.mock('../../components/PerpsBottomSheetTooltip', () => ({
__esModule: true,
default: ({ onClose, testID }: { onClose: () => void; testID?: string }) => {
@@ -205,6 +216,17 @@ describe('PerpsTabView', () => {
jest.requireMock('../../hooks').usePerpsFirstTimeUser;
const mockUsePerpsAccount = jest.requireMock('../../hooks').usePerpsAccount;
+ // Mock selectors
+ const mockSelectPerpsEligibility = jest.requireMock(
+ '../../selectors/perpsController',
+ ).selectPerpsEligibility;
+ const mockSelectHomepageRedesignV1Enabled = jest.requireMock(
+ '../../../../../selectors/featureFlagController/homepage',
+ ).selectHomepageRedesignV1Enabled;
+ const mockSelectSelectedInternalAccountByScope = jest.requireMock(
+ '../../../../../selectors/multichainAccounts/accounts',
+ ).selectSelectedInternalAccountByScope;
+
const mockPosition: Position = {
coin: 'ETH',
size: '2.5',
@@ -261,16 +283,15 @@ describe('PerpsTabView', () => {
mockUsePerpsAccount.mockReturnValue(null);
- // Default eligibility mock
- const mockSelectPerpsEligibility = jest.requireMock(
- '../../selectors/perpsController',
- ).selectPerpsEligibility;
+ // Setup selector mocks
(useSelector as jest.Mock).mockImplementation((selector: unknown) => {
if (selector === mockSelectPerpsEligibility) {
return true;
}
- // Handle the multichain selector
- if (typeof selector === 'function') {
+ if (selector === mockSelectHomepageRedesignV1Enabled) {
+ return false; // Default: V1 disabled
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
return () => ({
address: '0x1234567890123456789012345678901234567890',
id: 'mock-account-id',
@@ -634,14 +655,14 @@ describe('PerpsTabView', () => {
});
describe('Accessibility', () => {
- it('should have proper accessibility for manage balance button', () => {
+ it('has proper accessibility for manage balance button', () => {
render();
const manageBalanceButton = screen.getByTestId('manage-balance-button');
expect(manageBalanceButton).toBeOnTheScreen();
});
- it('should render text with proper variants and colors', () => {
+ it('renders positions section title when positions exist', () => {
mockUsePerpsLivePositions.mockReturnValue({
positions: [mockPosition],
isInitialLoading: false,
@@ -654,6 +675,104 @@ describe('PerpsTabView', () => {
).toBeOnTheScreen();
});
});
+
+ describe('Homepage Redesign V1 Feature', () => {
+ it('renders content without ScrollView when isHomepageRedesignV1Enabled is true', () => {
+ (useSelector as jest.Mock).mockImplementation((selector: unknown) => {
+ if (selector === mockSelectPerpsEligibility) {
+ return true;
+ }
+ if (selector === mockSelectHomepageRedesignV1Enabled) {
+ return true;
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
+ return () => ({
+ address: '0x1234567890123456789012345678901234567890',
+ id: 'mock-account-id',
+ type: 'eip155:eoa',
+ });
+ }
+ return undefined;
+ });
+
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [mockPosition],
+ isInitialLoading: false,
+ });
+
+ render();
+
+ expect(
+ screen.queryByTestId(PerpsTabViewSelectorsIDs.SCROLL_VIEW),
+ ).toBeNull();
+ expect(
+ screen.getByText(strings('perps.position.title')),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders content with ScrollView when isHomepageRedesignV1Enabled is false', () => {
+ (useSelector as jest.Mock).mockImplementation((selector: unknown) => {
+ if (selector === mockSelectPerpsEligibility) {
+ return true;
+ }
+ if (selector === mockSelectHomepageRedesignV1Enabled) {
+ return false;
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
+ return () => ({
+ address: '0x1234567890123456789012345678901234567890',
+ id: 'mock-account-id',
+ type: 'eip155:eoa',
+ });
+ }
+ return undefined;
+ });
+
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [mockPosition],
+ isInitialLoading: false,
+ });
+
+ render();
+
+ expect(
+ screen.getByTestId(PerpsTabViewSelectorsIDs.SCROLL_VIEW),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByText(strings('perps.position.title')),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays empty state when homepage redesign is enabled and no positions or orders', () => {
+ (useSelector as jest.Mock).mockImplementation((selector: unknown) => {
+ if (selector === mockSelectPerpsEligibility) {
+ return true;
+ }
+ if (selector === mockSelectHomepageRedesignV1Enabled) {
+ return true;
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
+ return () => ({
+ address: '0x1234567890123456789012345678901234567890',
+ id: 'mock-account-id',
+ type: 'eip155:eoa',
+ });
+ }
+ return undefined;
+ });
+
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [],
+ isInitialLoading: false,
+ });
+
+ mockUsePerpsLiveOrders.mockReturnValue({ orders: [] });
+
+ render();
+
+ expect(screen.getByTestId('perps-empty-state')).toBeOnTheScreen();
+ });
+ });
});
// Tests for PerpsTabViewWithProvider wrapper component
diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx
index 2bff1bcc2da1..b2b4d868f11e 100644
--- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx
+++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx
@@ -1,6 +1,6 @@
import { useNavigation, type NavigationProp } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
-import { Modal, ScrollView, TouchableOpacity, View } from 'react-native';
+import { Modal, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
PerpsPositionsViewSelectorsIDs,
@@ -23,6 +23,8 @@ import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip';
import PerpsCard from '../../components/PerpsCard';
import { PerpsTabControlBar } from '../../components/PerpsTabControlBar';
+import { useSelector } from 'react-redux';
+import { selectHomepageRedesignV1Enabled } from '../../../../../selectors/featureFlagController/homepage';
import {
PerpsEventProperties,
PerpsEventValues,
@@ -40,6 +42,8 @@ import styleSheet from './PerpsTabView.styles';
import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton';
import { PerpsEmptyState } from '../PerpsEmptyState';
+import ConditionalScrollView from '../../../../../component-library/components-temp/ConditionalScrollView';
+
interface PerpsTabViewProps {}
const PerpsTabView: React.FC = () => {
@@ -49,6 +53,9 @@ const PerpsTabView: React.FC = () => {
const navigation = useNavigation>();
const { account } = usePerpsLiveAccount();
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
const { positions, isInitialLoading } = usePerpsLivePositions({
throttleMs: 1000, // Update positions every second
@@ -227,29 +234,39 @@ const PerpsTabView: React.FC = () => {
};
return (
-
+
<>
-
-
- {!isInitialLoading && hasNoPositionsOrOrders ? (
-
- ) : (
-
- {renderPositionsSection()}
- {renderOrdersSection()}
-
- )}
-
-
+
+ {!isInitialLoading && hasNoPositionsOrOrders ? (
+
+ ) : (
+
+ {renderPositionsSection()}
+ {renderOrdersSection()}
+
+ )}
+
{isEligibilityModalVisible && (
// Android Compatibility: Wrap the in a plain component to prevent rendering issues and freezing.
diff --git a/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.test.tsx b/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.test.tsx
index db6690924dea..d5497a685e14 100644
--- a/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.test.tsx
+++ b/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.test.tsx
@@ -15,6 +15,13 @@ jest.mock('../../../../../util/theme', () => ({
}),
}));
+// Mock the tailwind hook
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: () => ({
+ style: jest.fn((className) => ({ className })),
+ }),
+}));
+
// Mock the i18n strings
jest.mock('../../../../../../locales/i18n', () => ({
strings: (key: string) => {
@@ -34,6 +41,17 @@ jest.mock('../../hooks/usePerpsConnection', () => ({
}),
}));
+// Mock react-redux
+const mockUseSelector = jest.fn();
+jest.mock('react-redux', () => ({
+ useSelector: (selector: unknown) => mockUseSelector(selector),
+}));
+
+// Mock the homepage redesign selector
+jest.mock('../../../../../selectors/featureFlagController/homepage', () => ({
+ selectHomepageRedesignV1Enabled: jest.fn(),
+}));
+
// Mock the design system components
jest.mock('@metamask/design-system-react-native', () => {
const {
@@ -111,11 +129,13 @@ describe('PerpsLoadingSkeleton', () => {
beforeEach(() => {
jest.useFakeTimers();
mockReconnect.mockClear();
+ mockUseSelector.mockReturnValue(false);
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
+ mockUseSelector.mockClear();
});
it('displays loading spinner initially', () => {
diff --git a/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.tsx b/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.tsx
index a59ef22abc1e..19c229ff633d 100644
--- a/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.tsx
+++ b/app/components/UI/Perps/components/PerpsLoadingSkeleton/PerpsLoadingSkeleton.tsx
@@ -15,6 +15,8 @@ import { useTheme } from '../../../../../util/theme';
import { strings } from '../../../../../../locales/i18n';
import { PERPS_CONSTANTS } from '../../constants/perpsConfig';
import { usePerpsConnection } from '../../hooks/usePerpsConnection';
+import { useSelector } from 'react-redux';
+import { selectHomepageRedesignV1Enabled } from '../../../../../selectors/featureFlagController/homepage';
interface PerpsLoadingSkeletonProps {
testID?: string;
@@ -32,6 +34,9 @@ const PerpsLoadingSkeleton: React.FC = ({
}) => {
const { colors } = useTheme();
const { reconnectWithNewContext } = usePerpsConnection();
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
const [showTimeout, setShowTimeout] = useState(false);
// Set timeout to show retry option after CONNECTION_TIMEOUT_MS
@@ -62,7 +67,11 @@ const PerpsLoadingSkeleton: React.FC = ({
return (
diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts
index 97922dffc917..1998bddc1a21 100644
--- a/app/components/UI/Perps/services/PerpsConnectionManager.ts
+++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts
@@ -260,9 +260,6 @@ class PerpsConnectionManagerClass {
// Clean up preloaded subscriptions
this.cleanupPreloadedSubscriptions();
- // Clean up state monitoring when leaving Perps
- this.cleanupStateMonitoring();
-
// Reset state before disconnecting to prevent race conditions
this.isConnected = false;
this.isInitialized = false;
@@ -286,9 +283,6 @@ class PerpsConnectionManagerClass {
})();
await this.disconnectPromise;
- } else {
- // Even if not connected, clean up monitoring when leaving Perps
- this.cleanupStateMonitoring();
}
} else {
DevLogger.log(
@@ -819,9 +813,6 @@ class PerpsConnectionManagerClass {
'PerpsConnectionManager: Starting grace period before disconnection',
);
this.scheduleGracePeriodDisconnection();
- } else {
- // Even if not connected, clean up monitoring when leaving Perps
- this.cleanupStateMonitoring();
}
}
}
diff --git a/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.styles.ts b/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.styles.ts
index 63abbb089043..b64353499a11 100644
--- a/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.styles.ts
+++ b/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.styles.ts
@@ -3,7 +3,6 @@ import { StyleSheet } from 'react-native';
const styleSheet = () =>
StyleSheet.create({
emptyState: {
- flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
diff --git a/app/components/UI/Predict/components/PredictPositions/PredictPositions.test.tsx b/app/components/UI/Predict/components/PredictPositions/PredictPositions.test.tsx
index f4f3e560b27b..fca599e12eb1 100644
--- a/app/components/UI/Predict/components/PredictPositions/PredictPositions.test.tsx
+++ b/app/components/UI/Predict/components/PredictPositions/PredictPositions.test.tsx
@@ -49,6 +49,10 @@ jest.mock('@shopify/flash-list', () => {
jest.mock('../../hooks/usePredictPositions');
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn().mockReturnValue('1.0.0'),
+}));
+
const mockOnPress = jest.fn();
jest.mock('../PredictPosition/PredictPosition', () => {
const ReactNative = jest.requireActual('react-native');
@@ -527,4 +531,390 @@ describe('PredictPositions', () => {
).toBeOnTheScreen();
});
});
+
+ describe('Homepage Redesign V1 Features', () => {
+ const mockLoadPositions = jest.fn();
+ const mockLoadClaimablePositions = jest.fn();
+
+ it('calculates correct activePositionsHeight when isHomepageRedesignV1Enabled is true', () => {
+ const positions = [createMockPosition(), createMockPosition()];
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions,
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByTestId('predict-active-positions-list'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calculates correct claimablePositionsHeight when isHomepageRedesignV1Enabled is true', () => {
+ const claimablePosition = createClaimablePosition();
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [claimablePosition],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByTestId('predict-claimable-positions-list'),
+ ).toBeOnTheScreen();
+ });
+
+ it('returns undefined activePositionsHeight when positions are empty', () => {
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(screen.getByTestId('predict-position-empty')).toBeOnTheScreen();
+ });
+
+ it('does not calculate fixed heights when isHomepageRedesignV1Enabled is false', () => {
+ const position = createMockPosition();
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [position],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByTestId('predict-active-positions-list'),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('Loading State Styling', () => {
+ const mockLoadPositions = jest.fn();
+ const mockLoadClaimablePositions = jest.fn();
+
+ it('applies correct styles for loading state when isHomepageRedesignV1Enabled is true', () => {
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: true,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(screen.getByTestId('activity-indicator')).toBeOnTheScreen();
+ });
+
+ it('applies correct styles for loading state when isHomepageRedesignV1Enabled is false', () => {
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: true,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(screen.getByTestId('activity-indicator')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Fixed Height Wrapping', () => {
+ const mockLoadPositions = jest.fn();
+ const mockLoadClaimablePositions = jest.fn();
+
+ it('wraps active positions FlashList with fixed height when homepage redesign is enabled', () => {
+ const positions = [
+ createMockPosition(),
+ createMockPosition({ id: '2' }),
+ createMockPosition({ id: '3' }),
+ ];
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions,
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByTestId('predict-active-positions-list'),
+ ).toBeOnTheScreen();
+ });
+
+ it('wraps claimable positions FlashList with fixed height when homepage redesign is enabled', () => {
+ const claimablePositions = [
+ createClaimablePosition(),
+ createClaimablePosition({ id: '4' }),
+ ];
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: claimablePositions,
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByTestId('predict-claimable-positions-list'),
+ ).toBeOnTheScreen();
+ });
+
+ it('does not wrap FlashLists when homepage redesign is disabled', () => {
+ const position = createMockPosition();
+ const claimablePosition = createClaimablePosition();
+ mockUsePredictPositions
+ .mockReturnValueOnce({
+ positions: [position],
+ isRefreshing: false,
+ loadPositions: mockLoadPositions,
+ isLoading: false,
+ error: null,
+ })
+ .mockReturnValueOnce({
+ positions: [claimablePosition],
+ isRefreshing: false,
+ loadPositions: mockLoadClaimablePositions,
+ isLoading: false,
+ error: null,
+ });
+
+ renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByTestId('predict-active-positions-list'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId('predict-claimable-positions-list'),
+ ).toBeOnTheScreen();
+ });
+ });
});
diff --git a/app/components/UI/Predict/components/PredictPositions/PredictPositions.tsx b/app/components/UI/Predict/components/PredictPositions/PredictPositions.tsx
index 23d87318fb8c..1f535f8d15cf 100644
--- a/app/components/UI/Predict/components/PredictPositions/PredictPositions.tsx
+++ b/app/components/UI/Predict/components/PredictPositions/PredictPositions.tsx
@@ -3,17 +3,17 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
- useRef,
} from 'react';
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { NavigationProp, useNavigation } from '@react-navigation/native';
-import { FlashList, FlashListRef } from '@shopify/flash-list';
import { ActivityIndicator, View } from 'react-native';
+import { useSelector } from 'react-redux';
import { strings } from '../../../../../../locales/i18n';
import { IconColor } from '../../../../../component-library/components/Icons/Icon';
import Routes from '../../../../../constants/navigation/Routes';
+import { selectHomepageRedesignV1Enabled } from '../../../../../selectors/featureFlagController/homepage';
import Engine from '../../../../../core/Engine';
import { usePredictPositions } from '../../hooks/usePredictPositions';
import { PredictPosition as PredictPositionType } from '../../types';
@@ -44,6 +44,9 @@ const PredictPositions = forwardRef<
const tw = useTailwind();
const navigation =
useNavigation>();
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
const { positions, isRefreshing, loadPositions, isLoading, error } =
usePredictPositions({
loadOnMount: true,
@@ -58,7 +61,6 @@ const PredictPositions = forwardRef<
loadOnMount: true,
refreshOnFocus: true,
});
- const listRef = useRef>(null);
// Notify parent of errors while keeping state isolated
useEffect(() => {
@@ -124,8 +126,18 @@ const PredictPositions = forwardRef<
if (isLoading || (isRefreshing && positions.length === 0)) {
return (
-
-
+
+
- `${item.outcomeId}:${item.outcomeIndex}`}
- removeClippedSubviews
- decelerationRate={0}
- ListEmptyComponent={isTrulyEmpty ? : null}
- ListFooterComponent={isTrulyEmpty ? null : }
- />
+
+ {isTrulyEmpty ? (
+
+ ) : (
+ <>
+ {positions.map((item) => (
+
+ {renderPosition({ item })}
+
+ ))}
+ >
+ )}
+
+ {!isTrulyEmpty && }
{claimablePositions.length > 0 && (
<>
@@ -164,16 +178,18 @@ const PredictPositions = forwardRef<
{strings('predict.tab.resolved_markets')}
-
- new Date(b.endDate).getTime() - new Date(a.endDate).getTime(),
- )}
- renderItem={renderResolvedPosition}
- scrollEnabled={false}
- keyExtractor={(item) => `${item.outcomeId}:${item.outcomeIndex}`}
- />
+
+ {claimablePositions
+ .sort(
+ (a, b) =>
+ new Date(b.endDate).getTime() - new Date(a.endDate).getTime(),
+ )
+ .map((item) => (
+
+ {renderResolvedPosition({ item })}
+
+ ))}
+
>
)}
>
diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx
index d2699700e1e1..0d793c1ecdf6 100644
--- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx
+++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx
@@ -152,7 +152,11 @@ const PredictPositionsHeader = forwardRef<
});
};
- if (isBalanceLoading || isUnrealizedPnLLoading) {
+ if (
+ isBalanceLoading ||
+ isUnrealizedPnLLoading ||
+ (!hasClaimableAmount && !shouldShowMainCard)
+ ) {
return null;
}
diff --git a/app/components/UI/Predict/views/PredictTabView/PredictTabView.test.tsx b/app/components/UI/Predict/views/PredictTabView/PredictTabView.test.tsx
index 15a7ede95b98..215bfc15ee76 100644
--- a/app/components/UI/Predict/views/PredictTabView/PredictTabView.test.tsx
+++ b/app/components/UI/Predict/views/PredictTabView/PredictTabView.test.tsx
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { render, screen, act } from '@testing-library/react-native';
+import { render, act } from '@testing-library/react-native';
import React from 'react';
+import { PredictTabViewSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
jest.mock('../../hooks/usePredictDepositToasts', () => ({
usePredictDepositToasts: jest.fn(),
@@ -216,7 +217,7 @@ jest.mock('../../../../../selectors/keyringController', () => ({
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
- useSelector: jest.fn(() => '0x123'),
+ useSelector: jest.fn(),
}));
jest.mock('../../../../../core/Engine', () => ({
@@ -305,10 +306,34 @@ jest.mock('@shopify/flash-list', () => {
});
import PredictTabView from './PredictTabView';
+import { useSelector } from 'react-redux';
+
+const mockUseSelector = useSelector as jest.MockedFunction;
+
+// Control variable for homepage redesign flag
+let isHomepageRedesignEnabled = true;
describe('PredictTabView', () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Reset to default
+ isHomepageRedesignEnabled = true;
+ // Mock useSelector to return appropriate values based on call order
+ // The component typically calls: selectHomepageRedesignV1Enabled first
+ let callCount = 0;
+ mockUseSelector.mockImplementation(() => {
+ callCount++;
+ // First call is usually for feature flag
+ if (callCount === 1) {
+ return isHomepageRedesignEnabled;
+ }
+ // Second call might be for chain ID or other boolean flags
+ if (callCount === 2) {
+ return true; // selectIsEvmNetworkSelected or similar
+ }
+ // Other calls return chain ID
+ return '0x1';
+ });
});
afterEach(() => {
@@ -316,31 +341,47 @@ describe('PredictTabView', () => {
});
it('renders without crashing', () => {
- renderWithProviders();
+ const { getByTestId } = renderWithProviders();
- expect(screen.getByTestId('predict-account-state')).toBeOnTheScreen();
- expect(screen.getByTestId('predict-positions')).toBeOnTheScreen();
- expect(screen.getByTestId('predict-add-funds-sheet')).toBeOnTheScreen();
+ expect(getByTestId('predict-account-state')).toBeOnTheScreen();
+ expect(getByTestId('predict-positions')).toBeOnTheScreen();
+ expect(getByTestId('predict-add-funds-sheet')).toBeOnTheScreen();
});
it('renders all child components', () => {
- renderWithProviders();
+ const { getByText } = renderWithProviders();
- expect(screen.getByText('Account State')).toBeOnTheScreen();
- expect(screen.getByText('Positions')).toBeOnTheScreen();
- expect(screen.getByText('Add Funds')).toBeOnTheScreen();
+ expect(getByText('Account State')).toBeOnTheScreen();
+ expect(getByText('Positions')).toBeOnTheScreen();
+ expect(getByText('Add Funds')).toBeOnTheScreen();
});
it('renders ScrollView with RefreshControl', () => {
- renderWithProviders();
+ const { getByTestId } = renderWithProviders();
// Component should render successfully with all child components
- expect(screen.getByTestId('predict-account-state')).toBeOnTheScreen();
- expect(screen.getByTestId('predict-positions')).toBeOnTheScreen();
- expect(screen.getByTestId('predict-add-funds-sheet')).toBeOnTheScreen();
+ expect(getByTestId('predict-account-state')).toBeOnTheScreen();
+ expect(getByTestId('predict-positions')).toBeOnTheScreen();
+ expect(getByTestId('predict-add-funds-sheet')).toBeOnTheScreen();
});
it('calls refresh on all child components when pull-to-refresh is triggered', async () => {
+ // Mock homepage redesign as disabled to render RefreshControl
+ isHomepageRedesignEnabled = false;
+
+ // Re-mock useSelector with the updated flag value
+ let callCount = 0;
+ mockUseSelector.mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ return isHomepageRedesignEnabled;
+ }
+ if (callCount === 2) {
+ return true;
+ }
+ return '0x1';
+ });
+
// Track the refresh functions from each mocked component
const mockRefreshFunctions = {
accountState: jest.fn().mockResolvedValue(undefined),
@@ -386,11 +427,11 @@ describe('PredictTabView', () => {
},
);
- const { UNSAFE_getByType } = renderWithProviders();
+ const { getByTestId } = renderWithProviders();
- // Get the RefreshControl component
- const { RefreshControl } = jest.requireActual('react-native');
- const refreshControl = UNSAFE_getByType(RefreshControl);
+ // Get the ScrollView and access RefreshControl through its props
+ const scrollView = getByTestId(PredictTabViewSelectorsIDs.SCROLL_VIEW);
+ const refreshControl = scrollView.props.refreshControl;
// Trigger the refresh wrapped in act
await act(async () => {
@@ -403,6 +444,22 @@ describe('PredictTabView', () => {
});
it('handles refresh state correctly', async () => {
+ // Mock homepage redesign as disabled to render RefreshControl
+ isHomepageRedesignEnabled = false;
+
+ // Re-mock useSelector with the updated flag value
+ let callCount = 0;
+ mockUseSelector.mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ return isHomepageRedesignEnabled;
+ }
+ if (callCount === 2) {
+ return true;
+ }
+ return '0x1';
+ });
+
const mockRefresh = jest.fn().mockResolvedValue(undefined);
// Mock one component to track refresh
@@ -424,10 +481,11 @@ describe('PredictTabView', () => {
},
);
- const { UNSAFE_getByType } = renderWithProviders();
+ const { getByTestId } = renderWithProviders();
- const { RefreshControl } = jest.requireActual('react-native');
- const refreshControl = UNSAFE_getByType(RefreshControl);
+ // Get the ScrollView and access RefreshControl through its props
+ const scrollView = getByTestId(PredictTabViewSelectorsIDs.SCROLL_VIEW);
+ const refreshControl = scrollView.props.refreshControl;
// Initially not refreshing
expect(refreshControl.props.refreshing).toBe(false);
@@ -472,17 +530,15 @@ describe('PredictTabView', () => {
},
);
- renderWithProviders();
+ const { getByTestId, queryByTestId } = renderWithProviders(
+ ,
+ );
// Should render error state instead of normal components
- expect(screen.getByTestId('predict-error-state')).toBeOnTheScreen();
- expect(
- screen.queryByTestId('predict-account-state'),
- ).not.toBeOnTheScreen();
- expect(screen.queryByTestId('predict-positions')).not.toBeOnTheScreen();
- expect(
- screen.queryByTestId('predict-add-funds-sheet'),
- ).not.toBeOnTheScreen();
+ expect(getByTestId('predict-error-state')).toBeOnTheScreen();
+ expect(queryByTestId('predict-account-state')).not.toBeOnTheScreen();
+ expect(queryByTestId('predict-positions')).not.toBeOnTheScreen();
+ expect(queryByTestId('predict-add-funds-sheet')).not.toBeOnTheScreen();
});
it('renders error state when header error occurs', () => {
@@ -515,17 +571,15 @@ describe('PredictTabView', () => {
},
);
- renderWithProviders();
+ const { getByTestId, queryByTestId } = renderWithProviders(
+ ,
+ );
// Should render error state instead of normal components
- expect(screen.getByTestId('predict-error-state')).toBeOnTheScreen();
- expect(
- screen.queryByTestId('predict-account-state'),
- ).not.toBeOnTheScreen();
- expect(screen.queryByTestId('predict-positions')).not.toBeOnTheScreen();
- expect(
- screen.queryByTestId('predict-add-funds-sheet'),
- ).not.toBeOnTheScreen();
+ expect(getByTestId('predict-error-state')).toBeOnTheScreen();
+ expect(queryByTestId('predict-account-state')).not.toBeOnTheScreen();
+ expect(queryByTestId('predict-positions')).not.toBeOnTheScreen();
+ expect(queryByTestId('predict-add-funds-sheet')).not.toBeOnTheScreen();
});
it('calls handleRefresh when retry button is pressed in error state', async () => {
@@ -582,7 +636,7 @@ describe('PredictTabView', () => {
);
// Re-render to apply the new mocks
- const { rerender } = renderWithProviders();
+ const { rerender, getByTestId } = renderWithProviders();
rerender();
// Now trigger errors to switch to error state
@@ -591,10 +645,10 @@ describe('PredictTabView', () => {
});
// Should now show error state
- expect(screen.getByTestId('predict-error-state')).toBeOnTheScreen();
+ expect(getByTestId('predict-error-state')).toBeOnTheScreen();
// The retry button should be present in the error state
- expect(screen.getByTestId('retry-button')).toBeOnTheScreen();
+ expect(getByTestId('retry-button')).toBeOnTheScreen();
});
it('handles positions error callback', () => {
@@ -629,7 +683,7 @@ describe('PredictTabView', () => {
},
);
- renderWithProviders();
+ const { queryByTestId } = renderWithProviders();
// Simulate calling the error callback
act(() => {
@@ -637,9 +691,7 @@ describe('PredictTabView', () => {
});
// Should render error state
- expect(
- screen.queryByTestId('predict-account-state'),
- ).not.toBeOnTheScreen();
+ expect(queryByTestId('predict-account-state')).not.toBeOnTheScreen();
});
it('handles header error callback', () => {
@@ -674,7 +726,7 @@ describe('PredictTabView', () => {
},
);
- renderWithProviders();
+ const { queryByTestId } = renderWithProviders();
// Simulate calling the error callback
act(() => {
@@ -682,9 +734,7 @@ describe('PredictTabView', () => {
});
// Should render error state
- expect(
- screen.queryByTestId('predict-account-state'),
- ).not.toBeOnTheScreen();
+ expect(queryByTestId('predict-account-state')).not.toBeOnTheScreen();
});
});
});
diff --git a/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx b/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx
index 51d4a629768e..b53fa28bfc7e 100644
--- a/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx
+++ b/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx
@@ -1,6 +1,7 @@
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { default as React, useRef, useState, useCallback } from 'react';
-import { RefreshControl, ScrollView, View } from 'react-native';
+import { RefreshControl, View } from 'react-native';
+import { useSelector } from 'react-redux';
import PredictPositionsHeader, {
PredictPositionsHeaderHandle,
} from '../../components/PredictPositionsHeader';
@@ -13,6 +14,8 @@ import { usePredictDepositToasts } from '../../hooks/usePredictDepositToasts';
import { usePredictClaimToasts } from '../../hooks/usePredictClaimToasts';
import { PredictTabViewSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
import { usePredictWithdrawToasts } from '../../hooks/usePredictWithdrawToasts';
+import { selectHomepageRedesignV1Enabled } from '../../../../../selectors/featureFlagController/homepage';
+import ConditionalScrollView from '../../../../../component-library/components-temp/ConditionalScrollView';
interface PredictTabViewProps {
isVisible?: boolean;
@@ -27,6 +30,10 @@ const PredictTabView: React.FC = ({ isVisible }) => {
const predictPositionsRef = useRef(null);
const predictPositionsHeaderRef = useRef(null);
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
+
usePredictDepositToasts();
usePredictClaimToasts();
usePredictWithdrawToasts();
@@ -56,31 +63,44 @@ const PredictTabView: React.FC = ({ isVisible }) => {
setHeaderError(error);
}, []);
+ const content = (
+ <>
+
+
+
+ >
+ );
+
return (
-
+
{hasError ? (
) : (
-
- }
+
+ ),
+ }}
>
-
-
-
-
+ {content}
+
)}
);
diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.test.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.test.ts
new file mode 100644
index 000000000000..fe6b97d0a1a0
--- /dev/null
+++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.test.ts
@@ -0,0 +1,237 @@
+import initialRootState, {
+ backgroundState,
+} from '../../../../util/test/initial-root-state';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+import useRampsUnifiedV1Enabled from './useRampsUnifiedV1Enabled';
+import { getVersion } from 'react-native-device-info';
+
+function mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag = true,
+ rampsUnifiedBuyV1MinimumVersionFlag = '1.0.0',
+}: {
+ rampsUnifiedBuyV1ActiveFlag?: boolean;
+ rampsUnifiedBuyV1MinimumVersionFlag?: string | null | undefined;
+}) {
+ return {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ rampsUnifiedBuyV1: {
+ active: rampsUnifiedBuyV1ActiveFlag,
+ minimumVersion: rampsUnifiedBuyV1MinimumVersionFlag,
+ },
+ },
+ },
+ },
+ },
+ };
+}
+
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn(),
+}));
+
+describe('useRampsUnifiedV1Enabled', () => {
+ const mockGetVersion = jest.mocked(getVersion);
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset process.env for each test
+ process.env = { ...originalEnv };
+ delete process.env.MM_RAMPS_UNIFIED_BUY_V1_ENABLED;
+ });
+
+ afterAll(() => {
+ // Restore original environment
+ process.env = originalEnv;
+ });
+
+ describe('Build flag precedence', () => {
+ it('returns true when build flag is set to "true" regardless of remote flags', () => {
+ process.env.MM_RAMPS_UNIFIED_BUY_V1_ENABLED = 'true';
+ mockGetVersion.mockReturnValue('1.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: false,
+ rampsUnifiedBuyV1MinimumVersionFlag: '2.0.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns false when build flag is set to "false" regardless of remote flags', () => {
+ process.env.MM_RAMPS_UNIFIED_BUY_V1_ENABLED = 'false';
+ mockGetVersion.mockReturnValue('2.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '1.0.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true when build flag is set to "true" even with version mismatch', () => {
+ process.env.MM_RAMPS_UNIFIED_BUY_V1_ENABLED = 'true';
+ mockGetVersion.mockReturnValue('1.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '2.0.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(true);
+ });
+ });
+
+ describe('Remote feature flag behavior when build flag is not set', () => {
+ it('returns true when unified V1 is enabled and version meets the minimum requirement', () => {
+ mockGetVersion.mockReturnValue('2.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '1.5.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns false when unified V1 is disabled', () => {
+ mockGetVersion.mockReturnValue('2.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: false,
+ rampsUnifiedBuyV1MinimumVersionFlag: '1.5.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when version does not meet the minimum requirement', () => {
+ mockGetVersion.mockReturnValue('1.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '1.5.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when minimum version is not defined', () => {
+ mockGetVersion.mockReturnValue('2.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: null,
+ }),
+ },
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when minimum version is empty string', () => {
+ mockGetVersion.mockReturnValue('2.0.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(false);
+ });
+ });
+
+ describe('Version comparison edge cases', () => {
+ it('returns true when current version exactly matches minimum version', () => {
+ mockGetVersion.mockReturnValue('1.5.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '1.5.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns true when current version is higher than minimum version', () => {
+ mockGetVersion.mockReturnValue('2.1.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '2.0.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns false when current version is lower than minimum version', () => {
+ mockGetVersion.mockReturnValue('1.9.0');
+
+ const { result } = renderHookWithProvider(
+ () => useRampsUnifiedV1Enabled(),
+ {
+ state: mockInitialState({
+ rampsUnifiedBuyV1ActiveFlag: true,
+ rampsUnifiedBuyV1MinimumVersionFlag: '2.0.0',
+ }),
+ },
+ );
+
+ expect(result.current).toBe(false);
+ });
+ });
+});
diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts
new file mode 100644
index 000000000000..e1691350ca34
--- /dev/null
+++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts
@@ -0,0 +1,46 @@
+import { useSelector } from 'react-redux';
+import { getVersion } from 'react-native-device-info';
+import compareVersions from 'compare-versions';
+import {
+ selectRampsUnifiedBuyV1ActiveFlag,
+ selectRampsUnifiedBuyV1MinimumVersionFlag,
+} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV1';
+
+function hasMinimumRequiredVersion(
+ minRequiredVersion: string | null | undefined,
+ isUnifiedV1Enabled: boolean,
+) {
+ if (!minRequiredVersion) return false;
+ const currentVersion = getVersion();
+ return (
+ isUnifiedV1Enabled &&
+ compareVersions.compare(currentVersion, minRequiredVersion, '>=')
+ );
+}
+
+export default function useRampsUnifiedV1Enabled() {
+ const rampsUnifiedBuyV1MinimumVersionFlag = useSelector(
+ selectRampsUnifiedBuyV1MinimumVersionFlag,
+ );
+ const rampsUnifiedBuyV1ActiveFlag = useSelector(
+ selectRampsUnifiedBuyV1ActiveFlag,
+ );
+
+ const rampsUnifiedBuyV1BuildFlag =
+ process.env.MM_RAMPS_UNIFIED_BUY_V1_ENABLED;
+
+ // if build flag is defined, it takes precedence over remote feature flag
+ if (
+ rampsUnifiedBuyV1BuildFlag === 'true' ||
+ rampsUnifiedBuyV1BuildFlag === 'false'
+ ) {
+ return rampsUnifiedBuyV1BuildFlag === 'true';
+ }
+
+ const isRampsUnifiedV1Enabled = hasMinimumRequiredVersion(
+ rampsUnifiedBuyV1MinimumVersionFlag,
+ rampsUnifiedBuyV1ActiveFlag,
+ );
+
+ return isRampsUnifiedV1Enabled;
+}
diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx
index d8db90e231f2..95e4f2c7d126 100644
--- a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx
+++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx
@@ -113,7 +113,11 @@ const BoostCard: React.FC = ({
if (boost.endDate) {
return (
-
+
{timeRemaining}
diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
index ed5daf5b088d..0971c1a475b7 100644
--- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
@@ -11,7 +11,6 @@ exports[`StakingBalance render matches snapshot 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -804,7 +803,6 @@ exports[`StakingBalance should match the snapshot 1`] = `
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx
new file mode 100644
index 000000000000..6327e62ac601
--- /dev/null
+++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import TokenListSkeleton from './TokenListSkeleton';
+
+// Mock the theme hook
+jest.mock('../../../../util/theme', () => ({
+ useTheme: () => ({
+ colors: {
+ background: {
+ section: '#E5E5E5',
+ subsection: '#F5F5F5',
+ default: '#FFFFFF',
+ },
+ border: {
+ muted: '#D6D9DC',
+ },
+ },
+ }),
+}));
+
+// Mock createStyles module completely
+jest.mock('../styles', () => {
+ const mockCreateStyles = jest.fn(() => ({
+ wrapperSkeleton: {
+ flex: 1,
+ padding: 16,
+ },
+ skeletonItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ skeletonTextContainer: {
+ flex: 1,
+ },
+ skeletonValueContainer: {
+ alignItems: 'flex-end',
+ },
+ }));
+
+ return mockCreateStyles;
+});
+
+describe('TokenListSkeleton', () => {
+ it('renders without errors', () => {
+ const { root } = render();
+
+ expect(root).toBeDefined();
+ });
+});
diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx
new file mode 100644
index 000000000000..86cce643c947
--- /dev/null
+++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { View } from 'react-native';
+import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
+import { useTheme } from '../../../../util/theme';
+import createStyles from '../styles';
+
+const TokenListSkeleton = () => {
+ const { colors } = useTheme();
+ const styles = createStyles(colors);
+
+ return (
+
+
+ {Array.from({ length: 10 }, (_, index) => (
+
+ {/* Token icon skeleton */}
+
+
+ {/* Token name and symbol skeleton */}
+
+
+
+
+
+ {/* Token value and percentage skeleton */}
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default TokenListSkeleton;
diff --git a/app/components/UI/Tokens/TokenList/index.test.tsx b/app/components/UI/Tokens/TokenList/index.test.tsx
index ad632938e0b9..c71eb01f1034 100644
--- a/app/components/UI/Tokens/TokenList/index.test.tsx
+++ b/app/components/UI/Tokens/TokenList/index.test.tsx
@@ -29,6 +29,23 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
+// Mock selectors
+jest.mock('../../../../selectors/preferencesController', () => ({
+ selectPrivacyMode: jest.fn(() => false),
+ selectIsTokenNetworkFilterEqualCurrentNetwork: jest.fn(() => true),
+}));
+
+jest.mock(
+ '../../../../selectors/featureFlagController/multichainAccounts',
+ () => ({
+ selectMultichainAccountsState2Enabled: jest.fn(() => false),
+ }),
+);
+
+jest.mock('../../../../selectors/featureFlagController/homepage', () => ({
+ selectHomepageRedesignV1Enabled: jest.fn(() => true),
+}));
+
// Mock child components
jest.mock('./TokenListItem', () => ({
TokenListItem: ({ assetKey }: { assetKey: { address: string } }) => {
@@ -56,9 +73,11 @@ jest.mock('@metamask/design-system-react-native', () => ({
Box: ({
children,
testID,
+ twClassName: _twClassName,
}: {
children: React.ReactNode;
testID?: string;
+ twClassName?: string;
}) => {
const React = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
@@ -68,10 +87,14 @@ jest.mock('@metamask/design-system-react-native', () => ({
children,
onPress,
testID,
+ variant: _variant,
+ isFullWidth: _isFullWidth,
}: {
children: React.ReactNode;
onPress: () => void;
testID?: string;
+ variant?: string;
+ isFullWidth?: boolean;
}) => {
const React = jest.requireActual('react');
const { TouchableOpacity, Text } = jest.requireActual('react-native');
@@ -139,25 +162,8 @@ describe('TokenList', () => {
navigate: mockNavigate,
} as unknown as ReturnType);
- // Mock useSelector to return default values
- mockUseSelector.mockImplementation((selector) => {
- if (selector.toString().includes('selectPrivacyMode')) {
- return false;
- }
- if (
- selector
- .toString()
- .includes('selectIsTokenNetworkFilterEqualCurrentNetwork')
- ) {
- return true;
- }
- if (
- selector.toString().includes('selectMultichainAccountsState2Enabled')
- ) {
- return false;
- }
- return undefined;
- });
+ // Mock useSelector to call the selector function with empty state
+ mockUseSelector.mockImplementation((selector) => selector({}));
});
const renderComponent = (props = {}, storeState = initialState) => {
@@ -179,11 +185,12 @@ describe('TokenList', () => {
expect(getByTestId('token-item-0x456')).toBeOnTheScreen();
});
- it('renders empty state when no tokens', () => {
- const { getByText } = renderComponent({ tokenKeys: [] });
+ it('renders empty container when no tokens', () => {
+ const { getByTestId } = renderComponent({ tokenKeys: [] });
- expect(getByText('wallet.no_tokens')).toBeOnTheScreen();
- expect(getByText('wallet.show_tokens_without_balance')).toBeOnTheScreen();
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST),
+ ).toBeOnTheScreen();
});
it('shows view all button when maxItems is exceeded', () => {
@@ -220,20 +227,18 @@ describe('TokenList', () => {
expect(mockNavigate).toHaveBeenCalledWith('TokensFullView');
});
- it('navigates to settings when show tokens without balance is pressed', () => {
- const { getByText } = renderComponent({ tokenKeys: [] });
-
- const showTokensLink = getByText('wallet.show_tokens_without_balance');
- fireEvent.press(showTokensLink);
+ it('renders container without items when tokenKeys is empty', () => {
+ const { getByTestId, queryByTestId } = renderComponent({ tokenKeys: [] });
- expect(mockNavigate).toHaveBeenCalledWith('SettingsView', {
- screen: 'GeneralSettings',
- });
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST),
+ ).toBeOnTheScreen();
+ expect(queryByTestId('token-item-0x123')).toBeNull();
});
it('calls onRefresh when refresh control is triggered', () => {
const onRefresh = jest.fn();
- const { getByTestId } = renderComponent({ onRefresh });
+ const { getByTestId } = renderComponent({ onRefresh, isFullView: true });
const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST);
const refreshControl = flashList.props.refreshControl;
@@ -243,12 +248,13 @@ describe('TokenList', () => {
expect(onRefresh).toHaveBeenCalledTimes(1);
});
- it('applies flashListProps when provided', () => {
- const customProps = { contentContainerStyle: { padding: 10 } };
- const { getByTestId } = renderComponent({ flashListProps: customProps });
+ it('applies contentContainerStyle when isFullView is true', () => {
+ const { getByTestId } = renderComponent({
+ isFullView: true,
+ });
const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST);
- expect(flashList.props.contentContainerStyle).toEqual({ padding: 10 });
+ expect(flashList.props.contentContainerStyle).toBeDefined();
});
it('uses TokenListItemBip44 when multichain accounts state 2 is enabled', () => {
@@ -290,19 +296,32 @@ describe('TokenList', () => {
});
it('handles undefined tokenKeys gracefully', () => {
- const { getByText } = renderComponent({ tokenKeys: undefined });
+ const { getByTestId, queryByTestId } = renderComponent({
+ tokenKeys: undefined,
+ });
- expect(getByText('wallet.no_tokens')).toBeOnTheScreen();
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST),
+ ).toBeOnTheScreen();
+ expect(queryByTestId('token-item-0x123')).toBeNull();
+ expect(queryByTestId('token-item-0x456')).toBeNull();
});
it('handles null tokenKeys gracefully', () => {
- const { getByText } = renderComponent({ tokenKeys: null });
+ const { getByTestId, queryByTestId } = renderComponent({ tokenKeys: null });
- expect(getByText('wallet.no_tokens')).toBeOnTheScreen();
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST),
+ ).toBeOnTheScreen();
+ expect(queryByTestId('token-item-0x123')).toBeNull();
+ expect(queryByTestId('token-item-0x456')).toBeNull();
});
it('shows refreshing state correctly', () => {
- const { getByTestId } = renderComponent({ refreshing: true });
+ const { getByTestId } = renderComponent({
+ refreshing: true,
+ isFullView: true,
+ });
const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST);
const refreshControl = flashList.props.refreshControl;
@@ -311,7 +330,7 @@ describe('TokenList', () => {
});
it('generates unique keys for token items', () => {
- const { getByTestId } = renderComponent();
+ const { getByTestId } = renderComponent({ isFullView: true });
const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST);
const keyExtractor = flashList.props.keyExtractor;
@@ -339,4 +358,84 @@ describe('TokenList', () => {
expect(showRemoveMenu).not.toHaveBeenCalled();
expect(setShowScamWarningModal).not.toHaveBeenCalled();
});
+
+ describe('Homepage Redesign V1 Features', () => {
+ beforeEach(() => {
+ // Reset selector mocks for this describe block
+ mockUseSelector.mockReset();
+ });
+
+ it('renders tokens directly in Box when isHomepageRedesignV1Enabled is true and not full view', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector.toString().includes('selectHomepageRedesignV1Enabled')) {
+ return true;
+ }
+ return selector({});
+ });
+
+ const { getByTestId } = renderComponent({ isFullView: false });
+
+ expect(getByTestId('token-item-0x123')).toBeOnTheScreen();
+ expect(getByTestId('token-item-0x456')).toBeOnTheScreen();
+ });
+
+ it('renders FlashList when isHomepageRedesignV1Enabled is true but isFullView is true', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector.toString().includes('selectHomepageRedesignV1Enabled')) {
+ return true;
+ }
+ return selector({});
+ });
+
+ const { getByTestId } = renderComponent({ isFullView: true });
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders FlashList when isHomepageRedesignV1Enabled is false', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector.toString().includes('selectHomepageRedesignV1Enabled')) {
+ return false;
+ }
+ return selector({});
+ });
+
+ const { getByTestId } = renderComponent();
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST),
+ ).toBeOnTheScreen();
+ });
+
+ it('shows view all button when homepage redesign is enabled and maxItems is exceeded', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector.toString().includes('selectHomepageRedesignV1Enabled')) {
+ return true;
+ }
+ return selector({});
+ });
+
+ const { getByText } = renderComponent({ maxItems: 1, isFullView: false });
+
+ expect(getByText('wallet.view_all_tokens')).toBeOnTheScreen();
+ });
+
+ it('renders mapped token items when homepage redesign is enabled and not full view', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector.toString().includes('selectHomepageRedesignV1Enabled')) {
+ return true;
+ }
+ return selector({});
+ });
+
+ const { queryByTestId } = renderComponent({ isFullView: false });
+
+ // When homepage redesign is enabled and not full view, tokens are rendered directly
+ // instead of in FlashList
+ expect(queryByTestId('token-item-0x123')).toBeOnTheScreen();
+ expect(queryByTestId('token-item-0x456')).toBeOnTheScreen();
+ });
+ });
});
diff --git a/app/components/UI/Tokens/TokenList/index.tsx b/app/components/UI/Tokens/TokenList/index.tsx
index bef176305a72..08f547a700fe 100644
--- a/app/components/UI/Tokens/TokenList/index.tsx
+++ b/app/components/UI/Tokens/TokenList/index.tsx
@@ -1,16 +1,13 @@
import React, { useCallback, useLayoutEffect, useRef, useMemo } from 'react';
-import { View, RefreshControl } from 'react-native';
-import { FlashList, FlashListRef, FlashListProps } from '@shopify/flash-list';
+import { RefreshControl } from 'react-native';
+import { FlashList, FlashListRef } from '@shopify/flash-list';
import { useSelector } from 'react-redux';
import { useTheme } from '../../../../util/theme';
import {
selectIsTokenNetworkFilterEqualCurrentNetwork,
selectPrivacyMode,
} from '../../../../selectors/preferencesController';
-import createStyles from '../styles';
-import TextComponent, {
- TextColor,
-} from '../../../../component-library/components/Texts/Text';
+
import { TokenI } from '../types';
import { strings } from '../../../../../locales/i18n';
import { TokenListItem, TokenListItemBip44 } from './TokenListItem';
@@ -18,11 +15,13 @@ import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/Wall
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../constants/navigation/Routes';
import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts';
+import { selectHomepageRedesignV1Enabled } from '../../../../selectors/featureFlagController/homepage';
import {
Box,
Button,
ButtonVariant,
} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
export interface FlashListAssetKey {
address: string;
@@ -37,8 +36,8 @@ interface TokenListProps {
showRemoveMenu: (arg: TokenI) => void;
showPercentageChange?: boolean;
setShowScamWarningModal: () => void;
- flashListProps?: Partial>;
maxItems?: number;
+ isFullView?: boolean;
}
const TokenListComponent = ({
@@ -48,14 +47,18 @@ const TokenListComponent = ({
showRemoveMenu,
showPercentageChange = true,
setShowScamWarningModal,
- flashListProps,
maxItems,
+ isFullView = false,
}: TokenListProps) => {
const { colors } = useTheme();
+ const tw = useTailwind();
const privacyMode = useSelector(selectPrivacyMode);
const isTokenNetworkFilterEqualCurrentNetwork = useSelector(
selectIsTokenNetworkFilterEqualCurrentNetwork,
);
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
// BIP44 MAINTENANCE: Once stable, only use TokenListItemBip44
const isMultichainAccountsState2Enabled = useSelector(
@@ -67,30 +70,21 @@ const TokenListComponent = ({
const listRef = useRef>(null);
- const styles = createStyles(colors);
const navigation = useNavigation();
useLayoutEffect(() => {
listRef.current?.recomputeViewableItems();
}, [isTokenNetworkFilterEqualCurrentNetwork]);
- const handleLink = () => {
- navigation.navigate(Routes.SETTINGS_VIEW, {
- screen: Routes.ONBOARDING.GENERAL_SETTINGS,
- });
- };
-
const handleViewAllTokens = useCallback(() => {
navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW);
}, [navigation]);
// Apply maxItems limit if specified
- const displayTokenKeys = useMemo(() => {
- if (maxItems === undefined) {
- return tokenKeys;
- }
- return tokenKeys?.slice(0, maxItems);
- }, [tokenKeys, maxItems]);
+ const displayTokenKeys = useMemo(
+ () => (tokenKeys || []).slice(0, maxItems || undefined),
+ [tokenKeys, maxItems],
+ );
// Determine if we should show the "View all tokens" button
const shouldShowViewAllButton = useMemo(
@@ -117,63 +111,66 @@ const TokenListComponent = ({
],
);
- return displayTokenKeys?.length ? (
-
- {
- const staked = item.isStaked ? 'staked' : 'unstaked';
- return `${item.address}-${item.chainId}-${staked}-${idx}`;
- }}
- decelerationRate="fast"
- refreshControl={
-
+ {displayTokenKeys.map((item, index) => (
+
- }
- extraData={{ isTokenNetworkFilterEqualCurrentNetwork }}
- scrollEnabled
- {...flashListProps}
- />
- {shouldShowViewAllButton && (
-
-
-
- )}
-
- ) : (
-
-
-
- {strings('wallet.no_tokens')}
-
-
- {strings('wallet.show_tokens_without_balance')}
-
-
-
- );
+ ))}
+ {shouldShowViewAllButton && (
+
+
+
+ )}
+
+ ) : (
+
+ {
+ const staked = item.isStaked ? 'staked' : 'unstaked';
+ return `${item.address}-${item.chainId}-${staked}-${idx}`;
+ }}
+ decelerationRate="fast"
+ refreshControl={
+
+ }
+ extraData={{ isTokenNetworkFilterEqualCurrentNetwork }}
+ contentContainerStyle={!isFullView ? undefined : tw`px-4`}
+ />
+
+ );
+
+ return tokenList;
};
export const TokenList = React.memo(TokenListComponent);
diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx
index d6edd74f2f6f..e5b5acd8ac6d 100644
--- a/app/components/UI/Tokens/index.test.tsx
+++ b/app/components/UI/Tokens/index.test.tsx
@@ -13,6 +13,10 @@ jest.mock('../../../core/NotificationManager', () => ({
showSimpleNotification: jest.fn(() => Promise.resolve()),
}));
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn().mockReturnValue('1.0.0'),
+}));
+
const selectedAddress = '0x123';
jest.mock('./TokensBottomSheet', () => ({
@@ -129,7 +133,7 @@ const initialState = {
NetworkController: {
networkConfigurationsByChainId: {
'0x1': {
- chainId: '0x1',
+ chainId: '0x1' as const,
name: 'Ethereum Mainnet',
nativeCurrency: 'ETH',
rpcEndpoints: [{ networkClientId: '0x1' }],
@@ -191,9 +195,9 @@ const initialState = {
tokenBalances: {
[selectedAddress]: {
'0x1': {
- '0x00': '0x2386F26FC10000',
- '0x01': '0xDE0B6B3A7640000',
- '0x02': '0x0',
+ '0x00': '0x2386F26FC10000' as const,
+ '0x01': '0xDE0B6B3A7640000' as const,
+ '0x02': '0x0' as const,
},
},
},
@@ -897,4 +901,95 @@ describe('Tokens', () => {
});
});
});
+
+ describe('Homepage Redesign V1 Features', () => {
+ it('renders tokens container when homepage redesign is enabled', async () => {
+ const { getByTestId, queryByTestId } = renderComponent({
+ ...initialState,
+ engine: {
+ ...initialState.engine,
+ backgroundState: {
+ ...initialState.engine.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ });
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER),
+ ).toBeOnTheScreen();
+ await waitFor(() => expect(queryByTestId('asset-ETH')).toBeDefined());
+ });
+
+ it('renders all tokens when isFullView is true regardless of homepage redesign', async () => {
+ const { getByTestId, queryByTestId } = renderWithProvider(
+
+
+ {() => }
+
+ ,
+ {
+ state: {
+ ...initialState,
+ engine: {
+ ...initialState.engine,
+ backgroundState: {
+ ...initialState.engine.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ homepageRedesignV1: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER),
+ ).toBeOnTheScreen();
+ await waitFor(() => expect(queryByTestId('asset-ETH')).toBeDefined());
+ });
+ });
+
+ describe('Multichain Accounts State 2', () => {
+ it('renders tokens when multichain accounts state 2 is enabled', async () => {
+ const { getByTestId, queryByTestId } = renderComponent({
+ ...initialState,
+ engine: {
+ ...initialState.engine,
+ backgroundState: {
+ ...initialState.engine.backgroundState,
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ multichainAccountsState2: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ });
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER),
+ ).toBeOnTheScreen();
+ await waitFor(() => expect(queryByTestId('asset-ETH')).toBeDefined());
+ });
+ });
});
diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx
index f53592164ed0..d2bf8578a1a1 100644
--- a/app/components/UI/Tokens/index.tsx
+++ b/app/components/UI/Tokens/index.tsx
@@ -29,13 +29,15 @@ import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetwork
import { TokenListControlBar } from './TokenListControlBar';
import { selectSelectedInternalAccountId } from '../../../selectors/accountsController';
import { ScamWarningModal } from './TokenList/ScamWarningModal';
+import TokenListSkeleton from './TokenList/TokenListSkeleton';
import { selectSortedTokenKeys } from '../../../selectors/tokenList';
import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts';
import { selectSortedAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list';
-import Loader from '../../../component-library/components-temp/Loader';
import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts';
import { SolScope } from '@metamask/keyring-api';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage';
+import { TokensEmptyState } from '../TokensEmptyState';
interface TokenListNavigationParamList {
AddAsset: { assetType: string };
@@ -74,15 +76,12 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => {
useSelector(selectSelectedInternalAccountByScope)(SolScope.Mainnet) || null;
const isSolanaSelected = selectedSolanaAccount !== null;
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
+
const [showScamWarningModal, setShowScamWarningModal] = useState(false);
- const [isTokensLoading, setIsTokensLoading] = useState(true);
- const [renderedTokenKeys, setRenderedTokenKeys] = useState<
- typeof sortedTokenKeys
- >([]);
- const [progressiveTokens, setProgressiveTokens] = useState<
- typeof sortedTokenKeys
- >([]);
- const lastTokenDataRef = useRef();
+ const [hasInitialLoad, setHasInitialLoad] = useState(false);
// BIP44 MAINTENANCE: Once stable, only use selectSortedAssetsBySelectedAccountGroup
const isMultichainAccountsState2Enabled = useSelector(
@@ -100,74 +99,14 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => {
),
);
- // High-performance async rendering with progressive loading
+ // Mark as loaded once we have data (even if empty)
useEffect(() => {
- // Debounce rapid data changes
- if (
- JSON.stringify(sortedTokenKeys) ===
- JSON.stringify(lastTokenDataRef.current)
- ) {
- return;
- }
- lastTokenDataRef.current = sortedTokenKeys;
-
- if (sortedTokenKeys?.length) {
- setIsTokensLoading(true);
- setProgressiveTokens([]);
-
- // Use InteractionManager for better performance than setTimeout
+ if (!hasInitialLoad && sortedTokenKeys) {
InteractionManager.runAfterInteractions(() => {
- const CHUNK_SIZE = 20; // Process 20 tokens at a time
- const chunks: (typeof sortedTokenKeys)[] = [];
-
- for (let i = 0; i < sortedTokenKeys.length; i += CHUNK_SIZE) {
- chunks.push(sortedTokenKeys.slice(i, i + CHUNK_SIZE));
- }
-
- // Progressive loading for better perceived performance
- let currentChunkIndex = 0;
- let accumulatedTokens: typeof sortedTokenKeys = [];
-
- const processChunk = () => {
- if (currentChunkIndex < chunks.length) {
- accumulatedTokens = [
- ...accumulatedTokens,
- ...chunks[currentChunkIndex],
- ];
- setProgressiveTokens([...accumulatedTokens]);
- currentChunkIndex++;
-
- // Process next chunk after allowing UI to update
- requestAnimationFrame(() => {
- if (currentChunkIndex < chunks.length) {
- setTimeout(processChunk, 0);
- } else {
- // All chunks processed
- const tokenMap = new Map();
- accumulatedTokens.forEach((item) => {
- const staked = item.isStaked ? 'staked' : 'unstaked';
- const key = `${item.address}-${item.chainId}-${staked}`;
- tokenMap.set(key, item);
- });
- const deduped = Array.from(tokenMap.values());
- setRenderedTokenKeys(deduped);
- setIsTokensLoading(false);
- }
- });
- }
- };
-
- processChunk();
+ setHasInitialLoad(true);
});
-
- return;
}
-
- // No tokens to render
- setRenderedTokenKeys([]);
- setProgressiveTokens([]);
- setIsTokensLoading(false);
- }, [sortedTokenKeys]);
+ }, [sortedTokenKeys, hasInitialLoad]);
const showRemoveMenu = useCallback(
(token: TokenI) => {
@@ -252,43 +191,44 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => {
setShowScamWarningModal((prev) => !prev);
}, []);
+ const maxItems = useMemo(() => {
+ if (isFullView) {
+ return undefined;
+ }
+ return isHomepageRedesignV1Enabled ? 10 : undefined;
+ }, [isFullView, isHomepageRedesignV1Enabled]);
+
return (
- {!isTokensLoading &&
- renderedTokenKeys.length === 0 &&
- progressiveTokens.length === 0 ? (
-
+ {!hasInitialLoad ? (
+
+
+
+ ) : sortedTokenKeys.length > 0 ? (
+
) : (
- <>
- {isTokensLoading && progressiveTokens.length === 0 && (
-
- )}
- {(progressiveTokens.length > 0 || renderedTokenKeys.length > 0) && (
-
- )}
- >
+
+
+
)}
{showScamWarningModal && (
bottomSheetText: {
width: '100%',
},
- emptyView: {
- backgroundColor: colors.background.default,
- justifyContent: 'center',
- alignItems: 'center',
- marginTop: 50,
- },
- emptyTokensView: {
- alignItems: 'center',
- marginTop: 130,
- },
- emptyTokensViewText: {
- fontFamily: 'Geist Medium',
- },
balances: {
flex: 1,
justifyContent: 'center',
@@ -118,6 +105,21 @@ const createStyles = (colors: Colors) =>
badge: {
marginTop: 8,
},
+ wrapperSkeleton: {
+ backgroundColor: colors.background.default,
+ },
+ skeletonItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ skeletonTextContainer: {
+ flex: 1,
+ marginLeft: 12,
+ },
+ skeletonValueContainer: {
+ alignItems: 'flex-end',
+ },
});
export default createStyles;
diff --git a/app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx b/app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx
new file mode 100644
index 000000000000..26f332303fe3
--- /dev/null
+++ b/app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+import configureMockStore from 'redux-mock-store';
+import { TokensEmptyState } from './TokensEmptyState';
+import { backgroundState } from '../../../util/test/initial-root-state';
+import Routes from '../../../constants/navigation/Routes';
+
+const mockStore = configureMockStore();
+const mockNavigate = jest.fn();
+
+// Mock navigation
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+}));
+
+// Mock the tailwind hook
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: () => ({
+ style: jest.fn((...args) => {
+ if (Array.isArray(args[0])) {
+ return args[0].join(' ');
+ }
+ return args.join(' ');
+ }),
+ }),
+}));
+
+// Mock i18n strings
+jest.mock('../../../../locales/i18n', () => ({
+ strings: (key: string) => {
+ const translations: Record = {
+ 'wallet.tokens_empty_description': 'Tokens you hold will appear here.',
+ 'wallet.show_tokens_without_balance': 'Show tokens without balance',
+ };
+ return translations[key] || key;
+ },
+}));
+
+// Mock TabEmptyState component to simplify testing
+jest.mock('../../../component-library/components-temp/TabEmptyState', () => ({
+ TabEmptyState: ({
+ icon,
+ description,
+ actionButtonText,
+ actionButtonProps,
+ testID,
+ }: {
+ icon?: React.ReactNode;
+ description?: string;
+ actionButtonText?: string;
+ actionButtonProps?: { onPress: () => void };
+ testID?: string;
+ }) => {
+ const { View, Text, TouchableOpacity } = jest.requireActual('react-native');
+ return (
+
+ {icon && {icon}}
+ {description && (
+ {description}
+ )}
+ {actionButtonText && actionButtonProps && (
+
+ {actionButtonText}
+
+ )}
+
+ );
+ },
+}));
+
+describe('TokensEmptyState', () => {
+ const initialState = {
+ engine: {
+ backgroundState,
+ },
+ user: {
+ appTheme: 'light',
+ },
+ };
+
+ const store = mockStore(initialState);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('tab-empty-state')).toBeOnTheScreen();
+ });
+
+ it('renders empty state icon', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('empty-state-icon')).toBeOnTheScreen();
+ });
+
+ it('renders empty state description text', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ expect(getByText('Tokens you hold will appear here.')).toBeOnTheScreen();
+ });
+
+ it('renders action button with correct text', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ expect(getByText('Show tokens without balance')).toBeOnTheScreen();
+ });
+
+ it('navigates to general settings when action button is pressed', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const actionButton = getByTestId('empty-state-action-button');
+ fireEvent.press(actionButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
+ screen: Routes.ONBOARDING.GENERAL_SETTINGS,
+ });
+ });
+
+ it('passes additional props to TabEmptyState', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('custom-empty-state')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/TokensEmptyState/TokensEmptyState.tsx b/app/components/UI/TokensEmptyState/TokensEmptyState.tsx
new file mode 100644
index 000000000000..bb592e2511d3
--- /dev/null
+++ b/app/components/UI/TokensEmptyState/TokensEmptyState.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { Image } from 'react-native';
+import {
+ TabEmptyState,
+ type TabEmptyStateProps,
+} from '../../../component-library/components-temp/TabEmptyState';
+import { useAssetFromTheme } from '../../../util/theme';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { useNavigation } from '@react-navigation/native';
+import { strings } from '../../../../locales/i18n';
+import Routes from '../../../constants/navigation/Routes';
+import emptyStateDefiLight from '../../../images/empty-state-defi-light.png';
+import emptyStateDefiDark from '../../../images/empty-state-defi-dark.png';
+
+interface TokensEmptyStateProps extends TabEmptyStateProps {}
+
+export const TokensEmptyState: React.FC = ({
+ ...props
+}) => {
+ const tokensImage = useAssetFromTheme(
+ emptyStateDefiLight,
+ emptyStateDefiDark,
+ );
+ const tw = useTailwind();
+ const navigation = useNavigation();
+
+ const handleLink = () => {
+ navigation.navigate(Routes.SETTINGS_VIEW, {
+ screen: Routes.ONBOARDING.GENERAL_SETTINGS,
+ });
+ };
+
+ return (
+
+ }
+ description={strings('wallet.tokens_empty_description')}
+ actionButtonText={strings('wallet.show_tokens_without_balance')}
+ actionButtonProps={{
+ onPress: handleLink,
+ }}
+ {...props}
+ />
+ );
+};
diff --git a/app/components/UI/TokensEmptyState/index.ts b/app/components/UI/TokensEmptyState/index.ts
new file mode 100644
index 000000000000..6e3913ee281c
--- /dev/null
+++ b/app/components/UI/TokensEmptyState/index.ts
@@ -0,0 +1 @@
+export { TokensEmptyState } from './TokensEmptyState';
diff --git a/app/components/Views/Asset/__snapshots__/index.test.js.snap b/app/components/Views/Asset/__snapshots__/index.test.js.snap
index a0ae7a07dbba..cb3a3144034c 100644
--- a/app/components/Views/Asset/__snapshots__/index.test.js.snap
+++ b/app/components/Views/Asset/__snapshots__/index.test.js.snap
@@ -1639,7 +1639,6 @@ exports[`Asset Multichain Functionality should exclude mixed token/SOL transacti
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -3413,7 +3412,6 @@ exports[`Asset Multichain Functionality should exclude transactions with empty a
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -5187,7 +5185,6 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -7002,7 +6999,6 @@ exports[`Asset Multichain Functionality should filter native SOL transactions co
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -8776,7 +8772,6 @@ exports[`Asset Multichain Functionality should handle state with no multichain t
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -10550,7 +10545,6 @@ exports[`Asset Multichain Functionality should handle unknown SPL token filterin
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -12895,7 +12889,6 @@ exports[`Asset Multichain Functionality should render non-EVM assets with Multic
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
@@ -14669,7 +14662,6 @@ exports[`Asset Multichain Functionality should sort filtered transactions by tim
style={
{
"alignItems": "center",
- "flex": 1,
"flexDirection": "row",
"height": 64,
}
diff --git a/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap
index 0db613e09e2a..a7b7e46d8d6b 100644
--- a/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap
@@ -781,7 +781,7 @@ exports[`AdvancedSettings should render correctly 1`] = `
}
}
>
- Select this to show fiat conversion on test networks
+ Select this to show fiat conversion on test networks.
- View the list of active WalletConnect sessions
+ View the list of active WalletConnect sessions.
- Currency conversion, primary currency, language and search engine
+ Currency conversion, primary currency, language and search engine.
- Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC
+ Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC.
- Manage the permissions given to sites and apps
+ Manage the permissions given to sites and apps.
- Add, edit, remove, and manage your accounts
+ Add, edit, remove, and manage your accounts.
{
expect(getByTestId('tokens-component')).toBeOnTheScreen();
});
- it('renders tokens component with isFullView prop', () => {
- // Arrange
- const { getByTestId } = renderScreen(TokensFullView, {
- name: 'TokensFullView',
- });
-
- // Act & Assert
- expect(getByTestId('tokens-component')).toBeOnTheScreen();
- });
-
it('calls goBack when back button is pressed', () => {
// Arrange
const { getByTestId } = renderScreen(TokensFullView, {
@@ -76,23 +66,10 @@ describe('TokensFullView', () => {
});
// Act
- const backButton = getByTestId('header').find(
- (element) => element.type?.toString() === 'TouchableOpacity',
- );
- backButton?.props.onPress();
+ const backButton = getByTestId('back-button');
+ backButton.props.onPress();
// Assert
expect(mockGoBack).toHaveBeenCalledTimes(1);
});
-
- it('displays correct header title', () => {
- // Arrange
- const { getByTestId } = renderScreen(TokensFullView, {
- name: 'TokensFullView',
- });
-
- // Act & Assert
- const headerTitle = getByTestId('header-title');
- expect(headerTitle).toBeOnTheScreen();
- });
});
diff --git a/app/components/Views/TokensFullView/TokensFullView.tsx b/app/components/Views/TokensFullView/TokensFullView.tsx
index 2d4af3e36378..f421b37e4b7f 100644
--- a/app/components/Views/TokensFullView/TokensFullView.tsx
+++ b/app/components/Views/TokensFullView/TokensFullView.tsx
@@ -35,9 +35,10 @@ const TokensFullView = () => {
size={ButtonIconSizes.Lg}
onPress={handleBackPress}
iconName={IconName.ArrowLeft}
+ testID="back-button"
/>
}
- includesTopInset
+ style={tw`p-4`}
>
{strings('wallet.tokens')}
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index 210c2012c7b4..23d5084f710f 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -49,6 +49,7 @@ import { ButtonVariants } from '../../../component-library/components/Buttons/Bu
import CustomText, {
TextColor,
} from '../../../component-library/components/Texts/Text';
+import ConditionalScrollView from '../../../component-library/components-temp/ConditionalScrollView';
import {
ToastContext,
ToastVariants,
@@ -117,6 +118,7 @@ import { Hex, KnownCaipNamespace } from '@metamask/utils';
import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController';
import { PortfolioBalance } from '../../UI/Tokens/TokenList/PortfolioBalance';
import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts';
+import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage';
import AccountGroupBalance from '../../UI/Assets/components/Balance/AccountGroupBalance';
import useCheckNftAutoDetectionModal from '../../hooks/useCheckNftAutoDetectionModal';
import useCheckMultiRpcModal from '../../hooks/useCheckMultiRpcModal';
@@ -246,6 +248,9 @@ const WalletTokensTabView = React.memo((props: WalletTokensTabViewProps) => {
const isMultichainAccountsState2Enabled = useSelector(
selectMultichainAccountsState2Enabled,
);
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
const isPerpsEnabled = useMemo(
() =>
isPerpsFlagEnabled &&
@@ -476,7 +481,14 @@ const WalletTokensTabView = React.memo((props: WalletTokensTabViewProps) => {
return (
-
+
{tabsToRender}
@@ -1064,6 +1076,9 @@ const Wallet = ({
const shouldDisplayCardButton = useSelector(selectDisplayCardButton);
const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
+ const isHomepageRedesignV1Enabled = useSelector(
+ selectHomepageRedesignV1Enabled,
+ );
useEffect(() => {
if (!selectedInternalAccount) return;
@@ -1288,80 +1303,64 @@ const Wallet = ({
basicFunctionalityEnabled &&
assetsDefiPositionsEnabled;
- const renderContent = useCallback(
- () => (
-
-
-
- {!basicFunctionalityEnabled ? (
-
- {strings('wallet.banner.link')}
-
- }
- />
- ) : null}
-
-
- <>
- {isMultichainAccountsState2Enabled ? (
-
- ) : (
-
- )}
-
-
-
- {isCarouselBannersEnabled && }
+ const scrollViewContentStyle = useMemo(
+ () => [
+ styles.wrapper,
+ isHomepageRedesignV1Enabled && { flex: undefined, flexGrow: 0 },
+ ],
+ [styles.wrapper, isHomepageRedesignV1Enabled],
+ );
-
+
+
+ {!basicFunctionalityEnabled ? (
+
+ {strings('wallet.banner.link')}
+
+ }
/>
- >
+ ) : null}
+
- ),
- [
- styles.banner,
- styles.carousel,
- styles.wrapper,
- basicFunctionalityEnabled,
- defiEnabled,
- isMultichainAccountsState2Enabled,
- turnOnBasicFunctionality,
- onChangeTab,
- navigation,
- goToSwaps,
- displayBuyButton,
- displaySwapsButton,
- onReceive,
- onSend,
- route.params,
- isCarouselBannersEnabled,
- collectiblesEnabled,
- ],
+ <>
+ {isMultichainAccountsState2Enabled ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isCarouselBannersEnabled && }
+
+
+ >
+ >
);
const renderLoader = useCallback(
() => (
@@ -1375,7 +1374,24 @@ const Wallet = ({
return (
- {selectedInternalAccount ? renderContent() : renderLoader()}
+ {selectedInternalAccount ? (
+
+
+ {content}
+
+
+ ) : (
+ renderLoader()
+ )}
);
diff --git a/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap
index bf975a502d33..8a1ad45a3ea7 100644
--- a/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap
@@ -4690,7 +4690,8 @@ exports[`Amount does not show a warning when transfering collectibles 1`] = `
"alignItems": "center",
"backgroundColor": "#f3f5f9",
"borderRadius": 8,
- "justifyContent": "flex-start",
+ "flex": 1,
+ "justifyContent": "center",
},
undefined,
undefined,
@@ -4739,8 +4740,8 @@ exports[`Amount does not show a warning when transfering collectibles 1`] = `
undefined,
undefined,
{
- "flex": 1,
- "marginTop": 16,
+ "alignItems": "center",
+ "justifyContent": "center",
"textAlign": "center",
},
]
diff --git a/app/core/Engine/messengers/bridge-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-controller-messenger/index.ts
index 349eeae72de1..3dd843cf455b 100644
--- a/app/core/Engine/messengers/bridge-controller-messenger/index.ts
+++ b/app/core/Engine/messengers/bridge-controller-messenger/index.ts
@@ -28,7 +28,6 @@ export function getBridgeControllerMessenger(
actions: [
'AccountsController:getAccountByAddress',
'SnapController:handleRequest',
- 'NetworkController:getState',
'NetworkController:getNetworkClientById',
'NetworkController:findNetworkClientIdByChainId',
'TokenRatesController:getState',
diff --git a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts
index ff907d082961..383dc4a6ea49 100644
--- a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts
+++ b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts
@@ -79,7 +79,10 @@ describe('RPCBridgeAdapter', () => {
describe('Initialization', () => {
it('should initialize lazily on first send, wait for engine, and create a client', async () => {
mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(true);
- const request = { jsonrpc: '2.0', method: 'eth_accounts' };
+ const request = {
+ name: 'metamask-multichain-provider',
+ data: { jsonrpc: '2.0', method: 'eth_accounts' },
+ };
adapter.send(request);
// Wait for all async operations to complete
@@ -91,17 +94,20 @@ describe('RPCBridgeAdapter', () => {
expect.any(Function),
);
expect(MockedBackgroundBridge).toHaveBeenCalledTimes(1);
- expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({
- name: 'metamask-multichain-provider',
- data: request,
- });
+ expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request);
});
it('should be idempotent and initialize only once', async () => {
mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(true);
- adapter.send({ method: 'test1' });
- adapter.send({ method: 'test2' });
+ adapter.send({
+ name: 'metamask-multichain-provider',
+ data: { method: 'test1' },
+ });
+ adapter.send({
+ name: 'metamask-multichain-provider',
+ data: { method: 'test2' },
+ });
// Wait for all async operations to complete
await new Promise(process.nextTick);
@@ -114,7 +120,10 @@ describe('RPCBridgeAdapter', () => {
describe('Message Queuing and Processing', () => {
it('should queue requests when the wallet is locked', async () => {
mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(false);
- const request = { method: 'test_locked' };
+ const request = {
+ name: 'metamask-multichain-provider',
+ data: { method: 'test_locked' },
+ };
adapter.send(request);
// Wait for all async operations to complete
@@ -127,8 +136,14 @@ describe('RPCBridgeAdapter', () => {
it('should process the queue when the wallet is unlocked', async () => {
// Start locked
mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(false);
- const request1 = { method: 'test_queued1' };
- const request2 = { method: 'test_queued2' };
+ const request1 = {
+ name: 'metamask-multichain-provider',
+ data: { method: 'test_queued1' },
+ };
+ const request2 = {
+ name: 'metamask-multichain-provider',
+ data: { method: 'test_queued2' },
+ };
adapter.send(request1);
adapter.send(request2);
@@ -144,28 +159,22 @@ describe('RPCBridgeAdapter', () => {
await new Promise(process.nextTick);
expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledTimes(2);
- expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({
- name: 'metamask-multichain-provider',
- data: request1,
- });
- expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({
- name: 'metamask-multichain-provider',
- data: request2,
- });
+ expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request1);
+ expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request2);
});
it('should process requests immediately if already unlocked', async () => {
mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(true);
- const request = { method: 'test_unlocked' };
+ const request = {
+ name: 'metamask-multichain-provider',
+ data: { method: 'test_unlocked' },
+ };
adapter.send(request);
// Wait for all async operations to complete
await new Promise(process.nextTick);
- expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({
- name: 'metamask-multichain-provider',
- data: request,
- });
+ expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request);
});
});
@@ -197,7 +206,10 @@ describe('RPCBridgeAdapter', () => {
// Add another item to the queue to test if it gets cleared
mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(false);
- adapter.send({ method: 'test_dispose' });
+ adapter.send({
+ name: 'metamask-multichain-provider',
+ data: { method: 'test_dispose' },
+ });
adapter.dispose();
diff --git a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts
index 344204098fc8..112c1a2b74f9 100644
--- a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts
+++ b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts
@@ -94,10 +94,7 @@ export class RPCBridgeAdapter
while (this.queue.length > 0) {
const request = this.queue.shift();
- this.client.onMessage({
- name: 'metamask-multichain-provider',
- data: request,
- });
+ this.client.onMessage(request);
}
this.processing = false;
diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts
new file mode 100644
index 000000000000..3cc4049fa2fe
--- /dev/null
+++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts
@@ -0,0 +1,145 @@
+import {
+ RampsUnifiedBuyV1Config,
+ selectRampsUnifiedBuyV1Config,
+ selectRampsUnifiedBuyV1ActiveFlag,
+ selectRampsUnifiedBuyV1MinimumVersionFlag,
+} from './rampsUnifiedBuyV1';
+import { selectRemoteFeatureFlags } from '..';
+import { FeatureFlags } from '@metamask/remote-feature-flag-controller';
+
+describe('RampsUnifiedBuyV1 selectors', () => {
+ const mockRemoteFeatureFlags: ReturnType & {
+ rampsUnifiedBuyV1: RampsUnifiedBuyV1Config;
+ } = {
+ rampsUnifiedBuyV1: {
+ active: true,
+ minimumVersion: '2.0.0',
+ },
+ };
+
+ const mockEmptyRemoteFeatureFlags = {};
+
+ describe('selectRampsUnifiedBuyV1Config', () => {
+ it('returns the rampsUnifiedBuyV1Config when it exists', () => {
+ const result = selectRampsUnifiedBuyV1Config.resultFunc(
+ mockRemoteFeatureFlags,
+ );
+ expect(result).toEqual(mockRemoteFeatureFlags.rampsUnifiedBuyV1);
+ });
+
+ it('returns an empty object when rampsUnifiedBuyV1Config does not exist', () => {
+ const result = selectRampsUnifiedBuyV1Config.resultFunc(
+ mockEmptyRemoteFeatureFlags,
+ );
+ expect(result).toEqual({});
+ });
+
+ it('returns an empty object when remoteFeatureFlags is null', () => {
+ const result = selectRampsUnifiedBuyV1Config.resultFunc(
+ null as unknown as FeatureFlags,
+ );
+ expect(result).toEqual({});
+ });
+
+ it('returns an empty object when remoteFeatureFlags is undefined', () => {
+ const result = selectRampsUnifiedBuyV1Config.resultFunc(
+ undefined as unknown as FeatureFlags,
+ );
+ expect(result).toEqual({});
+ });
+ });
+
+ describe('selectRampsUnifiedBuyV1ActiveFlag', () => {
+ it('returns true when active is set to true', () => {
+ const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc(
+ mockRemoteFeatureFlags.rampsUnifiedBuyV1,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('returns false when active is set to false', () => {
+ const mockConfigWithActiveFalse: RampsUnifiedBuyV1Config = {
+ active: false,
+ minimumVersion: '2.0.0',
+ };
+ const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc(
+ mockConfigWithActiveFalse,
+ );
+ expect(result).toBe(false);
+ });
+
+ it('returns false when active is not set', () => {
+ const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc({});
+ expect(result).toBe(false);
+ });
+
+ it('returns false when active is null', () => {
+ const mockConfigWithActiveNull: RampsUnifiedBuyV1Config = {
+ active: null as unknown as boolean,
+ minimumVersion: '2.0.0',
+ };
+ const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc(
+ mockConfigWithActiveNull,
+ );
+ expect(result).toBe(false);
+ });
+
+ it('returns false when active is undefined', () => {
+ const mockConfigWithActiveUndefined: RampsUnifiedBuyV1Config = {
+ active: undefined as unknown as boolean,
+ minimumVersion: '2.0.0',
+ };
+ const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc(
+ mockConfigWithActiveUndefined,
+ );
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('selectRampsUnifiedBuyV1MinimumVersionFlag', () => {
+ it('returns the minimumVersion when it exists', () => {
+ const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc(
+ mockRemoteFeatureFlags.rampsUnifiedBuyV1,
+ );
+ expect(result).toBe('2.0.0');
+ });
+
+ it('returns null when minimumVersion is not set', () => {
+ const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc({});
+ expect(result).toBeNull();
+ });
+
+ it('returns null when minimumVersion is null', () => {
+ const mockConfigWithVersionNull: RampsUnifiedBuyV1Config = {
+ active: true,
+ minimumVersion: null as unknown as string,
+ };
+ const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc(
+ mockConfigWithVersionNull,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('returns null when minimumVersion is undefined', () => {
+ const mockConfigWithVersionUndefined: RampsUnifiedBuyV1Config = {
+ active: true,
+ minimumVersion: undefined,
+ };
+ const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc(
+ mockConfigWithVersionUndefined,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('returns the minimumVersion when it is an empty string', () => {
+ const mockConfigWithEmptyVersion: RampsUnifiedBuyV1Config = {
+ active: true,
+ minimumVersion: '',
+ };
+ const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc(
+ mockConfigWithEmptyVersion,
+ );
+ expect(result).toBe('');
+ });
+ });
+});
diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts
new file mode 100644
index 000000000000..ddfde994e197
--- /dev/null
+++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts
@@ -0,0 +1,34 @@
+import { createSelector } from 'reselect';
+import { selectRemoteFeatureFlags } from '..';
+
+export interface RampsUnifiedBuyV1Config {
+ active?: boolean;
+ minimumVersion?: string;
+}
+
+const FLAG_KEY = 'rampsUnifiedBuyV1';
+
+export const selectRampsUnifiedBuyV1Config = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) => {
+ const rampsUnifiedBuyV1Config = remoteFeatureFlags?.[FLAG_KEY];
+ return (rampsUnifiedBuyV1Config ?? {}) as RampsUnifiedBuyV1Config;
+ },
+);
+
+export const selectRampsUnifiedBuyV1ActiveFlag = createSelector(
+ selectRampsUnifiedBuyV1Config,
+ (rampsUnifiedBuyV1Config) => {
+ const rampsUnifiedBuyV1ActiveFlag = rampsUnifiedBuyV1Config?.active;
+ return rampsUnifiedBuyV1ActiveFlag ?? false;
+ },
+);
+
+export const selectRampsUnifiedBuyV1MinimumVersionFlag = createSelector(
+ selectRampsUnifiedBuyV1Config,
+ (rampsUnifiedBuyV1Config) => {
+ const rampsUnifiedBuyV1MinimumVersion =
+ rampsUnifiedBuyV1Config?.minimumVersion;
+ return rampsUnifiedBuyV1MinimumVersion ?? null;
+ },
+);
diff --git a/app/util/bridge/hooks/useValidateBridgeTx.ts b/app/util/bridge/hooks/useValidateBridgeTx.ts
index e18fbd09c332..f49850d0bebd 100644
--- a/app/util/bridge/hooks/useValidateBridgeTx.ts
+++ b/app/util/bridge/hooks/useValidateBridgeTx.ts
@@ -10,12 +10,15 @@ export default function useValidateBridgeTx() {
const validateBridgeTx = async ({
quoteResponse,
+ signal,
}: {
quoteResponse: QuoteResponse & QuoteMetadata;
+ signal?: AbortSignal;
}) => {
const response = await fetch(
`${AppConstants.SECURITY_ALERTS_API.URL}/solana/message/scan`,
{
+ signal,
headers: {
'Content-Type': 'application/json',
accept: 'application/json',
diff --git a/babel.config.tests.js b/babel.config.tests.js
index acf822dc6206..88ec7910080e 100644
--- a/babel.config.tests.js
+++ b/babel.config.tests.js
@@ -23,6 +23,8 @@ const newOverrides = [
'app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.test.ts',
'app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.ts',
'app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.test.ts',
+ 'app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts',
+ 'app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.test.ts',
'app/store/migrations/**',
'app/util/networks/customNetworks.tsx',
],
diff --git a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts
index 33c93b558297..3bacec39de49 100644
--- a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts
+++ b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts
@@ -270,6 +270,12 @@ const DEFAULT_FEATURE_FLAGS_ARRAY: Record[] = [
{
additionalNetworksBlacklist: [], // Empty by default, can be overridden in tests
},
+ {
+ rampsUnifiedBuyV1: {
+ minimumVersion: '7.61.0',
+ active: false,
+ },
+ },
];
/**
diff --git a/e2e/pages/wallet/NetworkManager.ts b/e2e/pages/wallet/NetworkManager.ts
index 95419dfb6dfb..22e093220077 100644
--- a/e2e/pages/wallet/NetworkManager.ts
+++ b/e2e/pages/wallet/NetworkManager.ts
@@ -119,9 +119,10 @@ class NetworkManager {
/**
* Get token element by symbol
+ * Note: Gets the first instance in case of duplicates during render cycles
*/
getTokenBySymbol(symbol: string): DetoxElement {
- return Matchers.getElementByID(`asset-${symbol}`);
+ return Matchers.getElementByID(`asset-${symbol}`, 0);
}
/**
diff --git a/e2e/selectors/wallet/WalletView.selectors.ts b/e2e/selectors/wallet/WalletView.selectors.ts
index 43eda1eb99ef..f2edfc01b53c 100644
--- a/e2e/selectors/wallet/WalletView.selectors.ts
+++ b/e2e/selectors/wallet/WalletView.selectors.ts
@@ -75,6 +75,7 @@ export const WalletViewSelectorsIDs = {
DEFI_POSITIONS_CONTAINER: 'defi-positions-container',
DEFI_POSITIONS_NETWORK_FILTER: 'defi-positions-network-filter',
DEFI_POSITIONS_LIST: 'defi-positions-list',
+ DEFI_POSITIONS_SCROLL_VIEW: 'defi-positions-scroll-view',
DEFI_POSITIONS_DETAILS_CONTAINER: 'defi-positions-details-container',
// Wallet-specific action buttons to avoid conflicts with TokenOverview
WALLET_BUY_BUTTON: 'wallet-buy-button',
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 720be8004ae3..3488b52ec0a8 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -2046,6 +2046,7 @@
"learn_more": "Learn more",
"add_collectibles": "Import NFTs",
"nft_empty_description": "There's a world of NFTs out there. Start your collection today.",
+ "tokens_empty_description": "Nothing to see yet. Why not browse tokens or make a trade?",
"discover_nfts": "Import NFTs",
"no_transactions": "You have no transactions!",
"switch_network_to_view_transactions": "Please switch network to view transactions",
@@ -2503,7 +2504,7 @@
"nft_autodetect_mode": "Autodetect NFTs",
"nft_autodetect_desc": "Displaying NFT media & data may expose your IP address to centralized servers. Third-party APIs (like OpenSea) are used to detect NFTs in your wallet. This exposes your account address with those services. Leave this disabled if you don't want the app to pull data from those services.",
"show_fiat_on_testnets": "Show conversion on test networks",
- "show_fiat_on_testnets_desc": "Select this to show fiat conversion on test networks",
+ "show_fiat_on_testnets_desc": "Select this to show fiat conversion on test networks.",
"show_fiat_on_testnets_modal_title": "Be careful",
"show_fiat_on_testnets_modal_description": "If you've been asked to turn this feature on, you might be getting scammed. These tokens have no monetary value and are for testing purposes only. This feature helps developers make sure their apps work.",
"show_fiat_on_testnets_modal_learn_more": "Learn more.",
@@ -2517,9 +2518,9 @@
"jazzicons": "Jazzicons",
"blockies": "Blockies",
"general_title": "General",
- "general_desc": "Currency conversion, primary currency, language and search engine",
+ "general_desc": "Currency conversion, primary currency, language and search engine.",
"advanced_title": "Advanced",
- "advanced_desc": "Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC",
+ "advanced_desc": "Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC.",
"notifications_title": "Notifications",
"notifications_desc": "Manage your notifications",
"allow_notifications": "Allow notifications",
@@ -2543,9 +2544,9 @@
"perps_title": "Perps trading"
},
"contacts_title": "Contacts",
- "contacts_desc": "Add, edit, remove, and manage your accounts",
+ "contacts_desc": "Add, edit, remove, and manage your accounts.",
"permissions_title": "Permissions",
- "permissions_desc": "Manage the permissions given to sites and apps",
+ "permissions_desc": "Manage the permissions given to sites and apps.",
"no_permissions": "No permissions",
"no_permissions_desc": "If you connect an account to a site or an app, you’ll see it here.",
"security_title": "Security & Privacy",
@@ -4243,13 +4244,13 @@
},
"experimental_settings": {
"wallet_connect_dapps": "WalletConnect Sessions",
- "wallet_connect_dapps_desc": "View the list of active WalletConnect sessions",
+ "wallet_connect_dapps_desc": "View the list of active WalletConnect sessions.",
"wallet_connect_dapps_cta": "View sessions",
"network_not_supported": "Current network not supported",
"select_provider": "Select your preferred provider",
"switch_network": "Please switch to mainnet or sepolia",
"card_title": "Always show MetaMask Card button",
- "card_desc": "MetaMask Card is only available to residents of select countries"
+ "card_desc": "MetaMask Card is only available to residents of select countries."
},
"walletconnect_sessions": {
"no_active_sessions": "You have no active sessions",
diff --git a/package.json b/package.json
index 20577bae664f..cf5cff29f17c 100644
--- a/package.json
+++ b/package.json
@@ -201,7 +201,7 @@
"@metamask/assets-controllers": "^84.0.0",
"@metamask/base-controller": "^9.0.0",
"@metamask/bitcoin-wallet-snap": "^1.4.3",
- "@metamask/bridge-controller": "^56.0.0",
+ "@metamask/bridge-controller": "^56.0.3",
"@metamask/bridge-status-controller": "^56.0.0",
"@metamask/chain-agnostic-permission": "^1.2.2",
"@metamask/composable-controller": "^12.0.0",
diff --git a/scripts/build.sh b/scripts/build.sh
index 9c1be7cdbd94..d6fe322c5b96 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -470,6 +470,20 @@ generateIosBinary() {
scheme="$1"
configuration="${CONFIGURATION:-"Release"}"
+ # Check if configuration is valid
+ if [ "$configuration" != "Debug" ] && [ "$configuration" != "Release" ] ; then
+ # Configuration is not recognized
+ echo "Configuration $configuration is not recognized! Only Debug and Release are supported"
+ exit 1
+ fi
+
+ # Check if scheme is valid
+ if [ "$scheme" != "MetaMask" ] && [ "$scheme" != "MetaMask-QA" ] && [ "$scheme" != "MetaMask-Flask" ] ; then
+ # Scheme is not recognized
+ echo "Scheme $scheme is not recognized! Only MetaMask, MetaMask-QA, and MetaMask-Flask are supported"
+ exit 1
+ fi
+
if [ "$scheme" = "MetaMask" ] ; then
# Main target
if [ "$configuration" = "Debug" ] ; then
@@ -520,17 +534,27 @@ generateAndroidBinary() {
# Debug or Release
configuration="${CONFIGURATION:-"Release"}"
+ # Check if configuration is valid
+ if [ "$configuration" != "Debug" ] && [ "$configuration" != "Release" ] ; then
+ # Configuration is not recognized
+ echo "Configuration $configuration is not recognized! Only Debug and Release are supported"
+ exit 1
+ fi
+
+ # Check if flavor is valid
+ if [ "$flavor" != "Prod" ] && [ "$flavor" != "Flask" ] && [ "$flavor" != "Qa" ] ; then
+ # Flavor is not recognized
+ echo "Flavor $flavor is not recognized! Only Prod, Flask, and Qa (Deprecated - Do not use) are supported"
+ exit 1
+ fi
+
# Create flavor configuration
flavorConfiguration="app:assemble${flavor}${configuration}"
- # Create test configuration
- testConfiguration="app:assemble${flavor}${configuration}AndroidTest"
- # Generate Android binary
echo "Generating Android binary for ($flavor) flavor with ($configuration) configuration"
- ./gradlew $flavorConfiguration $testConfiguration --build-cache --parallel
-
-
if [ "$configuration" = "Release" ] ; then
+ # Generate Android binary
+ ./gradlew $flavorConfiguration --build-cache --parallel
# Generate AAB bundle
bundleConfiguration="bundle${flavor}Release"
echo "Generating AAB bundle for ($flavor) flavor with ($configuration) configuration"
@@ -541,6 +565,11 @@ generateAndroidBinary() {
checkSumCommand="build:android:checksum:${lowerCaseFlavor}"
echo "Generating checksum for ($flavor) flavor with ($configuration) configuration"
yarn $checkSumCommand
+ elif [ "$configuration" = "Debug" ] ; then
+ # Create test configuration
+ testConfiguration="app:assemble${flavor}DebugAndroidTest"
+ # Generate Android binary
+ ./gradlew $flavorConfiguration $testConfiguration --build-cache --parallel
fi
# Change directory back out
diff --git a/yarn.lock b/yarn.lock
index 85eca594b1f8..bf9568b0fdaf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7058,9 +7058,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bridge-controller@npm:^56.0.0":
- version: 56.0.0
- resolution: "@metamask/bridge-controller@npm:56.0.0"
+"@metamask/bridge-controller@npm:^56.0.3":
+ version: 56.0.3
+ resolution: "@metamask/bridge-controller@npm:56.0.3"
dependencies:
"@ethersproject/address": "npm:^5.7.0"
"@ethersproject/bignumber": "npm:^5.7.0"
@@ -7086,7 +7086,7 @@ __metadata:
"@metamask/remote-feature-flag-controller": ^2.0.0
"@metamask/snaps-controllers": ^14.0.0
"@metamask/transaction-controller": ^61.0.0
- checksum: 10/d63591ddb50565c969447050a5c8f7f12fade69fb30e884e87c24c2d86803074d7a337df42c4f9feaf91d01241baaba7da0b23101a993547af8331085c8df1a5
+ checksum: 10/399a53ec01e18a9b9add4242b12c42ace472372fc04ed6e21d6e65f277ecc4aa220456933547d8d838e8834927602e7b1c149a8264de8742367d051801573bbd
languageName: node
linkType: hard
@@ -34279,7 +34279,7 @@ __metadata:
"@metamask/auto-changelog": "npm:^5.1.0"
"@metamask/base-controller": "npm:^9.0.0"
"@metamask/bitcoin-wallet-snap": "npm:^1.4.3"
- "@metamask/bridge-controller": "npm:^56.0.0"
+ "@metamask/bridge-controller": "npm:^56.0.3"
"@metamask/bridge-status-controller": "npm:^56.0.0"
"@metamask/browser-passworder": "npm:^5.0.0"
"@metamask/build-utils": "npm:^3.0.0"