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** Screenshot 2025-11-03 at 12 44 18 ### **After** Screenshot 2025-11-03 at 12 44 08 ## **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 - image When an invalid configuration is passed to iOS - image When an invalid configuration is passed to Android - image When an invalid flavor is passed to Android - image ## **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 && ( - - )} - - ) : ( - - - - {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",