LNX
LNX

Reputation: 625

Stripe PaymentElement intermittently not displaying

I'm having a problem with Stripe's PaymentElement react component. It displays correctly but only sometimes. After doing multiple reloads/restarts of the entire react app, and multiple CTRL+F5, the form intermittently doesn't display. There doesn't seem to be any pattern to the times when it displays correctly nor the times when it fails to display.

When the modal displays, correct display of the payment form including PaymentElement component looks like this: enter image description here

When the PaymentElement fails to display, there are two possible appearances. Either it shows the form fields with nothing in them: enter image description here

Or the entire iFrame is empty, no fields or labels or any HTML content within the body. enter image description here

I followed this documentation to integrate the Elements and PaymentElements components into my react app.

Initially I was developing using public and secret keys issued to a free trial account. It was hoped that switching to paid might make a difference, but now that I'm using paid keys I'm getting the same intermittent results with the PaymentElement sometimes not displaying.

I keep comparing my code to the docs and can't find what I've missed. As per the docs, the stripe promise is instantiated outside the component's rendering.

import { useState, useEffect, useRef } from 'react';
import './App.css';
import DynamicTextArea from './components/DynamicTextArea';
import MultiSelect from "./components/MultiSelect";
import CheckoutForm from './components/CheckoutForm';
import ReCAPTCHA from "react-google-recaptcha";

import { loadStripe } from "@stripe/stripe-js";
import { Elements } from "@stripe/react-stripe-js";

const stripePromise = loadStripe("pk_test_xxxxxxxx");


function App() {

Payment intent is created with useEffect and an emtpy array so it triggers after page load. This react app is backed by a REST API (.NET6) which handles the calls to Stripe and returns the client secret.

const createPaymentIntent = () => {
    // Create PaymentIntent as soon as the page loads
    fetch("/payments/create-payment-intent", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: [{ id: "product001" }] }),
    })
        .then((res) => res.json())
        .then((data) => setClientSecret(data.clientSecret));
}

const showPaymentForm = async () => {
    setPaymentModalOptions({...paymentModalOptions, visible: true});
}

useEffect(() => {
    createPaymentIntent();
}, []);

Options to pass to the PaymentElement component, which is wrapped in a CheckoutForm component as per the docs:

const options = {
    clientSecret: clientSecret,
    appearance: {
        theme: 'stripe',
    }
};

The Stripe Elements component is wrapped in a bootstrap modal. To be clear, the modal always displays correctly, but intermittently the PaymentElement within the modal does not display.

    <div id="payment-modal" className={`modal ${paymentModalOptions.visible ? 'modal-show' : ''}`} role="dialog">
        <div className="modal-dialog" role="document">
            <div className="modal-content">
                <div className="modal-body">
                    {clientSecret && (
                        <Elements options={options} stripe={stripePromise}>
                            <CheckoutForm onPaymentSucceeded={handlePaymentSucceeded} onCancelPayment={handlePaymentCancelled}/>
                        </Elements>
                    )}
                </div>
            </div>
        </div>
    </div>

Lastly, the CheckouForm component where the PaymentElement actually resides.

import { useEffect, useState } from "react";
import {
  PaymentElement,
  useStripe,
  useElements
} from "@stripe/react-stripe-js";

import './CheckoutForm.css';

export default function CheckoutForm(props) {
  const stripe = useStripe();
  const elements = useElements();
  const [message, setMessage] = useState(null);
  const [paymentIsLoading, setPaymentIsLoading] = useState(false);

  const paymentElementOptions = {
    layout: "tabs"
  }

  const delay = async (ms) => new Promise(res => setTimeout(res, ms));

  const handleSubmit = async (evt) => {
    evt.preventDefault();

    if (!stripe || !elements) {
      // Stripe.js has not yet loaded.
      // Make sure to disable form submission until Stripe.js has loaded.
      return;
    }

    setPaymentIsLoading(true);

    const paymentResult = await stripe.confirmPayment({
      elements,
      confirmParams: {
        // Make sure to change this to your payment completion page
        return_url: "http://localhost:44472",
      },
      redirect: 'if_required'
    });
    
    if (paymentResult.paymentIntent.status === 'succeeded') {
      setMessage("Payment successful!");
      
      // wait two seconds, then close the payment modal
      await delay(2000);
      props.onPaymentSucceeded();
      
    } else {
      setMessage("Payment unsuccessful");
    }

    setPaymentIsLoading(false);
  }

  return (
    <form id="payment-form" onSubmit={handleSubmit}>
        <PaymentElement id="payment-element" options={paymentElementOptions} />
          <div id="payment-buttons-wrapper">
              <div className="payment-button-group">
                  {paymentIsLoading ?
                      <div className="spinner-border text-secondary" id="payment-spinner" role="status"></div>
                      : <button disabled={paymentIsLoading || !stripe || !elements} id="submit" className="btn btn-light border">
                          <span id="button-text">Pay now</span>
                        </button>
                  }
              </div>
              
              <button onClick={props.onCancelPayment} className="btn btn-light border">Cancel</button>
          </div>
      
      {/* Show any error or success messages */}
      {message && <div id="payment-message">{message}</div>}
    </form>
  );
}

