Skip to content

Commit d1989d6

Browse files
committed
feat(js): Backport interactive captcha spotlight UI for Core 2
Port the sign-in/sign-up spotlight into clerk-js's bundled UI: CaptchaElement gains onInteractiveChange/gapless, and the start cards collapse and inert the rest of the form while an interactive challenge is shown.
1 parent 75cb134 commit d1989d6

6 files changed

Lines changed: 104 additions & 26 deletions

File tree

.changeset/captcha-data-cl-interactive.md

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
Spotlight interactive bot-protection (Cloudflare Turnstile) challenges during sign-in and sign-up. When a challenge escalates to an interactive "Verify you are human" check, the start card now brings it to the foreground — collapsing and `inert`-ing the rest of the form until the challenge is solved — while keeping the header, footer, and passkey action reachable. Invisible challenges are unaffected.

packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ function SignInStartInternal(): JSX.Element {
155155
const hasSocialOrWeb3Buttons =
156156
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
157157
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
158+
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
159+
// rest of the card (see the descriptors.main column below).
160+
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
158161
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
159162
...currentIdentifier,
160163
isRequired: true,
@@ -574,6 +577,13 @@ function SignInStartInternal(): JSX.Element {
574577
<Col
575578
elementDescriptor={descriptors.main}
576579
gap={6}
580+
// @ts-ignore - `inert` is not yet in the installed React types
581+
inert={captchaIsInteractive ? '' : undefined}
582+
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
583+
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
584+
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
585+
// redundant-but-harmless.
586+
sx={captchaIsInteractive ? { display: 'none' } : undefined}
577587
>
578588
<SocialButtonsReversibleContainerWithDivider>
579589
{hasSocialOrWeb3Buttons && (
@@ -603,24 +613,27 @@ function SignInStartInternal(): JSX.Element {
603613
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />
604614
</Col>
605615
<Col center>
606-
<CaptchaElement />
607616
<Form.SubmitButton hasArrow />
608617
</Col>
609618
</Form.Root>
610619
) : null}
611620
</SocialButtonsReversibleContainerWithDivider>
612-
{!standardFormAttributes.length && <CaptchaElement />}
613-
{userSettings.attributes.passkey?.enabled &&
614-
userSettings.passkeySettings.show_sign_in_button &&
615-
isWebSupported && (
616-
<Card.Action elementId={'usePasskey'}>
617-
<Card.ActionLink
618-
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
619-
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
620-
/>
621-
</Card.Action>
622-
)}
623621
</Col>
622+
<CaptchaElement
623+
gapless
624+
onInteractiveChange={setCaptchaIsInteractive}
625+
/>
626+
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
627+
{userSettings.attributes.passkey?.enabled &&
628+
userSettings.passkeySettings.show_sign_in_button &&
629+
isWebSupported && (
630+
<Card.Action elementId={'usePasskey'}>
631+
<Card.ActionLink
632+
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
633+
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
634+
/>
635+
</Card.Action>
636+
)}
624637
</Card.Content>
625638
<Card.Footer>
626639
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (

packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
55
import type { FormControlState } from '@/ui/utils/useFormControl';
66

77
import { Col, localizationKeys, useAppearance } from '../../customizables';
8-
import { CaptchaElement } from '../../elements/CaptchaElement';
98
import { mqu } from '../../styledSystem';
109
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';
1110

@@ -115,7 +114,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
115114
</Col>
116115
)}
117116
<Col center>
118-
<CaptchaElement />
119117
<Col
120118
gap={6}
121119
sx={{

packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
6565
);
6666

6767
const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
68+
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
69+
// rest of the card (see the descriptors.main column below).
70+
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);
6871

6972
const {
7073
userSettings: { passwordSettings, usernameSettings },
@@ -428,6 +431,13 @@ function SignUpStartInternal(): JSX.Element {
428431
direction='col'
429432
elementDescriptor={descriptors.main}
430433
gap={6}
434+
// @ts-ignore - `inert` is not yet in the installed React types
435+
inert={captchaIsInteractive ? '' : undefined}
436+
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
437+
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
438+
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
439+
// redundant-but-harmless.
440+
sx={captchaIsInteractive ? { display: 'none' } : undefined}
431441
>
432442
<SocialButtonsReversibleContainerWithDivider>
433443
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
@@ -450,8 +460,11 @@ function SignUpStartInternal(): JSX.Element {
450460
/>
451461
)}
452462
</SocialButtonsReversibleContainerWithDivider>
453-
{!shouldShowForm && <CaptchaElement />}
454463
</Flex>
464+
<CaptchaElement
465+
gapless
466+
onInteractiveChange={setCaptchaIsInteractive}
467+
/>
455468
</Card.Content>
456469

457470
<Card.Footer>

packages/clerk-js/src/ui/elements/CaptchaElement.tsx

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
1-
import { useEffect, useRef } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22

33
import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha/constants';
44
import { 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

Comments
 (0)