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 (
-
)
};