stripe / stripe-terminal-react-native

React Native SDK for Stripe Terminal
https://stripe.com/docs/terminal/payments/setup-integration?terminal-sdk-platform=react-native
MIT License
110 stars 50 forks source link

Tap to pay iOS: Exception 'Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread' #481

Closed yaromyrshmid closed 1 year ago

yaromyrshmid commented 1 year ago

Describe the bug When using After using collectPaymentMethod from useStripeTerminalHook app crashes with Exception 'Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread'

To Reproduce Steps to reproduce the behavior:

  1. Initialize SDK according to https://github.com/stripe/stripe-terminal-react-native#initialization
  2. Use useStripeTerminal hook inside component.
  3. Inside useEffect call discoverReaders({ discoveryMethod: 'localMobile', simulated: true, })
  4. Inside useEffect call
    await connectLocalMobileReader({
      reader: discoveredReaders[0],
      locationId: "[location_id]",
      onBehalfOf: "[stripe_account_id]",
    });
  5. Get clientSecret from API.
  6. Call retrievePaymentIntent.
  7. Call
    await collectPaymentMethod({
        paymentIntentId: paymentIntentResponse.paymentIntent.id,
      });
  8. App will show an exception: image

Expected behavior A new screen should be displayed indicating that card should be pressed against the device (as on Android).

Stripe Terminal React Native SDK version ^0.0.1-beta.12

Smartphone:

Additional context React Native: 0.72.0-rc.5

On Android everything works as expected.

Thank you for investigating.

Dhaval1905 commented 1 year ago

@yaromyrshmid would you please share your code so I can help you.

yaromyrshmid commented 1 year ago

@yaromyrshmid would you please share your code so I can help you.

Thank you for the response. I've seen this topic: , so I'm not sure you'll be able to help ;) But here is our implementation in case it will help you with your problem.

useStripeTerminal:

import { useCallback, useEffect, useState } from 'react';
import { useStripeTerminal } from '@stripe/stripe-terminal-react-native';
import { APP_ENV } from 'react-native-dotenv';
import { useAppSelector } from 'store/hooks';
import { selectUser } from 'store/slices/user/userSlice';
import { PaymentAPI } from 'common/services/API/PaymentAPI/PaymentAPI';
import { ManualRequestData } from 'types/Request';
import { toastError, toastSuccess } from 'utils/toast';

export const useTapToPay = () => {
  const user = useAppSelector(selectUser);

  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const {
    discoverReaders,
    connectedReader,
    discoveredReaders,
    connectLocalMobileReader,
    cancelDiscovering,
    collectPaymentMethod,
    processPayment,
    retrievePaymentIntent,
    isInitialized,
  } = useStripeTerminal({
    onFinishDiscoveringReaders: (error) => {
      if (error?.message) {
        setError(error.message);
        setLoading(false);
      }
    },
    onDidChangeConnectionStatus: () => {
      setLoading(false);
    },
  });

  const discoverLocalMobile = useCallback(async () => {
    if (isInitialized && !connectedReader) {
      setError(null);
      setLoading(true);

      await cancelDiscovering();

      await discoverReaders({
        discoveryMethod: 'localMobile',
        simulated: APP_ENV === 'staging',
      });
    }
  }, [discoverReaders, isInitialized, connectedReader]);

  useEffect(() => {
    discoverLocalMobile();
  }, [discoverReaders]);

  const connectReader = async () => {
    await connectLocalMobileReader({
      reader: discoveredReaders[0],
      locationId: user?.company?.stripeLocationId || '',
      onBehalfOf: user?.company?.stripeAccountId || '',
    });
  };

  useEffect(() => {
    if (discoveredReaders[0]) {
      connectReader();
    }
  }, [discoveredReaders]);

  const handleTapToPay = async (requestData: ManualRequestData) => {
    try {
      if (!user?.company?.stripeAccountId)
        throw new Error('No stripe account id');

      const requestDto = {
        stockNumber: requestData.description,
        customerName: requestData.name || 'test',
        ...(requestData.type === 'email'
          ? {
              customerEmail: requestData.email,
            }
          : {
              customerPhone: requestData.phone,
            }),
      };

      const { clientSecret } = await PaymentAPI.collectReaderPayment({
        amount: requestData.amount,
        ...requestDto,
      });

      const paymentIntentResponse = await retrievePaymentIntent(clientSecret);

      if (paymentIntentResponse.error || !paymentIntentResponse.paymentIntent)
        throw new Error(
          paymentIntentResponse.error?.message ||
            'Failed to retrieve payment intent',
        );

      // Exception thrown executing this:
      const paymentMethodResponse = await collectPaymentMethod({
        paymentIntentId: paymentIntentResponse.paymentIntent.id,
      });

      if (paymentMethodResponse.error || !paymentMethodResponse.paymentIntent)
        throw new Error(
          paymentMethodResponse.error?.message ||
            'Failed to collect payment method',
        );

      const processPaymentResponse = await processPayment(
        paymentMethodResponse.paymentIntent.id,
      );

      if (processPaymentResponse.error || !processPaymentResponse.paymentIntent)
        throw new Error(
          processPaymentResponse.error?.message || 'Failed to process payment',
        );

      await PaymentAPI.captureReaderPayment({
        paymentIntentId: processPaymentResponse.paymentIntent.id,
        ...requestDto,
      });

      toastSuccess('Your payment has succeeded!');

      return true;
    } catch (error: any) {
      toastError(error?.message || 'Failed to process payment');
    }
  };

  return {
    handleTapToPay,
    loading,
    error,
  };
};

