Repository: braintree/braintree-web-drop-in
Pull request #759 (2021-08-10 21:18:10)

fix: correct issue where Drop-in could get into unusable state
### Summary

Fixes race condition where it was possible to go to the methods view when no payment methods were actually available.

Discovered because a merchant attempted to call `clearSelectedPaymentMethod` in the callback after calling `requestPaymentMethod` on the card view.

Basically, the card view involves some extra animations compared to the other payment methods. So it was possible to put Drop-in into a state where `requestPaymentMethod` had resolved, but the UI had not caught up the action. When something happens to clear out the saved payment methods (such as `clearSelectedPaymentMethod`), then the async action to finish animating the card form -> methods view transition occurs and attempts to put Drop-in into the methods view.... which no longer has any payment methods to display.

We tried a few different approaches for how to handle this, and settled on this one to go to a different view when the methods view was not actually available.

### Checklist

- [x] Added a changelog entry

### Authors
> List GitHub usernames for everyone who contributed to this pull request.

- @crookedneighbor 
- @jplukarski 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8312f90..9c77b81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ CHANGELOG
 unreleased
 ----------
 - Fix issue where a methods container for a cusotmer with 15+ payment methods would obscure the "Choose Another Way to Pay" button
+- Fix issue where Drop-in could get in an unusable state when `clearSelectedPaymentMethod` was called directly after `requestPaymentMethod`
 - Update braintree-web to v3.80.0
 
 1.31.1
diff --git a/src/dropin-model.js b/src/dropin-model.js
index e1172a5..eca3115 100644
--- a/src/dropin-model.js
+++ b/src/dropin-model.js
@@ -11,6 +11,7 @@ var isGuestCheckout = require('./lib/is-guest-checkout');
 var Promise = require('./lib/promise');
 var paymentSheetViews = require('./views/payment-sheet-views');
 var vaultManager = require('braintree-web/vault-manager');
