diff --git a/src/components/stripe-form/__tests__/stripe-form.test.js b/src/components/stripe-form/__tests__/stripe-form.test.js new file mode 100644 index 0000000..6b75029 --- /dev/null +++ b/src/components/stripe-form/__tests__/stripe-form.test.js @@ -0,0 +1,86 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import StripeForm from '../index'; + +// Stripe's own hooks reach for a real Elements context and a live iframe, so the +// SDK is stubbed down to the one thing these tests care about: where in the tree +// the PaymentElement ends up. +// Every DOM node the mock is ever mounted into, so a test can assert the Element +// was never attached inside a shadow tree, not even for one render. +const mountRoots = []; + +jest.mock('@stripe/react-stripe-js', () => ({ + useStripe: () => ({}), + useElements: () => ({ getElement: () => null }), + PaymentElement: () => ( +
{ if (node) mountRoots.push(node.getRootNode()); }} + /> + ), +})); + +beforeEach(() => { + mountRoots.length = 0; + document.body.innerHTML = ''; +}); + +const props = { + reservation: { owner_first_name: 'Ada', owner_last_name: 'Lovelace' }, + payTicket: jest.fn(), + userProfile: {}, + provider: 'stripe', + hidePostalCode: false, + stripeReturnUrl: 'https://example.test/return', + onError: jest.fn(), +}; + +const renderInShadowRoot = () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const mountPoint = document.createElement('div'); + shadowRoot.appendChild(mountPoint); + render(, { container: mountPoint }); + return { host, shadowRoot }; +}; + +describe('StripeForm shadow DOM handling', () => { + it('keeps the PaymentElement in the light DOM when shadow-mounted', () => { + const { host, shadowRoot } = renderInShadowRoot(); + + // Stripe cannot reach an element inside a shadow tree, so the wrapper has + // to be a child of the host itself. + const slotted = host.querySelector(':scope > [slot="stripe-payment"]'); + expect(slotted).not.toBeNull(); + expect(slotted.querySelector('[data-testid="payment-element"]')).not.toBeNull(); + expect(shadowRoot.contains(slotted)).toBe(false); + }); + + it('leaves a matching slot in the form to display it in flow', () => { + const { shadowRoot } = renderInShadowRoot(); + + const form = shadowRoot.querySelector('form#payment-form'); + expect(form.querySelector('slot[name="stripe-payment"]')).not.toBeNull(); + }); + + it('mounts the PaymentElement inline when there is no shadow root', () => { + const { container } = render(); + + const form = container.querySelector('form#payment-form'); + expect(form.querySelector('[data-testid="payment-element"]')).not.toBeNull(); + expect(form.querySelector('slot')).toBeNull(); + expect(document.querySelector('[slot="stripe-payment"]')).toBeNull(); + }); + + it('never attaches the PaymentElement inside the shadow tree', () => { + // The callback ref resolves the mount context during commit, and nothing + // is rendered until it has. Without that wait the first render would mount + // the Element in the shadow tree, where Stripe cannot reach it. + const { shadowRoot } = renderInShadowRoot(); + + expect(mountRoots.length).toBeGreaterThan(0); + expect(mountRoots).not.toContain(shadowRoot); + }); +}); diff --git a/src/components/stripe-form/index.js b/src/components/stripe-form/index.js index 7459410..6aea80e 100644 --- a/src/components/stripe-form/index.js +++ b/src/components/stripe-form/index.js @@ -11,7 +11,8 @@ * limitations under the License. **/ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useForm } from 'react-hook-form'; import { @@ -63,11 +64,24 @@ const stripeErrorCodeMap = { }; +// Slot names are scoped to their shadow root, so widgets on one page don't collide. +const PAYMENT_SLOT = 'stripe-payment'; + const StripeForm = ({ reservation, payTicket, userProfile, provider, hidePostalCode, stripeReturnUrl, onError }) => { const stripe = useStripe(); const elements = useElements(); const [paymentElement, setPaymentElement] = useState(null); + // Stripe cannot see into a shadow tree, so when shadow-mounted the Element is + // kept in the light DOM and slotted back in flow (stripe/stripe-js#143). + // undefined while detecting, null in the light DOM, else the shadow host. + const [slotHost, setSlotHost] = useState(undefined); + const detectSlotHost = useCallback((node) => { + if (!node) return; + const rootNode = node.getRootNode(); + setSlotHost(rootNode instanceof ShadowRoot ? rootNode.host : null); + }, []); + useEffect(() => { if (elements) { setPaymentElement(elements.getElement('payment')); @@ -156,9 +170,23 @@ const StripeForm = ({ reservation, payTicket, userProfile, provider, hidePostalC } } + const renderPaymentElement = () => { + const paymentEl = ; + // Wait for the callback ref: mounting before the context is known would + // put the Element in the shadow tree, out of Stripe's reach. + if (slotHost === undefined) return null; + if (!slotHost) return paymentEl; + return ( + <> + + {createPortal(
{paymentEl}
, slotHost)} + + ); + }; + return ( -
- + + {renderPaymentElement()} ) };