Here's the initialization hook:


import { useEffect } from 'react';

import {
  requestNeededAndroidPermissions,
  useStripeTerminal,
} from '@stripe/stripe-terminal-react-native';
import { Platform } from 'react-native';
import { selectAuthState } from 'store/slices/auth/authSlice';
import { useAppSelector } from 'store/hooks';
import { toastError } from 'utils/toast';

export const useInitializeStripeTerminal = () => {
  const { initialize } = useStripeTerminal();
  const { isAuth } = useAppSelector(selectAuthState);

  const initializeAndroid = async () => {
    const granted = await requestNeededAndroidPermissions({
      accessFineLocation: {
        title: 'Location Permission',
        message: 'Stripe Terminal needs access to your location',
        buttonPositive: 'Accept',
      },
    });
    if (granted) {
      initialize();
    } else {
      toastError(
        'Location and BT services are required in order to connect to a reader.',
      );
    }
  };

  useEffect(() => {
    if (isAuth) {
      if (Platform.OS === 'android') {
        initializeAndroid();
      } else {
        initialize();
      }
    }
  }, [isAuth]);
};

And here is snippet of App component:

const App = () => {
  return (
    <StripeTerminalProvider
      logLevel="verbose"
      tokenProvider={PaymentReadersAPI.getReaderConnectionToken}>
      ...other providers
       <RootNavigator />
   </StripeTerminalProvider>
dleavittpmc commented 1 year ago

Any update on this? I am also running into this issue.

dleavittpmc commented 1 year ago

For me, it only happens when simulated = true. If simulated = false, I am able to use a stripe test account and the terminal card perfectly fine.

Dhaval1905 commented 1 year ago

For discover readers which kind of readers are we can use? Can we use credit card or debit card information using discoverReader of the local mobile reader? Or we need stripe specify readers only.

dleavittpmc commented 1 year ago

You're able to use certain NFC enabled cards, so it can use both debit and credit cards. Here is a list of simulated/terminal cards you can use. Here is the discoverReaders javascript API docs. Discover readers is for all of the Stripe payment devices as well as discovering the local reader.

However, I don't see how this applies to the current issue.

nazli-stripe commented 1 year ago

hey @yaromyrshmid this is a known issue with simulated localMobile readers on iOS and will be fixed in the next release. I'll provide an update here when that's out

nazli-stripe commented 1 year ago

@yaromyrshmid we just released a new version last Friday and bumped the native iOS SDK version. That should fix this issue!