1- import { useEffect , useRef } from 'react' ;
1+ import { useEffect , useRef , useState } from 'react' ;
22
33import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants' ;
44import { Box , useAppearance , useLocalizations } from '../customizables' ;
55
66/**
77 * This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic,
8- * which operates outside the React lifecycle. It stores the observed state in ref to ensure that
8+ * which operates outside the React lifecycle. It stores the observed state in refs to ensure that
99 * any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders,
1010 * preventing unwanted layout resets.
11+ *
12+ * When Turnstile escalates to an interactive "Verify you are human" challenge it sets
13+ * `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange`
14+ * surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount.
1115 */
12- export const CaptchaElement = ( ) => {
13- const elementRef = useRef ( null ) ;
16+ export const CaptchaElement = ( {
17+ onInteractiveChange,
18+ gapless,
19+ } : {
20+ onInteractiveChange ?: ( interactive : boolean ) => void ;
21+ /**
22+ * When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
23+ * gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
24+ * other (non-spotlight) render sites keep their current positioning.
25+ */
26+ gapless ?: boolean ;
27+ } ) => {
28+ const elementRef = useRef < HTMLDivElement > ( null ) ;
1429 const maxHeightValueRef = useRef ( '0' ) ;
1530 const minHeightValueRef = useRef ( 'unset' ) ;
1631 const marginBottomValueRef = useRef ( 'unset' ) ;
32+ // State forces a re-render on the interactive transition, which re-applies the ref-held styles
33+ // above (preserving Turnstile's injected values) and drives the `gapless` position toggle.
34+ const [ isInteractive , setIsInteractive ] = useState ( false ) ;
35+ // The observer is set up once (`[]` deps), so it reads the latest callback through a ref.
36+ const onInteractiveChangeRef = useRef ( onInteractiveChange ) ;
37+ onInteractiveChangeRef . current = onInteractiveChange ;
38+ const isInteractiveRef = useRef ( false ) ;
1739 const { parsedCaptcha } = useAppearance ( ) ;
1840 const { locale } = useLocalizations ( ) ;
1941 const captchaTheme = parsedCaptcha ?. theme ;
@@ -30,17 +52,46 @@ export const CaptchaElement = () => {
3052 const observer = new MutationObserver ( mutations => {
3153 mutations . forEach ( mutation => {
3254 const target = mutation . target as HTMLDivElement ;
33- if ( mutation . type === 'attributes' && mutation . attributeName === 'style' && elementRef . current ) {
55+ if ( mutation . type !== 'attributes' || ! elementRef . current ) {
56+ return ;
57+ }
58+ if ( mutation . attributeName === 'style' ) {
59+ // Keep refs in sync so Turnstile's injected styles survive React re-renders.
3460 maxHeightValueRef . current = target . style . maxHeight || '0' ;
3561 minHeightValueRef . current = target . style . minHeight || 'unset' ;
3662 marginBottomValueRef . current = target . style . marginBottom || 'unset' ;
63+ // Fallback for old clerk-js that never writes data-cl-interactive: infer
64+ // interactive state from maxHeight. When the MutationObserver callback fires,
65+ // the DOM already reflects all mutations from the same microtask, so
66+ // `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets
67+ // the attribute alongside the style) passes the guard and is handled below.
68+ if ( ! ( 'clInteractive' in target . dataset ) ) {
69+ const mh = target . style . maxHeight ;
70+ const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px' ;
71+ if ( nowInteractive !== isInteractiveRef . current ) {
72+ isInteractiveRef . current = nowInteractive ;
73+ setIsInteractive ( nowInteractive ) ;
74+ onInteractiveChangeRef . current ?.( nowInteractive ) ;
75+ }
76+ }
77+ }
78+ if ( mutation . attributeName === 'data-cl-interactive' ) {
79+ // ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are
80+ // delivered before this one (DOM mutations are batched and replayed in order), so
81+ // the refs above are already up-to-date when the re-render triggered below runs.
82+ const nowInteractive = target . dataset . clInteractive === 'true' ;
83+ if ( nowInteractive !== isInteractiveRef . current ) {
84+ isInteractiveRef . current = nowInteractive ;
85+ setIsInteractive ( nowInteractive ) ;
86+ onInteractiveChangeRef . current ?.( nowInteractive ) ;
87+ }
3788 }
3889 } ) ;
3990 } ) ;
4091
4192 observer . observe ( elementRef . current , {
4293 attributes : true ,
43- attributeFilter : [ 'style' ] ,
94+ attributeFilter : [ 'style' , 'data-cl-interactive' ] ,
4495 } ) ;
4596
4697 return ( ) => observer . disconnect ( ) ;
@@ -56,6 +107,9 @@ export const CaptchaElement = () => {
56107 maxHeight : maxHeightValueRef . current ,
57108 minHeight : minHeightValueRef . current ,
58109 marginBottom : marginBottomValueRef . current ,
110+ // When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter
111+ // to its flex parent; rejoin flow once the interactive challenge expands it.
112+ position : gapless ? ( isInteractive ? 'static' : 'absolute' ) : undefined ,
59113 } }
60114 data-cl-theme = { captchaTheme }
61115 data-cl-size = { captchaSize }
0 commit comments