Repository: braintree/braintree-web-drop-in
Pull request #704 (2021-03-03 15:30:29)

Fix race condition
### Summary

The way Drop-in was constructed, it was possible to get into a race condition during set up where some dependencies (payment options, data collector, 3ds, etc) can report that they are ready before others even start setting up, causing Drop-in to attempt to finish the setup too early and fail (or miss some payment options).

This PR lets Drop-in know up front what dependencies it is setting up and periodically checks if all the dependencies have reached a terminal set up state (either done, failed or not enabled).

### 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 3b81b96..8322eb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@ CHANGELOG
 
 unreleased
 ----------
+- Fix issue where Drop-in may error or not set up completely due to dependency setup race condition (#700)
+- Fix issue where Drop-in may report it is ready even though no payment options are actually available
 - Google Pay
   - Fix issue where passing custom button option would caused a developer error in the console (#701)
 
diff --git a/src/constants.js b/src/constants.js
index 7bb43ed..7be5cf8 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -44,6 +44,12 @@ module.exports = {
     unionpay: 'UnionPay',
     maestro: 'Maestro'
   },
+  dependencySetupStates: {
+    DONE: 'done',
+    FAILED: 'failed',
+    INITIALIZING: 'initializing',
+    NOT_ENABLED: 'not-enabled'
+  },
   errors: {
     NO_PAYMENT_METHOD_ERROR: 'No payment method is available.',
     DEVELOPER_MISCONFIGURATION_MESSAGE: 'Developer Error: Something went wrong. Check the console for details.'
diff --git a/src/dropin-model.js b/src/dropin-model.js
index 41006d7..cd28589 100644
--- a/src/dropin-model.js
+++ b/src/dropin-model.js
@@ -6,6 +6,7 @@ var EventEmitter = require('@braintree/event-emitter');
 var constants = require('./constants');
 var paymentMethodTypes = constants.paymentMethodTypes;
 var paymentOptionIDs = constants.paymentOptionIDs;
+var dependencySetupStates = constants.dependencySetupStates;
 var isGuestCheckout = require('./lib/is-guest-checkout');
 var Promise = require('./lib/promise');
 var paymentSheetViews = require('./views/payment-sheet-views');
@@ -24,16 +25,28 @@ var DEFAULT_PAYMENT_OPTION_PRIORITY = [
   paymentOptionIDs.applePay,
   paymentOptionIDs.googlePay
 ];
+var ASYNC_DEPENDENCIES = DEFAULT_PAYMENT_OPTION_PRIORITY.concat(['threeDSecure', 'dataCollector']);
+var DEPENDENCY_READY_CHECK_INTERVAL = 200;
 
 function DropinModel(options) {
   this.rootNode = options.container;
   this.componentID = options.componentID;
   this.merchantConfiguration = options.merchantConfiguration;
-
   this.isGuestCheckout = isGuestCheckout(options.client);
 
-  this.dependenciesInitializing = 0;
-  this.dependencySuccessCount = 0;
+  this.dependencyStates = ASYNC_DEPENDENCIES.reduce(function (total, dependencyKey) {
+    if (dependencyKey in options.merchantConfiguration) {
+      total[dependencyKey] = dependencySetupStates.INITIALIZING;
+    }
+
+    return total;
+  }, {});
+  // card is on by default, so we need to set it's state to INITIALIZING
+  // unless the merchant has specifically opted out of using the card form
+  if (options.merchantConfiguration.card !== false) {
+    this.dependencyStates.card = dependencySetupStates.INITIALIZING;
+  }
+
   this.failedDependencies = {};
   this._options = options;
   this._setupComplete = false;
@@ -50,13 +63,30 @@ EventEmitter.createChild(DropinModel);
 
 DropinModel.prototype.initialize = function () {
   var self = this;
+  var dependencyReadyInterval = setInterval(function () {
+    var ready = true;
+
+    Object.keys(self.dependencyStates).forEach(function (dep) {
+      if (self.dependencyStates[dep] === dependencySetupStates.INITIALIZING) {
+        ready = false;
+      }
+    });
+
+    if (!ready) {
+      return;
+    }
+
+    clearInterval(dependencyReadyInterval);
+
+    self._emit('asyncDependenciesReady');
+  }, DEPENDENCY_READY_CHECK_INTERVAL);
 
   return vaultManager.create({
     client: self._options.client
   }).then(function (vaultManagerInstance) {
     self._vaultManager = vaultManagerInstance;
 
-    return getSupportedPaymentOptions(self._options);
+    return self._getSupportedPaymentOptions(self._options);
   }).then(function (paymentOptions) {
     self.supportedPaymentOptions = paymentOptions;
 
@@ -219,14 +249,21 @@ DropinModel.prototype.reportAppSwitchError = function (sheetId, error) {
   };
 };
 
-DropinModel.prototype.asyncDependencyStarting = function () {
-  this.dependenciesInitializing++;
+DropinModel.prototype.hasAtLeastOneAvailablePaymentOption = function () {
+  var self = this;
+  var i;
+
+  for (i = 0; i < this.supportedPaymentOptions.length; i++) {
+    if (self.dependencyStates[this.supportedPaymentOptions[i]] === dependencySetupStates.DONE) {
+      return true;
+    }
+  }
+
+  return false;
 };
 
-DropinModel.prototype.asyncDependencyReady = function () {
-  this.dependencySuccessCount++;
-  this.dependenciesInitializing--;
-  this._checkAsyncDependencyFinished();
+DropinModel.prototype.asyncDependencyReady = function (key) {
+  this.dependencyStates[key] = dependencySetupStates.DONE;
 };
 
 DropinModel.prototype.asyncDependencyFailed = function (options) {
@@ -234,14 +271,7 @@ DropinModel.prototype.asyncDependencyFailed = function (options) {
     return;
   }
   this.failedDependencies[options.view] = options.error;
-  this.dependenciesInitializing--;
-  this._checkAsyncDependencyFinished();
-};
-
-DropinModel.prototype._checkAsyncDependencyFinished = function () {
-  if (this.dependenciesInitializing === 0) {
-    this._emit('asyncDependenciesReady');
-  }
+  this.dependencyStates[options.view] = dependencySetupStates.FAILED;
 };
 
 DropinModel.prototype.cancelInitialization = function (error) {
@@ -329,7 +359,8 @@ DropinModel.prototype._getSupportedPaymentMethods = function (paymentMethods) {
   });
 };
 
-function getSupportedPaymentOptions(options) {
+DropinModel.prototype._getSupportedPaymentOptions = function (options) {
+  var self = this;
   var paymentOptionPriority = options.merchantConfiguration.paymentOptionPriority || DEFAULT_PAYMENT_OPTION_PRIORITY;
   var promises;
 
@@ -341,7 +372,13 @@ function getSupportedPaymentOptions(options) {
   paymentOptionPriority = paymentOptionPriority.filter(function (item, pos) { return paymentOptionPriority.indexOf(item) === pos; });
 
   promises = paymentOptionPriority.map(function (paymentOption) {
-    return getPaymentOption(paymentOption, options);
+    return getPaymentOption(paymentOption, options).then(function (result) {
+      if (!result.success) {
+        self.dependencyStates[result.id] = dependencySetupStates.NOT_ENABLED;
+      }
+
+      return result;
+    });
   });
 
   return Promise.all(promises).then(function (result) {
@@ -355,7 +392,7 @@ function getSupportedPaymentOptions(options) {
 
     return result.map(function (item) { return item.id; });
   });
-}
+};
 
 function getPaymentOption(paymentOption, options) {
   return isPaymentOptionEnabled(paymentOption, options).then(function (success) {
diff --git a/src/dropin.js b/src/dropin.js
index 881f937..ef9fd00 100644
--- a/src/dropin.js
+++ b/src/dropin.js
@@ -456,7 +456,7 @@ Dropin.prototype._initialize = function (callback) {
     });
 
     self._model.on('asyncDependenciesReady', function () {
-      if (self._model.dependencySuccessCount >= 1) {
+      if (self._model.hasAtLeastOneAvailablePaymentOption()) {
         analytics.sendEvent(self._client, 'appeared');
         self._disableErroredPaymentMethods();
 
@@ -597,11 +597,10 @@ Dropin.prototype._setUpDataCollector = function () {
   var self = this;
   var config = assign({}, self._merchantConfiguration.dataCollector, {client: self._client});
 
-  this._model.asyncDependencyStarting();
   this._dataCollector = new DataCollector(config);
 
   this._dataCollector.initialize().then(function () {
-    self._model.asyncDependencyReady();
+    self._model.asyncDependencyReady('dataCollector');
   }).catch(function (err) {
     self._model.cancelInitialization(new DropinError({
       message: 'Data Collector failed to set up.',
@@ -613,12 +612,10 @@ Dropin.prototype._setUpDataCollector = function () {
 Dropin.prototype._setUpThreeDSecure = function () {
   var self = this;
 
-  this._model.asyncDependencyStarting();
-
   this._threeDSecure = new ThreeDSecure(this._client, this._model);
 
   this._threeDSecure.initialize().then(function () {
-    self._model.asyncDependencyReady();
+    self._model.asyncDependencyReady('threeDSecure');
   }).catch(function (err) {
     self._model.cancelInitialization(new DropinError({
       message: '3D Secure failed to set up.',
diff --git a/src/views/payment-sheet-views/apple-pay-view.js b/src/views/payment-sheet-views/apple-pay-view.js
index 45d5877..548923b 100644
--- a/src/views/payment-sheet-views/apple-pay-view.js
+++ b/src/views/payment-sheet-views/apple-pay-view.js
@@ -26,8 +26,6 @@ ApplePayView.prototype.initialize = function () {
 
   delete self.applePayConfiguration.applePaySessionVersion;
 
-  self.model.asyncDependencyStarting();
-
   return btApplePay.create({client: this.client}).then(function (applePayInstance) {
     var buttonDiv = self.getElementById('apple-pay-button');
 
@@ -36,7 +34,7 @@ ApplePayView.prototype.initialize = function () {
     buttonDiv.onclick = self._showPaymentSheet.bind(self);
     buttonDiv.style['-apple-pay-button-style'] = self.model.merchantConfiguration.applePay.buttonStyle || 'black';
 
-    self.model.asyncDependencyReady();
+    self.model.asyncDependencyReady(ApplePayView.ID);
   }).catch(function (err) {
     self.model.asyncDependencyFailed({
       view: self.ID,
diff --git a/src/views/payment-sheet-views/base-paypal-view.js b/src/views/payment-sheet-views/base-paypal-view.js
index 6ddb6ce..a26ce38 100644
--- a/src/views/payment-sheet-views/base-paypal-view.js
+++ b/src/views/payment-sheet-views/base-paypal-view.js
@@ -34,7 +34,6 @@ BasePayPalView.prototype.initialize = function () {
 
   this.paypalConfiguration = assign({}, paypalConfiguration);
 
-  this.model.asyncDependencyStarting();
   asyncDependencyTimeoutHandler = setTimeout(function () {
     self.model.asyncDependencyFailed({
       view: self.ID,
@@ -94,7 +93,7 @@ BasePayPalView.prototype.initialize = function () {
     buttonSelector = dropinWrapperId + ' ' + buttonSelector;
 
     return global.paypal.Button.render(checkoutJSConfiguration, buttonSelector).then(function () {
-      self.model.asyncDependencyReady();
+      self.model.asyncDependencyReady(paypalType);
       setupComplete = true;
       clearTimeout(asyncDependencyTimeoutHandler);
     });
diff --git a/src/views/payment-sheet-views/card-view.js b/src/views/payment-sheet-views/card-view.js
index 63b8a2a..3870ac6 100644
--- a/src/views/payment-sheet-views/card-view.js
+++ b/src/views/payment-sheet-views/card-view.js
@@ -73,8 +73,6 @@ CardView.prototype.initialize = function () {
     this.saveCardInput.checked = false;
   }
 
-  this.model.asyncDependencyStarting();
-
   return hostedFields.create(hfOptions).then(function (hostedFieldsInstance) {
     this.hostedFieldsInstance = hostedFieldsInstance;
     this.hostedFieldsInstance.on('blur', this._onBlurEvent.bind(this));
@@ -89,7 +87,7 @@ CardView.prototype.initialize = function () {
       }.bind(this));
     }.bind(this));
 
-    this.model.asyncDependencyReady();
+    this.model.asyncDependencyReady(CardView.ID);
   }.bind(this)).catch(function (err) {
     this.model.asyncDependencyFailed({
       view: this.ID,
diff --git a/src/views/payment-sheet-views/google-pay-view.js b/src/views/payment-sheet-views/google-pay-view.js
index a25e449..735b769 100644
--- a/src/views/payment-sheet-views/google-pay-view.js
+++ b/src/views/payment-sheet-views/google-pay-view.js
@@ -43,8 +43,6 @@ GooglePayView.prototype.initialize = function () {
   delete self.googlePayConfiguration.merchantId;
   delete self.googlePayConfiguration.button;
 
-  self.model.asyncDependencyStarting();
-
   return btGooglePay.create({
     client: self.client,
     googlePayVersion: googlePayVersion,
@@ -57,7 +55,7 @@ GooglePayView.prototype.initialize = function () {
 
     buttonContainer.appendChild(self.paymentsClient.createButton(buttonOptions));
 
-    self.model.asyncDependencyReady();
+    self.model.asyncDependencyReady(GooglePayView.ID);
   }).catch(function (err) {
     self.model.asyncDependencyFailed({
       view: self.ID,
diff --git a/src/views/payment-sheet-views/venmo-view.js b/src/views/payment-sheet-views/venmo-view.js
index 5b7ccc0..1fd673d 100644
--- a/src/views/payment-sheet-views/venmo-view.js
+++ b/src/views/payment-sheet-views/venmo-view.js
@@ -19,8 +19,6 @@ VenmoView.prototype.initialize = function () {
   var self = this;
   var venmoConfiguration = assign({}, self.model.merchantConfiguration.venmo, {client: this.client});
 
-  self.model.asyncDependencyStarting();
-
   return btVenmo.create(venmoConfiguration).then(function (venmoInstance) {
     self.venmoInstance = venmoInstance;
 
@@ -57,7 +55,7 @@ VenmoView.prototype.initialize = function () {
       });
     });
 
-    self.model.asyncDependencyReady();
+    self.model.asyncDependencyReady(VenmoView.ID);
   }).catch(function (err) {
     self.model.asyncDependencyFailed({
       view: self.ID,


Test patch:
diff --git a/test/unit/dropin-model.js b/test/unit/dropin-model.js
index 0773f08..48f8857 100644
--- a/test/unit/dropin-model.js
+++ b/test/unit/dropin-model.js
@@ -142,6 +142,44 @@ describe('DropinModel', () => {
   });
 
   describe('initialize', () => {
+    test('emits asyncDependenciesReady event when no dependencies are set to initializing', (done) => {
+      jest.useFakeTimers();
+
+      const model = new DropinModel(testContext.modelOptions);
+
+      jest.spyOn(model, '_emit');
+
+      model.on('asyncDependenciesReady', () => {
+        jest.useRealTimers();
+
+        done();
+      });
+
+      return model.initialize().then(() => {
+        expect(model._emit).not.toBeCalledWith('asyncDependenciesReady');
+
+        model.asyncDependencyReady('paypal');
+
+        expect(model._emit).not.toBeCalledWith('asyncDependenciesReady');
+
+        jest.advanceTimersByTime(1000);
+
+        expect(model._emit).not.toBeCalledWith('asyncDependenciesReady');
+
+        model.asyncDependencyReady('venmo');
+
+        expect(model._emit).not.toBeCalledWith('asyncDependenciesReady');
+
+        jest.advanceTimersByTime(1000);
+
+        model.asyncDependencyReady('card');
+
+        expect(model._emit).not.toBeCalledWith('asyncDependenciesReady');
+
+        jest.advanceTimersByTime(1000);
+      });
+    });
+
     test('creates a vault manager', () => {
       const model = new DropinModel(testContext.modelOptions);
 
@@ -187,6 +225,7 @@ describe('DropinModel', () => {
         expect(model._paymentMethods).toEqual([
           { type: 'CreditCard', details: { lastTwo: '11' }, vaulted: true }
         ]);
+        expect(model.dependencyStates.paypal).toBe('not-enabled');
       });
     });
 
@@ -579,6 +618,38 @@ describe('DropinModel', () => {
     });
   });
 
+  describe('hasAtLeastOneAvailablePaymentOption', () => {
+    test('returns false when no payment methods have a setup status of done', async () => {
+      // all dependencies will have a setup status of initializing
+      const model = new DropinModel(testContext.modelOptions);
+
+      await model.initialize();
+
+      expect(model.hasAtLeastOneAvailablePaymentOption()).toBe(false);
+    });
+
+    test('returns true when at least one payment method has a setup status of done', async () => {
+      const model = new DropinModel(testContext.modelOptions);
+
+      await model.initialize();
+
+      model.asyncDependencyReady('card');
+
+      expect(model.hasAtLeastOneAvailablePaymentOption()).toBe(true);
+    });
+
+    test('returns false when no payment methods have a setup status of done, even if non-payment methods do', async () => {
+      const model = new DropinModel(testContext.modelOptions);
+
+      await model.initialize();
+
+      model.asyncDependencyReady('dataCollector');
+      model.asyncDependencyReady('threeDSecure');
+
+      expect(model.hasAtLeastOneAvailablePaymentOption()).toBe(false);
+    });
+  });
+
   describe('reportAppSwitchPayload', () => {
     test('saves app switch payload to instance', () => {
       const model = new DropinModel(testContext.modelOptions);
@@ -602,22 +673,20 @@ describe('DropinModel', () => {
     });
   });
 
-  describe('asyncDependencyStarting', () => {
+  describe('asyncDependencyFailed', () => {
     beforeEach(() => {
-      testContext.context = {
-        dependenciesInitializing: 0
-      };
+      testContext.model = new DropinModel(testContext.modelOptions);
     });
 
-    test('increments dependenciesInitializing by one', () => {
-      DropinModel.prototype.asyncDependencyStarting.call(testContext.context);
-      expect(testContext.context.dependenciesInitializing).toBe(1);
-    });
-  });
+    test('marks depenedency as failed', () => {
+      const err = new Error('a bad error');
 
-  describe('asyncDependencyFailed', () => {
-    beforeEach(() => {
-      testContext.model = new DropinModel(testContext.modelOptions);
+      expect(testContext.model.dependencyStates.venmo).toBe('initializing');
+      testContext.model.asyncDependencyFailed({
+        view: 'venmo',
+        error: err
+      });
+      expect(testContext.model.dependencyStates.venmo).toBe('failed');
     });
 
     test('adds an error to the failedDependencies object', () => {
@@ -656,10 +725,13 @@ describe('DropinModel', () => {
           done();
         });
 
-        model.asyncDependencyStarting();
-        model.asyncDependencyFailed({
-          view: 'id',
-          error: new Error('fake error')
+        model.initialize().then(() => {
+          model.asyncDependencyReady('paypal');
+          model.asyncDependencyFailed({
+            view: 'venmo',
+            error: new Error('fake error')
+          });
+          model.asyncDependencyReady('card');
         });
       }
     );
@@ -670,14 +742,27 @@ describe('DropinModel', () => {
       testContext.context = { callback: jest.fn() };
     });
 
-    test('decrements dependenciesInitializing by one', () => {
+    test('marks initializing dependency as done', () => {
       const model = new DropinModel(testContext.modelOptions);
 
-      model.dependenciesInitializing = 2;
+      // the modelOptions have a config for paypal and venmo
+      expect(model.dependencyStates.paypal).toBe('initializing');
+      expect(model.dependencyStates.venmo).toBe('initializing');
+
+      model.asyncDependencyReady('paypal');
+      expect(model.dependencyStates.paypal).toBe('done');
+      expect(model.dependencyStates.venmo).toBe('initializing');
+      expect(model.dependencyStates.card).toBe('initializing');
 
-      model.asyncDependencyReady();
+      model.asyncDependencyReady('venmo');
+      expect(model.dependencyStates.paypal).toBe('done');
+      expect(model.dependencyStates.venmo).toBe('done');
+      expect(model.dependencyStates.card).toBe('initializing');
 
-      expect(model.dependenciesInitializing).toBe(1);
+      model.asyncDependencyReady('card');
+      expect(model.dependencyStates.paypal).toBe('done');
+      expect(model.dependencyStates.venmo).toBe('done');
+      expect(model.dependencyStates.card).toBe('done');
     });
 
     test(
@@ -688,30 +773,34 @@ describe('DropinModel', () => {
         jest.spyOn(DropinModel.prototype, 'asyncDependencyReady');
 
         model.on('asyncDependenciesReady', () => {
-          expect(DropinModel.prototype.asyncDependencyReady).toBeCalledTimes(1);
+          expect(DropinModel.prototype.asyncDependencyReady).toBeCalledTimes(3);
           done();
         });
 
-        model.asyncDependencyStarting();
-        model.asyncDependencyReady();
+        model.initialize().then(() => {
+          model.asyncDependencyReady('paypal');
+          model.asyncDependencyReady('venmo');
+          model.asyncDependencyReady('card');
+        });
       }
     );
 
-    test('emits asyncDependenciesReady event with prior errors', () => {
+    test('emits asyncDependenciesReady event with prior errors', (done) => {
       const model = new DropinModel(testContext.modelOptions);
       const err = new Error('an earlier dependency failed');
 
-      jest.spyOn(model, '_emit');
-
-      model.asyncDependencyStarting();
-      model.asyncDependencyStarting();
-      model.asyncDependencyFailed({
-        view: 'id',
-        error: err
+      model.on('asyncDependenciesReady', () => {
+        done();
       });
-      model.asyncDependencyReady();
 
-      expect(model._emit).toBeCalledWith('asyncDependenciesReady');
+      return model.initialize().then(() => {
+        model.asyncDependencyReady('card');
+        model.asyncDependencyFailed({
+          view: 'venmo',
+          error: err
+        });
+        model.asyncDependencyReady('paypal');
+      });
     });
   });
 
diff --git a/test/unit/dropin.js b/test/unit/dropin.js
index b4d6ec9..9dfa087 100644
--- a/test/unit/dropin.js
+++ b/test/unit/dropin.js
@@ -487,17 +487,7 @@ describe('Dropin', () => {
       done => {
         const instance = new Dropin(testContext.dropinOptions);
 
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyReady').mockImplementation();
-
-        instance._initialize(err => {
-          done(err);
-        });
-
-        delay().then(() => {
-          instance._model.dependencySuccessCount = 1;
-          instance._model._emit('asyncDependenciesReady');
-        });
+        instance._initialize(done);
       }
     );
 
@@ -505,8 +495,6 @@ describe('Dropin', () => {
       const instance = new Dropin(testContext.dropinOptions);
       const error = new Error('error');
 
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyReady').mockImplementation();
       jest.spyOn(DropinModel.prototype, 'reportError').mockImplementation();
 
       instance._initialize(() => {
@@ -518,13 +506,11 @@ describe('Dropin', () => {
       });
 
       delay().then(() => {
-        instance._model.dependencySuccessCount = 1;
         instance._model.appSwitchError = {
           id: 'view-id',
           error: error
         };
         jest.spyOn(instance._mainView, 'setPrimaryView').mockImplementation();
-        instance._model._emit('asyncDependenciesReady');
       });
     });
 
@@ -532,8 +518,6 @@ describe('Dropin', () => {
       const instance = new Dropin(testContext.dropinOptions);
       const payload = { nonce: 'fake-nonce' };
 
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyReady').mockImplementation();
       jest.spyOn(DropinModel.prototype, 'addPaymentMethod').mockImplementation();
 
       instance._initialize(() => {
@@ -542,11 +526,7 @@ describe('Dropin', () => {
         done();
       });
 
-      delay().then(() => {
-        instance._model.dependencySuccessCount = 1;
-        instance._model.appSwitchPayload = payload;
-        instance._model._emit('asyncDependenciesReady');
-      });
+      instance._model.appSwitchPayload = payload;
     });
 
     test(
@@ -554,19 +534,11 @@ describe('Dropin', () => {
       done => {
         const instance = new Dropin(testContext.dropinOptions);
 
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyReady').mockImplementation();
-
         instance._initialize(() => {
           expect(analytics.sendEvent).toBeCalledTimes(1);
           expect(analytics.sendEvent).toBeCalledWith(instance._client, 'appeared');
           done();
         });
-
-        delay().then(() => {
-          instance._model.dependencySuccessCount = 2;
-          instance._model._emit('asyncDependenciesReady');
-        });
       }
     );
 
@@ -617,8 +589,6 @@ describe('Dropin', () => {
           { type: 'CreditCard', details: { lastTwo: '11' }},
           { type: 'PayPalAccount', details: { email: 'wow@example.com' }}
         ]);
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyReady').mockImplementation();
 
         instance._initialize(() => {
           expect(analytics.sendEvent).not.toBeCalledWith(instance._client, 'vaulted-card.appear');
@@ -628,13 +598,10 @@ describe('Dropin', () => {
         });
 
         delay().then(() => {
-          instance._model.dependencySuccessCount = 1;
           instance._model.appSwitchPayload = {
             nonce: 'a-nonce'
           };
           jest.spyOn(instance._mainView, 'setPrimaryView').mockImplementation();
-
-          instance._model._emit('asyncDependenciesReady');
         });
       }
     );
@@ -648,8 +615,6 @@ describe('Dropin', () => {
           { type: 'CreditCard', details: { lastTwo: '11' }},
           { type: 'PayPalAccount', details: { email: 'wow@example.com' }}
         ]);
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-        jest.spyOn(DropinModel.prototype, 'asyncDependencyReady').mockImplementation();
 
         instance._initialize(() => {
           expect(analytics.sendEvent).not.toBeCalledWith(instance._client, 'vaulted-card.appear');
@@ -659,14 +624,11 @@ describe('Dropin', () => {
         });
 
         delay().then(() => {
-          instance._model.dependencySuccessCount = 1;
           instance._model.appSwitchError = {
             ID: 'view',
             error: new Error('error')
           };
           jest.spyOn(instance._mainView, 'setPrimaryView').mockImplementation();
-
-          instance._model._emit('asyncDependenciesReady');
         });
       }
     );
@@ -812,32 +774,48 @@ describe('Dropin', () => {
   describe('loads data collector', () => {
     beforeEach(() => {
       testContext.dropinOptions.merchantConfiguration.dataCollector = { kount: true };
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting');
       jest.spyOn(DropinModel.prototype, 'asyncDependencyReady');
-      jest.spyOn(Dropin.prototype, '_setUpDataCollector').mockImplementation();
+      jest.spyOn(DataCollector.prototype, 'initialize').mockResolvedValue();
     });
 
-    test(
-      'does not load Data Collector if Data Collector is not enabled',
-      done => {
-        let instance;
+    test('does not load Data Collector if Data Collector is not enabled', done => {
+      let instance;
 
-        delete testContext.dropinOptions.merchantConfiguration.dataCollector;
-        instance = new Dropin(testContext.dropinOptions);
+      delete testContext.dropinOptions.merchantConfiguration.dataCollector;
+      instance = new Dropin(testContext.dropinOptions);
 
-        instance._initialize(() => {
-          expect(instance._setUpDataCollector).not.toBeCalled();
+      instance._initialize(() => {
+        expect(DataCollector.prototype.initialize).not.toBeCalled();
+        expect(DropinModel.prototype.asyncDependencyReady).not.toBeCalledWith('dataCollector');
 
-          done();
-        });
-      }
-    );
+        done();
+      });
+    });
 
     test('does load Data Collector if Data Collector is enabled', done => {
       const instance = new Dropin(testContext.dropinOptions);
 
       instance._initialize(() => {
-        expect(instance._setUpDataCollector).toBeCalled();
+        expect(DataCollector.prototype.initialize).toBeCalledTimes(1);
+        expect(DropinModel.prototype.asyncDependencyReady).toBeCalledWith('dataCollector');
+
+        done();
+      });
+    });
+
+    test('cancels initialization when Data Collection creation fails', done => {
+      const error = new Error('failed.');
+
+      jest.spyOn(DropinModel.prototype, 'cancelInitialization');
+
+      DataCollector.prototype.initialize.mockRejectedValue(error);
+
+      const instance = new Dropin(testContext.dropinOptions);
+
+      instance._initialize(() => {
+        expect(DataCollector.prototype.initialize).toBeCalledTimes(1);
+        expect(DropinModel.prototype.asyncDependencyReady).not.toBeCalledWith('dataCollector');
+        expect(DropinModel.prototype.cancelInitialization).toBeCalledTimes(1);
 
         done();
       });
@@ -847,9 +825,8 @@ describe('Dropin', () => {
   describe('loads 3D Secure', () => {
     beforeEach(() => {
       testContext.dropinOptions.merchantConfiguration.threeDSecure = {};
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting');
       jest.spyOn(DropinModel.prototype, 'asyncDependencyReady');
-      jest.spyOn(Dropin.prototype, '_setUpThreeDSecure');
+      jest.spyOn(ThreeDSecure.prototype, 'initialize').mockResolvedValue();
     });
 
     test('does not load 3D Secure if 3D Secure is not enabled', done => {
@@ -859,7 +836,8 @@ describe('Dropin', () => {
       instance = new Dropin(testContext.dropinOptions);
 
       instance._initialize(() => {
-        expect(instance._setUpThreeDSecure).not.toBeCalled();
+        expect(ThreeDSecure.prototype.initialize).not.toBeCalled();
+        expect(DropinModel.prototype.asyncDependencyReady).not.toBeCalledWith('threeDSecure');
 
         done();
       });
@@ -869,114 +847,12 @@ describe('Dropin', () => {
       const instance = new Dropin(testContext.dropinOptions);
 
       instance._initialize(() => {
-        expect(instance._setUpThreeDSecure).toBeCalled();
+        expect(ThreeDSecure.prototype.initialize).toBeCalled();
+        expect(DropinModel.prototype.asyncDependencyReady).toBeCalledWith('threeDSecure');
 
         done();
       });
     });
-  });
-
-  describe('_setUpDataCollector', () => {
-    beforeEach(() => {
-      testContext.model = fake.model({
-        componentID: 'foo',
-        client: testContext.client,
-        merchantConfiguration: {
-          container: '#foo',
-          authorization: fake.tokenizationKey
-        },
-        paymentMethods: ['card']
-      });
-      jest.spyOn(DataCollector.prototype, 'initialize').mockResolvedValue();
-    });
-
-    test('sets up datacollector', () => {
-      Dropin.prototype._setUpDataCollector.call({
-        _client: testContext.client,
-        _model: testContext.model,
-        _strings: {},
-        _merchantConfiguration: {
-          dataCollector: {
-            kount: true
-          }
-        }
-      });
-
-      expect(DataCollector.prototype.initialize).toBeCalledTimes(1);
-    });
-
-    test(
-      'fails initialization when Data Collection creation fails',
-      done => {
-        const error = new Error('failed.');
-
-        jest.spyOn(DropinModel.prototype, 'cancelInitialization');
-
-        DataCollector.prototype.initialize.mockRejectedValue(error);
-
-        Dropin.prototype._setUpDataCollector.call({
-          _client: testContext.client,
-          _model: testContext.model,
-          _merchantConfiguration: {
-            threeDSecure: {
-              foo: 'bar'
-            }
-          }
-        });
-
-        expect(DataCollector.prototype.initialize).toBeCalledTimes(1);
-        setTimeout(() => {
-          expect(DropinModel.prototype.cancelInitialization).toBeCalled();
-          done();
-        }, 1000);
-      }
-    );
-
-    test('starts an async dependency', () => {
-      function noop() {}
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting');
-
-      Dropin.prototype._setUpDataCollector.call({
-        _client: testContext.client,
-        _merchantConfiguration: {
-          threeDSecure: {
-            foo: 'bar'
-          }
-        },
-        _model: testContext.model
-      }, noop);
-
-      expect(DropinModel.prototype.asyncDependencyStarting).toBeCalledTimes(1);
-    });
-  });
-
-  describe('_setUpThreeDSecure', () => {
-    beforeEach(() => {
-      testContext.model = fake.model({
-        componentID: 'foo',
-        client: testContext.client,
-        merchantConfiguration: {
-          container: '#foo',
-          authorization: fake.tokenizationKey
-        },
-        paymentMethods: ['card']
-      });
-      jest.spyOn(ThreeDSecure.prototype, 'initialize').mockResolvedValue();
-    });
-
-    test('sets up 3ds', () => {
-      Dropin.prototype._setUpThreeDSecure.call({
-        _client: testContext.client,
-        _model: testContext.model,
-        _merchantConfiguration: {
-          threeDSecure: {
-            foo: 'bar'
-          }
-        }
-      });
-
-      expect(ThreeDSecure.prototype.initialize).toBeCalledTimes(1);
-    });
 
     test('fails initialization when 3DS creation fails', done => {
       const error = new Error('failed.');
@@ -985,38 +861,13 @@ describe('Dropin', () => {
 
       ThreeDSecure.prototype.initialize.mockRejectedValue(error);
 
-      Dropin.prototype._setUpThreeDSecure.call({
-        _client: testContext.client,
-        _model: testContext.model,
-        _merchantConfiguration: {
-          threeDSecure: {
-            foo: 'bar'
-          }
-        }
-      });
+      const instance = new Dropin(testContext.dropinOptions);
 
-      expect(ThreeDSecure.prototype.initialize).toBeCalledTimes(1);
-      setTimeout(() => {
+      instance._initialize(() => {
+        expect(ThreeDSecure.prototype.initialize).toBeCalledTimes(1);
         expect(DropinModel.prototype.cancelInitialization).toBeCalled();
         done();
-      }, 1000);
-    });
-
-    test('starts an async dependency', () => {
-      function noop() {}
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting');
-
-      Dropin.prototype._setUpThreeDSecure.call({
-        _client: testContext.client,
-        _merchantConfiguration: {
-          threeDSecure: {
-            foo: 'bar'
-          }
-        },
-        _model: testContext.model
-      }, noop);
-
-      expect(DropinModel.prototype.asyncDependencyStarting).toBeCalledTimes(1);
+      });
     });
   });
 
diff --git a/test/unit/views/payment-methods-view.js b/test/unit/views/payment-methods-view.js
index a880b37..6c0afde 100644
--- a/test/unit/views/payment-methods-view.js
+++ b/test/unit/views/payment-methods-view.js
@@ -153,6 +153,7 @@ describe('PaymentMethodsView', () => {
         model.getVaultedPaymentMethods.mockResolvedValue([{ foo: 'bar' }, fakePaymentMethod]);
 
         return model.initialize().then(() => {
+          model.asyncDependencyReady('card');
           model.changeActivePaymentMethod({ foo: 'bar' });
 
           paymentMethodsViews = new PaymentMethodsView({
diff --git a/test/unit/views/payment-sheet-views/apple-pay-view.js b/test/unit/views/payment-sheet-views/apple-pay-view.js
index 683cef2..15876e9 100644
--- a/test/unit/views/payment-sheet-views/apple-pay-view.js
+++ b/test/unit/views/payment-sheet-views/apple-pay-view.js
@@ -80,19 +80,12 @@ describe('ApplePayView', () => {
       testContext.view = new ApplePayView(testContext.applePayViewOptions);
     });
 
-    test('starts async dependency', () => {
-      jest.spyOn(testContext.view.model, 'asyncDependencyStarting').mockImplementation();
-
-      return testContext.view.initialize().then(() => {
-        expect(testContext.view.model.asyncDependencyStarting).toBeCalledTimes(1);
-      });
-    });
-
     test('notifies async dependency', () => {
       jest.spyOn(testContext.view.model, 'asyncDependencyReady').mockImplementation();
 
       return testContext.view.initialize().then(() => {
         expect(testContext.view.model.asyncDependencyReady).toBeCalledTimes(1);
+        expect(testContext.view.model.asyncDependencyReady).toBeCalledWith('applePay');
       });
     });
 
diff --git a/test/unit/views/payment-sheet-views/base-paypal-view.js b/test/unit/views/payment-sheet-views/base-paypal-view.js
index 1ff0358..27e7f93 100644
--- a/test/unit/views/payment-sheet-views/base-paypal-view.js
+++ b/test/unit/views/payment-sheet-views/base-paypal-view.js
@@ -80,19 +80,24 @@ describe('BasePayPalView', () => {
       testContext.view = new BasePayPalView(testContext.paypalViewOptions);
     });
 
-    test('starts async dependency', () => {
-      jest.spyOn(testContext.view.model, 'asyncDependencyStarting').mockImplementation();
+    test('notifies async dependency ready for paypal', () => {
+      jest.spyOn(testContext.view.model, 'asyncDependencyReady').mockImplementation();
 
       return testContext.view.initialize().then(() => {
-        expect(testContext.view.model.asyncDependencyStarting).toBeCalledTimes(1);
+        expect(testContext.view.model.asyncDependencyReady).toBeCalledTimes(1);
+        expect(testContext.view.model.asyncDependencyReady).toBeCalledWith('paypal');
       });
     });
 
-    test('notifies async dependency', () => {
+    test('notifies async dependency ready for paypalCredit', () => {
       jest.spyOn(testContext.view.model, 'asyncDependencyReady').mockImplementation();
 
+      testContext.view.model.merchantConfiguration.paypalCredit = testContext.view.model.merchantConfiguration.paypal;
+      testContext.view._isPayPalCredit = true;
+
       return testContext.view.initialize().then(() => {
         expect(testContext.view.model.asyncDependencyReady).toBeCalledTimes(1);
+        expect(testContext.view.model.asyncDependencyReady).toBeCalledWith('paypalCredit');
       });
     });
 
@@ -134,19 +139,6 @@ describe('BasePayPalView', () => {
       }
     );
 
-    test('calls asyncDependencyStarting when initializing', () => {
-      const fakeError = {
-        code: 'A_REAL_ERROR_CODE'
-      };
-
-      PayPalCheckout.create.mockRejectedValue(fakeError);
-
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting').mockImplementation();
-      testContext.view.initialize();
-
-      expect(testContext.view.model.asyncDependencyStarting).toBeCalledTimes(1);
-    });
-
     test('calls paypal.Button.render', () => {
       return testContext.view.initialize().then(() => {
         expect(testContext.paypal.Button.render).toBeCalledTimes(1);
diff --git a/test/unit/views/payment-sheet-views/card-view.js b/test/unit/views/payment-sheet-views/card-view.js
index c2d1ca1..293cf9d 100644
--- a/test/unit/views/payment-sheet-views/card-view.js
+++ b/test/unit/views/payment-sheet-views/card-view.js
@@ -319,21 +319,6 @@ describe('CardView', () => {
       });
     });
 
-    test('starts async dependency', () => {
-      jest.spyOn(DropinModel.prototype, 'asyncDependencyStarting');
-
-      const view = new CardView({
-        element: cardElement,
-        model: fakeModel,
-        client: fakeClient,
-        strings: strings
-      });
-
-      return view.initialize().then(() => {
-        expect(DropinModel.prototype.asyncDependencyStarting).toBeCalledTimes(1);
-      });
-    });
-
     test(
       'notifies async dependency is ready when Hosted Fields is created',
       () => {
@@ -348,6 +333,7 @@ describe('CardView', () => {
 
         return view.initialize().then(() => {
           expect(DropinModel.prototype.asyncDependencyReady).toBeCalledTimes(1);
+          expect(DropinModel.prototype.asyncDependencyReady).toBeCalledWith('card');
         });
       }
     );
diff --git a/test/unit/views/payment-sheet-views/google-pay-view.js b/test/unit/views/payment-sheet-views/google-pay-view.js
index 481a835..2f9bfc2 100644
--- a/test/unit/views/payment-sheet-views/google-pay-view.js
+++ b/test/unit/views/payment-sheet-views/google-pay-view.js
@@ -107,19 +107,12 @@ describe('GooglePayView', () => {
       testContext.view = new GooglePayView(testContext.googlePayViewOptions);
     });
 
-    test('starts async dependency', () => {
-      jest.spyOn(testContext.view.model, 'asyncDependencyStarting').mockImplementation();
-
-      return testContext.view.initialize().then(() => {
-        expect(testContext.view.model.asyncDependencyStarting).toBeCalledTimes(1);
-      });
-    });
-
     test('notifies async dependency', () => {
       jest.spyOn(testContext.view.model, 'asyncDependencyReady').mockImplementation();
 
       return testContext.view.initialize().then(() => {
         expect(testContext.view.model.asyncDependencyReady).toBeCalledTimes(1);
+        expect(testContext.view.model.asyncDependencyReady).toBeCalledWith('googlePay');
       });
     });
 
diff --git a/test/unit/views/payment-sheet-views/venmo-view.js b/test/unit/views/payment-sheet-views/venmo-view.js
index f62b255..9cb42dd 100644
--- a/test/unit/views/payment-sheet-views/venmo-view.js
+++ b/test/unit/views/payment-sheet-views/venmo-view.js
@@ -61,19 +61,12 @@ describe('VenmoView', () => {
       testContext.view = new VenmoView(testContext.venmoViewOptions);
     });
 
-    test('starts async dependency', () => {
-      jest.spyOn(testContext.view.model, 'asyncDependencyStarting').mockImplementation();
-
-      return testContext.view.initialize().then(() => {
-        expect(testContext.view.model.asyncDependencyStarting).toBeCalledTimes(1);
-      });
-    });
-
     test('notifies async dependency', () => {
       jest.spyOn(testContext.view.model, 'asyncDependencyReady').mockImplementation();
 
       return testContext.view.initialize().then(() => {
         expect(testContext.view.model.asyncDependencyReady).toBeCalledTimes(1);
+        expect(testContext.view.model.asyncDependencyReady).toBeCalledWith('venmo');
       });
     });