diff --git a/.changeset/fast-kiwis-visit.md b/.changeset/fast-kiwis-visit.md new file mode 100644 index 00000000000..a09c3e74212 --- /dev/null +++ b/.changeset/fast-kiwis-visit.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix captcha layout shift on transfer flow, custom flows and invisible diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx index 6cb45a6426b..2bd85057e71 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx @@ -113,7 +113,7 @@ export const SignUpForm = (props: SignUpFormProps) => { )} - + )} - {!shouldShowForm && } + {!shouldShowForm && } diff --git a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx index 26edf2c5a8c..7a5a5408f20 100644 --- a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx +++ b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx @@ -1,13 +1,53 @@ +import { useEffect, useRef } from 'react'; + import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha'; import { Box } from '../customizables'; -type CaptchaElementProps = { - maxHeight?: string; -}; +/** + * This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic, + * which operates outside the React lifecycle. It stores the observed state in ref to ensure that + * any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders, + * preventing unwanted layout resets. + */ +export const CaptchaElement = () => { + const elementRef = useRef(null); + const maxHeightValueRef = useRef('0'); + const minHeightValueRef = useRef('unset'); + const marginBottomValueRef = useRef('unset'); + + useEffect(() => { + if (!elementRef.current) return; + + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + const target = mutation.target as HTMLDivElement; + if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) { + maxHeightValueRef.current = target.style.maxHeight || '0'; + minHeightValueRef.current = target.style.minHeight || 'unset'; + marginBottomValueRef.current = target.style.marginBottom || 'unset'; + } + }); + }); -export const CaptchaElement = ({ maxHeight }: CaptchaElementProps) => ( - -); + observer.observe(elementRef.current, { + attributes: true, + attributeFilter: ['style'], + }); + + return () => observer.disconnect(); + }, []); + + return ( + + ); +}; diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index b160a8e9bf9..1d2724cb07d 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -141,6 +141,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { if (visibleDiv) { captchaWidgetType = 'smart'; widgetContainerQuerySelector = `#${CAPTCHA_ELEMENT_ID}`; + visibleDiv.style.maxHeight = '0'; // This is to prevent the layout shift when the render method is called } else { console.error( 'Cannot initialize Smart CAPTCHA widget because the `clerk-captcha` DOM element was not found; falling back to Invisible CAPTCHA widget. If you are using a custom flow, visit https://clerk.com/docs/custom-flows/bot-sign-up-protection for instructions', @@ -155,6 +156,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { widgetContainerQuerySelector = `.${CAPTCHA_INVISIBLE_CLASSNAME}`; const div = document.createElement('div'); div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + div.style.maxHeight = '0'; // This is to prevent the layout shift when the render method is called document.body.appendChild(div); } @@ -178,8 +180,12 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { } else { const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleWidget) { + // We unset the max-height to allow the widget to expand visibleWidget.style.maxHeight = 'unset'; - visibleWidget.style.minHeight = '68px'; // this is the height of the Turnstile widget + // We set the min-height to the height of the Turnstile widget + // because the widget initially does a small layout shift + // and then expands to the correct height + visibleWidget.style.minHeight = '68px'; visibleWidget.style.marginBottom = '1.5rem'; } }