+var paymentOptionsViewID = require('./views/payment-options-view').ID;
 
 var VAULTED_PAYMENT_METHOD_TYPES_THAT_SHOULD_BE_HIDDEN = [
   paymentMethodTypes.applePay,
@@ -261,6 +262,18 @@ DropinModel.prototype.getActivePaymentMethod = function () {
   return this._activePaymentMethod;
 };
 
+DropinModel.prototype.hasPaymentMethods = function () {
+  return this.getPaymentMethods().length > 0;
+};
+
+DropinModel.prototype.getInitialViewId = function () {
+  if (this.supportedPaymentOptions.length > 1) {
+    return paymentOptionsViewID;
+  }
+
+  return this.supportedPaymentOptions[0];
+};
+
 DropinModel.prototype.getActivePaymentViewId = function () {
   return this._activePaymentViewId;
 };
diff --git a/src/dropin.js b/src/dropin.js
index f2cb6f0..b6cd798 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -11,7 +11,6 @@ var assets = require('@braintree/asset-loader');
 var fs = require('fs');
 var MainView = require('./views/main-view');
 var paymentMethodsViewID = require('./views/payment-methods-view').ID;
-var paymentOptionsViewID = require('./views/payment-options-view').ID;
 var paymentOptionIDs = constants.paymentOptionIDs;
 var translations = require('./translations').translations;
 var isUtf8 = require('./lib/is-utf-8');
@@ -717,22 +716,17 @@ Dropin.prototype._removeUnvaultedPaymentMethods = function (filter) {
 };
 
 Dropin.prototype._navigateToInitialView = function () {
-  var hasNoSavedPaymentMethods, hasOnlyOneSupportedPaymentOption;
   var isOnMethodsView = this._mainView.primaryView.ID === paymentMethodsViewID;
 
-  if (isOnMethodsView) {
-    hasNoSavedPaymentMethods = this._model.getPaymentMethods().length === 0;
-
-    if (hasNoSavedPaymentMethods) {
-      hasOnlyOneSupportedPaymentOption = this._model.supportedPaymentOptions.length === 1;
+  if (!isOnMethodsView) {
+    return;
+  }
 
-      if (hasOnlyOneSupportedPaymentOption) {
-        this._mainView.setPrimaryView(this._model.supportedPaymentOptions[0]);
-      } else {
-        this._mainView.setPrimaryView(paymentOptionsViewID);
-      }
-    }
+  if (this._model.hasPaymentMethods()) {
+    return;
   }
+
+  this._mainView.setPrimaryView(this._model.getInitialViewId());
 };
 
 Dropin.prototype._supportsPaymentOption = function (paymentOption) {
diff --git a/src/views/main-view.js b/src/views/main-view.js
index 4c6f434..e670c96 100644
--- a/src/views/main-view.js
+++ b/src/views/main-view.js
@@ -94,7 +94,16 @@ MainView.prototype._initialize = function () {
 
   this.model.on('changeActivePaymentMethod', function () {
     wait.delay(CHANGE_ACTIVE_PAYMENT_METHOD_TIMEOUT).then(function () {
-      this.setPrimaryView(PaymentMethodsView.ID);
+      var id = PaymentMethodsView.ID;
+
+      // if Drop-in gets into the state where it's told to go to the methods
+      // view, but there are no saved payment methods, it should instead
+      // redirect to the view it started on
+      if (!this.model.hasPaymentMethods()) {
+        id = this.model.getInitialViewId();
+      }
+
+      this.setPrimaryView(id);
     }.bind(this));
   }.bind(this));
 


Test patch:
diff --git a/test/unit/dropin-model.js b/test/unit/dropin-model.js
index 1f6e0ce..0d43e0c 100644
--- a/test/unit/dropin-model.js
+++ b/test/unit/dropin-model.js
@@ -760,6 +760,59 @@ describe('DropinModel', () => {
     });
   });
 
+  describe('hasPaymentMethods', () => {
+    test('returns true when there is at least one payment method', () => {
+      const model = new DropinModel(testContext.modelOptions);
+
+      jest.spyOn(model, 'getPaymentMethods').mockReturnValue([{
+        type: 'PayPalAccount',
+        nonce: 'fake-paypal-nonce'
+      }]);
+      expect(model.hasPaymentMethods()).toBe(true);
+    });
+
+    test('returns false when there are no payment methods', () => {
+      const model = new DropinModel(testContext.modelOptions);
+
+      jest.spyOn(model, 'getPaymentMethods').mockReturnValue([]);
+      expect(model.hasPaymentMethods()).toBe(false);
+    });
+  });
+
+  describe('getInitialViewId', () => {
+    test('returns options id when there are more than 1 supported payment options', async () => {
+      VenmoView.isEnabled.mockResolvedValue(true);
+      CardView.isEnabled.mockResolvedValue(true);
+
+      ApplePayView.isEnabled.mockResolvedValue(false);
+      GooglePayView.isEnabled.mockResolvedValue(false);
+      PayPalView.isEnabled.mockResolvedValue(false);
+      PayPalCreditView.isEnabled.mockResolvedValue(false);
+
+      const model = new DropinModel(testContext.modelOptions);
+
+      await model.initialize();
+
+      expect(model.getInitialViewId()).toBe('options');
+    });
+
+    test('returns the id for the only payment option when there is just 1 supported payment option', async () => {
+      VenmoView.isEnabled.mockResolvedValue(true);
+
+      CardView.isEnabled.mockResolvedValue(false);
+      ApplePayView.isEnabled.mockResolvedValue(false);
+      GooglePayView.isEnabled.mockResolvedValue(false);
+      PayPalView.isEnabled.mockResolvedValue(false);
+      PayPalCreditView.isEnabled.mockResolvedValue(false);
+
+      const model = new DropinModel(testContext.modelOptions);
+
+      await model.initialize();
+
+      expect(model.getInitialViewId()).toBe('venmo');
+    });
+  });
+
   describe('reportAppSwitchPayload', () => {
     test('saves app switch payload to instance', () => {
       const model = new DropinModel(testContext.modelOptions);
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index 79d91ed..639ef83 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -54,6 +54,7 @@ describe('Dropin', () => {
     jest.spyOn(hostedFields, 'create').mockResolvedValue(fake.hostedFields());
     jest.spyOn(paypalCheckout, 'create').mockResolvedValue(fake.paypalInstance);
     jest.spyOn(threeDSecure, 'create').mockResolvedValue(fake.threeDSecureInstance);
+    jest.spyOn(DropinModel.prototype, 'hasPaymentMethods').mockReturnValue(true);
   });
 
   afterEach(() => {
@@ -1395,7 +1396,9 @@ describe('Dropin', () => {
       const instance = new Dropin(testContext.dropinOptions);
 
       instance._model = {
-        isPaymentMethodRequestable: jest.fn().mockReturnValue('foo')
+        isPaymentMethodRequestable: jest.fn().mockReturnValue('foo'),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       expect(instance.isPaymentMethodRequestable()).toBe('foo');
@@ -1450,7 +1453,9 @@ describe('Dropin', () => {
         }
       };
       instance._model = {
-        getPaymentMethods: jest.fn().mockReturnValue([])
+        getPaymentMethods: jest.fn().mockReturnValue([]),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       instance.updateConfiguration('paypal', 'foo', 'bar');
@@ -1480,7 +1485,9 @@ describe('Dropin', () => {
         }
       };
       instance._model = {
-        getPaymentMethods: jest.fn().mockReturnValue([])
+        getPaymentMethods: jest.fn().mockReturnValue([]),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       instance.updateConfiguration('paypalCredit', 'foo', 'bar');
@@ -1510,7 +1517,9 @@ describe('Dropin', () => {
         }
       };
       instance._model = {
-        getPaymentMethods: jest.fn().mockReturnValue([])
+        getPaymentMethods: jest.fn().mockReturnValue([]),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       instance.updateConfiguration('applePay', 'foo', 'bar');
@@ -1540,7 +1549,9 @@ describe('Dropin', () => {
         }
       };
       instance._model = {
-        getPaymentMethods: jest.fn().mockReturnValue([])
+        getPaymentMethods: jest.fn().mockReturnValue([]),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       instance.updateConfiguration('googlePay', 'foo', 'bar');
@@ -1606,7 +1617,9 @@ describe('Dropin', () => {
             { nonce: '4', type: 'PayPalAccount', vaulted: true },
             { nonce: '5', type: 'PayPalAccount' }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1650,7 +1663,9 @@ describe('Dropin', () => {
             { nonce: '2', type: 'CreditCard', vaulted: true },
             { nonce: '3', type: 'PayPalAccount', vaulted: true }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1695,7 +1710,9 @@ describe('Dropin', () => {
             { nonce: '4', type: 'ApplePayCard', vaulted: true },
             { nonce: '5', type: 'ApplePayCard' }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1739,7 +1756,9 @@ describe('Dropin', () => {
             { nonce: '2', type: 'CreditCard', vaulted: true },
             { nonce: '3', type: 'ApplePayCard', vaulted: true }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1784,7 +1803,9 @@ describe('Dropin', () => {
             { nonce: '4', type: 'AndroidPayCard', vaulted: true },
             { nonce: '5', type: 'AndroidPayCard' }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1828,7 +1849,9 @@ describe('Dropin', () => {
             { nonce: '2', type: 'CreditCard', vaulted: true },
             { nonce: '3', type: 'AndroidPayCard', vaulted: true }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1846,7 +1869,7 @@ describe('Dropin', () => {
     );
 
     test(
-      'sets primary view to options if on the methods view and there are no saved payment methods and supportedPaymentOptions is greater than 1',
+      'sets primary view to initial view if on the methods view and there are no saved payment methods and supportedPaymentOptions is greater than 1',
       () => {
         const instance = new Dropin(testContext.dropinOptions);
         const getViewStub = jest.fn();
@@ -1869,7 +1892,9 @@ describe('Dropin', () => {
         instance._model = {
           getPaymentMethods: jest.fn().mockReturnValue([]),
           supportedPaymentOptions: ['paypal', 'card'],
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(false),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1887,48 +1912,6 @@ describe('Dropin', () => {
       }
     );
 
-    test(
-      'sets primary view to available payment option view if on the methods view and there are not saved payment methods and only one payment option is available',
-      () => {
-        const instance = new Dropin(testContext.dropinOptions);
-        const getViewStub = jest.fn();
-        const fakePayPalView = {
-          updateConfiguration: jest.fn()
-        };
-        const fakeMethodsView = {
-          getPaymentMethod: jest.fn().mockReturnValue({
-            type: 'PayPalAccount'
-          })
-        };
-
-        instance._mainView = {
-          getView: getViewStub,
-          primaryView: {
-            ID: 'methods'
-          },
-          setPrimaryView: jest.fn()
-        };
-        instance._model = {
-          getPaymentMethods: jest.fn().mockReturnValue([]),
-          supportedPaymentOptions: ['paypal'],
-          removePaymentMethod: jest.fn()
-        };
-
-        getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
-          if (arg === 'paypal') {
-            return fakePayPalView;
-          } else if (arg === 'methods') {
-            return fakeMethodsView;
-          }
-        });
-
-        instance.updateConfiguration('paypal', 'foo', 'bar');
-
-        expect(instance._mainView.setPrimaryView).toBeCalledTimes(1);
-        expect(instance._mainView.setPrimaryView).toBeCalledWith('paypal');
-      }
-    );
-
     test(
       'does not set primary view if current primary view is not methods',
       () => {
@@ -1952,7 +1935,9 @@ describe('Dropin', () => {
         };
         instance._model = {
           getPaymentMethods: jest.fn().mockReturnValue([]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(false),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -1994,7 +1979,9 @@ describe('Dropin', () => {
           getPaymentMethods: jest.fn().mockReturnValue([
             { nonce: '1', type: 'CreditCard' }
           ]),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -2058,7 +2045,9 @@ describe('Dropin', () => {
         ]),
         refreshPaymentMethods: jest.fn().mockResolvedValue(),
         removeActivePaymentMethod: jest.fn(),
-        removePaymentMethod: jest.fn()
+        removePaymentMethod: jest.fn(),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -2099,7 +2088,9 @@ describe('Dropin', () => {
             { nonce: '3', type: 'PayPalAccount', vaulted: true }
           ]),
           removeActivePaymentMethod: jest.fn(),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('options')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -2115,48 +2106,7 @@ describe('Dropin', () => {
     );
 
     test(
-      'sets primary view to options if on the methods view and there are no saved payment methods and supportedPaymentOptions is greater than 1',
-      () => {
-        const instance = new Dropin(testContext.dropinOptions);
-        const getViewStub = jest.fn();
-        const fakeMethodsView = {
-          getPaymentMethod: jest.fn().mockReturnValue({
-            type: 'PayPalAccount'
-          })
-        };
-
-        instance._mainView = {
-          getView: getViewStub,
-          showLoadingIndicator: jest.fn(),
-          hideLoadingIndicator: jest.fn(),
-          primaryView: {
-            ID: 'methods'
-          },
-          setPrimaryView: jest.fn()
-        };
-        instance._model = {
-          refreshPaymentMethods: jest.fn().mockResolvedValue(),
-          getPaymentMethods: jest.fn().mockReturnValue([]),
-          removeActivePaymentMethod: jest.fn(),
-          supportedPaymentOptions: ['paypal', 'card'],
-          removePaymentMethod: jest.fn()
-        };
-
-        getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
-          if (arg === 'methods') {
-            return fakeMethodsView;
-          }
-        });
-
-        instance.clearSelectedPaymentMethod();
-
-        expect(instance._mainView.setPrimaryView).toBeCalledTimes(1);
-        expect(instance._mainView.setPrimaryView).toBeCalledWith('options');
-      }
-    );
-
-    test(
-      'sets primary view to available payment option view if on the methods view and there are not saved payment methods and only one payment option is available',
+      'sets primary view to the initial view if on the methods view and there are no saved payment methods and only one payment option is available',
       () => {
         const instance = new Dropin(testContext.dropinOptions);
         const getViewStub = jest.fn();
@@ -2180,7 +2130,9 @@ describe('Dropin', () => {
           getPaymentMethods: jest.fn().mockReturnValue([]),
           removeActivePaymentMethod: jest.fn(),
           supportedPaymentOptions: ['paypal'],
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(false),
+          getInitialViewId: jest.fn().mockReturnValue('paypal')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -2220,7 +2172,9 @@ describe('Dropin', () => {
           refreshPaymentMethods: jest.fn().mockResolvedValue(),
           getPaymentMethods: jest.fn().mockReturnValue([]),
           removeActivePaymentMethod: jest.fn(),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(false),
+          getInitialViewId: jest.fn().mockReturnValue('paypal')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -2261,7 +2215,9 @@ describe('Dropin', () => {
             { nonce: '1', type: 'CreditCard' }
           ]),
           removeActivePaymentMethod: jest.fn(),
-          removePaymentMethod: jest.fn()
+          removePaymentMethod: jest.fn(),
+          hasPaymentMethods: jest.fn().mockReturnValue(true),
+          getInitialViewId: jest.fn().mockReturnValue('paypal')
         };
 
         getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
@@ -2300,7 +2256,9 @@ describe('Dropin', () => {
           { nonce: '1', type: 'CreditCard' }
         ]),
         removeActivePaymentMethod: jest.fn(),
-        removePaymentMethod: jest.fn()
+        removePaymentMethod: jest.fn(),
+        hasPaymentMethods: jest.fn().mockReturnValue(true),
+        getInitialViewId: jest.fn().mockReturnValue('options')
       };
 
       getViewStub.mockImplementation(arg => { // eslint-disable-line consistent-return
diff --git a/test/unit/views/main-view.js b/test/unit/views/main-view.js
index 27ff97f..1bc6877 100644
--- a/test/unit/views/main-view.js
+++ b/test/unit/views/main-view.js
@@ -245,10 +245,12 @@ describe('MainView', () => {
       );
 
       test('sets the PaymentMethodsView as the primary view', done => {
+        jest.spyOn(MainView.prototype, 'setPrimaryView').mockImplementation();
+
         const mainView = new MainView(testContext.mainViewOptions);
 
         setTimeout(() => {
-          expect(mainView.primaryView.ID).toBe(PaymentMethodsView.ID);
+          expect(mainView.setPrimaryView).toBeCalledWith(PaymentMethodsView.ID);
           done();
         }, CHANGE_ACTIVE_PAYMENT_METHOD_TIMEOUT);
       });
@@ -332,6 +334,8 @@ describe('MainView', () => {
 
       wrapper.innerHTML = templateHTML;
 
+      jest.spyOn(model, 'hasPaymentMethods').mockReturnValue(true);
+
       return model.initialize().then(() => {
         model.supportedPaymentOptions = ['card', 'paypal', 'paypalCredit', 'applePay', 'googlePay', 'venmo'];
 
@@ -794,16 +798,35 @@ describe('MainView', () => {
     });
 
     describe('for changeActivePaymentMethod', () => {
-      test('sets the PaymentMethodsView as the primary view', done => {
+      beforeEach(() => {
+        jest.spyOn(wait, 'delay').mockResolvedValue();
+      });
+
+      test('sets the PaymentMethodsView as the primary view when it has payment methods', async () => {
         testContext.mainView.paymentMethodsViews.activeMethodView = { setActive: function () {} };
+        jest.spyOn(testContext.mainView.model, 'hasPaymentMethods').mockReturnValue(true);
         jest.spyOn(testContext.mainView, 'setPrimaryView').mockImplementation();
 
         testContext.model._emit('changeActivePaymentMethod', {});
 
-        setTimeout(() => {
-          expect(testContext.mainView.setPrimaryView).toBeCalled();
-          done();
-        }, CHANGE_ACTIVE_PAYMENT_METHOD_TIMEOUT);
+        // let the delay mock resolve
+        await Promise.resolve();
+
+        expect(testContext.mainView.setPrimaryView).toBeCalledWith('methods');
+      });
+
+      test('sets the initial view as the primary view when there are no payment methods', async () => {
+        testContext.mainView.paymentMethodsViews.activeMethodView = { setActive: function () {} };
+        jest.spyOn(testContext.mainView, 'setPrimaryView').mockImplementation();
+        jest.spyOn(testContext.mainView.model, 'hasPaymentMethods').mockReturnValue(false);
+        jest.spyOn(testContext.mainView.model, 'getInitialViewId').mockReturnValue('options');
+
+        testContext.model._emit('changeActivePaymentMethod', {});
+
+        // let the delay mock resolve
+        await Promise.resolve();
+
+        expect(testContext.mainView.setPrimaryView).toBeCalledWith('options');
       });
     });
 
@@ -991,13 +1014,14 @@ describe('MainView', () => {
         test('sets the PaymentOptionsView as the primary view', () => {
           const mainView = new MainView(testContext.mainViewOptions);
 
-          jest.spyOn(mainView, 'setPrimaryView');
           mainView.setPrimaryView(CardView.ID);
+
+          jest.spyOn(mainView, 'setPrimaryView').mockImplementation();
+
           mainView.toggle.click();
 
           return wait.delay(1).then(() => {
             expect(mainView.setPrimaryView).toBeCalledWith(PaymentOptionsView.ID);
-            expect(testContext.wrapper.className).toMatch('braintree-show-' + PaymentOptionsView.ID);
           });
         });
       });
@@ -1011,9 +1035,10 @@ describe('MainView', () => {
             testContext.mainViewOptions.model.supportedPaymentOptions = ['card', 'paypal'];
             testContext.mainView = new MainView(testContext.mainViewOptions);
 
-            jest.spyOn(testContext.mainView, 'setPrimaryView');
-
             testContext.mainView.setPrimaryView(CardView.ID);
+
+            jest.spyOn(testContext.mainView, 'setPrimaryView').mockImplementation();
+
             testContext.mainView.toggle.click();
 
             return wait.delay(1);
@@ -1022,12 +1047,10 @@ describe('MainView', () => {
 
         test('sets the PaymentMethodsView as the primary view', () => {
           expect(testContext.mainView.setPrimaryView).toBeCalledWith(PaymentMethodsView.ID, expect.anything());
-          expect(testContext.wrapper.className).toMatch('braintree-show-' + PaymentMethodsView.ID);
-          expect(testContext.mainView.model.getActivePaymentViewId()).toBe(PaymentMethodsView.ID);
         });
 
-        test('exposes the PaymentOptionsView', () => {
-          expect(testContext.wrapper.className).toMatch('braintree-show-' + PaymentOptionsView.ID);
+        test('exposes the PaymentOptionsView as the secondary view', () => {
+          expect(testContext.mainView.setPrimaryView).toBeCalledWith(PaymentMethodsView.ID, PaymentOptionsView.ID);
         });
 
         test('hides the toggle', () => {
@@ -1272,6 +1295,7 @@ describe('MainView', () => {
           strings: strings
         };
         testContext.mainView = new MainView(testContext.mainViewOptions);
+        jest.spyOn(testContext.mainView, 'setPrimaryView').mockImplementation();
       });
     });