From 24744adff956b513ae834d835306ba46ce1e5ddf Mon Sep 17 00:00:00 2001 From: Daniel Carroll Date: Thu, 5 Nov 2026 12:43:05 -0600 Subject: [PATCH 1/3] fix(account): wait for remote config before the first balances fetch The Account view fired its first balances fetch on mount, before the Amplitude flags had loaded. useGetBalances reads use_balances_v2 at call time, so that first request read the hardcoded default (false) and went to the v1 endpoint even for users enrolled in v2. Later polls used v2, which skewed the v1/v2 rollout comparison. The mount effect now waits for isRemoteConfigInitialized. The reducer sets it on the failure path too, so Amplitude being down costs one round trip instead of a stuck screen. The view already renders IDLE as loading, so the wait shows the existing spinner. The test Wrapper now preloads a settled remoteConfig slice; without it every Account test would sit on the loader. Adds a regression test that asserts no balances request goes out until the flags settle. Fixes #3032 Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/__testHelpers__/index.tsx | 15 ++++ extension/src/popup/views/Account/index.tsx | 16 ++++- .../popup/views/__tests__/Account.test.tsx | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/__testHelpers__/index.tsx b/extension/src/popup/__testHelpers__/index.tsx index 489cdd014e..b1d80444b3 100644 --- a/extension/src/popup/__testHelpers__/index.tsx +++ b/extension/src/popup/__testHelpers__/index.tsx @@ -69,6 +69,21 @@ export const Wrapper: React.FunctionComponent = ({ applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, }, transactionSubmission: transactionSubmissionInitialState, + // A rendered popup has already settled its Amplitude flags: the reducer + // sets `isInitialized` on the failure path too, so `false` is a transient + // startup state rather than a steady one. Screens that hold their first + // load until the flags land (Account, #3032) would otherwise sit on the + // loader in every test that doesn't say otherwise. Flag values here match + // the duck's defaults; tests exercising flag behavior pass their own + // `remoteConfig`, which replaces this whole slice. + remoteConfig: { + isInitialized: true, + use_token_prices_v2: true, + use_balances_v2: false, + use_token_price_history: true, + maintenance_banner: { enabled: false, payload: undefined }, + maintenance_screen: { enabled: false, payload: undefined }, + }, ...state, }); testStore = store; diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 47e3dab025..ea4c844cf8 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -16,6 +16,7 @@ import { accountNameSelector, publicKeySelector, } from "popup/ducks/accountServices"; +import { isRemoteConfigInitializedSelector } from "popup/ducks/remoteConfig"; import { closeWindow, openTab } from "popup/helpers/navigate"; import { isFullscreenMode } from "popup/helpers/isFullscreenMode"; import { useSwapTopTokensPrewarm } from "popup/helpers/useSwapTopTokensPrewarm"; @@ -61,6 +62,9 @@ export const Account = () => { const reduxPublicKey = useSelector(publicKeySelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const { activeTab } = useContext(AccountTabsContext); + const isRemoteConfigInitialized = useSelector( + isRemoteConfigInitializedSelector, + ); const isFullscreenModeEnabled = isFullscreenMode(); const { @@ -89,13 +93,23 @@ export const Account = () => { const previousAccountBalancesRef = useRef(null); const sorobanErrorShownRef = useRef(false); + // Hold the first load until the Amplitude flags have settled. `useGetBalances` + // reads `balancesV2Selector` at call time, so firing before the fetch resolves + // would read the hardcoded default and send an enrolled user to the v1 + // endpoint for the first request of every popup session (#3032). + // `isInitialized` is set on failure too, so Amplitude being down delays this + // by one round trip rather than wedging the screen. `accountData` sits in + // `RequestState.IDLE` meanwhile, which already renders as loading. useEffect(() => { + if (!isRemoteConfigInitialized) { + return; + } const getData = async () => { await fetchData({ useAppDataCache: false }); }; getData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isRemoteConfigInitialized]); useEffect(() => { if (!isSorobanSuported && !sorobanErrorShownRef.current) { diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index 848bc9f87a..b7371537bd 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -46,7 +46,9 @@ import { TEST_CANONICAL, TEST_PUBLIC_KEY, TEST_USDC_CANONICAL, + getTestStore, } from "../../__testHelpers__"; +import { fetchFeatureFlags } from "popup/ducks/remoteConfig"; import { Account } from "../Account"; import { ROUTES } from "popup/constants/routes"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; @@ -844,6 +846,72 @@ describe("Account view", () => { }); }); + // #3032: `useGetBalances` reads the v2 flag at call time, so a fetch that + // starts before the Amplitude round trip lands reads the hardcoded default + // and sends an enrolled user to the wrong endpoint for the first request of + // the session. The guard is "no request at all until the flags settle" — + // asserting on the endpoint would pass for the wrong reason, since the + // default happens to match v1. + it("does not fetch balances until the remote config flags have settled", async () => { + const balancesSpy = jest + .spyOn(ApiInternal, "getAccountBalances") + .mockImplementation(() => Promise.resolve(mockBalances)); + + render( + + + , + ); + + // Give the mount effects a tick; the fetch must still not have gone out. + await act(async () => { + await Promise.resolve(); + }); + expect(balancesSpy).not.toHaveBeenCalled(); + + // Flags land (this is also the shape the rejected path produces, which + // sets `isInitialized` so a dead Amplitude can't wedge the screen). + await act(async () => { + getTestStore()?.dispatch({ + type: fetchFeatureFlags.fulfilled.type, + payload: { + isInitialized: true, + use_token_prices_v2: true, + use_balances_v2: true, + use_token_price_history: true, + maintenance_banner: { enabled: false, payload: undefined }, + maintenance_screen: { enabled: false, payload: undefined }, + }, + }); + }); + + await waitFor(() => { + expect(balancesSpy).toHaveBeenCalled(); + }); + }); + it("goes to account details", async () => { render( Date: Thu, 5 Nov 2026 15:28:15 -0600 Subject: [PATCH 2/3] chore(account): trim comments from the remote config gate Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/__testHelpers__/index.tsx | 7 ------- extension/src/popup/views/Account/index.tsx | 8 +------- extension/src/popup/views/__tests__/Account.test.tsx | 9 --------- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/extension/src/popup/__testHelpers__/index.tsx b/extension/src/popup/__testHelpers__/index.tsx index b1d80444b3..6abb3e5caa 100644 --- a/extension/src/popup/__testHelpers__/index.tsx +++ b/extension/src/popup/__testHelpers__/index.tsx @@ -69,13 +69,6 @@ export const Wrapper: React.FunctionComponent = ({ applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, }, transactionSubmission: transactionSubmissionInitialState, - // A rendered popup has already settled its Amplitude flags: the reducer - // sets `isInitialized` on the failure path too, so `false` is a transient - // startup state rather than a steady one. Screens that hold their first - // load until the flags land (Account, #3032) would otherwise sit on the - // loader in every test that doesn't say otherwise. Flag values here match - // the duck's defaults; tests exercising flag behavior pass their own - // `remoteConfig`, which replaces this whole slice. remoteConfig: { isInitialized: true, use_token_prices_v2: true, diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index ea4c844cf8..a1fbee64db 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -93,13 +93,7 @@ export const Account = () => { const previousAccountBalancesRef = useRef(null); const sorobanErrorShownRef = useRef(false); - // Hold the first load until the Amplitude flags have settled. `useGetBalances` - // reads `balancesV2Selector` at call time, so firing before the fetch resolves - // would read the hardcoded default and send an enrolled user to the v1 - // endpoint for the first request of every popup session (#3032). - // `isInitialized` is set on failure too, so Amplitude being down delays this - // by one round trip rather than wedging the screen. `accountData` sits in - // `RequestState.IDLE` meanwhile, which already renders as loading. + // The balances fetch reads feature flags, so wait until they've loaded. useEffect(() => { if (!isRemoteConfigInitialized) { return; diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index b7371537bd..f93e692917 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -846,12 +846,6 @@ describe("Account view", () => { }); }); - // #3032: `useGetBalances` reads the v2 flag at call time, so a fetch that - // starts before the Amplitude round trip lands reads the hardcoded default - // and sends an enrolled user to the wrong endpoint for the first request of - // the session. The guard is "no request at all until the flags settle" — - // asserting on the endpoint would pass for the wrong reason, since the - // default happens to match v1. it("does not fetch balances until the remote config flags have settled", async () => { const balancesSpy = jest .spyOn(ApiInternal, "getAccountBalances") @@ -885,14 +879,11 @@ describe("Account view", () => { , ); - // Give the mount effects a tick; the fetch must still not have gone out. await act(async () => { await Promise.resolve(); }); expect(balancesSpy).not.toHaveBeenCalled(); - // Flags land (this is also the shape the rejected path produces, which - // sets `isInitialized` so a dead Amplitude can't wedge the screen). await act(async () => { getTestStore()?.dispatch({ type: fetchFeatureFlags.fulfilled.type, From 19edd8c4856676209e3b174ab725aafb3919689f Mon Sep 17 00:00:00 2001 From: Daniel Carroll Date: Mon, 9 Nov 2026 15:46:53 -0600 Subject: [PATCH 3/3] fix(account): wait for flags inside the balances and prices hooks Move the remote config wait from the Account view into the five places that read a flag to pick an endpoint (useGetBalances, useGetTokenPrices, AddToken), so the popup, fullscreen tabs, sidebar, and sign-transaction window all wait. Cap the Amplitude fetch at 1.5s, stub the Experiment request by default in e2e, and keep the price-history kill switch off in the test Wrapper. Co-Authored-By: Claude Opus 5.5 --- extension/e2e-tests/helpers/stubs.ts | 12 ++++ .../helpers/__tests__/useGetBalances.test.tsx | 52 ++++++++++++++++ .../__tests__/useGetTokenPrices.test.tsx | 62 ++++++++++++++++++- extension/src/helpers/experimentClient.ts | 14 ++++- .../src/helpers/hooks/useGetBalances.tsx | 25 +++++--- .../src/helpers/hooks/useGetTokenPrices.tsx | 8 ++- extension/src/popup/__testHelpers__/index.tsx | 3 +- extension/src/popup/ducks/remoteConfig.ts | 22 +++++++ extension/src/popup/views/Account/index.tsx | 10 +-- extension/src/popup/views/AddToken/index.tsx | 6 +- .../popup/views/__tests__/Account.test.tsx | 2 +- 11 files changed, 189 insertions(+), 27 deletions(-) diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index a072e0eb2c..9063fa7670 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -3377,6 +3377,7 @@ export const stubAllExternalApis = async ( // Feature flags await stubFeatureFlags(context); + await stubExperimentDefaults(context); // RPC health is fetched by the background service worker await stubRpcHealth(context); @@ -3565,6 +3566,17 @@ export const stubReportTransactionWarning = async ( const AMPLITUDE_EXPERIMENT_VARDATA_ROUTE = "**/sdk/v2/vardata**"; const MAINTENANCE_VARIANT_VALUE = "on"; +/** + * Answers the Experiment flag request with no variants, so every flag keeps + * its default and balances don't wait on a real Amplitude request. Specs that + * need a variant register a page route, which takes precedence over this one. + */ +export const stubExperimentDefaults = async (context: BrowserContext) => { + await context.route(AMPLITUDE_EXPERIMENT_VARDATA_ROUTE, async (route) => { + await route.fulfill({ json: {} }); + }); +}; + const stubExperimentVariants = async ( page: Page, variants: Record, diff --git a/extension/src/helpers/__tests__/useGetBalances.test.tsx b/extension/src/helpers/__tests__/useGetBalances.test.tsx index 55d8e88dd9..d2956b5c4f 100644 --- a/extension/src/helpers/__tests__/useGetBalances.test.tsx +++ b/extension/src/helpers/__tests__/useGetBalances.test.tsx @@ -8,6 +8,10 @@ import { } from "../hooks/useGetBalances"; import { getAccountBalances } from "@shared/api/internal"; import { makeDummyStore, TEST_PUBLIC_KEY } from "popup/__testHelpers__"; +import { + fetchFeatureFlags, + reducer as remoteConfigReducer, +} from "popup/ducks/remoteConfig"; import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { getIconUrlFromIssuer } from "@shared/api/helpers/getIconUrlFromIssuer"; import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; @@ -260,6 +264,54 @@ describe("useGetBalances (flag routing)", () => { ); }); + it("waits for the remote config flags before choosing the balances endpoint", async () => { + const store = makeDummyStore({ + cache: { balanceData: {}, icons: {}, tokenLists: [] }, + settings: { assetsLists: [] }, + remoteConfig: { + ...remoteConfigReducer(undefined, { type: "@@init" }), + isInitialized: false, + }, + }); + const { result } = renderHook( + () => useGetBalances({ showHidden: false, includeIcons: false }), + { wrapper: Wrapper(store) }, + ); + + let fetchPromise: Promise = Promise.resolve(); + await act(async () => { + fetchPromise = result.current.fetchData( + publicKey, + true, + TESTNET_NETWORK_DETAILS, + false, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(getAccountBalances).not.toHaveBeenCalled(); + + await act(async () => { + store.dispatch({ + type: fetchFeatureFlags.fulfilled.type, + payload: { + ...remoteConfigReducer(undefined, { type: "@@init" }), + isInitialized: true, + use_balances_v2: true, + }, + }); + await fetchPromise; + }); + + expect(getAccountBalances).toHaveBeenCalledTimes(1); + expect(getAccountBalances).toHaveBeenCalledWith( + publicKey, + TESTNET_NETWORK_DETAILS, + true, + false, + true, + ); + }); + it("passes localOnlyTokenIds through to the payload", async () => { const localOnlyTokenIds = ["CLOCALTOKENCONTRACTID"]; (getAccountBalances as jest.Mock).mockResolvedValue({ diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index f237373924..d4023d779b 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -4,7 +4,10 @@ import { Provider } from "react-redux"; import { renderHook, act } from "@testing-library/react"; import { useGetTokenPrices } from "../hooks/useGetTokenPrices"; import { makeDummyStore } from "popup/__testHelpers__"; -import { reducer as remoteConfigReducer } from "popup/ducks/remoteConfig"; +import { + fetchFeatureFlags, + reducer as remoteConfigReducer, +} from "popup/ducks/remoteConfig"; // Flags settled, as in a running popup once `fetchFeatureFlags` has resolved. const SETTLED_REMOTE_CONFIG = { @@ -80,6 +83,63 @@ describe("useGetTokenPrices", () => { }, }); }); + it("waits for the remote config flags before choosing the prices endpoint", async () => { + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockResolvedValue({}); + const store = makeDummyStore({ + remoteConfig: { + ...remoteConfigReducer(undefined, { type: "@@init" }), + isInitialized: false, + }, + cache: { tokenPrices: {} }, + }); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + let fetchPromise: Promise = Promise.resolve(); + await act(async () => { + fetchPromise = result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + networkDetails: MAINNET_NETWORK_DETAILS, + } as any); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(getTokenPricesSpy).not.toHaveBeenCalled(); + + await act(async () => { + store.dispatch({ + type: fetchFeatureFlags.fulfilled.type, + payload: { + ...SETTLED_REMOTE_CONFIG, + use_token_prices_v2: false, + }, + }); + await fetchPromise; + }); + + expect(getTokenPricesSpy).toHaveBeenCalledTimes(1); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + MAINNET_NETWORK_DETAILS, + false, + ); + }); + it("should return token prices from API with expired cache", async () => { const getTokenPricesSpy = jest .spyOn(ApiInternal, "getTokenPrices") diff --git a/extension/src/helpers/experimentClient.ts b/extension/src/helpers/experimentClient.ts index 5ff8b006d3..a707fdc693 100644 --- a/extension/src/helpers/experimentClient.ts +++ b/extension/src/helpers/experimentClient.ts @@ -1,10 +1,18 @@ -import { Experiment, ExperimentClient } from "@amplitude/experiment-js-client"; +import { + Experiment, + ExperimentClient, + ExperimentConfig, +} from "@amplitude/experiment-js-client"; import { AMPLITUDE_EXPERIMENT_DEPLOYMENT_KEY } from "constants/env"; import { isDev } from "@shared/helpers/dev"; const TEST_EXPERIMENT_DEPLOYMENT_KEY = "playwright-test-deployment-key"; +// Balance and price fetches wait on the flags, so a slow Amplitude host holds +// the spinner for this long before falling back to the default flags. +const CONFIG: ExperimentConfig = { fetchTimeoutMillis: 1500 }; + // Console log message constants const LOG_MESSAGES = { EXPERIMENT_PREFIX: "[Experiment]", @@ -48,8 +56,8 @@ export const initExperimentClient = (): void => { try { client = AMPLITUDE_EXPERIMENT_DEPLOYMENT_KEY - ? Experiment.initializeWithAmplitudeAnalytics(deploymentKey) - : Experiment.initialize(deploymentKey); + ? Experiment.initializeWithAmplitudeAnalytics(deploymentKey, CONFIG) + : Experiment.initialize(deploymentKey, CONFIG); } catch (e) { console.error( `${LOG_MESSAGES.EXPERIMENT_PREFIX} ${LOG_MESSAGES.INIT_FAILED}`, diff --git a/extension/src/helpers/hooks/useGetBalances.tsx b/extension/src/helpers/hooks/useGetBalances.tsx index d0014ea1f7..68c4fe7812 100644 --- a/extension/src/helpers/hooks/useGetBalances.tsx +++ b/extension/src/helpers/hooks/useGetBalances.tsx @@ -21,7 +21,10 @@ import { AssetType } from "@shared/api/types/account-balance"; import { settingsSelector } from "popup/ducks/settings"; import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; import { AppDispatch, AppState } from "popup/App"; -import { balancesV2Selector } from "popup/ducks/remoteConfig"; +import { + balancesV2Selector, + waitForRemoteConfig, +} from "popup/ducks/remoteConfig"; import { balancesSelector, iconsSelector, @@ -106,15 +109,17 @@ function useGetBalances(options: { const accountBalances = useCache && isBalancesCacheValid ? cachedBalanceData - : await getAccountBalances( - publicKey, - networkDetails, - isMainnet, - shouldSkipScan, - // Read the flag from the store at call time (not a - // render-captured value) so a freshly resolved Amplitude flag - // isn't missed — mirrors useGetTokenPrices. - balancesV2Selector(store.getState()), + : await waitForRemoteConfig(store).then(() => + getAccountBalances( + publicKey, + networkDetails, + isMainnet, + shouldSkipScan, + // Read the flag from the store at call time (not a + // render-captured value) so a freshly resolved Amplitude flag + // isn't missed — mirrors useGetTokenPrices. + balancesV2Selector(store.getState()), + ), ); const { balances, unfilteredBalances } = await formatBalances({ diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index bf58566186..c0f57168dd 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -2,7 +2,10 @@ import { useReducer } from "react"; import { captureException } from "@sentry/browser"; import { useDispatch, useSelector, useStore } from "react-redux"; import { tokenPricesSelector } from "popup/ducks/cache"; -import { tokenPricesV2Selector } from "popup/ducks/remoteConfig"; +import { + tokenPricesV2Selector, + waitForRemoteConfig, +} from "popup/ducks/remoteConfig"; import { getTokenPrices } from "@shared/api/internal"; import { initialState, isCacheValid, reducer } from "helpers/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -80,6 +83,7 @@ export function useGetTokenPrices() { ); if (unseenAssetIds.length) { try { + await waitForRemoteConfig(store); const useV2 = tokenPricesV2Selector(store.getState()); const unseenPrices = await getTokenPrices( unseenAssetIds, @@ -117,6 +121,7 @@ export function useGetTokenPrices() { // value) so a freshly resolved Amplitude flag isn't missed when this // fetch runs inside a long-lived async flow that closed over a stale // default. + await waitForRemoteConfig(store); const useV2 = tokenPricesV2Selector(store.getState()); const fetchedTokenPrices = await getTokenPrices( assetIds, @@ -148,6 +153,7 @@ export function useGetTokenPrices() { const missingExtra = additionalAssetIds.filter((id) => !(id in resolved)); if (missingExtra.length) { try { + await waitForRemoteConfig(store); const useV2 = tokenPricesV2Selector(store.getState()); const extraPrices = await getTokenPrices( missingExtra, diff --git a/extension/src/popup/__testHelpers__/index.tsx b/extension/src/popup/__testHelpers__/index.tsx index 6abb3e5caa..9c7a840381 100644 --- a/extension/src/popup/__testHelpers__/index.tsx +++ b/extension/src/popup/__testHelpers__/index.tsx @@ -69,11 +69,12 @@ export const Wrapper: React.FunctionComponent = ({ applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, }, transactionSubmission: transactionSubmissionInitialState, + // Loaded so balance fetches don't wait forever; kill switches stay off. remoteConfig: { isInitialized: true, use_token_prices_v2: true, use_balances_v2: false, - use_token_price_history: true, + use_token_price_history: false, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }, diff --git a/extension/src/popup/ducks/remoteConfig.ts b/extension/src/popup/ducks/remoteConfig.ts index 08a6b30843..6fe85e0eb6 100644 --- a/extension/src/popup/ducks/remoteConfig.ts +++ b/extension/src/popup/ducks/remoteConfig.ts @@ -2,6 +2,7 @@ import { createAsyncThunk, createSelector, createSlice, + Store, } from "@reduxjs/toolkit"; import type { Variant } from "@amplitude/experiment-js-client"; @@ -278,3 +279,24 @@ export const isRemoteConfigInitializedSelector = createSelector( remoteConfigSelector, (rc) => rc.isInitialized, ); + +/** + * Resolves once the Experiment flags have been fetched (or failed). Await it + * before reading a flag that picks a backend, since every popup, tab, and + * sidebar starts with the flags unloaded. + */ +export const waitForRemoteConfig = ( + store: Store<{ remoteConfig: RemoteConfigState }>, +): Promise => + new Promise((resolve) => { + if (isRemoteConfigInitializedSelector(store.getState())) { + resolve(); + return; + } + const unsubscribe = store.subscribe(() => { + if (isRemoteConfigInitializedSelector(store.getState())) { + unsubscribe(); + resolve(); + } + }); + }); diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index a1fbee64db..47e3dab025 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -16,7 +16,6 @@ import { accountNameSelector, publicKeySelector, } from "popup/ducks/accountServices"; -import { isRemoteConfigInitializedSelector } from "popup/ducks/remoteConfig"; import { closeWindow, openTab } from "popup/helpers/navigate"; import { isFullscreenMode } from "popup/helpers/isFullscreenMode"; import { useSwapTopTokensPrewarm } from "popup/helpers/useSwapTopTokensPrewarm"; @@ -62,9 +61,6 @@ export const Account = () => { const reduxPublicKey = useSelector(publicKeySelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const { activeTab } = useContext(AccountTabsContext); - const isRemoteConfigInitialized = useSelector( - isRemoteConfigInitializedSelector, - ); const isFullscreenModeEnabled = isFullscreenMode(); const { @@ -93,17 +89,13 @@ export const Account = () => { const previousAccountBalancesRef = useRef(null); const sorobanErrorShownRef = useRef(false); - // The balances fetch reads feature flags, so wait until they've loaded. useEffect(() => { - if (!isRemoteConfigInitialized) { - return; - } const getData = async () => { await fetchData({ useAppDataCache: false }); }; getData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRemoteConfigInitialized]); + }, []); useEffect(() => { if (!isSorobanSuported && !sorobanErrorShownRef.current) { diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index 2db3030379..46bcb0bf79 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -24,7 +24,10 @@ import { newTabHref, parsedSearchParam, TokenToAdd } from "helpers/urls"; import { AppState } from "popup/App"; import { rejectToken, addToken } from "popup/ducks/access"; -import { balancesV2Selector } from "popup/ducks/remoteConfig"; +import { + balancesV2Selector, + waitForRemoteConfig, +} from "popup/ducks/remoteConfig"; import { isNonSSLEnabledSelector } from "popup/ducks/settings"; import { useSetupAddTokenFlow } from "popup/helpers/useSetupAddTokenFlow"; import { @@ -396,6 +399,7 @@ export const AddToken = () => { const fetchClassicTrustline = async () => { try { + await waitForRemoteConfig(store); const accountBalances = await getAccountBalances( hydratedPublicKey, hydratedNetworkDetails, diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index f93e692917..83647a825b 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -1662,7 +1662,7 @@ describe("Account view", () => { // Fast-forward 30 seconds jest.advanceTimersByTime(30000); - expect(getAccountBalancesSpy).toHaveBeenCalledTimes(3); + await waitFor(() => expect(getAccountBalancesSpy).toHaveBeenCalledTimes(3)); }); // An unfunded account holds nothing, so zero is its real total even on a