From 81be280e4df978c0fb4d1ba08a93e758ddd2dad8 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 22 Aug 2026 21:05:06 -0300 Subject: [PATCH 1/3] Slot the Stripe PaymentElement out of the shadow root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripe Elements cannot mount inside a shadow root — Stripe reaches its iframes through window.frames, which cannot see into shadow trees. When StripeForm is mounted in a shadow root, render the PaymentElement into a light-DOM node and project it back in-flow through a named , per Stripe's recommended workaround (stripe/stripe-js#143). A callback ref on the form detects whether it sits in a shadow root and picks the slot path or the inline path accordingly; outside a shadow root the Element mounts inline as before. --- src/components/stripe-form/index.js | 43 +++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/components/stripe-form/index.js b/src/components/stripe-form/index.js index 7459410..31af959 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,34 @@ const stripeErrorCodeMap = { }; +// Name of the slot the PaymentElement is projected into when this form is mounted +// inside a shadow root. Scoped to each shadow root, so multiple registration +// 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 Elements cannot be mounted inside a shadow root — Stripe reaches its + // iframes through window.frames, which can't see into shadow trees. Stripe's + // own recommended workaround is to keep the Element in the light DOM and + // "punch a hole" through the shadow with a (stripe/stripe-js#143). When + // this form is shadow-mounted, render the PaymentElement into a light-DOM node + // that is slotted back into the form: it stays in the light DOM (Stripe accepts + // it) but displays in-flow at the slot position. `slotHost` is undefined while + // detecting, null when already in the light DOM (mount inline), or the shadow + // host element when shadow-mounted. A callback ref resolves it when the form + // node attaches — during commit, before paint — so the PaymentElement is only + // rendered once the context is known and never attempts an in-shadow mount. + 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 +180,22 @@ const StripeForm = ({ reservation, payTicket, userProfile, provider, hidePostalC } } + const paymentEl = ; + return ( -
- + + {slotHost === undefined + ? null + : slotHost + // Shadow-mounted: keep the Element in the light DOM, slotted in-flow. + ? ( + <> + + {createPortal(
{paymentEl}
, slotHost)} + + ) + // Already in the light DOM: mount inline as before. + : paymentEl} ) }; From bb4e0815d14338c27891b19339de7d818a079ba4 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 15:17:52 -0300 Subject: [PATCH 2/3] Cover the shadow-DOM path of StripeForm The Element has to reach the light DOM for Stripe to find it, and nothing checked that. Tests pin the portal target, the slot left behind in the form, the unchanged inline path, and that nothing mounts before the mount context is known. --- .../stripe-form/__tests__/stripe-form.test.js | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/components/stripe-form/__tests__/stripe-form.test.js 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); + }); +}); From ba815a8479d08f2eda3a76db9c6b659cdb40a3f0 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 15:18:49 -0300 Subject: [PATCH 3/3] Move the mount-path choice into a helper Replace the chained ternary in the render with renderPaymentElement, and cut the comments down to the reason. --- src/components/stripe-form/index.js | 45 ++++++++++++----------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/components/stripe-form/index.js b/src/components/stripe-form/index.js index 31af959..6aea80e 100644 --- a/src/components/stripe-form/index.js +++ b/src/components/stripe-form/index.js @@ -64,9 +64,7 @@ const stripeErrorCodeMap = { }; -// Name of the slot the PaymentElement is projected into when this form is mounted -// inside a shadow root. Scoped to each shadow root, so multiple registration -// widgets on one page don't collide. +// 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 }) => { @@ -74,17 +72,9 @@ const StripeForm = ({ reservation, payTicket, userProfile, provider, hidePostalC const elements = useElements(); const [paymentElement, setPaymentElement] = useState(null); - // Stripe Elements cannot be mounted inside a shadow root — Stripe reaches its - // iframes through window.frames, which can't see into shadow trees. Stripe's - // own recommended workaround is to keep the Element in the light DOM and - // "punch a hole" through the shadow with a (stripe/stripe-js#143). When - // this form is shadow-mounted, render the PaymentElement into a light-DOM node - // that is slotted back into the form: it stays in the light DOM (Stripe accepts - // it) but displays in-flow at the slot position. `slotHost` is undefined while - // detecting, null when already in the light DOM (mount inline), or the shadow - // host element when shadow-mounted. A callback ref resolves it when the form - // node attaches — during commit, before paint — so the PaymentElement is only - // rendered once the context is known and never attempts an in-shadow mount. + // 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; @@ -180,22 +170,23 @@ const StripeForm = ({ reservation, payTicket, userProfile, provider, hidePostalC } } - const paymentEl = ; + 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 (
- {slotHost === undefined - ? null - : slotHost - // Shadow-mounted: keep the Element in the light DOM, slotted in-flow. - ? ( - <> - - {createPortal(
{paymentEl}
, slotHost)} - - ) - // Already in the light DOM: mount inline as before. - : paymentEl} + {renderPaymentElement()} ) };