From f3526c410a713abaad8cfc2954cc58c35bed227b Mon Sep 17 00:00:00 2001
From: sophieqgu <37032128+sophieqgu@users.noreply.github.com>
Date: Mon, 3 Nov 2025 13:41:27 -0500
Subject: [PATCH 01/13] fix: cp-7.58.0 time remaining icon color (#22031)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Sets the time-remaining clock `Icon` in `ActiveBoosts.tsx` to use
white text color.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9f2249bf43220e742bc674634ff19ff6445914f0. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx
index d8db90e231f..95e4f2c7d12 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}
From 793cefb924370e0a9eb60a5dea3cf18dd97ddd47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patryk=20=C5=81ucka?=
<5708018+PatrykLucka@users.noreply.github.com>
Date: Mon, 3 Nov 2025 19:46:29 +0100
Subject: [PATCH 02/13] feat: add missing dots to settings descriptions
(#22053)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR fixes missing periods at the end of settings descriptions.
## **Changelog**
CHANGELOG entry: Fixed missing periods at the end of settings
descriptions
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/18521
https://consensyssoftware.atlassian.net/browse/TMCU-122
## **Manual testing steps**
```gherkin
Feature: Go to settings
Scenario: user goes to settings
Given user onboarded
When user navigates to settings
Then all settings descriptions have periods at the end
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Adds trailing periods to several settings descriptions in `en.json`
and updates corresponding Jest snapshots.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
297ee4f2017c9f70951f0e61c6508cd765891c89. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../__snapshots__/index.test.tsx.snap | 2 +-
.../__snapshots__/index.test.tsx.snap | 2 +-
.../Settings/__snapshots__/index.test.tsx.snap | 8 ++++----
locales/languages/en.json | 14 +++++++-------
4 files changed, 13 insertions(+), 13 deletions(-)
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 0db613e09e2..a7b7e46d8d6 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.
Date: Mon, 3 Nov 2025 19:46:33 +0100
Subject: [PATCH 03/13] fix: inconsistent top padding in networks list (#22052)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR fixes inconsistent top padding in networks list
## **Changelog**
CHANGELOG entry: Fixed inconsistent top padding in networks list
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-70
## **Manual testing steps**
```gherkin
Feature: Open custom networks list
Scenario: user opens custom networks list
Given user onboarded
When user navigates to activity and clicks networks selector and them "Custom" tab
Then padding between tabs header and first item is consistent with paddings between items
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Adds `paddingVertical: 12` to `CustomNetworkSelector` `container`
style to standardize top spacing in the custom networks list.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
d7a0255a4f997d125e83f9ffedb5a25ed5441bf1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
index b5ae74d45c4..8fcd492461d 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,
From 411ac108657a26fce98252c5be5340d31fae00eb Mon Sep 17 00:00:00 2001
From: Cal Leung
Date: Mon, 3 Nov 2025 11:08:01 -0800
Subject: [PATCH 04/13] fix: Remove test task for android release builds
(#22063)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This fix removes Android's test task for release builds since it doesn't
exist and is not needed for those variants. Tests remains intact for
debug builds. Also added check on generate binary helpers to validate
scheme, flavor, and configuration.
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
nightly_exp_builds_pipeline -
https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/db618aea-1aee-4596-84c4-53487bac4ef4
nightly_rc_builds_pipeline -
https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/4e3bb45e-e7af-41a4-8ded-c46e0b35797e
expo_dev_pipeline -
https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/d0247767-59dd-4ffe-a5fe-e9b405e1bfa0
When an invalid scheme is passed to iOS -
When an invalid configuration is passed to iOS -
When an invalid configuration is passed to Android -
When an invalid flavor is passed to Android -
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Add strict build param validation and remove Android release
AndroidTest task while keeping debug tests and release AAB/checksum
steps.
>
> - **scripts/build.sh**:
> - **Build parameter validation**:
> - `generateIosBinary`: Validate `configuration` (`Debug|Release`) and
`scheme` (`MetaMask|MetaMask-QA|MetaMask-Flask`).
> - `generateAndroidBinary`: Validate `configuration` (`Debug|Release`)
and `flavor` (`Prod|Flask|Qa`).
> - **Android build behavior**:
> - Release: stop assembling `AndroidTest`; only assemble app, then
build AAB and generate checksum.
> - Debug: continue assembling `AndroidTest` alongside app.
> - Minor: clearer echo messages and branching for gradle tasks.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
d8dd18f2ede8c6b55af47fd30e7f9eda00c2c6be. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
scripts/build.sh | 41 +++++++++++++++++++++++++++++++++++------
1 file changed, 35 insertions(+), 6 deletions(-)
diff --git a/scripts/build.sh b/scripts/build.sh
index 9c1be7cdbd9..d6fe322c5b9 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
From 179605b1e2ae3366632deba3c332314bcaf65dda Mon Sep 17 00:00:00 2001
From: Pedro Pablo Aste Kompen
Date: Mon, 3 Nov 2025 16:20:25 -0300
Subject: [PATCH 05/13] test(ramp): add unified ff mock (#22069)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This pull request adds a new default feature flag configuration for
`rampsUnifiedBuyV1` in the `remoteFeatureFlagsHelper.ts` file. This
feature flag is set with a minimum version and is inactive by default,
allowing tests to override or activate it as needed. No other changes
are included.
- Added a default feature flag entry for `rampsUnifiedBuyV1` with
`minimumVersion` set to `'7.61.0'` and `active` set to `false` in
`DEFAULT_FEATURE_FLAGS_ARRAY`
(`e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts`).
## **Changelog**
CHANGELOG entry: Adds Ramps Unified buy v1 feature flag mock to e2e
tests
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2805
## **Manual testing steps**
N/A
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Adds default `rampsUnifiedBuyV1` flag (minimumVersion `7.61.0`,
`active: false`) to the e2e remote feature flags helper.
>
> - **Testing helpers**:
> - Add default `rampsUnifiedBuyV1` flag to
`DEFAULT_FEATURE_FLAGS_ARRAY` in
`e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts` with
`minimumVersion: '7.61.0'` and `active: false`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c6df1d22a20108ea11381f544dea7b853a004c36. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts
index 33c93b55829..3bacec39de4 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,
+ },
+ },
];
/**
From 0f7d357239f79ebb7d29b9f4663ec249a199ab16 Mon Sep 17 00:00:00 2001
From: jiexi
Date: Mon, 3 Nov 2025 11:30:46 -0800
Subject: [PATCH 06/13] fix: stop wrapping MMC messages in multichain substream
(#22027)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fixes a MM Connect bug that was wrapping the messages from the dapp to
be shaped as if they came from the multichain provider substream. We
should be allowing the dapp side to fully handle that rather than doing
that in the wallet side.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
See: https://github.com/MetaMask/connect-monorepo/pull/28
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> RPCBridgeAdapter now sends raw messages to BackgroundBridge (no
multichain wrapper), and tests updated to use/expect the new message
shape.
>
> - **SDKConnectV2**
> - **RPCBridgeAdapter**: Stop wrapping queued requests with `name:
'metamask-multichain-provider'`; forward the original `request` directly
to `client.onMessage`.
> - **Tests**
(`app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts`):
> - Update requests to include `{ name: 'metamask-multichain-provider',
data: ... }` where appropriate and adjust assertions to expect direct
forwarding of the full request object.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
54455cd93b535b8fdf9ba15fdff6acc993aee260. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../adapters/rpc-bridge-adapter.test.ts | 60 +++++++++++--------
.../adapters/rpc-bridge-adapter.ts | 5 +-
2 files changed, 37 insertions(+), 28 deletions(-)
diff --git a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts
index ff907d08296..383dc4a6ea4 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 344204098fc..112c1a2b74f 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;
From 9998e6384f116335bf27083801c7e77851440d56 Mon Sep 17 00:00:00 2001
From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com>
Date: Mon, 3 Nov 2025 16:13:12 -0330
Subject: [PATCH 07/13] chore: sync stable to main for version 7.57.1 (#21899)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR syncs the stable branch to main for version 7.57.1.
*Synchronization Process:*
- Fetches the latest changes from the remote repository
- Resets the branch to match the stable branch
- Attempts to merge changes from main into the branch
- Handles merge conflicts if they occur
*File Preservation:*
Preserves specific files from the stable branch:
- CHANGELOG.md
- bitrise.yml
- android/app/build.gradle
- ios/MetaMask.xcodeproj/project.pbxproj
- package.json
Indicates the next version candidate of main to 7.59.0
---
> [!NOTE]
> Updates CHANGELOG for 7.57.1 with two fixes, corrects comparison
links, and normalizes bullet formatting in prior sections.
>
> - **Documentation**:
> - **CHANGELOG (`CHANGELOG.md`)**:
> - Add `7.57.1` section under Fixed:
> - show edit account bottomsheet on Android when behind keyboard
> - patch Touchable issue in React Native
> - Update links:
> - `[Unreleased]` now compares `v7.57.1...HEAD`
> - add `[7.57.1]` compare link `v7.57.0...v7.57.1`
> - Normalize bullets/formatting in prior `7.56.x` sections and adjust a
sonar coverage exclusion entry.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
1f3d7b918f9401e2fd7bbba72cf4e18392e0489c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Bryan Fullam
Co-authored-by: metamaskbot
Co-authored-by: Wei Sun
Co-authored-by: Nicholas Smith
Co-authored-by: ieow <4881057+ieow@users.noreply.github.com>
Co-authored-by: runway-github[bot] <73448015+runway-github[bot]@users.noreply.github.com>
Co-authored-by: Matthew Grainger <46547583+Matt561@users.noreply.github.com>
Co-authored-by: Micaela <100321200+micaelae@users.noreply.github.com>
Co-authored-by: SteP-n-s
Co-authored-by: Micaela Estabillo
Co-authored-by: Matthew Walsh
Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Co-authored-by: Claude
Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com>
Co-authored-by: Xavier Brochard
Co-authored-by: gantunesr <17601467+gantunesr@users.noreply.github.com>
Co-authored-by: Gaurav Goel
Co-authored-by: Nicholas Gambino
Co-authored-by: Arthur Breton
Co-authored-by: sethkfman
Co-authored-by: sethkfman <10342624+sethkfman@users.noreply.github.com>
Co-authored-by: Pedro Pablo Aste Kompen
Co-authored-by: infiniteflower <139582705+infiniteflower@users.noreply.github.com>
Co-authored-by: Matthew Grainger
Co-authored-by: himanshuchawla009
Co-authored-by: Nico MASSART
Co-authored-by: Brian August Nguyen
Co-authored-by: dylanbutler1 <99672693+dylanbutler1@users.noreply.github.com>
Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com>
Co-authored-by: Vince Howard
Co-authored-by: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Co-authored-by: Ganesh Suresh Patra
Co-authored-by: Jyoti Puri
Co-authored-by: OGPoyraz
Co-authored-by: Prithpal Sooriya
Co-authored-by: Salim TOUBAL
Co-authored-by: Cal Leung
Co-authored-by: Satyajeet Kolhapure <77279246+satyajeetkolhapure@users.noreply.github.com>
Co-authored-by: Jean-Baptiste Blanc
Co-authored-by: George Weiler
Co-authored-by: Caainã Jeronimo
Co-authored-by: Luis Taniça
Co-authored-by: VGR
Co-authored-by: George Marshall
Co-authored-by: AxelGes <34173844+AxelGes@users.noreply.github.com>
Co-authored-by: Patryk Łucka
Co-authored-by: Davide Brocchetto
Co-authored-by: Curtis David
Co-authored-by: Monte Lai
Co-authored-by: Bruno Nascimento
Co-authored-by: Maarten Zuidhoorn
Co-authored-by: cmd-ob
Co-authored-by: João
Co-authored-by: sophieqgu
Co-authored-by: Alejandro Garcia Anglada
Co-authored-by: Mathieu Artu
Co-authored-by: sophieqgu <37032128+sophieqgu@users.noreply.github.com>
Co-authored-by: Charly Chevalier
Co-authored-by: Bryan Fullam <8902170+bfullam@users.noreply.github.com>
Co-authored-by: António Regadas
Co-authored-by: Edouard Bougon <15703023+EdouardBougon@users.noreply.github.com>
Co-authored-by: Alex Donesky
Co-authored-by: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com>
Co-authored-by: Fabio Bozzo
Co-authored-by: Owen Craston
Co-authored-by: Pavel Dvorkin
Co-authored-by: Kevin Bluer
Co-authored-by: Nodonisko
Co-authored-by: Tamas
Co-authored-by: ffmcgee <51971598+ffmcgee725@users.noreply.github.com>
Co-authored-by: Alexandre Chappaz
Co-authored-by: Harika <153644847+hjetpoluru@users.noreply.github.com>
Co-authored-by: Erik Nilsson
Co-authored-by: Patryk Łucka <5708018+PatrykLucka@users.noreply.github.com>
Co-authored-by: Bernardo Garces Chapero
Co-authored-by: Christian Montoya
Co-authored-by: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com>
Co-authored-by: Gauthier Petetin
Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com>
Co-authored-by: Xiaoming Wang <7315988+dawnseeker8@users.noreply.github.com>
Co-authored-by: Elliot Winkler
Co-authored-by: Frederik Bolding
Co-authored-by: Charly Chevalier
Co-authored-by: salimtb
Co-authored-by: tommasini
Co-authored-by: jake-perkins <128608287+jake-perkins@users.noreply.github.com>
---
CHANGELOG.md | 38 +++++++++++++++++++++++---------------
1 file changed, 23 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2138c62d19..985f34f0f8c 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
From 3b9734560e0feab65d7518b3fcf2ade99fc107c3 Mon Sep 17 00:00:00 2001
From: tommasini <46944231+tommasini@users.noreply.github.com>
Date: Mon, 3 Nov 2025 19:44:48 +0000
Subject: [PATCH 08/13] chore: bump cross-spawn wdio dep (#21314)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
wdio/cli cross-spawn package bump to remove an audit issue
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Adds `npmAuditIgnoreAdvisories` in `.yarnrc.yml` to ignore cross-spawn
ReDoS advisory `1104663` used via wdio dev dependency.
>
> - **Tooling/Config**:
> - Update `.yarnrc.yml` to add `npmAuditIgnoreAdvisories` with advisory
`1104663` (cross-spawn ReDoS) and related comments.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
f2b1b6c900b0016ba46e0239614497d8d57ac9c7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: metamaskbot
---
.yarnrc.yml | 5 -----
1 file changed, 5 deletions(-)
diff --git a/.yarnrc.yml b/.yarnrc.yml
index dad119471ed..8a6d20d53c6 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
From a2812ef9112cd0726f535a987cc0e4f90a743129 Mon Sep 17 00:00:00 2001
From: Micaela <100321200+micaelae@users.noreply.github.com>
Date: Mon, 3 Nov 2025 12:09:01 -0800
Subject: [PATCH 09/13] chore: improve quote streaming UX and prevent premature
tx submission (#21685)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Handles progressive quote streaming gracefully
- disables swap tx submission until quotes are fully loaded so the user
can see warnings beforehand
- cancels ongoing tx scan when a new quote is streamed to prevent
overlapping requests
- reduces input debounce time to 300ms
- bumps bridge-controller and bridge-status-controller to latest
versions
## **Changelog**
CHANGELOG entry: chore: improve quote streaming UX and prevent premature
tx submission
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3243
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
When SSE is enabled
1. Get quotes for <$10 of any ethereum token to sol:USDC
2. Wait 30s for quotes to refresh
3. The Swap button should be disabled while quotes are loading
4. Page should only show alerts after quotes are finished loading
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Disables swap submission while quotes load, adds abortable Solana
validation to prevent overlapping requests, reduces input debounce to
300ms, and updates @metamask/bridge-controller.
>
> - **Bridge UX/Logic**:
> - Disables `Confirm` button while quotes are `isLoading` in
`BridgeView/index.tsx`.
> - Adds abortable validation in `useBridgeQuoteData` using
`AbortController`, cancels previous requests on new quotes/unmount, and
guards results via a validation ID.
> - Passes `AbortSignal` to `useValidateBridgeTx` and threads `signal`
through fetch.
> - Lowers quote request debounce to `300ms` in `useBridgeQuoteRequest`.
> - **Messaging/Permissions**:
> - Removes `NetworkController:getState` from delegated actions in
`bridge-controller-messenger`.
> - **Tests**:
> - Updates `useBridgeQuoteData` tests to expect `signal: AbortSignal`
in validation calls.
> - **Dependencies**:
> - Bumps `@metamask/bridge-controller` to `^56.0.3`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
de66fb0a02f904f98f2903b36913beaae874228e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Bryan Fullam
---
app/components/UI/Bridge/Views/BridgeView/index.tsx | 1 +
.../UI/Bridge/hooks/useBridgeQuoteData/index.ts | 13 +++++++++++++
.../useBridgeQuoteData/useBridgeQuoteData.test.ts | 1 +
.../UI/Bridge/hooks/useBridgeQuoteRequest/index.ts | 2 +-
.../messengers/bridge-controller-messenger/index.ts | 1 -
app/util/bridge/hooks/useValidateBridgeTx.ts | 3 +++
package.json | 2 +-
yarn.lock | 10 +++++-----
8 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx
index 2cf3d679286..7c4a85e1953 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 912dbe62b14..6eea4d028f5 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 a8d3ddace78..0e53507e4c5 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 f4e0e12f999..ca09395bf10 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/core/Engine/messengers/bridge-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-controller-messenger/index.ts
index 349eeae72de..3dd843cf455 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/util/bridge/hooks/useValidateBridgeTx.ts b/app/util/bridge/hooks/useValidateBridgeTx.ts
index e18fbd09c33..f49850d0beb 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/package.json b/package.json
index 20577bae664..cf5cff29f17 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/yarn.lock b/yarn.lock
index 85eca594b1f..bf9568b0fda 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"
From eab9e80974f0b18ac9837888eff91f60893e9103 Mon Sep 17 00:00:00 2001
From: Pedro Pablo Aste Kompen
Date: Mon, 3 Nov 2025 17:35:08 -0300
Subject: [PATCH 10/13] feat(ramp): add ramps unified v1 feature flag selectors
+ hook (#22062)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This pull request introduces a new feature flag system for enabling the
"Ramps Unified Buy V1" functionality, allowing for both build-time and
remote feature flag control, along with robust version gating. It
includes the implementation of selectors, a custom React hook, and
comprehensive unit tests to ensure correct precedence and behavior of
flags and version checks.
Feature flag infrastructure:
* Added new selectors in `rampsUnifiedBuyV1.ts` to retrieve the unified
buy V1 feature flag configuration, including active status and minimum
required version, from remote feature flags.
* Implemented unit tests for these selectors to ensure they handle all
edge cases (e.g., missing, null, or undefined values).
Custom hook for feature enablement logic:
* Created `useRampsUnifiedV1Enabled` hook that determines if the feature
should be enabled, prioritizing the build flag
(`MM_RAMPS_UNIFIED_BUY_V1_ENABLED`) over remote flags, and enforcing a
minimum app version check using `compare-versions`.
Testing and configuration:
* Added comprehensive tests for the `useRampsUnifiedV1Enabled` hook to
validate precedence of build and remote flags, version gating, and edge
cases.
* Updated Babel test configuration to include the new hook and its
tests.
**Build flag**:
in `.js.env` presence of this flag means overriding the value, to remove
the override just set it to `""`
```
## Unified Buy v1
export MM_RAMPS_UNIFIED_BUY_V1_ENABLED="true"
```
## **Changelog**
CHANGELOG entry: Add Ramps Unified Buy feature flag
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2803
## **Manual testing steps**
N/A
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Adds selectors and a hook to enable Ramps Unified Buy V1 via
build/remote flags with minimum-version checks, plus tests and Babel
config updates.
>
> - **Feature flags**:
> - Add selectors `selectRampsUnifiedBuyV1Config`,
`selectRampsUnifiedBuyV1ActiveFlag`,
`selectRampsUnifiedBuyV1MinimumVersionFlag` in
`app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts`.
> - **Hook**:
> - Implement `useRampsUnifiedV1Enabled` in
`app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts` with build
flag (`MM_RAMPS_UNIFIED_BUY_V1_ENABLED`) precedence and
`compare-versions` minimum-version gating.
> - **Tests**:
> - Add unit tests for selectors and hook covering flag precedence and
version edge cases.
> - **Config**:
> - Update `babel.config.tests.js` to exclude the new hook/tests from
env var inlining.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8274e811ffc31823270b69b284d816a0dabc21a6. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../hooks/useRampsUnifiedV1Enabled.test.ts | 237 ++++++++++++++++++
.../UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts | 46 ++++
.../ramps/rampsUnifiedBuyV1.test.ts | 145 +++++++++++
.../ramps/rampsUnifiedBuyV1.ts | 34 +++
babel.config.tests.js | 2 +
5 files changed, 464 insertions(+)
create mode 100644 app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.test.ts
create mode 100644 app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts
create mode 100644 app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts
create mode 100644 app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts
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 00000000000..fe6b97d0a1a
--- /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 00000000000..e1691350ca3
--- /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/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts
new file mode 100644
index 00000000000..3cc4049fa2f
--- /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 00000000000..ddfde994e19
--- /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/babel.config.tests.js b/babel.config.tests.js
index acf822dc620..88ec7910080 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',
],
From a68cbf93fa9169abffdd28465d66a81ceb2a141e Mon Sep 17 00:00:00 2001
From: Brian August Nguyen
Date: Mon, 3 Nov 2025 13:43:30 -0800
Subject: [PATCH 11/13] feat: Updated TabsList to no longer use ScrollView, but
only rendering active tab (#21822)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR simplifies the `TabsList` component by removing complex
ScrollView-based tab navigation in favor of a lightweight gesture-based
approach.
**What is the reason for the change?**
The previous `TabsList` implementation had significant complexity that
was unnecessary for the wallet page use case:
- Heavy ScrollView with horizontal paging and complex scroll handling
- Multiple refs, timeouts, and state management for scroll coordination
- Complex height measurement logic with debouncing and timing constants
- Adjacent tab preloading logic
- Over 1,600 lines of code across implementation and tests
This complexity caused:
- Difficult maintenance and debugging
- Performance overhead from scroll event handlers
- Conflicts with page-level scrolling in the wallet redesign
- Flaky tests due to timing-dependent behavior
**What is the improvement/solution?**
This PR dramatically simplifies TabsList by:
1. **Replacing ScrollView with gesture-based navigation** using
`react-native-gesture-handler`
- Pan gestures detect swipe left/right
- Direct tab switching with `hidden` CSS class
- No scroll event coordination needed
2. **Simplifying tab rendering**
- Removed horizontal ScrollView and paging logic
- Uses simple show/hide with `hidden` class
- All tabs render in place, inactive tabs are hidden
3. **Streamlined caching**
- Only loads the active tab on demand
- Removed adjacent tab preloading
- Removed complex timing coordination
4. **Reduced code complexity**
- **389 lines removed** from TabsList.tsx
- **1,357 lines removed** from tests
- Removed refs for scroll coordination, timeout management, and
measurement timers
- Removed dependencies: `useTailwind`, `Dimensions`, scroll event types
5. **Improved gesture support**
- Natural swipe thresholds (50px or 500 velocity)
- Proper worklet execution with `react-native-reanimated`
- Skips disabled tabs during swipe navigation
**Benefits:**
- ✅ Simpler, more maintainable code
- ✅ Better compatibility with page-level scrolling
- ✅ No scroll event overhead
- ✅ More reliable tests (no timing dependencies)
- ✅ Cleaner architecture for wallet page integration
## **Changelog**
CHANGELOG entry: Simplified TabsList component with gesture-based
navigation
## **Related issues**
Fixes:
https://consensyssoftware.atlassian.net/jira/software/c/projects/DSYS/boards/1888?search=tab&selectedIssue=DSYS-247
## **Manual testing steps**
```gherkin
Feature: Simplified TabsList navigation
Scenario: user swipes between tabs
Given TabsList with multiple tabs
When user swipes left
Then next tab displays
And transition is smooth
When user swipes right
Then previous tab displays
Scenario: user taps tabs directly
Given TabsList with multiple tabs
When user taps a tab button
Then that tab becomes active immediately
And only the active tab content loads
Scenario: disabled tabs are skipped during swipe
Given TabsList with some disabled tabs
When user swipes through tabs
Then disabled tabs are skipped
And only enabled tabs are accessible
Scenario: lazy loading of tab content
Given TabsList with 4 tabs
And user is on first tab
When user switches to third tab
Then third tab content loads
And unused tabs remain unloaded
And no preloading occurs
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/51537667-68f6-4ed3-8e15-a8a1b0262588
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable (✅ Tests updated to match
simplified implementation)
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Refactors TabsList to use pan-gesture navigation and
InteractionManager-driven lazy loading, rendering only loaded/active
tabs and updating tests/snapshots accordingly.
>
> - **Component `TabsList`**:
> - Replace `ScrollView` paging and scroll handlers with
`GestureDetector` pan gestures; remove scroll refs/timeouts and layout
width logic.
> - Add lazy loading via `InteractionManager.runAfterInteractions`;
cache loaded tabs; render only active/loaded content; skip disabled
tabs; preserve selection by `key` on children changes.
> - Keep ref API (`goToTabIndex`, `getCurrentIndex`); pass through
`tabsBarProps` and testIDs; simplify layout to a single `Box` container.
> - **Tests & Snapshots**:
> - Rewrite tests to mock `InteractionManager`, verify deferred loading,
cancellation, immediate loads for cached tabs, and gesture wrapper
presence.
> - Update/refocus tests on tab press behavior, disabled tabs handling,
ref navigation, dynamic tabs by key, and edge cases (non-React children,
initialActiveIndex).
> - Refresh snapshots to reflect new structure without `ScrollView`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2d17260f597bffaf9c8f593d96b2eb70e51993bd. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../Tabs/TabsList/TabsList.test.tsx | 1440 ++---------------
.../Tabs/TabsList/TabsList.tsx | 411 ++---
.../__snapshots__/TabsList.test.tsx.snap | 603 ++++---
3 files changed, 686 insertions(+), 1768 deletions(-)
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 3c261f7d438..35efd2dba34 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 e85cae59aac..9449c08e4e9 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 e11054f6e10..5e5cfa951c5 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
+
-
-
-
+
`;
From e0a8a4b43f5b060f1de88adf787210b2bd52e008 Mon Sep 17 00:00:00 2001
From: Nick Gambino <35090461+gambinish@users.noreply.github.com>
Date: Mon, 3 Nov 2025 13:59:12 -0800
Subject: [PATCH 12/13] fix: perps not reconnecting on account switch (#22068)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR introduces a change that keeps the Perps redux subscription
active throughout the app lifecycle. This ensures that things like
`reconnectWithNewContext()` gets triggered automatically when things
like account switches get called. We were noticing that this
reconnection was regressing on some
[PerpsTab](https://github.com/MetaMask/metamask-mobile/pull/22044)
refactoring for full page scroll, so this should help mitigate this type
of regression.
I don't think this will introduce any major performance regression. The
Redux subscription is pretty lightweight, and I think we can keep it
alive throughout the app lifecycle. Worth reviewing with @abretonc7s
here though.
## **Changelog**
CHANGELOG entry: Maintain Perps redux state monitoring throughout app
lifecycle
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Keeps Perps Redux monitoring active by eliminating
`cleanupStateMonitoring()` during disconnect/grace paths and only
cleaning preloaded subscriptions.
>
> - **PerpsConnectionManager
(`app/components/UI/Perps/services/PerpsConnectionManager.ts`)**:
> - **Disconnection flow**:
> - Remove calls to `cleanupStateMonitoring()` during
actual/grace-period disconnection.
> - Keep Redux monitoring active even when disconnecting or not
connected.
> - Continue cleaning only preloaded subscriptions via
`cleanupPreloadedSubscriptions()`.
> - **Grace period handling**:
> - Drop redundant else-branches that previously cleaned up monitoring
when not connected.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7b9269993b410e40bc42e2ceffeb8b2a945f89ff. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/Perps/services/PerpsConnectionManager.ts | 9 ---------
1 file changed, 9 deletions(-)
diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts
index 97922dffc91..1998bddc1a2 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();
}
}
}
From 0c80dc9ca5cfaff8ca3560de424a0567c3c551bd Mon Sep 17 00:00:00 2001
From: Brian August Nguyen
Date: Mon, 3 Nov 2025 14:22:28 -0800
Subject: [PATCH 13/13] feat: Disable scroll from wallet tabs, enable wallet
scroll (#22044)
## **Description**
This PR refactors the wallet page scroll architecture to improve
performance and user experience.
**What is the reason for the change?**
Previously, each wallet tab (Tokens, NFTs, DeFi Positions, Predictions)
used virtualized lists (FlashList/FlatList) with individual scroll
containers. This approach caused:
- Performance overhead from multiple virtualized lists
- Complex scroll coordination issues
- Inconsistent scroll behavior across tabs
- Memory overhead from maintaining multiple list states
**What is the improvement/solution?**
This PR consolidates scrolling by:
1. **Enabling scroll at the wallet page level** - The entire wallet page
now scrolls as a single container
2. **Removing scroll from individual tabs** - Each tab (Tokens, NFTs,
DeFi, Predictions) now renders content statically without
FlashList/FlatList when scroll is disabled at the tab level
3. **Adding skeleton loaders** - New skeleton components provide better
loading states (TokenListSkeleton, NftGridSkeleton)
4. **Adding empty state components** - Improved empty state handling
with dedicated components (TokensEmptyState)
The changes affect:
- **Tokens tab**: Replaced FlashList with static rendering when scroll
disabled
- **NFT Grid**: Removed FlashList, uses ScrollView-compatible layout
- **DeFi Positions**: Removed FlatList in favor of direct rendering
- **Predictions tab**: Removed FlashList, uses static layout
- **Wallet page**: Added ScrollView wrapper for unified scrolling
This architecture pairs with the TabsList auto-height capability (in
`fix/tab-individual-height`) to allow each tab to display all content
with the page-level scroll handling navigation between sections.
## **Changelog**
CHANGELOG entry: Improved wallet page scrolling performance by
consolidating scroll behavior at the page level
## **Related issues**
Fixes:
https://consensyssoftware.atlassian.net/jira/software/c/projects/DSYS/boards/1888?selectedIssue=DSYS-250
## **Manual testing steps**
```gherkin
Feature: Wallet page scroll improvements
Scenario: user scrolls through wallet tabs
Given user is on the Wallet page with tokens
When user scrolls down
Then the entire page scrolls smoothly
And all tabs remain accessible
And tab content loads without flickering
Scenario: user switches between tabs
Given user is on the Wallet page
When user taps on different tabs (Tokens, NFTs, DeFi, Predictions)
Then each tab displays content without FlashList artifacts
And scroll position resets appropriately per tab
And tab transitions are smooth
Scenario: user views empty states
Given user has no tokens/NFTs/positions
When user navigates to empty tabs
Then appropriate empty state messages display
And layout remains stable
Scenario: user views loading states
Given wallet data is loading
When user opens the wallet page
Then skeleton loaders display for each tab
And transitions to actual content are smooth
```
## **Screenshots/Recordings**
### **Before**
https://github.com/user-attachments/assets/28e93320-e519-4fc4-860f-1e5477d90bb9
https://github.com/user-attachments/assets/912fd548-c613-4fb5-9b5b-8dd864f20d2d
### **After**
When homepage redesign feature flag V1 is false
- Tokens Tab
https://github.com/user-attachments/assets/15809592-6066-48ee-a8b7-2af0a03cdb55
- NFTs Tab
https://github.com/user-attachments/assets/8b821113-fcf8-4416-a629-740571d49d59
- Perps Tab
https://github.com/user-attachments/assets/b364311c-6162-4f2a-bfbc-476daea5587a
- Defi Tab
https://github.com/user-attachments/assets/c59acab7-0cfa-4a75-92c5-4eb0bcd7b2a3
- Predictions Tab
https://github.com/user-attachments/assets/1f584e55-a0fd-40d9-9942-7ceb5335837f
When homepage redesign feature flag V1 is true
- Tokens Tab
https://github.com/user-attachments/assets/c10f0f2a-aa50-4f38-be42-b65d7f28d521
- NFTs Tab
https://github.com/user-attachments/assets/0263d21c-0bbc-4e57-8784-37eeecbdea60
- Perps Tab
https://github.com/user-attachments/assets/602db546-455c-462b-ab87-8f74624d0d73
- Defi Tab
https://github.com/user-attachments/assets/4a154aae-2245-42dd-bdca-e19a15eea68b
- Predictions Tab
https://github.com/user-attachments/assets/8fdfeeed-3cee-4ec2-b627-3fd8fe966166
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> Enables global wallet scrolling and refactors
Tokens/NFTs/DeFi/Perps/Predict tabs to render without inner scroll
(behind homepageRedesignV1), adding skeleton loaders and empty states.
>
> - **Wallet**:
> - **Global Scroll**: Wraps wallet content in `ConditionalScrollView`
(new) to enable page-level scrolling; tabs pass content without inner
scroll when `homepageRedesignV1` is enabled.
> - **New Components**:
> - `components-temp/ConditionalScrollView` (+ types, tests).
> - `TokensEmptyState` and `TokenListSkeleton`; `NftGridSkeleton`.
> - **Tabs Refactor (flag-driven)**:
> - **Tokens**: `TokenList` renders items directly (no `FlashList`) when
not full view; adds skeleton and empty state; simplifies `Tokens`
(removes progressive loader) and applies `maxItems` (10) for homepage
redesign.
> - **NFTs**: `NftGrid` renders grid directly or via `FlashList` in full
view; adds skeleton, limits to 18 items with "View all"; UI tweaks in
`NftGridItem`.
> - **DeFi Positions**: Replace `FlatList` with direct render; optional
scroll via `ConditionalScrollView` and new testID for scroll view.
> - **Perps**: Use `ConditionalScrollView`; loading skeleton layout
adapts to flag.
> - **Predict**: Replace `FlashList` with direct render;
`PredictTabView` uses `ConditionalScrollView`.
> - **UI/Styling**:
> - Centered `CollectibleMedia` fallback text; minor layout cleanups;
token header back button `testID`.
> - **Tests & E2E**:
> - Extensive unit test updates for new render/flag behavior; add
skeleton/empty-state tests; update selectors (e.g.,
`DEFI_POSITIONS_SCROLL_VIEW`) and e2e token matcher to fetch first
instance.
> - **Localization**:
> - Adds `wallet.tokens_empty_description`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6bedb5f7ccbd2e778b973d05accb75be2104cfc1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../ConditionalScrollView.test.tsx | 101 +++++
.../ConditionalScrollView.tsx | 20 +
.../ConditionalScrollView.types.ts | 16 +
.../ConditionalScrollView/index.ts | 1 +
.../__snapshots__/index.test.tsx.snap | 1 -
app/components/UI/AssetElement/index.tsx | 1 -
.../Balance/__snapshots__/index.test.tsx.snap | 2 -
.../__snapshots__/CardAssetItem.test.tsx.snap | 5 -
.../CollectibleMedia.styles.ts | 7 +-
.../CollectibleModal.test.tsx.snap | 3 +-
.../__snapshots__/index.test.tsx.snap | 4 +-
.../DeFiPositions/DeFiPositionsList.test.tsx | 186 ++++++++-
.../UI/DeFiPositions/DeFiPositionsList.tsx | 53 ++-
.../EarnLendingBalance.test.tsx.snap | 1 -
app/components/UI/NftGrid/NftGrid.test.tsx | 225 +++++++++-
app/components/UI/NftGrid/NftGrid.tsx | 146 ++++---
app/components/UI/NftGrid/NftGridItem.tsx | 46 +--
.../UI/NftGrid/NftGridSkeleton.test.tsx | 35 ++
app/components/UI/NftGrid/NftGridSkeleton.tsx | 33 ++
.../Views/PerpsTabView/PerpsTabView.styles.ts | 3 -
.../Views/PerpsTabView/PerpsTabView.test.tsx | 135 +++++-
.../Perps/Views/PerpsTabView/PerpsTabView.tsx | 53 ++-
.../PerpsLoadingSkeleton.test.tsx | 20 +
.../PerpsLoadingSkeleton.tsx | 11 +-
.../PredictPositionEmpty.styles.ts | 1 -
.../PredictPositions.test.tsx | 390 ++++++++++++++++++
.../PredictPositions/PredictPositions.tsx | 70 ++--
.../PredictPositionsHeader.tsx | 6 +-
.../PredictTabView/PredictTabView.test.tsx | 150 ++++---
.../views/PredictTabView/PredictTabView.tsx | 62 ++-
.../StakingBalance.test.tsx.snap | 2 -
.../TokenList/TokenListSkeleton.test.tsx | 50 +++
.../UI/Tokens/TokenList/TokenListSkeleton.tsx | 63 +++
.../UI/Tokens/TokenList/index.test.tsx | 183 ++++++--
app/components/UI/Tokens/TokenList/index.tsx | 149 ++++---
app/components/UI/Tokens/index.test.tsx | 103 ++++-
app/components/UI/Tokens/index.tsx | 142 ++-----
app/components/UI/Tokens/styles.ts | 28 +-
.../TokensEmptyState.test.tsx | 157 +++++++
.../UI/TokensEmptyState/TokensEmptyState.tsx | 50 +++
app/components/UI/TokensEmptyState/index.ts | 1 +
.../Asset/__snapshots__/index.test.js.snap | 8 -
.../TokensFullView/TokensFullView.test.tsx | 27 +-
.../Views/TokensFullView/TokensFullView.tsx | 3 +-
app/components/Views/Wallet/index.tsx | 162 ++++----
.../Amount/__snapshots__/index.test.tsx.snap | 7 +-
e2e/pages/wallet/NetworkManager.ts | 3 +-
e2e/selectors/wallet/WalletView.selectors.ts | 1 +
locales/languages/en.json | 1 +
49 files changed, 2278 insertions(+), 649 deletions(-)
create mode 100644 app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx
create mode 100644 app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx
create mode 100644 app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts
create mode 100644 app/component-library/components-temp/ConditionalScrollView/index.ts
create mode 100644 app/components/UI/NftGrid/NftGridSkeleton.test.tsx
create mode 100644 app/components/UI/NftGrid/NftGridSkeleton.tsx
create mode 100644 app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx
create mode 100644 app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx
create mode 100644 app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx
create mode 100644 app/components/UI/TokensEmptyState/TokensEmptyState.tsx
create mode 100644 app/components/UI/TokensEmptyState/index.ts
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 00000000000..20affdd5ca8
--- /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 00000000000..c6ab4878ca1
--- /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 00000000000..07f452c2c6c
--- /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 00000000000..81b7bba21a0
--- /dev/null
+++ b/app/component-library/components-temp/ConditionalScrollView/index.ts
@@ -0,0 +1 @@
+export { default } from './ConditionalScrollView';
diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap
index b3ce36d57db..f094f0109ad 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 a5cf7e096f4..6a3a15eb925 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 4d73eab508b..892cedb7612 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/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap
index 2f79c643c13..e3226a12b4c 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 017c1a83db2..24173dbaf41 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 11419769401..85bb3f2eb2f 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 1c39ce3a1b4..8df3c85ba4a 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/DeFiPositions/DeFiPositionsList.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx
index 748dd18967e..7f5de8520ba 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 c26907c1541..b53213e29ec 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 21a277a7516..5d30af5b4ff 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 2723837f28a..25b6db7dfaa 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 7c52a9ec5ed..724be654f22 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 6b7fa4bdc2b..7a08b6ae94f 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 00000000000..28398865e58
--- /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 00000000000..ddde4667ad0
--- /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 1468372c3b5..96959c51998 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 93e0b875222..2beadf591ac 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 2bff1bcc2da..b2b4d868f11 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 db6690924de..d5497a685e1 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 a59ef22abc1..19c229ff633 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/Predict/components/PredictPositionEmpty/PredictPositionEmpty.styles.ts b/app/components/UI/Predict/components/PredictPositionEmpty/PredictPositionEmpty.styles.ts
index 63abbb08904..b64353499a1 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 f4f3e560b27..fca599e12eb 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 23d87318fb8..1f535f8d15c 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 d2699700e1e..0d793c1ecdf 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 15a7ede95b9..215bfc15ee7 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 51d4a629768..b53fa28bfc7 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/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
index ed5daf5b088..0971c1a475b 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 00000000000..6327e62ac60
--- /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 00000000000..86cce643c94
--- /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 ad632938e0b..c71eb01f103 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 bef176305a7..08f547a700f 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 d6edd74f2f6..e5b5acd8ac6 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 f53592164ed..d2bf8578a1a 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 00000000000..26f332303fe
--- /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 00000000000..bb592e2511d
--- /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 00000000000..6e3913ee281
--- /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 a0ae7a07dbb..cb3a3144034 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/TokensFullView/TokensFullView.test.tsx b/app/components/Views/TokensFullView/TokensFullView.test.tsx
index 9c29df3f4b7..e1931a944a0 100644
--- a/app/components/Views/TokensFullView/TokensFullView.test.tsx
+++ b/app/components/Views/TokensFullView/TokensFullView.test.tsx
@@ -59,16 +59,6 @@ describe('TokensFullView', () => {
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 2d4af3e3637..f421b37e4b7 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 210c2012c7b..23d5084f710 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 bf975a502d3..8a1ad45a3ea 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/e2e/pages/wallet/NetworkManager.ts b/e2e/pages/wallet/NetworkManager.ts
index 95419dfb6df..22e09322007 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 43eda1eb99e..f2edfc01b53 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 30a9e2a76f9..3488b52ec0a 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",