I've seen intermittent rendering problems in React before and its often caused because of a pending response to an async call, or failing to use useEffect() correctly (I've done it multiple times). In this case I don't see where I'm going wrong. Thanks for your help.

UPDATE: This error appears in the console intermittently. The error sometimes appears even when the PaymentElement displays correctly. Other times the error doesn't appear even though the Payment Element fails to display. I'm not convinced they are connected. enter image description here

UPDATE: I have reached out to Stripe support on this. In the meantime I'm sharing an additional screenshot. Sometimes when the form fails to display, there's an iFrame present in the page that contains the full payment form, but apparently the iFrame has opacity: 0. This is not a full explanation for the problems I'm having because sometimes the iFrame's body tag is literally empty, so it wouldn't matter whether it was invisible or not. But in this example, the iFrame is fully loaded with the Stripe Payment form, it's just invisible due to the opacity. I some point in the loading process I'm guessing the Stripe components are responsible for changing this property so the form displays, and that process is being interrupted before it gets there. Cause is still unknown. enter image description here

The other failure state results in an empty root div within the iFrame's html body. In this failure state, as with the previous one shown above, there are no errors in the console. enter image description here

Upvotes: 5

Views: 1441

Answers (1)

b.sullender
b.sullender

Reputation: 341

EDIT: Stripe emailed me today that the issue involving the race condition with elements.update() has been fixed (05/20/2024).

I understand you have switched to using Stripe-hosted checkout page instead, but I wanted to give a possible reason and solution since I encountered a similar issue.

From my debugging, at-least for me, the issue was the Payment Element not detecting the client secret during creation resulting in a stuck loading phase. I'm not a react dev so I'm not sure, but it's likely you're missing a needed await somewhere between creating the client secret and the payment element.

In my particular case I was trying to update the elements object with the mode, currency and amount so I could create the payment element without the payment intent (client secret), however. The elements.update and elements.create functions have a race condition bug causing the same issue that you were experiencing.

To solve the issue I added a sleep function between updating the elements object and creating the payment element. The following HTML/JavaScript code demonstrates the issue and fix. Uncomment line 74 to fix the loading issue, don't forget to update the public key on line 28.

<head>
  <script src="https://js.stripe.com/v3/"></script>
</head>

<body>
  <form id="payment-form">
    <div id="address-element">
    </div>
    <button id="next-button" style="margin-top: 10px;">
      <div class="spinner hidden" id="spinner"></div>
      <span id="next-button-text">Next</span>
    </button>
    <div id="payment-element">
    </div>
    <button id="pay-button" style="display: none;">
      <div class="spinner hidden" id="spinner"></div>
      <span id="pay-button-text">Pay now</span>
    </button>
  </form>
  <script>
    // Set temp subtotal
    var subtotal = 5400;
    // Get shipping and tax after user enters shipping address
    var shipping = 0;
    var tax = 0;
    
    // Stripe public test key
    const stripe = Stripe("");
    
    // Create the elements object
    var elements = stripe.elements({});
    // Create the Address Element in shipping mode
    
    var addressElement = elements.create('address', {
      mode: 'shipping',
    });
    addressElement.mount("#address-element");
    addressElement.on('change', async function(event) {
      if (event.complete) {
        // Set shipping and tax (testing)
        shipping = 538;
        tax = 125;
        // Enable the next button
        document.getElementById('next-button').disabled = false;
      } else {
        // Disable the next button
        document.getElementById('next-button').disabled = true;
      }
    });
    
    // Function for sleeping
    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    // Function to get shipping, tax and create the payment element
    document.getElementById('next-button').addEventListener('click', async function(event) {
      event.preventDefault();
      
      // Hide the address element and next button
      document.getElementById('address-element').style.display = 'none';
      document.getElementById('next-button').style.display = 'none';
      // Show the pay button
      document.getElementById('pay-button').style.display = 'block';
      
      // Update elements with order total
      elements.update({
        mode: 'payment',
        currency: 'usd',
        amount: subtotal + shipping + tax,
      });
      
      // Sleep needed to fix issue of payment element intermittently not showing
      //await sleep(2000);
      
      // Create the payment element without an intent
      var paymentElement = elements.create('payment');
      paymentElement.mount('#payment-element');
    });
  </script>
</body>

PS: I have already notified Stripe of the race condition bug.

Upvotes: 3

Related Questions